diff --git a/config/harmonic.yaml b/config/harmonic.yaml index d1e5a7dd6..be52b01b0 100644 --- a/config/harmonic.yaml +++ b/config/harmonic.yaml @@ -18,6 +18,10 @@ exchangeStrategies: interval: 1s window: 500 quantity: 0.05 + # Draw pnl + drawGraph: true + graphPNLPath: "./pnl.png" + graphCumPNLPath: "./cumpnl.png" backtest: sessions: diff --git a/pkg/strategy/harmonic/draw.go b/pkg/strategy/harmonic/draw.go new file mode 100644 index 000000000..a949126a0 --- /dev/null +++ b/pkg/strategy/harmonic/draw.go @@ -0,0 +1,90 @@ +package harmonic + +import ( + "bytes" + "fmt" + "os" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/interact" + "github.com/c9s/bbgo/pkg/types" + "github.com/wcharczuk/go-chart/v2" +) + +func (s *Strategy) InitDrawCommands(profit, cumProfit types.Series) { + bbgo.RegisterCommand("/pnl", "Draw PNL(%) per trade", func(reply interact.Reply) { + canvas := DrawPNL(s.InstanceID(), profit) + var buffer bytes.Buffer + if err := canvas.Render(chart.PNG, &buffer); err != nil { + log.WithError(err).Errorf("cannot render pnl in drift") + reply.Message(fmt.Sprintf("[error] cannot render pnl in ewo: %v", err)) + return + } + bbgo.SendPhoto(&buffer) + }) + bbgo.RegisterCommand("/cumpnl", "Draw Cummulative PNL(Quote)", func(reply interact.Reply) { + canvas := DrawCumPNL(s.InstanceID(), cumProfit) + var buffer bytes.Buffer + if err := canvas.Render(chart.PNG, &buffer); err != nil { + log.WithError(err).Errorf("cannot render cumpnl in drift") + reply.Message(fmt.Sprintf("[error] canot render cumpnl in drift: %v", err)) + return + } + bbgo.SendPhoto(&buffer) + }) +} + +func (s *Strategy) Draw(profit, cumProfit types.Series) error { + + canvas := DrawPNL(s.InstanceID(), profit) + f, err := os.Create(s.GraphPNLPath) + if err != nil { + return fmt.Errorf("cannot create on path " + s.GraphPNLPath) + } + defer f.Close() + if err = canvas.Render(chart.PNG, f); err != nil { + return fmt.Errorf("cannot render pnl") + } + canvas = DrawCumPNL(s.InstanceID(), cumProfit) + f, err = os.Create(s.GraphCumPNLPath) + if err != nil { + return fmt.Errorf("cannot create on path " + s.GraphCumPNLPath) + } + defer f.Close() + if err = canvas.Render(chart.PNG, f); err != nil { + return fmt.Errorf("cannot render cumpnl") + } + + return nil +} + +func DrawPNL(instanceID string, profit types.Series) *types.Canvas { + canvas := types.NewCanvas(instanceID) + length := profit.Length() + log.Infof("pnl Highest: %f, Lowest: %f", types.Highest(profit, length), types.Lowest(profit, length)) + canvas.PlotRaw("pnl %", profit, length) + canvas.YAxis = chart.YAxis{ + ValueFormatter: func(v interface{}) string { + if vf, isFloat := v.(float64); isFloat { + return fmt.Sprintf("%.4f", vf) + } + return "" + }, + } + canvas.PlotRaw("1", types.NumberSeries(1), length) + return canvas +} + +func DrawCumPNL(instanceID string, cumProfit types.Series) *types.Canvas { + canvas := types.NewCanvas(instanceID) + canvas.PlotRaw("cummulative pnl", cumProfit, cumProfit.Length()) + canvas.YAxis = chart.YAxis{ + ValueFormatter: func(v interface{}) string { + if vf, isFloat := v.(float64); isFloat { + return fmt.Sprintf("%.4f", vf) + } + return "" + }, + } + return canvas +} diff --git a/pkg/strategy/harmonic/strategy.go b/pkg/strategy/harmonic/strategy.go index 8a24b9faf..559c09f00 100644 --- a/pkg/strategy/harmonic/strategy.go +++ b/pkg/strategy/harmonic/strategy.go @@ -1,22 +1,16 @@ package harmonic import ( - "bytes" "context" - "errors" "fmt" - "os" - "sync" - "time" - "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/data/tsv" "github.com/c9s/bbgo/pkg/datatype/floats" "github.com/c9s/bbgo/pkg/fixedpoint" - "github.com/c9s/bbgo/pkg/interact" + "github.com/c9s/bbgo/pkg/indicator" "github.com/c9s/bbgo/pkg/types" "github.com/sirupsen/logrus" - "github.com/wcharczuk/go-chart/v2" ) const ID = "harmonic" @@ -52,27 +46,145 @@ type Strategy struct { shark *SHARK - // plotting - bbgo.SourceSelector - priceLines *types.Queue - midPrice fixedpoint.Value - lock sync.RWMutex `ignore:"true"` - positionLock sync.RWMutex `ignore:"true"` - startTime time.Time - minutesCounter int - frameKLine *types.KLine - kline1m *types.KLine - CanvasPath string `json:"canvasPath"` - HLRangeWindow int `json:"hlRangeWindow"` - Window1m int `json:"window1m"` + AccountValueCalculator *bbgo.AccountValueCalculator - // This is not related to trade but for statistics graph generation - // Will deduct fee in percentage from every trade - GraphPNLDeductFee bool `json:"graphPNLDeductFee"` - GraphPNLPath string `json:"graphPNLPath"` - GraphCumPNLPath string `json:"graphCumPNLPath"` - // Whether to generate graph when shutdown - GenerateGraph bool `json:"generateGraph"` + // whether to draw graph or not by the end of backtest + DrawGraph bool `json:"drawGraph"` + GraphPNLPath string `json:"graphPNLPath"` + GraphCumPNLPath string `json:"graphCumPNLPath"` + + // for position + buyPrice float64 `persistence:"buy_price"` + sellPrice float64 `persistence:"sell_price"` + highestPrice float64 `persistence:"highest_price"` + lowestPrice float64 `persistence:"lowest_price"` + + // Accumulated profit report + AccumulatedProfitReport *AccumulatedProfitReport `json:"accumulatedProfitReport"` +} + +// AccumulatedProfitReport For accumulated profit report output +type AccumulatedProfitReport struct { + // AccumulatedProfitMAWindow Accumulated profit SMA window, in number of trades + AccumulatedProfitMAWindow int `json:"accumulatedProfitMAWindow"` + + // IntervalWindow interval window, in days + IntervalWindow int `json:"intervalWindow"` + + // NumberOfInterval How many intervals to output to TSV + NumberOfInterval int `json:"NumberOfInterval"` + + // TsvReportPath The path to output report to + TsvReportPath string `json:"tsvReportPath"` + + // AccumulatedDailyProfitWindow The window to sum up the daily profit, in days + AccumulatedDailyProfitWindow int `json:"accumulatedDailyProfitWindow"` + + // Accumulated profit + accumulatedProfit fixedpoint.Value + accumulatedProfitPerDay floats.Slice + previousAccumulatedProfit fixedpoint.Value + + // Accumulated profit MA + accumulatedProfitMA *indicator.SMA + accumulatedProfitMAPerDay floats.Slice + + // Daily profit + dailyProfit floats.Slice + + // Accumulated fee + accumulatedFee fixedpoint.Value + accumulatedFeePerDay floats.Slice + + // Win ratio + winRatioPerDay floats.Slice + + // Profit factor + profitFactorPerDay floats.Slice + + // Trade number + dailyTrades floats.Slice + accumulatedTrades int + previousAccumulatedTrades int +} + +func (r *AccumulatedProfitReport) Initialize() { + if r.AccumulatedProfitMAWindow <= 0 { + r.AccumulatedProfitMAWindow = 60 + } + if r.IntervalWindow <= 0 { + r.IntervalWindow = 7 + } + if r.AccumulatedDailyProfitWindow <= 0 { + r.AccumulatedDailyProfitWindow = 7 + } + if r.NumberOfInterval <= 0 { + r.NumberOfInterval = 1 + } + r.accumulatedProfitMA = &indicator.SMA{IntervalWindow: types.IntervalWindow{Interval: types.Interval1d, Window: r.AccumulatedProfitMAWindow}} +} + +func (r *AccumulatedProfitReport) RecordProfit(profit fixedpoint.Value) { + r.accumulatedProfit = r.accumulatedProfit.Add(profit) +} + +func (r *AccumulatedProfitReport) RecordTrade(fee fixedpoint.Value) { + r.accumulatedFee = r.accumulatedFee.Add(fee) + r.accumulatedTrades += 1 +} + +func (r *AccumulatedProfitReport) DailyUpdate(tradeStats *types.TradeStats) { + // Daily profit + r.dailyProfit.Update(r.accumulatedProfit.Sub(r.previousAccumulatedProfit).Float64()) + r.previousAccumulatedProfit = r.accumulatedProfit + + // Accumulated profit + r.accumulatedProfitPerDay.Update(r.accumulatedProfit.Float64()) + + // Accumulated profit MA + r.accumulatedProfitMA.Update(r.accumulatedProfit.Float64()) + r.accumulatedProfitMAPerDay.Update(r.accumulatedProfitMA.Last()) + + // Accumulated Fee + r.accumulatedFeePerDay.Update(r.accumulatedFee.Float64()) + + // Win ratio + r.winRatioPerDay.Update(tradeStats.WinningRatio.Float64()) + + // Profit factor + r.profitFactorPerDay.Update(tradeStats.ProfitFactor.Float64()) + + // Daily trades + r.dailyTrades.Update(float64(r.accumulatedTrades - r.previousAccumulatedTrades)) + r.previousAccumulatedTrades = r.accumulatedTrades +} + +// Output Accumulated profit report to a TSV file +func (r *AccumulatedProfitReport) Output(symbol string) { + if r.TsvReportPath != "" { + tsvwiter, err := tsv.AppendWriterFile(r.TsvReportPath) + if err != nil { + panic(err) + } + defer tsvwiter.Close() + // Output symbol, total acc. profit, acc. profit 60MA, interval acc. profit, fee, win rate, profit factor + _ = tsvwiter.Write([]string{"#", "Symbol", "accumulatedProfit", "accumulatedProfitMA", fmt.Sprintf("%dd profit", r.AccumulatedDailyProfitWindow), "accumulatedFee", "winRatio", "profitFactor", "60D trades"}) + for i := 0; i <= r.NumberOfInterval-1; i++ { + accumulatedProfit := r.accumulatedProfitPerDay.Index(r.IntervalWindow * i) + accumulatedProfitStr := fmt.Sprintf("%f", accumulatedProfit) + accumulatedProfitMA := r.accumulatedProfitMAPerDay.Index(r.IntervalWindow * i) + accumulatedProfitMAStr := fmt.Sprintf("%f", accumulatedProfitMA) + intervalAccumulatedProfit := r.dailyProfit.Tail(r.AccumulatedDailyProfitWindow+r.IntervalWindow*i).Sum() - r.dailyProfit.Tail(r.IntervalWindow*i).Sum() + intervalAccumulatedProfitStr := fmt.Sprintf("%f", intervalAccumulatedProfit) + accumulatedFee := fmt.Sprintf("%f", r.accumulatedFeePerDay.Index(r.IntervalWindow*i)) + winRatio := fmt.Sprintf("%f", r.winRatioPerDay.Index(r.IntervalWindow*i)) + profitFactor := fmt.Sprintf("%f", r.profitFactorPerDay.Index(r.IntervalWindow*i)) + trades := r.dailyTrades.Tail(60+r.IntervalWindow*i).Sum() - r.dailyTrades.Tail(r.IntervalWindow*i).Sum() + tradesStr := fmt.Sprintf("%f", trades) + + _ = tsvwiter.Write([]string{fmt.Sprintf("%d", i+1), symbol, accumulatedProfitStr, accumulatedProfitMAStr, intervalAccumulatedProfitStr, accumulatedFee, winRatio, profitFactor, tradesStr}) + } + } } func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) { @@ -93,6 +205,11 @@ func (s *Strategy) InstanceID() string { return fmt.Sprintf("%s:%s", ID, s.Symbol) } +func (s *Strategy) CalcAssetValue(price fixedpoint.Value) fixedpoint.Value { + balances := s.session.GetAccount().Balances() + return balances[s.Market.BaseCurrency].Total().Mul(price).Add(balances[s.Market.QuoteCurrency].Total()) +} + func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, session *bbgo.ExchangeSession) error { var instanceID = s.InstanceID() @@ -125,19 +242,81 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se s.session = session + // Set fee rate + if s.session.MakerFeeRate.Sign() > 0 || s.session.TakerFeeRate.Sign() > 0 { + s.Position.SetExchangeFeeRate(s.session.ExchangeName, types.ExchangeFee{ + MakerFeeRate: s.session.MakerFeeRate, + TakerFeeRate: s.session.TakerFeeRate, + }) + } + s.orderExecutor = bbgo.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) s.orderExecutor.BindEnvironment(s.Environment) s.orderExecutor.BindProfitStats(s.ProfitStats) s.orderExecutor.BindTradeStats(s.TradeStats) - profit := floats.Slice{1., 1.} - price, _ := s.session.LastPrice(s.Symbol) + // AccountValueCalculator + s.AccountValueCalculator = bbgo.NewAccountValueCalculator(s.session, s.Market.QuoteCurrency) + + // Accumulated profit report + if bbgo.IsBackTesting { + if s.AccumulatedProfitReport == nil { + s.AccumulatedProfitReport = &AccumulatedProfitReport{} + } + s.AccumulatedProfitReport.Initialize() + s.orderExecutor.TradeCollector().OnProfit(func(trade types.Trade, profit *types.Profit) { + if profit == nil { + return + } + + s.AccumulatedProfitReport.RecordProfit(profit.Profit) + }) + // s.orderExecutor.TradeCollector().OnTrade(func(trade types.Trade, profit fixedpoint.Value, netProfit fixedpoint.Value) { + // s.AccumulatedProfitReport.RecordTrade(trade.Fee) + // }) + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, types.Interval1d, func(kline types.KLine) { + s.AccumulatedProfitReport.DailyUpdate(s.TradeStats) + })) + } + + // For drawing + profitSlice := floats.Slice{1., 1.} + price, _ := session.LastPrice(s.Symbol) initAsset := s.CalcAssetValue(price).Float64() - cumProfit := floats.Slice{initAsset, initAsset} - s.orderExecutor.TradeCollector().OnTrade(func(trade types.Trade, _profit, netProfit fixedpoint.Value) { - profit.Update(netProfit.Float64()) - cumProfit.Update(s.CalcAssetValue(trade.Price).Float64()) + cumProfitSlice := floats.Slice{initAsset, initAsset} + + s.orderExecutor.TradeCollector().OnTrade(func(trade types.Trade, profit fixedpoint.Value, netProfit fixedpoint.Value) { + if bbgo.IsBackTesting { + s.AccumulatedProfitReport.RecordTrade(trade.Fee) + } + + // For drawing/charting + price := trade.Price.Float64() + if s.buyPrice > 0 { + profitSlice.Update(price / s.buyPrice) + cumProfitSlice.Update(s.CalcAssetValue(trade.Price).Float64()) + } else if s.sellPrice > 0 { + profitSlice.Update(s.sellPrice / price) + cumProfitSlice.Update(s.CalcAssetValue(trade.Price).Float64()) + } + if s.Position.IsDust(trade.Price) { + s.buyPrice = 0 + s.sellPrice = 0 + s.highestPrice = 0 + s.lowestPrice = 0 + } else if s.Position.IsLong() { + s.buyPrice = price + s.sellPrice = 0 + s.highestPrice = s.buyPrice + s.lowestPrice = 0 + } else { + s.sellPrice = price + s.buyPrice = 0 + s.highestPrice = 0 + s.lowestPrice = s.sellPrice + } }) + s.orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { bbgo.Sync(s) }) @@ -214,156 +393,5 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se } })) - bbgo.RegisterCommand("/draw", "Draw Indicators", func(reply interact.Reply) { - canvas := s.DrawIndicators(s.frameKLine.StartTime) - var buffer bytes.Buffer - if err := canvas.Render(chart.PNG, &buffer); err != nil { - log.WithError(err).Errorf("cannot render indicators in oneliner") - reply.Message(fmt.Sprintf("[error] cannot render indicators in harmonic: %v", err)) - return - } - bbgo.SendPhoto(&buffer) - }) - - bbgo.RegisterCommand("/pnl", "Draw PNL(%) per trade", func(reply interact.Reply) { - canvas := s.DrawPNL(&profit) - var buffer bytes.Buffer - if err := canvas.Render(chart.PNG, &buffer); err != nil { - log.WithError(err).Errorf("cannot render pnl in oneliner") - reply.Message(fmt.Sprintf("[error] cannot render pnl in harmonic: %v", err)) - return - } - bbgo.SendPhoto(&buffer) - }) - - bbgo.RegisterCommand("/cumpnl", "Draw Cumulative PNL(Quote)", func(reply interact.Reply) { - canvas := s.DrawCumPNL(&cumProfit) - var buffer bytes.Buffer - if err := canvas.Render(chart.PNG, &buffer); err != nil { - log.WithError(err).Errorf("cannot render cumpnl in oneliner") - reply.Message(fmt.Sprintf("[error] canot render cumpnl in harmonic: %v", err)) - return - } - bbgo.SendPhoto(&buffer) - }) - - bbgo.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { - defer wg.Done() - - _, _ = fmt.Fprintln(os.Stderr, s.TradeStats.String()) - _ = s.orderExecutor.GracefulCancel(ctx) - }) - return nil } - -func (s *Strategy) CalcAssetValue(price fixedpoint.Value) fixedpoint.Value { - balances := s.session.GetAccount().Balances() - return balances[s.Market.BaseCurrency].Total().Mul(price).Add(balances[s.Market.QuoteCurrency].Total()) -} - -func (s *Strategy) DrawPNL(profit types.Series) *types.Canvas { - canvas := types.NewCanvas(s.InstanceID()) - //log.Errorf("pnl Highest: %f, Lowest: %f", types.Highest(profit, profit.Length()), types.Lowest(profit, profit.Length())) - length := profit.Length() - if s.GraphPNLDeductFee { - canvas.PlotRaw("pnl (with Fee Deducted)", profit, length) - } else { - canvas.PlotRaw("pnl", profit, length) - } - canvas.YAxis = chart.YAxis{ - ValueFormatter: func(v interface{}) string { - if vf, isFloat := v.(float64); isFloat { - return fmt.Sprintf("%.4f", vf) - } - return "" - }, - } - canvas.PlotRaw("1", types.NumberSeries(1), length) - return canvas -} - -func (s *Strategy) DrawCumPNL(cumProfit types.Series) *types.Canvas { - canvas := types.NewCanvas(s.InstanceID()) - canvas.PlotRaw("cumulative pnl", cumProfit, cumProfit.Length()) - canvas.YAxis = chart.YAxis{ - ValueFormatter: func(v interface{}) string { - if vf, isFloat := v.(float64); isFloat { - return fmt.Sprintf("%.4f", vf) - } - return "" - }, - } - return canvas -} - -func (s *Strategy) initIndicators(store *bbgo.SerialMarketDataStore) error { - - klines, ok := store.KLinesOfInterval(s.Interval) - klinesLength := len(*klines) - if !ok || klinesLength == 0 { - return errors.New("klines not exists") - } - if s.frameKLine != nil && klines != nil { - s.frameKLine.Set(&(*klines)[len(*klines)-1]) - } - klines, ok = store.KLinesOfInterval(types.Interval1m) - klinesLength = len(*klines) - if !ok || klinesLength == 0 { - return errors.New("klines not exists") - } - if s.kline1m != nil && klines != nil { - s.kline1m.Set(&(*klines)[len(*klines)-1]) - } - s.startTime = s.kline1m.StartTime.Time().Add(s.kline1m.Interval.Duration()) - return nil -} - -func (s *Strategy) DrawIndicators(time types.Time) *types.Canvas { - canvas := types.NewCanvas(s.InstanceID(), s.Interval) - Length := s.priceLines.Length() - if Length > 300 { - Length = 300 - } - log.Infof("draw indicators with %d data", Length) - mean := s.priceLines.Mean(Length) - - canvas.Plot("zero", types.NumberSeries(mean), time, Length) - canvas.Plot("price", s.priceLines, time, Length) - return canvas -} - -func (s *Strategy) Draw(time types.Time, profit types.Series, cumProfit types.Series) { - canvas := s.DrawIndicators(time) - f, err := os.Create(s.CanvasPath) - if err != nil { - log.WithError(err).Errorf("cannot create on %s", s.CanvasPath) - return - } - defer f.Close() - if err := canvas.Render(chart.PNG, f); err != nil { - log.WithError(err).Errorf("cannot render in harmonic") - } - - canvas = s.DrawPNL(profit) - f, err = os.Create(s.GraphPNLPath) - if err != nil { - log.WithError(err).Errorf("open pnl") - return - } - defer f.Close() - if err := canvas.Render(chart.PNG, f); err != nil { - log.WithError(err).Errorf("render pnl") - } - - canvas = s.DrawCumPNL(cumProfit) - f, err = os.Create(s.GraphCumPNLPath) - if err != nil { - log.WithError(err).Errorf("open cumpnl") - return - } - defer f.Close() - if err := canvas.Render(chart.PNG, f); err != nil { - log.WithError(err).Errorf("render cumpnl") - } -}