package optimizer import ( "context" "encoding/json" "fmt" "sort" jsonpatch "github.com/evanphx/json-patch/v5" "git.qtrade.icu/lychiyu/qbtrade/pkg/backtest" "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" ) type MetricValueFunc func(summaryReport *backtest.SummaryReport) float64 var TotalProfitMetricValueFunc = func(summaryReport *backtest.SummaryReport) float64 { return summaryReport.TotalProfit.Float64() } var TotalVolume = func(summaryReport *backtest.SummaryReport) float64 { if len(summaryReport.SymbolReports) == 0 { return 0 } buyVolume := summaryReport.SymbolReports[0].PnL.BuyVolume.Float64() sellVolume := summaryReport.SymbolReports[0].PnL.SellVolume.Float64() return buyVolume + sellVolume } var TotalEquityDiff = func(summaryReport *backtest.SummaryReport) float64 { if len(summaryReport.SymbolReports) == 0 { return 0 } initEquity := summaryReport.InitialEquityValue.Float64() finalEquity := summaryReport.FinalEquityValue.Float64() return finalEquity - initEquity } var ProfitFactorMetricValueFunc = func(summaryReport *backtest.SummaryReport) float64 { if len(summaryReport.SymbolReports) == 0 { return 0 } if len(summaryReport.SymbolReports) > 1 { panic("multiple symbols' profitfactor optimization not supported") } report := summaryReport.SymbolReports[0] pf := report.ProfitFactor.Float64() win := report.WinningRatio.Float64() return pf*0.9 + win*0.1 } type Metric struct { // Labels is the labels of the given parameters Labels []string `json:"labels,omitempty"` // Params is the parameters used to output the metrics result Params []interface{} `json:"params,omitempty"` // Key is the metric name Key string `json:"key"` // Value is the metric value of the metric Value float64 `json:"value,omitempty"` } func copyParams(params []interface{}) []interface{} { var c = make([]interface{}, len(params)) copy(c, params) return c } func copyLabels(labels []string) []string { var c = make([]string, len(labels)) copy(c, labels) return c } type GridOptimizer struct { Config *Config ParamLabels []string CurrentParams []interface{} } func (o *GridOptimizer) buildOps() []OpFunc { var ops []OpFunc o.CurrentParams = make([]interface{}, len(o.Config.Matrix)) o.ParamLabels = make([]string, len(o.Config.Matrix)) for i, selector := range o.Config.Matrix { var path = selector.Path var ii = i // copy variable because we need to use them in the closure if selector.Label != "" { o.ParamLabels[ii] = selector.Label } else { o.ParamLabels[ii] = selector.Path } switch selector.Type { case selectorTypeRange, selectorTypeRangeFloat, selectorTypeRangeInt: min := selector.Min max := selector.Max step := selector.Step if step.IsZero() { step = fixedpoint.One } var values []fixedpoint.Value for val := min; val.Compare(max) <= 0; val = val.Add(step) { values = append(values, val) } f := func(configJson []byte, next func(configJson []byte) error) error { for _, val := range values { jsonOp := []byte(reformatJson(fmt.Sprintf(`[{"op": "replace", "path": "%s", "value": %v }]`, path, val))) patch, err := jsonpatch.DecodePatch(jsonOp) if err != nil { return err } log.Debugf("json op: %s", jsonOp) patchedJson, err := patch.ApplyIndent(configJson, " ") if err != nil { return err } valCopy := val o.CurrentParams[ii] = valCopy if err := next(patchedJson); err != nil { return err } } return nil } ops = append(ops, f) case selectorTypeIterate, selectorTypeString: values := selector.Values f := func(configJson []byte, next func(configJson []byte) error) error { for _, val := range values { log.Debugf("%d %s: %v of %v", ii, path, val, values) jsonOp := []byte(reformatJson(fmt.Sprintf(`[{"op": "replace", "path": "%s", "value": "%s"}]`, path, val))) patch, err := jsonpatch.DecodePatch(jsonOp) if err != nil { return err } log.Debugf("json op: %s", jsonOp) patchedJson, err := patch.ApplyIndent(configJson, " ") if err != nil { return err } valCopy := val o.CurrentParams[ii] = valCopy if err := next(patchedJson); err != nil { return err } } return nil } ops = append(ops, f) case selectorTypeBool: values := []bool{true, false} f := func(configJson []byte, next func(configJson []byte) error) error { for _, val := range values { log.Debugf("%d %s: %v of %v", ii, path, val, values) jsonOp := []byte(reformatJson(fmt.Sprintf(`[{"op": "replace", "path": "%s", "value": %v}]`, path, val))) patch, err := jsonpatch.DecodePatch(jsonOp) if err != nil { return err } log.Debugf("json op: %s", jsonOp) patchedJson, err := patch.ApplyIndent(configJson, " ") if err != nil { return err } valCopy := val o.CurrentParams[ii] = valCopy if err := next(patchedJson); err != nil { return err } } return nil } ops = append(ops, f) } } return ops } func (o *GridOptimizer) Run(executor Executor, configJson []byte) (map[string][]Metric, error) { o.CurrentParams = make([]interface{}, len(o.Config.Matrix)) var valueFunctions = map[string]MetricValueFunc{ "totalProfit": TotalProfitMetricValueFunc, "totalVolume": TotalVolume, "totalEquityDiff": TotalEquityDiff, "profitFactor": ProfitFactorMetricValueFunc, } var metrics = map[string][]Metric{} var ops = o.buildOps() var taskC = make(chan BacktestTask, 10000) var taskCnt = 0 var app = func(configJson []byte, next func(configJson []byte) error) error { var labels = copyLabels(o.ParamLabels) var params = copyParams(o.CurrentParams) taskC <- BacktestTask{ ConfigJson: configJson, Params: params, Labels: labels, } return nil } var appCnt = func(configJson []byte, next func(configJson []byte) error) error { taskCnt++ return nil } log.Debugf("build %d ops", len(ops)) var wrapper = func(configJson []byte) error { return app(configJson, nil) } var wrapperCnt = func(configJson []byte) error { return appCnt(configJson, nil) } for i := len(ops) - 1; i >= 0; i-- { cur := ops[i] inner := wrapper innerCnt := wrapperCnt wrapper = func(configJson []byte) error { return cur(configJson, inner) } wrapperCnt = func(configJson []byte) error { return cur(configJson, innerCnt) } } if err := wrapperCnt(configJson); err != nil { return nil, err } var bar = pb.Full.New(taskCnt) bar.SetTemplateString(`{{ string . "log" | green}} | {{counters . }} {{bar . }} {{percent . }} {{etime . }} {{rtime . "ETA %s"}}`) ctx := context.Background() var taskGenErr error go func() { taskGenErr = wrapper(configJson) close(taskC) // this will shut down the executor }() resultsC, err := executor.Run(ctx, taskC, bar) if err != nil { return nil, err } for result := range resultsC { bar.Increment() if result.Report == nil { log.Errorf("no summaryReport found for params: %+v", result.Params) continue } for metricKey, metricFunc := range valueFunctions { var metricValue = metricFunc(result.Report) bar.Set("log", fmt.Sprintf("params: %+v => %s %+v", result.Params, metricKey, metricValue)) metrics[metricKey] = append(metrics[metricKey], Metric{ Params: result.Params, Labels: result.Labels, Key: metricKey, Value: metricValue, }) } } bar.Finish() for n := range metrics { sort.Slice(metrics[n], func(i, j int) bool { a := metrics[n][i].Value b := metrics[n][j].Value return a > b }) } if taskGenErr != nil { return metrics, taskGenErr } else { return metrics, err } } func reformatJson(text string) string { var a interface{} var err = json.Unmarshal([]byte(text), &a) if err != nil { return "{invalid json}" } out, _ := json.MarshalIndent(a, "", " ") return string(out) }