diff --git a/backtest/README.md b/backtest/README.md new file mode 100644 index 0000000..fffa95a --- /dev/null +++ b/backtest/README.md @@ -0,0 +1,201 @@ + + +# backtest + +```go +import "github.com/cinar/indicator/v2/backtest" +``` + +Package backtest contains the backtest functions. + +This package belongs to the Indicator project. Indicator is a Golang module that supplies a variety of technical indicators, strategies, and a backtesting framework for analysis. + +### License + +``` +Copyright (c) 2021-2024 Onur Cinar. +The source code is provided under GNU AGPLv3 License. +https://github.com/cinar/indicator +``` + +### Disclaimer + +The information provided on this project is strictly for informational purposes and is not to be construed as advice or solicitation to buy or sell any security. + +## Index + +- [Constants](<#constants>) +- [type Backtest](<#Backtest>) + - [func NewBacktest\(repository asset.Repository, report Report\) \*Backtest](<#NewBacktest>) + - [func \(b \*Backtest\) Run\(\) error](<#Backtest.Run>) +- [type HTMLReport](<#HTMLReport>) + - [func NewHTMLReport\(outputDir string\) \*HTMLReport](<#NewHTMLReport>) + - [func \(h \*HTMLReport\) AssetBegin\(name string, strategies \[\]strategy.Strategy\) error](<#HTMLReport.AssetBegin>) + - [func \(h \*HTMLReport\) AssetEnd\(name string\) error](<#HTMLReport.AssetEnd>) + - [func \(h \*HTMLReport\) Begin\(assetNames \[\]string, \_ \[\]strategy.Strategy\) error](<#HTMLReport.Begin>) + - [func \(h \*HTMLReport\) End\(\) error](<#HTMLReport.End>) + - [func \(h \*HTMLReport\) Write\(assetName string, currentStrategy strategy.Strategy, snapshots \<\-chan \*asset.Snapshot, actions \<\-chan strategy.Action, outcomes \<\-chan float64\) error](<#HTMLReport.Write>) +- [type Report](<#Report>) + + +## Constants + + + +```go +const ( + // DefaultBacktestWorkers is the default number of backtest workers. + DefaultBacktestWorkers = 1 + + // DefaultLastDays is the default number of days backtest should go back. + DefaultLastDays = 365 +) +``` + + + +```go +const ( + // DefaultWriteStrategyReports is the default state of writing individual strategy reports. + DefaultWriteStrategyReports = true +) +``` + + +## type [Backtest]() + +Backtest function rigorously evaluates the potential performance of the specified strategies applied to a defined set of assets. It generates comprehensive visual representations for each strategy\-asset pairing. + +```go +type Backtest struct { + + // Names is the names of the assets to backtest. + Names []string + + // Strategies is the list of strategies to apply. + Strategies []strategy.Strategy + + // Workers is the number of concurrent workers. + Workers int + + // LastDays is the number of days backtest should go back. + LastDays int + // contains filtered or unexported fields +} +``` + + +### func [NewBacktest]() + +```go +func NewBacktest(repository asset.Repository, report Report) *Backtest +``` + +NewBacktest function initializes a new backtest instance. + + +### func \(\*Backtest\) [Run]() + +```go +func (b *Backtest) Run() error +``` + +Run executes a comprehensive performance evaluation of the designated strategies, applied to a specified collection of assets. In the absence of explicitly defined assets, encompasses all assets within the repository. Likewise, in the absence of explicitly defined strategies, encompasses all the registered strategies. + + +## type [HTMLReport]() + +HTMLReport is the backtest HTML report interface. + +```go +type HTMLReport struct { + Report + + // WriteStrategyReports indicates whether the individual strategy reports should be generated. + WriteStrategyReports bool + + // DateFormat is the date format that is used in the reports. + DateFormat string + // contains filtered or unexported fields +} +``` + + +### func [NewHTMLReport]() + +```go +func NewHTMLReport(outputDir string) *HTMLReport +``` + +NewHTMLReport initializes a new HTML report instance. + + +### func \(\*HTMLReport\) [AssetBegin]() + +```go +func (h *HTMLReport) AssetBegin(name string, strategies []strategy.Strategy) error +``` + +AssetBegin is called when backtesting for the given asset begins. + + +### func \(\*HTMLReport\) [AssetEnd]() + +```go +func (h *HTMLReport) AssetEnd(name string) error +``` + +AssetEnd is called when backtesting for the given asset ends. + + +### func \(\*HTMLReport\) [Begin]() + +```go +func (h *HTMLReport) Begin(assetNames []string, _ []strategy.Strategy) error +``` + +Begin is called when the backtest starts. + + +### func \(\*HTMLReport\) [End]() + +```go +func (h *HTMLReport) End() error +``` + +End is called when the backtest ends. + + +### func \(\*HTMLReport\) [Write]() + +```go +func (h *HTMLReport) Write(assetName string, currentStrategy strategy.Strategy, snapshots <-chan *asset.Snapshot, actions <-chan strategy.Action, outcomes <-chan float64) error +``` + +Write writes the given strategy actions and outomes to the report. + + +## type [Report]() + +Report is the backtest report interface. + +```go +type Report interface { + // Begin is called when the backtest begins. + Begin(assetNames []string, strategies []strategy.Strategy) error + + // AssetBegin is called when backtesting for the given asset begins. + AssetBegin(name string, strategies []strategy.Strategy) error + + // Write writes the given strategy actions and outomes to the report. + Write(assetName string, currentStrategy strategy.Strategy, snapshots <-chan *asset.Snapshot, actions <-chan strategy.Action, outcomes <-chan float64) error + + // AssetEnd is called when backtesting for the given asset ends. + AssetEnd(name string) error + + // End is called when the backtest ends. + End() error +} +``` + +Generated by [gomarkdoc]() diff --git a/backtest/backtest.go b/backtest/backtest.go new file mode 100644 index 0000000..fb67659 --- /dev/null +++ b/backtest/backtest.go @@ -0,0 +1,167 @@ +// Package backtest contains the backtest functions. +// +// This package belongs to the Indicator project. Indicator is +// a Golang module that supplies a variety of technical +// indicators, strategies, and a backtesting framework +// for analysis. +// +// # License +// +// Copyright (c) 2021-2024 Onur Cinar. +// The source code is provided under GNU AGPLv3 License. +// https://github.com/cinar/indicator +// +// # Disclaimer +// +// The information provided on this project is strictly for +// informational purposes and is not to be construed as +// advice or solicitation to buy or sell any security. +package backtest + +import ( + "fmt" + "log" + "sync" + "time" + + "github.com/cinar/indicator/v2/asset" + "github.com/cinar/indicator/v2/helper" + "github.com/cinar/indicator/v2/strategy" +) + +const ( + // DefaultBacktestWorkers is the default number of backtest workers. + DefaultBacktestWorkers = 1 + + // DefaultLastDays is the default number of days backtest should go back. + DefaultLastDays = 365 +) + +// Backtest function rigorously evaluates the potential performance of the +// specified strategies applied to a defined set of assets. It generates +// comprehensive visual representations for each strategy-asset pairing. +type Backtest struct { + // repository is the repository to retrieve the assets from. + repository asset.Repository + + // report is the report writer for the backtest. + report Report + + // Names is the names of the assets to backtest. + Names []string + + // Strategies is the list of strategies to apply. + Strategies []strategy.Strategy + + // Workers is the number of concurrent workers. + Workers int + + // LastDays is the number of days backtest should go back. + LastDays int +} + +// NewBacktest function initializes a new backtest instance. +func NewBacktest(repository asset.Repository, report Report) *Backtest { + return &Backtest{ + repository: repository, + report: report, + Names: []string{}, + Strategies: []strategy.Strategy{}, + Workers: DefaultBacktestWorkers, + LastDays: DefaultLastDays, + } +} + +// Run executes a comprehensive performance evaluation of the designated strategies, +// applied to a specified collection of assets. In the absence of explicitly defined +// assets, encompasses all assets within the repository. Likewise, in the absence of +// explicitly defined strategies, encompasses all the registered strategies. +func (b *Backtest) Run() error { + // When asset names are absent, considers all assets within the provided repository for evaluation. + if len(b.Names) == 0 { + assets, err := b.repository.Assets() + if err != nil { + return err + } + + b.Names = assets + } + + // When strategies are absent, considers all strategies. + if len(b.Strategies) == 0 { + b.Strategies = []strategy.Strategy{ + strategy.NewBuyAndHoldStrategy(), + } + } + + // Begin report. + err := b.report.Begin(b.Names, b.Strategies) + if err != nil { + return fmt.Errorf("unable to begin report: %w", err) + } + + // Run the backtest workers. + names := helper.SliceToChan(b.Names) + wg := &sync.WaitGroup{} + + for i := 0; i < b.Workers; i++ { + wg.Add(1) + go b.worker(names, wg) + } + + // Wait for all workers to finish. + wg.Wait() + + // End report. + err = b.report.End() + if err != nil { + return fmt.Errorf("unable to end report: %w", err) + } + + return nil +} + +// worker is a backtesting worker that concurrently executes backtests for individual +// assets. It receives asset names from the provided channel, and performs backtests +// using the given strategies. +func (b *Backtest) worker(names <-chan string, wg *sync.WaitGroup) { + defer wg.Done() + + since := time.Now().AddDate(0, 0, -b.LastDays) + + for name := range names { + log.Printf("Backtesting %s...", name) + snapshots, err := b.repository.GetSince(name, since) + if err != nil { + log.Printf("Unable to retrieve the snapshots for %s: %v", name, err) + continue + } + + // We don't expect the snapshots to be a stream during backtesting. + snapshotsSlice := helper.ChanToSlice(snapshots) + + // Backtesting asset has begun. + err = b.report.AssetBegin(name, b.Strategies) + if err != nil { + log.Printf("Unable to asset begin for %s: %v", name, err) + continue + } + + // Backtest strategies on the given asset. + for _, currentStrategy := range b.Strategies { + snapshotsSplice := helper.Duplicate(helper.SliceToChan(snapshotsSlice), 2) + + actions, outcomes := strategy.ComputeWithOutcome(currentStrategy, snapshotsSplice[0]) + err = b.report.Write(name, currentStrategy, snapshotsSplice[1], actions, outcomes) + if err != nil { + log.Printf("Unable to report write for %s: %v", name, err) + } + } + + // Backtesting asset had ended + err = b.report.AssetEnd(name) + if err != nil { + log.Printf("Unable to asset end for %s: %v", name, err) + } + } +} diff --git a/strategy/backtest_test.go b/backtest/backtest_test.go similarity index 77% rename from strategy/backtest_test.go rename to backtest/backtest_test.go index d99e8af..33ccd9b 100644 --- a/strategy/backtest_test.go +++ b/backtest/backtest_test.go @@ -2,14 +2,14 @@ // The source code is provided under GNU AGPLv3 License. // https://github.com/cinar/indicator -package strategy_test +package backtest_test import ( "os" "testing" "github.com/cinar/indicator/v2/asset" - "github.com/cinar/indicator/v2/strategy" + "github.com/cinar/indicator/v2/backtest" "github.com/cinar/indicator/v2/strategy/trend" ) @@ -23,7 +23,8 @@ func TestBacktest(t *testing.T) { defer os.RemoveAll(outputDir) - backtest := strategy.NewBacktest(repository, outputDir) + htmlReport := backtest.NewHTMLReport(outputDir) + backtest := backtest.NewBacktest(repository, htmlReport) backtest.Names = append(backtest.Names, "brk-b") backtest.Strategies = append(backtest.Strategies, trend.NewApoStrategy()) @@ -43,7 +44,8 @@ func TestBacktestAllAssetsAndStrategies(t *testing.T) { defer os.RemoveAll(outputDir) - backtest := strategy.NewBacktest(repository, outputDir) + htmlReport := backtest.NewHTMLReport(outputDir) + backtest := backtest.NewBacktest(repository, htmlReport) err = backtest.Run() if err != nil { @@ -61,7 +63,8 @@ func TestBacktestNonExistingAsset(t *testing.T) { defer os.RemoveAll(outputDir) - backtest := strategy.NewBacktest(repository, outputDir) + htmlReport := backtest.NewHTMLReport(outputDir) + backtest := backtest.NewBacktest(repository, htmlReport) backtest.Names = append(backtest.Names, "non_existing") err = backtest.Run() diff --git a/strategy/backtest_asset_report.tmpl b/backtest/html_asset_report.tmpl similarity index 100% rename from strategy/backtest_asset_report.tmpl rename to backtest/html_asset_report.tmpl diff --git a/backtest/html_report.go b/backtest/html_report.go new file mode 100644 index 0000000..6ea4dc2 --- /dev/null +++ b/backtest/html_report.go @@ -0,0 +1,262 @@ +// Copyright (c) 2021-2024 Onur Cinar. +// The source code is provided under GNU AGPLv3 License. +// https://github.com/cinar/indicator + +package backtest + +import ( + // Go embed report template. + _ "embed" + "fmt" + "log" + "os" + "path" + "path/filepath" + "slices" + "text/template" + "time" + + "github.com/cinar/indicator/v2/asset" + "github.com/cinar/indicator/v2/helper" + "github.com/cinar/indicator/v2/strategy" +) + +const ( + // DefaultWriteStrategyReports is the default state of writing individual strategy reports. + DefaultWriteStrategyReports = true +) + +//go:embed "html_report.tmpl" +var htmlReportTmpl string + +//go:embed "html_asset_report.tmpl" +var htmlAssetReportTmpl string + +// HTMLReport is the backtest HTML report interface. +type HTMLReport struct { + Report + + // outputDir is the output directory for the generated reports. + outputDir string + + // assetResults is the mapping from the asset name to strategy results. + assetResults map[string][]*htmlReportResult + + // bestResults is the best results for each asset. + bestResults []*htmlReportResult + + // WriteStrategyReports indicates whether the individual strategy reports should be generated. + WriteStrategyReports bool + + // DateFormat is the date format that is used in the reports. + DateFormat string +} + +// htmlReportResult encapsulates the outcome of running a strategy. +type htmlReportResult struct { + // AssetName is the name of the asset. + AssetName string + + // StrategyName is the name of the strategy. + StrategyName string + + // Action is the last recommended action by the strategy. + Action strategy.Action + + // Since indicates how long the current action recommendation has been in effect. + Since int + + // Outcome is the effectiveness of applying the recommended actions. + Outcome float64 + + // Transactions is the number of transactions made by the strategy. + Transactions int +} + +// NewHTMLReport initializes a new HTML report instance. +func NewHTMLReport(outputDir string) *HTMLReport { + return &HTMLReport{ + outputDir: outputDir, + assetResults: make(map[string][]*htmlReportResult), + WriteStrategyReports: DefaultWriteStrategyReports, + DateFormat: helper.DefaultReportDateFormat, + } +} + +// Begin is called when the backtest starts. +func (h *HTMLReport) Begin(assetNames []string, _ []strategy.Strategy) error { + // Make sure that output directory exists. + err := os.MkdirAll(h.outputDir, 0o700) + if err != nil { + return fmt.Errorf("unable to make the output directory: %w", err) + } + + h.bestResults = make([]*htmlReportResult, 0, len(assetNames)) + + return nil +} + +// AssetBegin is called when backtesting for the given asset begins. +func (h *HTMLReport) AssetBegin(name string, strategies []strategy.Strategy) error { + _, ok := h.assetResults[name] + if ok { + return fmt.Errorf("asset has already begun: %s", name) + } + + h.assetResults[name] = make([]*htmlReportResult, 0, len(strategies)) + + return nil +} + +// Write writes the given strategy actions and outomes to the report. +func (h *HTMLReport) Write(assetName string, currentStrategy strategy.Strategy, snapshots <-chan *asset.Snapshot, actions <-chan strategy.Action, outcomes <-chan float64) error { + actionsSplice := helper.Duplicate(actions, 3) + + actions = helper.Last(actionsSplice[0], 1) + sinces := helper.Last(helper.Since[strategy.Action, int](actionsSplice[1]), 1) + outcomes = helper.Last(outcomes, 1) + transactions := helper.Last(strategy.CountTransactions(actionsSplice[2]), 1) + + // Generate inidividual strategy report. + if h.WriteStrategyReports { + report := currentStrategy.Report(snapshots) + report.DateFormat = h.DateFormat + + reportFile := h.strategyReportFileName(assetName, currentStrategy.Name()) + + err := report.WriteToFile(path.Join(h.outputDir, reportFile)) + if err != nil { + return fmt.Errorf("unable to write report for %s (%v)", assetName, err) + } + } else { + go helper.Drain(snapshots) + } + + // Get asset strategy results. + results, ok := h.assetResults[assetName] + if !ok { + return fmt.Errorf("asset has not begun: %s", assetName) + } + + // Append current strategy result for the asset. + h.assetResults[assetName] = append(results, &htmlReportResult{ + AssetName: assetName, + StrategyName: currentStrategy.Name(), + Action: <-actions, + Since: <-sinces, + Outcome: <-outcomes * 100, + Transactions: <-transactions, + }) + + return nil +} + +// AssetEnd is called when backtesting for the given asset ends. +func (h *HTMLReport) AssetEnd(name string) error { + results, ok := h.assetResults[name] + if !ok { + return fmt.Errorf("asset has not begun: %s", name) + } + + delete(h.assetResults, name) + + // Sort the backtest results by the outcomes. + slices.SortFunc(results, func(a, b *htmlReportResult) int { + return int(b.Outcome - a.Outcome) + }) + + bestResult := results[0] + + // Report the best result for the current asset. + log.Printf("Best outcome for %s is %.2f%% with %s.", name, bestResult.Outcome, bestResult.StrategyName) + h.bestResults = append(h.bestResults, bestResult) + + // Write the asset report. + err := h.writeAssetReport(name, results) + if err != nil { + return fmt.Errorf("unable to write report for %s: %w", name, err) + } + + return nil +} + +// End is called when the backtest ends. +func (h *HTMLReport) End() error { + // Sort the best results by the outcomes. + slices.SortFunc(h.bestResults, func(a, b *htmlReportResult) int { + return int(b.Outcome - a.Outcome) + }) + + return h.writeReport() +} + +// strategyReportFileName defines the HTML report file name for the given asset and strategy. +func (*HTMLReport) strategyReportFileName(assetName, strategyName string) string { + return fmt.Sprintf("%s - %s.html", assetName, strategyName) +} + +// writeAssetReport generates a detailed report for the asset, summarizing the backtest results. +func (h *HTMLReport) writeAssetReport(name string, results []*htmlReportResult) error { + type Model struct { + AssetName string + Results []*htmlReportResult + GeneratedOn string + } + + model := Model{ + AssetName: name, + Results: results, + GeneratedOn: time.Now().String(), + } + + file, err := os.Create(filepath.Join(h.outputDir, fmt.Sprintf("%s.html", name))) + if err != nil { + return fmt.Errorf("unable to open asset report file for %s: %w", name, err) + } + + defer helper.CloseAndLogError(file, "unable to close asset report file") + + tmpl, err := template.New("report").Parse(htmlAssetReportTmpl) + if err != nil { + return fmt.Errorf("unable to initialize asset report template: %w", err) + } + + err = tmpl.Execute(file, model) + if err != nil { + return fmt.Errorf("unable to execute asset report template: %w", err) + } + + return nil +} + +// writeReport generates a detailed report for the best results for all the assets. +func (h *HTMLReport) writeReport() error { + type Model struct { + Results []*htmlReportResult + GeneratedOn string + } + + model := Model{ + Results: h.bestResults, + GeneratedOn: time.Now().String(), + } + + file, err := os.Create(filepath.Join(h.outputDir, "index.html")) + if err != nil { + return fmt.Errorf("unable to open main report file: %w", err) + } + + defer helper.CloseAndLogError(file, "unable to close main report file") + + tmpl, err := template.New("report").Parse(htmlReportTmpl) + if err != nil { + return fmt.Errorf("unable to execute main report template: %w", err) + } + + err = tmpl.Execute(file, model) + if err != nil { + return fmt.Errorf("unable to execute main report template: %w", err) + } + + return nil +} diff --git a/strategy/backtest_report.tmpl b/backtest/html_report.tmpl similarity index 100% rename from strategy/backtest_report.tmpl rename to backtest/html_report.tmpl diff --git a/backtest/report.go b/backtest/report.go new file mode 100644 index 0000000..26d25c5 --- /dev/null +++ b/backtest/report.go @@ -0,0 +1,28 @@ +// Copyright (c) 2021-2024 Onur Cinar. +// The source code is provided under GNU AGPLv3 License. +// https://github.com/cinar/indicator + +package backtest + +import ( + "github.com/cinar/indicator/v2/asset" + "github.com/cinar/indicator/v2/strategy" +) + +// Report is the backtest report interface. +type Report interface { + // Begin is called when the backtest begins. + Begin(assetNames []string, strategies []strategy.Strategy) error + + // AssetBegin is called when backtesting for the given asset begins. + AssetBegin(name string, strategies []strategy.Strategy) error + + // Write writes the given strategy actions and outomes to the report. + Write(assetName string, currentStrategy strategy.Strategy, snapshots <-chan *asset.Snapshot, actions <-chan strategy.Action, outcomes <-chan float64) error + + // AssetEnd is called when backtesting for the given asset ends. + AssetEnd(name string) error + + // End is called when the backtest ends. + End() error +} diff --git a/backtest/testdata/repository/brk-b.csv b/backtest/testdata/repository/brk-b.csv new file mode 120000 index 0000000..53db519 --- /dev/null +++ b/backtest/testdata/repository/brk-b.csv @@ -0,0 +1 @@ +../../../asset/testdata/repository/brk-b.csv \ No newline at end of file diff --git a/cmd/indicator-backtest/main.go b/cmd/indicator-backtest/main.go index e8ae154..84b18bc 100644 --- a/cmd/indicator-backtest/main.go +++ b/cmd/indicator-backtest/main.go @@ -12,6 +12,7 @@ import ( "os" "github.com/cinar/indicator/v2/asset" + "github.com/cinar/indicator/v2/backtest" "github.com/cinar/indicator/v2/helper" "github.com/cinar/indicator/v2/strategy" "github.com/cinar/indicator/v2/strategy/compound" @@ -40,9 +41,9 @@ func main() { flag.StringVar(&sourceName, "source-name", "filesystem", "source repository type") flag.StringVar(&sourceConfig, "source-config", "", "source repository config") flag.StringVar(&outputDir, "output", ".", "output directory") - flag.IntVar(&workers, "workers", strategy.DefaultBacktestWorkers, "number of concurrent workers") - flag.IntVar(&lastDays, "last", strategy.DefaultLastDays, "number of days to do backtest") - flag.BoolVar(&writeStrategyRerpots, "write-strategy-reports", strategy.DefaultWriteStrategyReports, "write individual strategy reports") + flag.IntVar(&workers, "workers", backtest.DefaultBacktestWorkers, "number of concurrent workers") + flag.IntVar(&lastDays, "last", backtest.DefaultLastDays, "number of days to do backtest") + flag.BoolVar(&writeStrategyRerpots, "write-strategy-reports", backtest.DefaultWriteStrategyReports, "write individual strategy reports") flag.BoolVar(&addSplits, "splits", false, "add the split strategies") flag.BoolVar(&addAnds, "ands", false, "add the and strategies") flag.StringVar(&dateFormat, "date-format", helper.DefaultReportDateFormat, "date format to use") @@ -53,11 +54,13 @@ func main() { log.Fatalf("unable to initialize source: %v", err) } - backtest := strategy.NewBacktest(source, outputDir) + htmlReport := backtest.NewHTMLReport(outputDir) + htmlReport.WriteStrategyReports = writeStrategyRerpots + htmlReport.DateFormat = dateFormat + + backtest := backtest.NewBacktest(source, htmlReport) backtest.Workers = workers backtest.LastDays = lastDays - backtest.WriteStrategyReports = writeStrategyRerpots - backtest.DateFormat = dateFormat backtest.Names = append(backtest.Names, flag.Args()...) backtest.Strategies = append(backtest.Strategies, compound.AllStrategies()...) backtest.Strategies = append(backtest.Strategies, momentum.AllStrategies()...) diff --git a/helper/README.md b/helper/README.md index 8720aa4..64e4eb2 100644 --- a/helper/README.md +++ b/helper/README.md @@ -36,6 +36,7 @@ The information provided on this project is strictly for informational purposes - [func ChangePercent\[T Number\]\(c \<\-chan T, before int\) \<\-chan T](<#ChangePercent>) - [func ChangeRatio\[T Number\]\(c \<\-chan T, before int\) \<\-chan T](<#ChangeRatio>) - [func CheckEquals\[T comparable\]\(inputs ...\<\-chan T\) error](<#CheckEquals>) +- [func CloseAndLogError\(closer io.Closer, message string\)](<#CloseAndLogError>) - [func Count\[T Number, O any\]\(from T, other \<\-chan O\) \<\-chan T](<#Count>) - [func DecrementBy\[T Number\]\(c \<\-chan T, d T\) \<\-chan T](<#DecrementBy>) - [func Divide\[T Number\]\(ac, bc \<\-chan T\) \<\-chan T](<#Divide>) @@ -309,6 +310,15 @@ func CheckEquals[T comparable](inputs ...<-chan T) error CheckEquals determines whether the two channels are equal. + +## func [CloseAndLogError]() + +```go +func CloseAndLogError(closer io.Closer, message string) +``` + +CloseAndLogError attempts to close the closer and logs any error. + ## func [Count]() diff --git a/helper/closer.go b/helper/closer.go new file mode 100644 index 0000000..9e73ae8 --- /dev/null +++ b/helper/closer.go @@ -0,0 +1,18 @@ +// Copyright (c) 2021-2024 Onur Cinar. +// The source code is provided under GNU AGPLv3 License. +// https://github.com/cinar/indicator + +package helper + +import ( + "io" + "log" +) + +// CloseAndLogError attempts to close the closer and logs any error. +func CloseAndLogError(closer io.Closer, message string) { + err := closer.Close() + if err != nil { + log.Printf("%s: %v", message, err) + } +} diff --git a/strategy/README.md b/strategy/README.md index c4038bd..df7693a 100644 --- a/strategy/README.md +++ b/strategy/README.md @@ -24,7 +24,6 @@ The information provided on this project is strictly for informational purposes ## Index -- [Constants](<#constants>) - [func ActionSources\(strategies \[\]Strategy, snapshots \<\-chan \*asset.Snapshot\) \[\]\<\-chan Action](<#ActionSources>) - [func ActionsToAnnotations\(ac \<\-chan Action\) \<\-chan string](<#ActionsToAnnotations>) - [func ComputeWithOutcome\(s Strategy, c \<\-chan \*asset.Snapshot\) \(\<\-chan Action, \<\-chan float64\)](<#ComputeWithOutcome>) @@ -40,9 +39,6 @@ The information provided on this project is strictly for informational purposes - [func \(a \*AndStrategy\) Compute\(snapshots \<\-chan \*asset.Snapshot\) \<\-chan Action](<#AndStrategy.Compute>) - [func \(a \*AndStrategy\) Name\(\) string](<#AndStrategy.Name>) - [func \(a \*AndStrategy\) Report\(c \<\-chan \*asset.Snapshot\) \*helper.Report](<#AndStrategy.Report>) -- [type Backtest](<#Backtest>) - - [func NewBacktest\(repository asset.Repository, outputDir string\) \*Backtest](<#NewBacktest>) - - [func \(b \*Backtest\) Run\(\) error](<#Backtest.Run>) - [type BuyAndHoldStrategy](<#BuyAndHoldStrategy>) - [func NewBuyAndHoldStrategy\(\) \*BuyAndHoldStrategy](<#NewBuyAndHoldStrategy>) - [func \(\*BuyAndHoldStrategy\) Compute\(snapshots \<\-chan \*asset.Snapshot\) \<\-chan Action](<#BuyAndHoldStrategy.Compute>) @@ -71,23 +67,6 @@ The information provided on this project is strictly for informational purposes - [func AllStrategies\(\) \[\]Strategy](<#AllStrategies>) -## Constants - - - -```go -const ( - // DefaultBacktestWorkers is the default number of backtest workers. - DefaultBacktestWorkers = 1 - - // DefaultLastDays is the default number of days backtest should go back. - DefaultLastDays = 30 - - // DefaultWriteStrategyReports is the default state of writing individual strategy reports. - DefaultWriteStrategyReports = true -) -``` - ## func [ActionSources]() @@ -249,53 +228,6 @@ func (a *AndStrategy) Report(c <-chan *asset.Snapshot) *helper.Report Report processes the provided asset snapshots and generates a report annotated with the recommended actions. - -## type [Backtest]() - -Backtest function rigorously evaluates the potential performance of the specified strategies applied to a defined set of assets. It generates comprehensive visual representations for each strategy\-asset pairing. - -```go -type Backtest struct { - - // Names is the names of the assets to backtest. - Names []string - - // Strategies is the list of strategies to apply. - Strategies []Strategy - - // Workers is the number of concurrent workers. - Workers int - - // LastDays is the number of days backtest should go back. - LastDays int - - // WriteStrategyReports indicates whether the individual strategy reports should be generated. - WriteStrategyReports bool - - // DateFormat is the date format that is used in the reports. - DateFormat string - // contains filtered or unexported fields -} -``` - - -### func [NewBacktest]() - -```go -func NewBacktest(repository asset.Repository, outputDir string) *Backtest -``` - -NewBacktest function initializes a new backtest instance. - - -### func \(\*Backtest\) [Run]() - -```go -func (b *Backtest) Run() error -``` - -Run executes a comprehensive performance evaluation of the designated strategies, applied to a specified collection of assets. In the absence of explicitly defined assets, encompasses all assets within the repository. Likewise, in the absence of explicitly defined strategies, encompasses all the registered strategies. - ## type [BuyAndHoldStrategy]() diff --git a/strategy/backtest.go b/strategy/backtest.go deleted file mode 100644 index 277661d..0000000 --- a/strategy/backtest.go +++ /dev/null @@ -1,307 +0,0 @@ -// Copyright (c) 2021-2024 Onur Cinar. -// The source code is provided under GNU AGPLv3 License. -// https://github.com/cinar/indicator - -package strategy - -import ( - // Go embed report template. - _ "embed" - "fmt" - "log" - "os" - "path" - "path/filepath" - "slices" - "sync" - "text/template" - "time" - - "github.com/cinar/indicator/v2/asset" - "github.com/cinar/indicator/v2/helper" -) - -const ( - // DefaultBacktestWorkers is the default number of backtest workers. - DefaultBacktestWorkers = 1 - - // DefaultLastDays is the default number of days backtest should go back. - DefaultLastDays = 30 - - // DefaultWriteStrategyReports is the default state of writing individual strategy reports. - DefaultWriteStrategyReports = true -) - -//go:embed "backtest_report.tmpl" -var backtestReportTmpl string - -//go:embed "backtest_asset_report.tmpl" -var backtestAssetReportTmpl string - -// Backtest function rigorously evaluates the potential performance of the -// specified strategies applied to a defined set of assets. It generates -// comprehensive visual representations for each strategy-asset pairing. -type Backtest struct { - // repository is the repository to retrieve the assets from. - repository asset.Repository - - // outputDir is the output directory for the generated reports. - outputDir string - - // Names is the names of the assets to backtest. - Names []string - - // Strategies is the list of strategies to apply. - Strategies []Strategy - - // Workers is the number of concurrent workers. - Workers int - - // LastDays is the number of days backtest should go back. - LastDays int - - // WriteStrategyReports indicates whether the individual strategy reports should be generated. - WriteStrategyReports bool - - // DateFormat is the date format that is used in the reports. - DateFormat string -} - -// backtestResult encapsulates the outcome of running a strategy. -type backtestResult struct { - // AssetName is the name of the asset. - AssetName string - - // StrategyName is the name of the strategy. - StrategyName string - - // Action is the last recommended action by the strategy. - Action Action - - // Since indicates how long the current action recommendation has been in effect. - Since int - - // Outcome is the effectiveness of applying the recommended actions. - Outcome float64 - - // Transactions is the number of transactions made by the strategy. - Transactions int -} - -// NewBacktest function initializes a new backtest instance. -func NewBacktest(repository asset.Repository, outputDir string) *Backtest { - return &Backtest{ - repository: repository, - outputDir: outputDir, - Names: []string{}, - Strategies: []Strategy{}, - Workers: DefaultBacktestWorkers, - LastDays: DefaultLastDays, - WriteStrategyReports: DefaultWriteStrategyReports, - DateFormat: helper.DefaultReportDateFormat, - } -} - -// Run executes a comprehensive performance evaluation of the designated strategies, -// applied to a specified collection of assets. In the absence of explicitly defined -// assets, encompasses all assets within the repository. Likewise, in the absence of -// explicitly defined strategies, encompasses all the registered strategies. -func (b *Backtest) Run() error { - // When asset names are absent, considers all assets within the - // provided repository for evaluation. - if len(b.Names) == 0 { - assets, err := b.repository.Assets() - if err != nil { - return err - } - - b.Names = assets - } - - // When strategies are absent, considers all strategies. - if len(b.Strategies) == 0 { - b.Strategies = b.allStrategies() - } - - // Make sure that output directory exists. - err := os.MkdirAll(b.outputDir, 0o700) - if err != nil { - return err - } - - // Run the backtest workers and get the resutls. - results := b.runWorkers() - - // Write the backtest report. - return b.writeReport(results) -} - -// allStrategies returns a slice containing references to all available strategies. -func (*Backtest) allStrategies() []Strategy { - return []Strategy{ - NewBuyAndHoldStrategy(), - } -} - -// runWorkers initiates and manages the execution of multiple backtest workers. -func (b *Backtest) runWorkers() []*backtestResult { - names := helper.SliceToChan(b.Names) - results := make(chan *backtestResult) - wg := &sync.WaitGroup{} - - for i := 0; i < b.Workers; i++ { - wg.Add(1) - go b.worker(names, results, wg) - } - - // Wait for all workers to finish. - go func() { - wg.Wait() - close(results) - }() - - resultsSlice := helper.ChanToSlice(results) - - // Sort the backtest results by the outcomes. - slices.SortFunc(resultsSlice, func(a, b *backtestResult) int { - return int(b.Outcome - a.Outcome) - }) - - return resultsSlice -} - -// strategyReportFileName -func (*Backtest) strategyReportFileName(assetName, strategyName string) string { - return fmt.Sprintf("%s - %s.html", assetName, strategyName) -} - -// worker is a backtesting worker that concurrently executes backtests for individual -// assets. It receives asset names from the provided channel, and performs backtests -// using the given strategies. -func (b *Backtest) worker(names <-chan string, bestResults chan<- *backtestResult, wg *sync.WaitGroup) { - defer wg.Done() - - since := time.Now().AddDate(0, 0, -b.LastDays) - - for name := range names { - log.Printf("Backtesting %s...", name) - snapshots, err := b.repository.GetSince(name, since) - if err != nil { - log.Printf("Unable to retrieve the snapshots for %s (%v)", name, err) - continue - } - - // We don't expect the snapshots to be a stream during backtesting. - snapshotsSlice := helper.ChanToSlice(snapshots) - - results := make([]*backtestResult, 0, len(b.Strategies)) - - for _, st := range b.Strategies { - actions, outcomes := ComputeWithOutcome(st, helper.SliceToChan(snapshotsSlice)) - actionsSplice := helper.Duplicate(actions, 3) - - actions = helper.Last(DenormalizeActions(actionsSplice[0]), 1) - sinces := helper.Last(helper.Since[Action, int](actionsSplice[1]), 1) - outcomes = helper.Last(outcomes, 1) - transactions := helper.Last(CountTransactions(actionsSplice[2]), 1) - - // Generate inidividual strategy report. - if b.WriteStrategyReports { - report := st.Report(helper.SliceToChan(snapshotsSlice)) - report.DateFormat = b.DateFormat - - err := report.WriteToFile(path.Join(b.outputDir, b.strategyReportFileName(name, st.Name()))) - if err != nil { - log.Printf("Unable to write report for %s (%v)", name, err) - continue - } - } - - results = append(results, &backtestResult{ - AssetName: name, - StrategyName: st.Name(), - Action: <-actions, - Since: <-sinces, - Outcome: <-outcomes * 100, - Transactions: <-transactions, - }) - } - - // Sort the backtest results by the outcomes. - slices.SortFunc(results, func(a, b *backtestResult) int { - return int(b.Outcome - a.Outcome) - }) - - // Report the best result for the current asset. - log.Printf("Best outcome for %s is %.2f%% with %s.", name, results[0].Outcome, results[0].StrategyName) - bestResults <- results[0] - - // Write the asset report. - err = b.writeAssetReport(name, results) - if err != nil { - log.Printf("Unable to write report with %v.", err) - } - } -} - -// writeAssetReport generates a detailed report for the asset, summarizing the backtest results. -func (b *Backtest) writeAssetReport(name string, results []*backtestResult) error { - type Model struct { - AssetName string - Results []*backtestResult - GeneratedOn string - } - - model := Model{ - AssetName: name, - Results: results, - GeneratedOn: time.Now().String(), - } - - file, err := os.Create(filepath.Join(b.outputDir, fmt.Sprintf("%s.html", name))) - if err != nil { - return err - } - - tmpl, err := template.New("report").Parse(backtestAssetReportTmpl) - if err != nil { - return err - } - - err = tmpl.Execute(file, model) - if err != nil { - return err - } - - return file.Close() -} - -// writeReport generates a detailed report for the backtest results. -func (b *Backtest) writeReport(results []*backtestResult) error { - type Model struct { - Results []*backtestResult - GeneratedOn string - } - - model := Model{ - Results: results, - GeneratedOn: time.Now().String(), - } - - file, err := os.Create(filepath.Join(b.outputDir, "index.html")) - if err != nil { - return err - } - - tmpl, err := template.New("report").Parse(backtestReportTmpl) - if err != nil { - return err - } - - err = tmpl.Execute(file, model) - if err != nil { - return err - } - - return file.Close() -}