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

This commit is contained in:
zenix 2022-07-19 18:38:42 +09:00
parent b6fb5e958d
commit a5039de6aa
9 changed files with 226 additions and 40 deletions

View File

@ -20,6 +20,8 @@ exchangeStrategies:
predictOffset: 14
noStopPrice: true
noTrailingStopLoss: false
# stddev on high/low-source
hlVarianceMultiplier: 0.35
generateGraph: true
graphPNLDeductFee: false

View File

@ -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

View File

@ -1 +0,0 @@
package statistics

View File

@ -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)

30
pkg/types/omega.go Normal file
View File

@ -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
}

12
pkg/types/omega_test.go Normal file
View File

@ -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)
}

View File

@ -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))
}

View File

@ -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)

View File

@ -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)