From e85b594e04663dccf47d7c8b0ca2296a4169c6d3 Mon Sep 17 00:00:00 2001 From: Onur Cinar Date: Sun, 24 Dec 2023 17:52:16 -0800 Subject: [PATCH] Backtest added. --- asset/README.md | 22 +- asset/file_system_repository_test.go | 23 +- asset/snapshot.go | 20 + asset/snapshot_test.go | 37 ++ asset/testdata/{ => repository}/brk-b.csv | 0 helper/report.tmpl | 2 +- strategy/README.md | 61 ++- strategy/apo_strategy.go | 53 +- strategy/apo_strategy_test.go | 26 +- strategy/backtest.go | 257 ++++++++++ strategy/backtest_asset_report.tmpl | 60 +++ strategy/backtest_report.tmpl | 62 +++ strategy/backtest_test.go | 32 ++ strategy/strategy.go | 4 +- strategy/testdata/apo_strategy.csv | 504 +++++++++---------- strategy/testdata/{ => repository}/brk-b.csv | 0 16 files changed, 845 insertions(+), 318 deletions(-) create mode 100644 asset/snapshot_test.go rename asset/testdata/{ => repository}/brk-b.csv (100%) create mode 100644 strategy/backtest.go create mode 100644 strategy/backtest_asset_report.tmpl create mode 100644 strategy/backtest_report.tmpl create mode 100644 strategy/backtest_test.go rename strategy/testdata/{ => repository}/brk-b.csv (100%) diff --git a/asset/README.md b/asset/README.md index bccdff3..4298fed 100644 --- a/asset/README.md +++ b/asset/README.md @@ -24,6 +24,8 @@ The information provided on this project is strictly for informational purposes ## Index +- [func SnapshotsAsClosings\(snapshots \<\-chan \*Snapshot\) \<\-chan float64](<#SnapshotsAsClosings>) +- [func SnapshotsAsDates\(snapshots \<\-chan \*Snapshot\) \<\-chan time.Time](<#SnapshotsAsDates>) - [func Sync\(source, target Repository, defaultStartDate time.Time, workers int\) error](<#Sync>) - [type FileSystemRepository](<#FileSystemRepository>) - [func NewFileSystemRepository\(base string\) \*FileSystemRepository](<#NewFileSystemRepository>) @@ -53,6 +55,24 @@ The information provided on this project is strictly for informational purposes - [func \(r \*TiingoRepository\) LastDate\(name string\) \(time.Time, error\)](<#TiingoRepository.LastDate>) + +## func [SnapshotsAsClosings]() + +```go +func SnapshotsAsClosings(snapshots <-chan *Snapshot) <-chan float64 +``` + +SnapshotsAsClosings extracts the close field from each snapshot in the provided channel and returns a new channel containing only those close values.The original snapshots channel can no longer be directly used afterwards. + + +## func [SnapshotsAsDates]() + +```go +func SnapshotsAsDates(snapshots <-chan *Snapshot) <-chan time.Time +``` + +SnapshotsAsDates extracts the date field from each snapshot in the provided channel and returns a new channel containing only those date values.The original snapshots channel can no longer be directly used afterwards. + ## func [Sync]() @@ -223,7 +243,7 @@ type Repository interface { ``` -## type [Snapshot]() +## type [Snapshot]() Snapshot captures a single observation of an asset's price at a specific moment. diff --git a/asset/file_system_repository_test.go b/asset/file_system_repository_test.go index fef19ab..bd52491 100644 --- a/asset/file_system_repository_test.go +++ b/asset/file_system_repository_test.go @@ -7,6 +7,7 @@ package asset_test import ( "fmt" "os" + "path" "reflect" "testing" "time" @@ -15,9 +16,11 @@ import ( "github.com/cinar/indicator/helper" ) +var repositoryBase = "testdata/repository" + func TestFileSystemRepositoryAssets(t *testing.T) { - repository := asset.NewFileSystemRepository("testdata") - expected := []string{"brk-b", "empty", "since"} + repository := asset.NewFileSystemRepository(repositoryBase) + expected := []string{"brk-b"} actual, err := repository.Assets() if err != nil { @@ -39,7 +42,7 @@ func TestFileSystemRepositoryAssetsNonExisting(t *testing.T) { } func TestFileSystemRepositoryGet(t *testing.T) { - repository := asset.NewFileSystemRepository("testdata") + repository := asset.NewFileSystemRepository(repositoryBase) snapshots, err := repository.Get("brk-b") if err != nil { @@ -59,7 +62,7 @@ func TestFileSystemRepositoryGetNonExisting(t *testing.T) { } func TestFileSystemRepositoryGetSince(t *testing.T) { - repository := asset.NewFileSystemRepository("testdata") + repository := asset.NewFileSystemRepository(repositoryBase) date := time.Date(2022, 12, 20, 0, 0, 0, 0, time.UTC) actual, err := repository.GetSince("brk-b", date) @@ -79,7 +82,7 @@ func TestFileSystemRepositoryGetSince(t *testing.T) { } func TestFileSystemRepositoryGetSinceNonExisting(t *testing.T) { - repository := asset.NewFileSystemRepository("testdata") + repository := asset.NewFileSystemRepository(repositoryBase) date := time.Date(2022, 12, 01, 0, 0, 0, 0, time.UTC) _, err := repository.GetSince("brk", date) @@ -91,7 +94,7 @@ func TestFileSystemRepositoryGetSinceNonExisting(t *testing.T) { func TestFileSystemRepositoryLastDate(t *testing.T) { expeted := time.Date(2022, 12, 30, 0, 0, 0, 0, time.UTC) - repository := asset.NewFileSystemRepository("testdata") + repository := asset.NewFileSystemRepository(repositoryBase) actual, err := repository.LastDate("brk-b") if err != nil { @@ -104,7 +107,7 @@ func TestFileSystemRepositoryLastDate(t *testing.T) { } func TestFileSystemRepositoryLastDateNonExisting(t *testing.T) { - repository := asset.NewFileSystemRepository("testdata") + repository := asset.NewFileSystemRepository(repositoryBase) _, err := repository.LastDate("brk") if err == nil { @@ -113,7 +116,7 @@ func TestFileSystemRepositoryLastDateNonExisting(t *testing.T) { } func TestFileSystemRepositoryLastDateEmpty(t *testing.T) { - repository := asset.NewFileSystemRepository("testdata") + repository := asset.NewFileSystemRepository(repositoryBase) _, err := repository.LastDate("empty") if err == nil { @@ -122,7 +125,7 @@ func TestFileSystemRepositoryLastDateEmpty(t *testing.T) { } func TestFileSystemRepositoryAppend(t *testing.T) { - repository := asset.NewFileSystemRepository("testdata") + repository := asset.NewFileSystemRepository(repositoryBase) expected, err := repository.Get("brk-b") if err != nil { @@ -130,7 +133,7 @@ func TestFileSystemRepositoryAppend(t *testing.T) { } name := "test_file_system_repository_append" - defer os.Remove(fmt.Sprintf("testdata/%s.csv", name)) + defer os.Remove(path.Join(repositoryBase, fmt.Sprintf("%s.csv", name))) err = repository.Append(name, expected) if err != nil { diff --git a/asset/snapshot.go b/asset/snapshot.go index 9db4143..9a42fdf 100644 --- a/asset/snapshot.go +++ b/asset/snapshot.go @@ -6,6 +6,8 @@ package asset import ( "time" + + "github.com/cinar/indicator/helper" ) // Snapshot captures a single observation of an asset's price @@ -34,3 +36,21 @@ type Snapshot struct { // the asset during the snapshot period. Volume int64 } + +// SnapshotsAsDates extracts the date field from each snapshot in the provided +// channel and returns a new channel containing only those date values.The +// original snapshots channel can no longer be directly used afterwards. +func SnapshotsAsDates(snapshots <-chan *Snapshot) <-chan time.Time { + return helper.Map(snapshots, func(snapshot *Snapshot) time.Time { + return snapshot.Date + }) +} + +// SnapshotsAsClosings extracts the close field from each snapshot in the provided +// channel and returns a new channel containing only those close values.The +// original snapshots channel can no longer be directly used afterwards. +func SnapshotsAsClosings(snapshots <-chan *Snapshot) <-chan float64 { + return helper.Map(snapshots, func(snapshot *Snapshot) float64 { + return snapshot.Close + }) +} diff --git a/asset/snapshot_test.go b/asset/snapshot_test.go new file mode 100644 index 0000000..e12f5d8 --- /dev/null +++ b/asset/snapshot_test.go @@ -0,0 +1,37 @@ +// Copyright (c) 2023 Onur Cinar. All Rights Reserved. +// The source code is provided under MIT License. +// https://github.com/cinar/indicator + +package asset_test + +import ( + "testing" + + "github.com/cinar/indicator/asset" + "github.com/cinar/indicator/helper" +) + +func TestSnapshotsAs(t *testing.T) { + snapshots, err := helper.ReadFromCsvFile[asset.Snapshot]("testdata/repository/brk-b.csv", true) + if err != nil { + t.Fatal(err) + } + + snapshotsCopies := helper.Duplicate(snapshots, 3) + + dates := asset.SnapshotsAsDates(snapshotsCopies[1]) + closings := asset.SnapshotsAsClosings(snapshotsCopies[2]) + + for snapshot := range snapshotsCopies[0] { + date := <-dates + closing := <-closings + + if !date.Equal(snapshot.Date) { + t.Fatalf("actual %v expected %v", date, snapshot.Date) + } + + if closing != snapshot.Close { + t.Fatalf("actual %v expected %v", closing, snapshot.Close) + } + } +} diff --git a/asset/testdata/brk-b.csv b/asset/testdata/repository/brk-b.csv similarity index 100% rename from asset/testdata/brk-b.csv rename to asset/testdata/repository/brk-b.csv diff --git a/helper/report.tmpl b/helper/report.tmpl index ea3669e..307404b 100644 --- a/helper/report.tmpl +++ b/helper/report.tmpl @@ -92,7 +92,7 @@ {{ range .Date }} data.addRow([ - new Date({{ .Format "2006, 1, 2" }}), + new Date("{{ .Format "2006-01-02" }}"), {{ range $.Columns }} {{ .Value }}, {{ end }} diff --git a/strategy/README.md b/strategy/README.md index 93ce343..ca11214 100644 --- a/strategy/README.md +++ b/strategy/README.md @@ -32,9 +32,12 @@ The information provided on this project is strictly for informational purposes - [func \(a Action\) Annotation\(\) string](<#Action.Annotation>) - [type ApoStrategy](<#ApoStrategy>) - [func NewApoStrategy\(\) \*ApoStrategy](<#NewApoStrategy>) - - [func \(a \*ApoStrategy\) Compute\(snapshots \<\-chan \*asset.Snapshot\) \<\-chan Action](<#ApoStrategy.Compute>) + - [func \(a \*ApoStrategy\) Compute\(snapshots \<\-chan \*asset.Snapshot\) \(\<\-chan Action, \<\-chan float64\)](<#ApoStrategy.Compute>) - [func \(a \*ApoStrategy\) Name\(\) string](<#ApoStrategy.Name>) - [func \(a \*ApoStrategy\) Report\(c \<\-chan \*asset.Snapshot\) \*helper.Report](<#ApoStrategy.Report>) +- [type Backtest](<#Backtest>) + - [func NewBacktest\(repository asset.Repository, outputDir string\) \*Backtest](<#NewBacktest>) + - [func \(b \*Backtest\) Run\(\) error](<#Backtest.Run>) - [type Strategy](<#Strategy>) @@ -113,7 +116,7 @@ func (a Action) Annotation() string Annotation returns a single character string representing the recommended action. It returns "S" for Sell, "B" for Buy, and an empty string for Hold. -## type [ApoStrategy]() +## type [ApoStrategy]() ApoStrategy represents the configuration parameters for calculating the APO strategy. An APO value crossing above zero suggests a bullish trend, while crossing below zero indicates a bearish trend. Positive APO values signify an upward trend, while negative values signify a downward trend. @@ -126,7 +129,7 @@ type ApoStrategy struct { ``` -### func [NewApoStrategy]() +### func [NewApoStrategy]() ```go func NewApoStrategy() *ApoStrategy @@ -135,16 +138,16 @@ func NewApoStrategy() *ApoStrategy NewApoStrategy function initializes a new APO strategy instance with the default parameters. -### func \(\*ApoStrategy\) [Compute]() +### func \(\*ApoStrategy\) [Compute]() ```go -func (a *ApoStrategy) Compute(snapshots <-chan *asset.Snapshot) <-chan Action +func (a *ApoStrategy) Compute(snapshots <-chan *asset.Snapshot) (<-chan Action, <-chan float64) ``` -Compute processes the provided asset snapshots and generates a stream of actionable recommendations, +Compute processes the provided asset snapshots and generates a stream of actionable recommendations and outcomes. -### func \(\*ApoStrategy\) [Name]() +### func \(\*ApoStrategy\) [Name]() ```go func (a *ApoStrategy) Name() string @@ -153,7 +156,7 @@ func (a *ApoStrategy) Name() string Name returns the name of the strategy. -### func \(\*ApoStrategy\) [Report]() +### func \(\*ApoStrategy\) [Report]() ```go func (a *ApoStrategy) Report(c <-chan *asset.Snapshot) *helper.Report @@ -161,6 +164,44 @@ func (a *ApoStrategy) 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 + // 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 [Strategy]() @@ -172,8 +213,8 @@ type Strategy interface { Name() string // Compute processes the provided asset snapshots and generates a - // stream of actionable recommendations, - Compute(snapshots <-chan *asset.Snapshot) <-chan Action + // stream of actionable recommendations and outcomes. + Compute(snapshots <-chan *asset.Snapshot) (<-chan Action, <-chan float64) // Report processes the provided asset snapshots and generates a // report annotated with the recommended actions. diff --git a/strategy/apo_strategy.go b/strategy/apo_strategy.go index edcc371..6e6782f 100644 --- a/strategy/apo_strategy.go +++ b/strategy/apo_strategy.go @@ -5,8 +5,6 @@ package strategy import ( - "time" - "github.com/cinar/indicator/asset" "github.com/cinar/indicator/helper" "github.com/cinar/indicator/trend" @@ -35,13 +33,11 @@ func (a *ApoStrategy) Name() string { } // Compute processes the provided asset snapshots and generates a -// stream of actionable recommendations, -func (a *ApoStrategy) Compute(snapshots <-chan *asset.Snapshot) <-chan Action { - closing := helper.Map(snapshots, func(snapshot *asset.Snapshot) float64 { - return snapshot.Close - }) +// stream of actionable recommendations and outcomes. +func (a *ApoStrategy) Compute(snapshots <-chan *asset.Snapshot) (<-chan Action, <-chan float64) { + closings := helper.Duplicate(asset.SnapshotsAsClosings(snapshots), 2) - apo := a.Apo.Compute(closing) + apo := a.Apo.Compute(closings[0]) apo = helper.Buffered(apo, 2) inputs := helper.Duplicate(apo, 2) @@ -49,7 +45,7 @@ func (a *ApoStrategy) Compute(snapshots <-chan *asset.Snapshot) <-chan Action { // Ship the first value inputs[1] = helper.Skip(inputs[1], 1) - return helper.Shift(helper.Operate(inputs[0], inputs[1], func(b, c float64) Action { + actions := helper.Operate(inputs[0], inputs[1], func(b, c float64) Action { // An APO value crossing above zero suggests a bullish trend. if c >= 0 && b < 0 { return Buy @@ -61,7 +57,15 @@ func (a *ApoStrategy) Compute(snapshots <-chan *asset.Snapshot) <-chan Action { } return Hold - }), 1, Hold) + }) + + // APO starts only after the slow period. + actions = helper.Shift(actions, a.Apo.SlowPeriod, Hold) + + actionsCopies := helper.Duplicate(actions, 2) + outcomes := Outcome(closings[1], actionsCopies[1]) + + return actionsCopies[0], outcomes } // Report processes the provided asset snapshots and generates a @@ -69,31 +73,18 @@ func (a *ApoStrategy) Compute(snapshots <-chan *asset.Snapshot) <-chan Action { func (a *ApoStrategy) Report(c <-chan *asset.Snapshot) *helper.Report { // // snapshots[0] -> dates - // snapshots[1] -> Compute -> actions[0] -> annotations - // -> actions[1] |> outcome - // snapshots[2] -> closings[2] -> | - // -> closings[0] -> close + // snapshots[1] -> Compute -> actions -> annotations + // snapshots[2] -> closings[0] -> close // -> closings[1] -> Apo.Compute -> apo // snapshots := helper.Duplicate(c, 3) - dates := helper.Map(snapshots[0], func(snapshot *asset.Snapshot) time.Time { - return snapshot.Date - }) - - closings := helper.Duplicate(helper.Map(snapshots[2], func(snapshot *asset.Snapshot) float64 { - return snapshot.Close - }), 3) - - dates = helper.Skip(dates, a.Apo.SlowPeriod-1) - - closings[0] = helper.Skip(closings[0], a.Apo.SlowPeriod-1) - closings[2] = helper.Skip(closings[2], a.Apo.SlowPeriod-1) - apo := a.Apo.Compute(closings[1]) + dates := asset.SnapshotsAsDates(snapshots[0]) + closings := helper.Duplicate(asset.SnapshotsAsClosings(snapshots[2]), 2) + apo := helper.Shift(a.Apo.Compute(closings[1]), a.Apo.SlowPeriod, 0) - actions := helper.Duplicate(a.Compute(snapshots[1]), 2) - annotations := ActionsToAnnotations(actions[0]) - outcome := Outcome(closings[2], actions[1]) + actions, outcomes := a.Compute(snapshots[1]) + annotations := ActionsToAnnotations(actions) report := helper.NewReport(a.Name(), dates) report.AddChart() @@ -103,7 +94,7 @@ func (a *ApoStrategy) Report(c <-chan *asset.Snapshot) *helper.Report { report.AddColumn(helper.NewNumericReportColumn("APO", apo), 1) report.AddColumn(helper.NewAnnotationReportColumn(annotations), 0, 1) - report.AddColumn(helper.NewNumericReportColumn("Outcome", outcome), 2) + report.AddColumn(helper.NewNumericReportColumn("Outcome", outcomes), 2) return report } diff --git a/strategy/apo_strategy_test.go b/strategy/apo_strategy_test.go index f1ce343..0e4021b 100644 --- a/strategy/apo_strategy_test.go +++ b/strategy/apo_strategy_test.go @@ -15,10 +15,11 @@ import ( func TestApoStrategy(t *testing.T) { type Result struct { - Action strategy.Action + Action strategy.Action + Outcome float64 } - snapshots, err := helper.ReadFromCsvFile[asset.Snapshot]("testdata/brk-b.csv", true) + snapshots, err := helper.ReadFromCsvFile[asset.Snapshot]("testdata/repository/brk-b.csv", true) if err != nil { t.Fatal(err) } @@ -29,23 +30,26 @@ func TestApoStrategy(t *testing.T) { } apo := strategy.NewApoStrategy() + actions, outcomes := apo.Compute(snapshots) - expected := helper.Map(results, func(r *Result) strategy.Action { - return r.Action - }) + outcomes = helper.RoundDigits(outcomes, 2) - expected = helper.Skip(expected, apo.Apo.SlowPeriod-1) + for result := range results { + action := <-actions + outcome := <-outcomes - actual := apo.Compute(snapshots) + if action != result.Action { + t.Fatalf("actual %v expected %v", action, result.Action) + } - err = helper.CheckEquals(actual, expected) - if err != nil { - t.Fatal(err) + if outcome != result.Outcome { + t.Fatalf("actual %v expected %v", outcome, result.Outcome) + } } } func TestApoStrategyReport(t *testing.T) { - snapshots, err := helper.ReadFromCsvFile[asset.Snapshot]("testdata/brk-b.csv", true) + snapshots, err := helper.ReadFromCsvFile[asset.Snapshot]("testdata/repository/brk-b.csv", true) if err != nil { t.Fatal(err) } diff --git a/strategy/backtest.go b/strategy/backtest.go new file mode 100644 index 0000000..1ac8024 --- /dev/null +++ b/strategy/backtest.go @@ -0,0 +1,257 @@ +// Copyright (c) 2023 Onur Cinar. All Rights Reserved. +// The source code is provided under MIT License. +// https://github.com/cinar/indicator + +package strategy + +import ( + // Go embed report template. + _ "embed" + "fmt" + "log" + "os" + "path" + "path/filepath" + "slices" + "sync" + "text/template" + + "github.com/cinar/indicator/asset" + "github.com/cinar/indicator/helper" +) + +//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 +} + +// 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 + + // Outcome is the effectiveness of applying the recommended actions. + Outcome float64 +} + +// 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: 1, + } +} + +// 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() + } + + // 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 (b *Backtest) allStrategies() []Strategy { + return []Strategy{ + NewApoStrategy(), + } +} + +// 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 (b *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() + + for name := range names { + log.Printf("Backtesting %s...", name) + snapshots, err := b.repository.Get(name) + 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 { + snapshotCopies := helper.Duplicate(helper.SliceToChan(snapshotsSlice), 2) + + actions, outcomes := st.Compute(snapshotCopies[0]) + report := st.Report(snapshotCopies[1]) + + actions = helper.Last(actions, 1) + outcomes = helper.Last(outcomes, 1) + + 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, + Outcome: <-outcomes * 100, + }) + } + + // 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 + } + + model := Model{ + AssetName: name, + Results: results, + } + + 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 + } + + model := Model{ + Results: results, + } + + 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() +} diff --git a/strategy/backtest_asset_report.tmpl b/strategy/backtest_asset_report.tmpl new file mode 100644 index 0000000..9194462 --- /dev/null +++ b/strategy/backtest_asset_report.tmpl @@ -0,0 +1,60 @@ + + + + + + + {{ .AssetName }} + + + + +
+
+
+

