package backtest import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "time" "github.com/fatih/color" "github.com/gofrs/flock" "github.com/c9s/bbgo/pkg/accounting/pnl" "github.com/c9s/bbgo/pkg/bbgo" "github.com/c9s/bbgo/pkg/fixedpoint" "github.com/c9s/bbgo/pkg/types" "github.com/c9s/bbgo/pkg/util" ) type Run struct { ID string `json:"id"` Config *bbgo.Config `json:"config"` Time time.Time `json:"time"` } type ReportIndex struct { Runs []Run `json:"runs,omitempty"` } // SummaryReport is the summary of the back-test session type SummaryReport struct { StartTime time.Time `json:"startTime"` EndTime time.Time `json:"endTime"` Sessions []string `json:"sessions"` Symbols []string `json:"symbols"` Intervals []types.Interval `json:"intervals"` InitialTotalBalances types.BalanceMap `json:"initialTotalBalances"` FinalTotalBalances types.BalanceMap `json:"finalTotalBalances"` InitialEquityValue fixedpoint.Value `json:"initialEquityValue"` FinalEquityValue fixedpoint.Value `json:"finalEquityValue"` // TotalProfit is the profit aggregated from the symbol reports TotalProfit fixedpoint.Value `json:"totalProfit,omitempty"` TotalUnrealizedProfit fixedpoint.Value `json:"totalUnrealizedProfit,omitempty"` TotalGrossProfit fixedpoint.Value `json:"totalGrossProfit,omitempty"` TotalGrossLoss fixedpoint.Value `json:"totalGrossLoss,omitempty"` SymbolReports []SessionSymbolReport `json:"symbolReports,omitempty"` Manifests Manifests `json:"manifests,omitempty"` } func ReadSummaryReport(filename string) (*SummaryReport, error) { o, err := os.ReadFile(filename) if err != nil { return nil, err } var report SummaryReport err = json.Unmarshal(o, &report) return &report, err } // SessionSymbolReport is the report per exchange session // trades are merged, collected and re-calculated type SessionSymbolReport struct { Exchange types.ExchangeName `json:"exchange"` Symbol string `json:"symbol,omitempty"` Intervals []types.Interval `json:"intervals,omitempty"` Subscriptions []types.Subscription `json:"subscriptions"` Market types.Market `json:"market"` LastPrice fixedpoint.Value `json:"lastPrice,omitempty"` StartPrice fixedpoint.Value `json:"startPrice,omitempty"` PnL *pnl.AverageCostPnLReport `json:"pnl,omitempty"` InitialBalances types.BalanceMap `json:"initialBalances,omitempty"` FinalBalances types.BalanceMap `json:"finalBalances,omitempty"` Manifests Manifests `json:"manifests,omitempty"` Sharpe fixedpoint.Value `json:"sharpeRatio"` Sortino fixedpoint.Value `json:"sortinoRatio"` ProfitFactor fixedpoint.Value `json:"profitFactor"` WinningRatio fixedpoint.Value `json:"winningRatio"` } func (r *SessionSymbolReport) InitialEquityValue() fixedpoint.Value { return InQuoteAsset(r.InitialBalances, r.Market, r.StartPrice) } func (r *SessionSymbolReport) FinalEquityValue() fixedpoint.Value { return InQuoteAsset(r.FinalBalances, r.Market, r.LastPrice) } func (r *SessionSymbolReport) Print(wantBaseAssetBaseline bool) { color.Green("%s %s PROFIT AND LOSS REPORT", r.Exchange, r.Symbol) color.Green("===============================================") r.PnL.Print() initQuoteAsset := r.InitialEquityValue() finalQuoteAsset := r.FinalEquityValue() color.Green("INITIAL ASSET IN %s ~= %s %s (1 %s = %v)", r.Market.QuoteCurrency, r.Market.FormatQuantity(initQuoteAsset), r.Market.QuoteCurrency, r.Market.BaseCurrency, r.StartPrice) color.Green("FINAL ASSET IN %s ~= %s %s (1 %s = %v)", r.Market.QuoteCurrency, r.Market.FormatQuantity(finalQuoteAsset), r.Market.QuoteCurrency, r.Market.BaseCurrency, r.LastPrice) if r.PnL.Profit.Sign() > 0 { color.Green("REALIZED PROFIT: +%v %s", r.PnL.Profit, r.Market.QuoteCurrency) } else { color.Red("REALIZED PROFIT: %v %s", r.PnL.Profit, r.Market.QuoteCurrency) } if r.PnL.UnrealizedProfit.Sign() > 0 { color.Green("UNREALIZED PROFIT: +%v %s", r.PnL.UnrealizedProfit, r.Market.QuoteCurrency) } else { color.Red("UNREALIZED PROFIT: %v %s", r.PnL.UnrealizedProfit, r.Market.QuoteCurrency) } if finalQuoteAsset.Compare(initQuoteAsset) > 0 { color.Green("ASSET INCREASED: +%v %s (+%s)", finalQuoteAsset.Sub(initQuoteAsset), r.Market.QuoteCurrency, finalQuoteAsset.Sub(initQuoteAsset).Div(initQuoteAsset).FormatPercentage(2)) } else { color.Red("ASSET DECREASED: %v %s (%s)", finalQuoteAsset.Sub(initQuoteAsset), r.Market.QuoteCurrency, finalQuoteAsset.Sub(initQuoteAsset).Div(initQuoteAsset).FormatPercentage(2)) } if r.Sharpe.Sign() > 0 { color.Green("REALIZED SHARPE RATIO: %s", r.Sharpe.FormatString(4)) } else { color.Red("REALIZED SHARPE RATIO: %s", r.Sharpe.FormatString(4)) } if r.Sortino.Sign() > 0 { color.Green("REALIZED SORTINO RATIO: %s", r.Sortino.FormatString(4)) } else { color.Red("REALIZED SORTINO RATIO: %s", r.Sortino.FormatString(4)) } if wantBaseAssetBaseline { if r.LastPrice.Compare(r.StartPrice) > 0 { color.Green("%s BASE ASSET PERFORMANCE: +%s (= (%s - %s) / %s)", r.Market.BaseCurrency, r.LastPrice.Sub(r.StartPrice).Div(r.StartPrice).FormatPercentage(2), r.LastPrice.FormatString(2), r.StartPrice.FormatString(2), r.StartPrice.FormatString(2)) } else { color.Red("%s BASE ASSET PERFORMANCE: %s (= (%s - %s) / %s)", r.Market.BaseCurrency, r.LastPrice.Sub(r.StartPrice).Div(r.StartPrice).FormatPercentage(2), r.LastPrice.FormatString(2), r.StartPrice.FormatString(2), r.StartPrice.FormatString(2)) } } } const SessionTimeFormat = "2006-01-02T15_04" // FormatSessionName returns the back-test session name func FormatSessionName(sessions []string, symbols []string, startTime, endTime time.Time) string { return fmt.Sprintf("%s_%s_%s-%s", strings.Join(sessions, "-"), strings.Join(symbols, "-"), startTime.Format(SessionTimeFormat), endTime.Format(SessionTimeFormat), ) } func WriteReportIndex(outputDirectory string, reportIndex *ReportIndex) error { indexFile := getReportIndexPath(outputDirectory) indexLock := flock.New(indexFile) if err := indexLock.Lock(); err != nil { log.WithError(err).Errorf("report index file lock error while write report: %s", err) return err } defer func() { if err := indexLock.Unlock(); err != nil { log.WithError(err).Errorf("report index file unlock error while write report: %s", err) } }() return writeReportIndexLocked(outputDirectory, reportIndex) } func LoadReportIndex(outputDirectory string) (*ReportIndex, error) { indexFile := getReportIndexPath(outputDirectory) indexLock := flock.New(indexFile) if err := indexLock.Lock(); err != nil { log.WithError(err).Errorf("report index file lock error while load report: %s", err) return nil, err } defer func() { if err := indexLock.Unlock(); err != nil { log.WithError(err).Errorf("report index file unlock error while load report: %s", err) } }() return loadReportIndexLocked(indexFile) } func AddReportIndexRun(outputDirectory string, run Run) error { // append report index indexFile := getReportIndexPath(outputDirectory) indexLock := flock.New(indexFile) if err := indexLock.Lock(); err != nil { log.WithError(err).Errorf("report index file lock error: %s", err) return err } defer func() { if err := indexLock.Unlock(); err != nil { log.WithError(err).Errorf("report index file unlock error: %s", err) } }() reportIndex, err := loadReportIndexLocked(indexFile) if err != nil { return err } reportIndex.Runs = append(reportIndex.Runs, run) return writeReportIndexLocked(indexFile, reportIndex) } // InQuoteAsset converts all balances in quote asset func InQuoteAsset(balances types.BalanceMap, market types.Market, price fixedpoint.Value) fixedpoint.Value { quote := balances[market.QuoteCurrency] base := balances[market.BaseCurrency] return base.Total().Mul(price).Add(quote.Total()) } func getReportIndexPath(outputDirectory string) string { return filepath.Join(outputDirectory, "index.json") } // writeReportIndexLocked must be protected by file lock func writeReportIndexLocked(indexFilePath string, reportIndex *ReportIndex) error { if err := util.WriteJsonFile(indexFilePath, reportIndex); err != nil { return err } return nil } // loadReportIndexLocked must be protected by file lock func loadReportIndexLocked(indexFilePath string) (*ReportIndex, error) { var reportIndex ReportIndex if fileInfo, err := os.Stat(indexFilePath); err != nil { return nil, err } else if fileInfo.Size() != 0 { o, err := os.ReadFile(indexFilePath) if err != nil { return nil, err } if err := json.Unmarshal(o, &reportIndex); err != nil { return nil, err } } return &reportIndex, nil }