package backtest import ( "encoding/json" "fmt" "io/ioutil" "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"` InitialTotalBalances types.BalanceMap `json:"initialTotalBalances"` FinalTotalBalances types.BalanceMap `json:"finalTotalBalances"` SymbolReports []SessionSymbolReport `json:"symbolReports,omitempty"` Manifests Manifests `json:"manifests,omitempty"` } // 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"` 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"` } func (r *SessionSymbolReport) Print(wantBaseAssetBaseline bool) { color.Green("%s %s PROFIT AND LOSS REPORT", r.Exchange, r.Symbol) color.Green("===============================================") r.PnL.Print() initQuoteAsset := inQuoteAsset(r.InitialBalances, r.Market, r.StartPrice) finalQuoteAsset := inQuoteAsset(r.FinalBalances, r.Market, r.LastPrice) 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 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 := filepath.Join(outputDirectory, "index.json") if err := util.WriteJsonFile(indexFile, reportIndex); err != nil { return err } return nil } func LoadReportIndex(outputDirectory string) (*ReportIndex, error) { var reportIndex ReportIndex indexFile := filepath.Join(outputDirectory, "index.json") if _, err := os.Stat(indexFile); err == nil { o, err := ioutil.ReadFile(indexFile) if err != nil { return nil, err } if err := json.Unmarshal(o, &reportIndex); err != nil { return nil, err } } return &reportIndex, nil } func AddReportIndexRun(outputDirectory string, run Run) error { // append report index lockFile := filepath.Join(outputDirectory, ".report.lock") fileLock := flock.New(lockFile) err := fileLock.Lock() if err != nil { return err } defer func() { if err := fileLock.Unlock(); err != nil { log.WithError(err).Errorf("report index file lock error: %s", lockFile) } if err := os.Remove(lockFile); err != nil { log.WithError(err).Errorf("can not remove lock file: %s", lockFile) } }() reportIndex, err := LoadReportIndex(outputDirectory) if err != nil { return err } reportIndex.Runs = append(reportIndex.Runs, run) return WriteReportIndex(outputDirectory, 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()) }