From a5039de6aa496ae0056bb04fa3e1c1cd5b194ee4 Mon Sep 17 00:00:00 2001 From: zenix Date: Tue, 19 Jul 2022 18:38:42 +0900 Subject: [PATCH] feature: add omega ratio, print sharpe/omega/interval profit from trade_stats, use stdev for high/low diff for drift to estimate the variance and improve profit, add yaml marshal for dnum fixedpoint --- config/drift.yaml | 2 + pkg/bbgo/environment.go | 4 + pkg/statistics/omega.go | 1 - pkg/strategy/drift/strategy.go | 47 +++++--- pkg/types/omega.go | 30 +++++ pkg/types/omega_test.go | 12 ++ pkg/{statistics => types}/sharp.go | 16 +-- pkg/{statistics => types}/sharp_test.go | 5 +- pkg/types/trade_stats.go | 149 +++++++++++++++++++++--- 9 files changed, 226 insertions(+), 40 deletions(-) delete mode 100644 pkg/statistics/omega.go create mode 100644 pkg/types/omega.go create mode 100644 pkg/types/omega_test.go rename pkg/{statistics => types}/sharp.go (57%) rename pkg/{statistics => types}/sharp_test.go (84%) diff --git a/config/drift.yaml b/config/drift.yaml index 157085771..406512b14 100644 --- a/config/drift.yaml +++ b/config/drift.yaml @@ -20,6 +20,8 @@ exchangeStrategies: predictOffset: 14 noStopPrice: true noTrailingStopLoss: false + # stddev on high/low-source + hlVarianceMultiplier: 0.35 generateGraph: true graphPNLDeductFee: false diff --git a/pkg/bbgo/environment.go b/pkg/bbgo/environment.go index 415bd52de..c60f05680 100644 --- a/pkg/bbgo/environment.go +++ b/pkg/bbgo/environment.go @@ -453,6 +453,10 @@ func (environ *Environment) SetStartTime(t time.Time) *Environment { return environ } +func (environ *Environment) StartTime() time.Time { + return environ.startTime +} + // SetSyncStartTime overrides the default trade scan time (-7 days) func (environ *Environment) SetSyncStartTime(t time.Time) *Environment { environ.syncStartTime = t diff --git a/pkg/statistics/omega.go b/pkg/statistics/omega.go deleted file mode 100644 index 12c0dbc25..000000000 --- a/pkg/statistics/omega.go +++ /dev/null @@ -1 +0,0 @@ -package statistics diff --git a/pkg/strategy/drift/strategy.go b/pkg/strategy/drift/strategy.go index a93d7d344..b9fb3fc1b 100644 --- a/pkg/strategy/drift/strategy.go +++ b/pkg/strategy/drift/strategy.go @@ -48,17 +48,21 @@ type Strategy struct { *types.ProfitStats `persistence:"profit_stats"` *types.TradeStats `persistence:"trade_stats"` - drift *indicator.Drift - atr *indicator.ATR - midPrice fixedpoint.Value - lock sync.RWMutex + ma types.UpdatableSeriesExtend + stdevHigh *indicator.StdDev + stdevLow *indicator.StdDev + drift *indicator.Drift + atr *indicator.ATR + midPrice fixedpoint.Value + lock sync.RWMutex - Source string `json:"source"` - StopLoss fixedpoint.Value `json:"stoploss"` - CanvasPath string `json:"canvasPath"` - PredictOffset int `json:"predictOffset"` - NoStopPrice bool `json:"noStopPrice"` - NoTrailingStopLoss bool `json:"noTrailingStopLoss"` + Source string `json:"source"` + StopLoss fixedpoint.Value `json:"stoploss"` + CanvasPath string `json:"canvasPath"` + PredictOffset int `json:"predictOffset"` + HighLowVarianceMultiplier float64 `json:"hlVarianceMultiplier"` + NoStopPrice bool `json:"noStopPrice"` + NoTrailingStopLoss bool `json:"noTrailingStopLoss"` // This is not related to trade but for statistics graph generation // Will deduct fee in percentage from every trade @@ -94,6 +98,7 @@ func (s *Strategy) Print(o *os.File) { hiyellow(f, "window: %d\n", s.Window) hiyellow(f, "noStopPrice: %v\n", s.NoStopPrice) hiyellow(f, "noTrailingStopLoss: %v\n", s.NoTrailingStopLoss) + hiyellow(f, "hlVarianceMutiplier: %f\n", s.HighLowVarianceMultiplier) hiyellow(f, "\n") } @@ -225,6 +230,9 @@ func (s *Strategy) BindStopLoss(ctx context.Context) { } func (s *Strategy) InitIndicators() error { + s.ma = &indicator.EWMA{IntervalWindow: types.IntervalWindow{Interval: s.Interval, Window: 5}} + s.stdevHigh = &indicator.StdDev{IntervalWindow: types.IntervalWindow{Interval: s.Interval, Window: 6}} + s.stdevLow = &indicator.StdDev{IntervalWindow: types.IntervalWindow{Interval: s.Interval, Window: 6}} s.drift = &indicator.Drift{ MA: &indicator.SMA{IntervalWindow: s.IntervalWindow}, IntervalWindow: s.IntervalWindow, @@ -238,6 +246,11 @@ func (s *Strategy) InitIndicators() error { for _, kline := range *klines { source := s.getSource(&kline).Float64() + high := kline.High.Float64() + low := kline.Low.Float64() + s.ma.Update(source) + s.stdevHigh.Update(high - s.ma.Last()) + s.stdevLow.Update(s.ma.Last() - low) s.drift.Update(source) s.atr.PushK(kline) } @@ -382,6 +395,9 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se if s.TradeStats == nil { s.TradeStats = types.NewTradeStats(s.Symbol) } + startTime := s.Environment.StartTime() + s.TradeStats.SetIntervalProfitCollector(types.NewIntervalProfitCollector(types.Interval1d, startTime)) + s.TradeStats.SetIntervalProfitCollector(types.NewIntervalProfitCollector(types.Interval1w, startTime)) // StrategyController s.Status = types.StrategyStatusRunning @@ -546,6 +562,7 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se source := s.getSource(dynamicKLine) sourcef := source.Float64() priceLine.Update(sourcef) + s.ma.Update(sourcef) s.drift.Update(sourcef) s.atr.PushK(kline) drift = s.drift.Array(2) @@ -555,6 +572,10 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se pricef := price.Float64() lowf := math.Min(kline.Low.Float64(), pricef) highf := math.Max(kline.High.Float64(), pricef) + lowdiff := s.ma.Last() - lowf + s.stdevLow.Update(lowdiff) + highdiff := highf - s.ma.Last() + s.stdevHigh.Update(highdiff) avg := s.Position.AverageCost.Float64() shortCondition := (driftPred <= 0 && drift[0] <= 0) @@ -581,10 +602,10 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se log.Errorf("unable to get baseBalance") return } + source = source.Add(fixedpoint.NewFromFloat(s.stdevHigh.Last() * s.HighLowVarianceMultiplier)) if source.Compare(price) < 0 { source = price } - source = source.Mul(fixedpoint.NewFromFloat(1.0002)) if s.Market.IsDustQuantity(baseBalance.Available, source) { return @@ -628,10 +649,10 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se log.WithError(err).Errorf("cannot cancel orders") return } + source = source.Sub(fixedpoint.NewFromFloat(s.stdevLow.Last() * s.HighLowVarianceMultiplier)) if source.Compare(price) > 0 { source = price } - source = source.Mul(fixedpoint.NewFromFloat(0.9998)) quoteBalance, ok := s.Session.GetAccount().Balance(s.Market.QuoteCurrency) if !ok { log.Errorf("unable to get quoteCurrency") @@ -682,7 +703,7 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se defer s.Print(os.Stdout) - defer fmt.Fprintln(os.Stdout, s.TradeStats.String()) + defer fmt.Fprintln(os.Stdout, s.TradeStats.BriefString()) if s.GenerateGraph { s.Draw(dynamicKLine.StartTime, priceLine, &profit, &cumProfit) diff --git a/pkg/types/omega.go b/pkg/types/omega.go new file mode 100644 index 000000000..f32201b7c --- /dev/null +++ b/pkg/types/omega.go @@ -0,0 +1,30 @@ +package types + +import () + +// Determines the Omega ratio of a strategy +// See https://en.wikipedia.org/wiki/Omega_ratio for more details +// +// @param returns (Series): Series of profit/loss percentage every specific interval +// @param returnThresholds(float64): threshold for returns filtering +// @return Omega ratio for give return series and threshold +func Omega(returns Series, returnThresholds ...float64) float64 { + threshold := 0.0 + if len(returnThresholds) > 0 { + threshold = returnThresholds[0] + } else { + threshold = Mean(returns) + } + length := returns.Length() + win := 0.0 + loss := 0.0 + for i := 0; i < length; i++ { + out := threshold - returns.Index(i) + if out > 0 { + win += out + } else { + loss -= out + } + } + return win / loss +} diff --git a/pkg/types/omega_test.go b/pkg/types/omega_test.go new file mode 100644 index 000000000..5a8fcce03 --- /dev/null +++ b/pkg/types/omega_test.go @@ -0,0 +1,12 @@ +package types + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestOmega(t *testing.T) { + var a Series = &Float64Slice{0.08, 0.09, 0.07, 0.15, 0.02, 0.03, 0.04, 0.05, 0.06, 0.01} + output := Omega(a) + assert.InDelta(t, output, 1, 0.0001) +} diff --git a/pkg/statistics/sharp.go b/pkg/types/sharp.go similarity index 57% rename from pkg/statistics/sharp.go rename to pkg/types/sharp.go index bf2114249..f60f00eaf 100644 --- a/pkg/statistics/sharp.go +++ b/pkg/types/sharp.go @@ -1,32 +1,28 @@ -package statistics +package types import ( "math" - - "github.com/c9s/bbgo/pkg/types" ) // Sharpe: Calcluates the sharpe ratio of access returns // +// @param returns (Series): Series of profit/loss percentage every specific interval // @param periods (int): Freq. of returns (252/365 for daily, 12 for monthy) // @param annualize (bool): return annualize sharpe? // @param smart (bool): return smart sharpe ratio -func Sharpe(returns types.Series, periods int, annualize bool, smart bool) float64 { +func Sharpe(returns Series, periods int, annualize bool, smart bool) float64 { data := returns num := data.Length() - if types.Lowest(data, num) >= 0 && types.Highest(data, num) > 1 { - data = types.PercentageChange(returns) - } - divisor := types.Stdev(data, data.Length(), 1) + divisor := Stdev(data, data.Length(), 1) if smart { sum := 0. - coef := math.Abs(types.Correlation(data, types.Shift(data, 1), num-1)) + coef := math.Abs(Correlation(data, Shift(data, 1), num-1)) for i := 1; i < num; i++ { sum += float64(num-i) / float64(num) * math.Pow(coef, float64(i)) } divisor = divisor * math.Sqrt(1.+2.*sum) } - result := types.Mean(data) / divisor + result := Mean(data) / divisor if annualize { return result * math.Sqrt(float64(periods)) } diff --git a/pkg/statistics/sharp_test.go b/pkg/types/sharp_test.go similarity index 84% rename from pkg/statistics/sharp_test.go rename to pkg/types/sharp_test.go index 7d373301c..e6f90d1c9 100644 --- a/pkg/statistics/sharp_test.go +++ b/pkg/types/sharp_test.go @@ -1,7 +1,6 @@ -package statistics +package types import ( - "github.com/c9s/bbgo/pkg/types" "github.com/stretchr/testify/assert" "testing" ) @@ -17,7 +16,7 @@ print(qx.stats.sharpe(pd.Series([0.01, 0.1, 0.001]), 0, 252, False, False)) print(qx.stats.sharpe(pd.Series([0.01, 0.1, 0.001]), 0, 252, True, False)) */ func TestSharpe(t *testing.T) { - var a types.Series = &types.Float64Slice{0.01, 0.1, 0.001} + var a Series = &Float64Slice{0.01, 0.1, 0.001} output := Sharpe(a, 0, false, false) assert.InDelta(t, output, 0.67586, 0.0001) output = Sharpe(a, 252, false, false) diff --git a/pkg/types/trade_stats.go b/pkg/types/trade_stats.go index ce0281d8c..afd4bba6c 100644 --- a/pkg/types/trade_stats.go +++ b/pkg/types/trade_stats.go @@ -1,30 +1,132 @@ package types import ( + "encoding/json" + "time" + "gopkg.in/yaml.v3" "github.com/c9s/bbgo/pkg/fixedpoint" ) +type IntervalProfitCollector struct { + Interval Interval + Profits *Float64Slice + tmpTime time.Time +} + +func NewIntervalProfitCollector(i Interval, startTime time.Time) *IntervalProfitCollector { + return &IntervalProfitCollector{Interval: i, tmpTime: startTime, Profits: &Float64Slice{1.}} +} + +// Update the collector by every traded profit +func (s *IntervalProfitCollector) Update(profit *Profit) { + if s.tmpTime.IsZero() { + panic("No valid start time. Did you create IntervalProfitCollector instance using NewIntervalProfitCollector?") + } else { + duration := s.Interval.Duration() + if profit.TradedAt.Before(s.tmpTime.Add(duration)) { + (*s.Profits)[len(*s.Profits)-1] *= 1. + profit.NetProfitMargin.Float64() + } else { + for { + s.Profits.Update(1.) + s.tmpTime = s.tmpTime.Add(duration) + if profit.TradedAt.Before(s.tmpTime.Add(duration)) { + (*s.Profits)[len(*s.Profits)-1] *= 1. + profit.NetProfitMargin.Float64() + break + } + } + } + } +} + +// Get number of profitable traded intervals +func (s *IntervalProfitCollector) GetNumOfProfitableIntervals() (profit int) { + if s.Profits == nil { + panic("profits array empty. Did you create IntervalProfitCollector instance using NewIntervalProfitCollector?") + } + for _, v := range *s.Profits { + if v > 1. { + profit += 1 + } + } + return profit +} + +// Get number of non-profitable traded intervals +// (no trade within the interval or pnl = 0 will be also included here) +func (s *IntervalProfitCollector) GetNumOfNonProfitableIntervals() (nonprofit int) { + if s.Profits == nil { + panic("profits array empty. Did you create IntervalProfitCollector instance using NewIntervalProfitCollector?") + } + for _, v := range *s.Profits { + if v <= 1. { + nonprofit += 1 + } + } + return nonprofit +} + +// Get sharpe value with the interval of profit collected. +// no smart sharpe ON for the calculated result +func (s *IntervalProfitCollector) GetSharpe() float64 { + if s.tmpTime.IsZero() { + panic("No valid start time. Did you create IntervalProfitCollector instance using NewIntervalProfitCollector?") + } + if s.Profits == nil { + panic("profits array empty. Did you create IntervalProfitCollector instance using NewIntervalProfitCollector?") + } + return Sharpe(Minus(s.Profits, 1.), s.Profits.Length(), true, false) +} + +func (s *IntervalProfitCollector) GetOmega() float64 { + return Omega(Minus(s.Profits, 1.)) +} + +func (s IntervalProfitCollector) MarshalYAML() (interface{}, error) { + result := make(map[string]interface{}) + result["Sharpe Ratio"] = s.GetSharpe() + result["Omega Ratio"] = s.GetOmega() + result["Profitable Count"] = s.GetNumOfProfitableIntervals() + result["NonProfitable Count"] = s.GetNumOfNonProfitableIntervals() + return result, nil +} + +func (s *IntervalProfitCollector) MarshalJSON() ([]byte, error) { + result := make(map[string]interface{}) + result["Sharpe Ratio"] = s.GetSharpe() + result["Omega Ratio"] = s.GetOmega() + result["Profitable Count"] = s.GetNumOfProfitableIntervals() + result["NonProfitable Count"] = s.GetNumOfNonProfitableIntervals() + return json.Marshal(result) + +} + // TODO: Add more stats from the reference: // See https://www.metatrader5.com/en/terminal/help/algotrading/testing_report type TradeStats struct { - Symbol string `json:"symbol"` - WinningRatio fixedpoint.Value `json:"winningRatio" yaml:"winningRatio"` - NumOfLossTrade int `json:"numOfLossTrade" yaml:"numOfLossTrade"` - NumOfProfitTrade int `json:"numOfProfitTrade" yaml:"numOfProfitTrade"` - GrossProfit fixedpoint.Value `json:"grossProfit" yaml:"grossProfit"` - GrossLoss fixedpoint.Value `json:"grossLoss" yaml:"grossLoss"` - Profits []fixedpoint.Value `json:"profits" yaml:"profits"` - Losses []fixedpoint.Value `json:"losses" yaml:"losses"` - MostProfitableTrade fixedpoint.Value `json:"mostProfitableTrade" yaml:"mostProfitableTrade"` - MostLossTrade fixedpoint.Value `json:"mostLossTrade" yaml:"mostLossTrade"` - ProfitFactor fixedpoint.Value `json:"profitFactor" yaml:"profitFactor"` - TotalNetProfit fixedpoint.Value `json:"totalNetProfit" yaml:"totalNetProfit"` + Symbol string `json:"symbol"` + WinningRatio fixedpoint.Value `json:"winningRatio" yaml:"winningRatio"` + NumOfLossTrade int `json:"numOfLossTrade" yaml:"numOfLossTrade"` + NumOfProfitTrade int `json:"numOfProfitTrade" yaml:"numOfProfitTrade"` + GrossProfit fixedpoint.Value `json:"grossProfit" yaml:"grossProfit"` + GrossLoss fixedpoint.Value `json:"grossLoss" yaml:"grossLoss"` + Profits []fixedpoint.Value `json:"profits" yaml:"profits"` + Losses []fixedpoint.Value `json:"losses" yaml:"losses"` + MostProfitableTrade fixedpoint.Value `json:"mostProfitableTrade" yaml:"mostProfitableTrade"` + MostLossTrade fixedpoint.Value `json:"mostLossTrade" yaml:"mostLossTrade"` + ProfitFactor fixedpoint.Value `json:"profitFactor" yaml:"profitFactor"` + TotalNetProfit fixedpoint.Value `json:"totalNetProfit" yaml:"totalNetProfit"` + IntervalProfits map[Interval]*IntervalProfitCollector `json:"intervalProfits,omitempty" yaml: "intervalProfits,omitempty"` } func NewTradeStats(symbol string) *TradeStats { - return &TradeStats{Symbol: symbol} + return &TradeStats{Symbol: symbol, IntervalProfits: make(map[Interval]*IntervalProfitCollector)} +} + +// Set IntervalProfitCollector explicitly to enable the sharpe ratio calculation +func (s *TradeStats) SetIntervalProfitCollector(c *IntervalProfitCollector) { + s.IntervalProfits[c.Interval] = c } func (s *TradeStats) Add(profit *Profit) { @@ -33,6 +135,9 @@ func (s *TradeStats) Add(profit *Profit) { } s.add(profit.Profit) + for _, v := range s.IntervalProfits { + v.Update(profit) + } } func (s *TradeStats) add(pnl fixedpoint.Value) { @@ -61,6 +166,24 @@ func (s *TradeStats) add(pnl fixedpoint.Value) { s.ProfitFactor = s.GrossProfit.Div(s.GrossLoss.Abs()) } +// Output TradeStats without Profits and Losses +func (s *TradeStats) BriefString() string { + out, _ := yaml.Marshal(&TradeStats{ + Symbol: s.Symbol, + WinningRatio: s.WinningRatio, + NumOfLossTrade: s.NumOfLossTrade, + NumOfProfitTrade: s.NumOfProfitTrade, + GrossProfit: s.GrossProfit, + GrossLoss: s.GrossLoss, + MostProfitableTrade: s.MostProfitableTrade, + MostLossTrade: s.MostLossTrade, + ProfitFactor: s.ProfitFactor, + TotalNetProfit: s.TotalNetProfit, + IntervalProfits: s.IntervalProfits, + }) + return string(out) +} + func (s *TradeStats) String() string { out, _ := yaml.Marshal(s) return string(out)