+ {{ .AssetName }} - Asset Report +

+ + + + + + + + + + + {{ range .Results }} + + + + + + {{ end }} + +
StrategyActionOutcome
{{ .StrategyName }} + {{ if eq .Action -1 }} + Sell + {{ else if eq .Action 0 }} + Hold + {{ else }} + Buy + {{ end }} + + {{ if lt .Outcome 0.0 }} + ˅ + {{ else if gt .Outcome 0.0 }} + ˄ + {{ else }} + ˄ + {{ end }} + {{ printf "%.2f" .Outcome }}% + +
+
+
+
+ + + \ No newline at end of file diff --git a/strategy/backtest_report.tmpl b/strategy/backtest_report.tmpl new file mode 100644 index 0000000..87e00ab --- /dev/null +++ b/strategy/backtest_report.tmpl @@ -0,0 +1,62 @@ + + + + + + + Backtest Report + + + + +
+
+
+

+ Backtest Report +

+ + + + + + + + + + + + {{ range .Results }} + + + + + + + {{ end }} + +
AssetStrategyActionOutcome
{{ .AssetName }}{{ .StrategyName }} + {{ if eq .Action -1 }} + Sell + {{ else if eq .Action 0 }} + Hold + {{ else }} + Buy + {{ end }} + + {{ if lt .Outcome 0.0 }} + ˅ + {{ else if gt .Outcome 0.0 }} + ˄ + {{ else }} + ˄ + {{ end }} + {{ printf "%.2f" .Outcome }}% + +
+
+
+
+ + + \ No newline at end of file diff --git a/strategy/backtest_test.go b/strategy/backtest_test.go new file mode 100644 index 0000000..343a61b --- /dev/null +++ b/strategy/backtest_test.go @@ -0,0 +1,32 @@ +// Copyright (c) 2023 Onur Cinar. All Rights Reserved. +// The source code is provided under MIT License. +// https://github.com/cinar/indicator + +package strategy_test + +import ( + "os" + "testing" + + "github.com/cinar/indicator/asset" + "github.com/cinar/indicator/strategy" +) + +func TestBacktest(t *testing.T) { + repository := asset.NewFileSystemRepository("testdata/repository") + + outputDir, err := os.MkdirTemp("", "backtest") + if err != nil { + t.Fatal(err) + } + + defer os.RemoveAll(outputDir) + + backtest := strategy.NewBacktest(repository, outputDir) + backtest.Names = append(backtest.Names, "brk-b") + + err = backtest.Run() + if err != nil { + t.Fatal(err) + } +} diff --git a/strategy/strategy.go b/strategy/strategy.go index 8e9fcd2..944b0ce 100644 --- a/strategy/strategy.go +++ b/strategy/strategy.go @@ -29,8 +29,8 @@ type Strategy interface { Name() string // Compute processes the provided asset snapshots and generates a - // stream of actionable recommendations, - Compute(snapshots <-chan *asset.Snapshot) <-chan Action + // stream of actionable recommendations and outcomes. + Compute(snapshots <-chan *asset.Snapshot) (<-chan Action, <-chan float64) // Report processes the provided asset snapshots and generates a // report annotated with the recommended actions. diff --git a/strategy/testdata/apo_strategy.csv b/strategy/testdata/apo_strategy.csv index abd2664..3f58559 100644 --- a/strategy/testdata/apo_strategy.csv +++ b/strategy/testdata/apo_strategy.csv @@ -1,252 +1,252 @@ -Action -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -1 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 --1 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -1 -0 -0 -0 -0 -0 -0 -0 -0 --1 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -1 --1 -1 -0 --1 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -1 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 --1 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 -0 \ No newline at end of file +Action,Outcome +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +1,0 +0,-0 +0,0 +0,-0.01 +0,-0.01 +0,0 +0,-0.01 +0,-0.01 +0,-0.01 +0,0.01 +0,-0 +0,-0.01 +0,-0.01 +0,-0.01 +0,-0.03 +0,-0.03 +0,-0.03 +0,-0.02 +0,-0.02 +0,-0.02 +0,-0.02 +0,-0.01 +0,0 +0,0.02 +0,-0 +0,-0 +0,-0.02 +0,-0.03 +0,-0.03 +0,-0.02 +0,-0.04 +0,-0.03 +0,-0.06 +0,-0.03 +0,-0.02 +0,-0.04 +0,-0.04 +0,-0.04 +0,-0.03 +0,-0.03 +0,-0.02 +0,-0.02 +0,-0.01 +0,-0 +0,-0.01 +0,-0 +-1,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +0,0 +1,0 +0,-0.01 +0,-0.01 +0,-0 +0,0 +0,-0 +0,0 +0,0.02 +0,0.02 +-1,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +1,0.04 +-1,0.04 +1,0.04 +0,0.05 +-1,0.05 +0,0.05 +0,0.05 +0,0.05 +0,0.05 +0,0.05 +0,0.05 +0,0.05 +0,0.05 +0,0.05 +0,0.05 +0,0.05 +0,0.05 +0,0.05 +0,0.05 +0,0.05 +0,0.05 +1,0.05 +0,0.03 +0,0.03 +0,0.04 +0,0.04 +0,0.04 +0,0.05 +0,0.05 +0,0.04 +0,0.04 +0,0.04 +0,0.04 +0,0.02 +0,0.02 +0,0.01 +0,0.01 +0,0.02 +0,0.01 +0,0.01 +0,-0 +0,0.01 +0,0.03 +0,0.03 +0,0.05 +0,0.06 +0,0.04 +0,0.04 +0,0.04 +0,0.05 +0,0.05 +-1,0.05 +0,0.05 +0,0.05 +0,0.05 +0,0.05 +0,0.05 +0,0.05 +0,0.05 +0,0.05 +0,0.05 +0,0.05 +0,0.05 diff --git a/strategy/testdata/brk-b.csv b/strategy/testdata/repository/brk-b.csv similarity index 100% rename from strategy/testdata/brk-b.csv rename to strategy/testdata/repository/brk-b.csv