Slippage and more statistics

This commit is contained in:
Luke I. Wilson 2023-05-18 20:17:29 -05:00
parent 565aa6c9fd
commit 5a0a4d0c33
5 changed files with 99 additions and 16 deletions

View File

@ -7,6 +7,7 @@ import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
"text/tabwriter"
"time" "time"
"github.com/go-echarts/go-echarts/v2/charts" "github.com/go-echarts/go-echarts/v2/charts"
@ -39,6 +40,29 @@ func Backtest(trader *Trader) {
log.Printf("Backtest completed on %d candles. Opening report...\n", trader.Stats().Dated.Len()) log.Printf("Backtest completed on %d candles. Opening report...\n", trader.Stats().Dated.Len())
stats := trader.Stats() stats := trader.Stats()
// Divide net profit by maximum drawdown to get the profit factor.
var maxDrawdown float64
stats.Dated.Series("Drawdown").ForEach(func(i int, val any) {
f := val.(float64)
if f > maxDrawdown {
maxDrawdown = f
}
})
profit := stats.Dated.Float("Profit", -1)
profitFactor := stats.Dated.Float("Profit", -1) / maxDrawdown
maxDrawdownPct := 100 * maxDrawdown / stats.Dated.Float("Equity", 0)
// Print a summary of the statistics to the console.
{
w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', 0)
fmt.Fprintln(w)
fmt.Fprintf(w, "Net Profit:\t$%.2f (%.2f%%)\t\n", profit, 100*profit/stats.Dated.Float("Equity", 0))
fmt.Fprintf(w, "Profit Factor:\t%.2f\t\n", profitFactor)
fmt.Fprintf(w, "Max Drawdown:\t$%.2f (%.2f%%)\t\n", maxDrawdown, maxDrawdownPct)
fmt.Fprintln(w)
w.Flush()
}
// Pick a datetime layout based on the frequency. // Pick a datetime layout based on the frequency.
dateLayout := time.DateTime dateLayout := time.DateTime
if strings.Contains(trader.Frequency, "S") { // Seconds if strings.Contains(trader.Frequency, "S") { // Seconds
@ -302,12 +326,25 @@ type TestBroker struct {
Cash float64 Cash float64
Leverage float64 Leverage float64
Spread float64 // Number of pips to add to the price when buying and subtract when selling. (Forex) Spread float64 // Number of pips to add to the price when buying and subtract when selling. (Forex)
Slippage float64 // A percentage of the price to add when buying and subtract when selling.
candleCount int // The number of candles anyone outside this broker has seen. Also equal to the number of times Candles has been called. candleCount int // The number of candles anyone outside this broker has seen. Also equal to the number of times Candles has been called.
orders []Order orders []Order
positions []Position positions []Position
} }
func NewTestBroker(dataBroker Broker, data *DataFrame, cash, leverage, spread float64, startCandles int) *TestBroker {
return &TestBroker{
DataBroker: dataBroker,
Data: data,
Cash: cash,
Leverage: Max(leverage, 1),
Spread: spread,
Slippage: 0.005, // Price +/- 0.5%
candleCount: Max(startCandles, 1),
}
}
// CandleIndex returns the index of the current candle. // CandleIndex returns the index of the current candle.
func (b *TestBroker) CandleIndex() int { func (b *TestBroker) CandleIndex() int {
return Max(b.candleCount-1, 0) return Max(b.candleCount-1, 0)
@ -321,6 +358,16 @@ func (b *TestBroker) Advance() {
} }
} }
// Bid returns the price a seller pays for the current candle.
func (b *TestBroker) Bid(_ string) float64 {
return b.Data.Close(b.CandleIndex())
}
// Ask returns the price a buyer pays for the current candle.
func (b *TestBroker) Ask(_ string) float64 {
return b.Data.Close(b.CandleIndex()) + b.Spread
}
// Candles returns the last count candles for the given symbol and frequency. If count is greater than the number of candles, then a dataframe with zero rows is returned. // Candles returns the last count candles for the given symbol and frequency. If count is greater than the number of candles, then a dataframe with zero rows is returned.
// //
// If the TestBroker has a data broker set, then it will use that to get candles. Otherwise, it will return the candles from the data that was set. The first call to Candles will fetch candles from the data broker if it is set, so it is recommended to set the data broker before the first call to Candles and to call Candles the first time with the number of candles you want to fetch. // If the TestBroker has a data broker set, then it will use that to get candles. Otherwise, it will return the candles from the data that was set. The first call to Candles will fetch candles from the data broker if it is set, so it is recommended to set the data broker before the first call to Candles and to call Candles the first time with the number of candles you want to fetch.
@ -352,7 +399,16 @@ func (b *TestBroker) MarketOrder(symbol string, units float64, stopLoss, takePro
return nil, err return nil, err
} }
} }
price := b.Data.Close(b.CandleIndex()) // Get the last close price.
var price float64
if units < 0 {
price = b.Bid("")
} else {
price = b.Ask("")
}
slippage := rand.Float64() * b.Slippage * price
price += slippage - slippage/2 // Get a slippage as +/- 50% of the slippage.
order := &TestOrder{ order := &TestOrder{
id: strconv.Itoa(rand.Int()), id: strconv.Itoa(rand.Int()),
@ -436,17 +492,6 @@ func (b *TestBroker) Positions() []Position {
return b.positions return b.positions
} }
func NewTestBroker(dataBroker Broker, data *DataFrame, cash, leverage, spread float64, startCandles int) *TestBroker {
return &TestBroker{
DataBroker: dataBroker,
Data: data,
Cash: cash,
Leverage: Max(leverage, 1),
Spread: spread,
candleCount: Max(startCandles, 1),
}
}
type TestPosition struct { type TestPosition struct {
broker *TestBroker broker *TestBroker
closed bool closed bool
@ -466,7 +511,11 @@ func (p *TestPosition) Close() error {
return ErrPositionClosed return ErrPositionClosed
} }
p.closed = true p.closed = true
p.closePrice = p.broker.Data.Close(p.broker.CandleIndex()) - p.broker.Spread // Get the last close price. if p.units < 0 {
p.closePrice = p.broker.Ask("") // Ask because we are short so we have to buy.
} else {
p.closePrice = p.broker.Bid("") // Ask because we are long so we have to sell.
}
p.broker.Cash += p.Value() // Return the value of the position to the broker. p.broker.Cash += p.Value() // Return the value of the position to the broker.
p.broker.SignalEmit("PositionClosed", p) p.broker.SignalEmit("PositionClosed", p)
return nil return nil
@ -524,8 +573,13 @@ func (p *TestPosition) Value() float64 {
if p.closed { if p.closed {
return p.closePrice * p.units return p.closePrice * p.units
} }
bid := p.broker.Data.Close(p.broker.CandleIndex()) - p.broker.Spread var price float64
return bid * p.units if p.units < 0 {
price = p.broker.Ask("")
} else {
price = p.broker.Bid("")
}
return price * p.units
} }
type TestOrder struct { type TestOrder struct {

View File

@ -90,6 +90,7 @@ func TestBacktestingBrokerFunctions(t *testing.T) {
func TestBacktestingBrokerOrders(t *testing.T) { func TestBacktestingBrokerOrders(t *testing.T) {
data := newTestingDataframe() data := newTestingDataframe()
broker := NewTestBroker(nil, data, 100_000, 50, 0, 0) broker := NewTestBroker(nil, data, 100_000, 50, 0, 0)
broker.Slippage = 0
timeBeforeOrder := time.Now() timeBeforeOrder := time.Now()
order, err := broker.MarketOrder("EUR_USD", 50_000, 0, 0) // Buy 50,000 USD for 1000 EUR with no stop loss or take profit order, err := broker.MarketOrder("EUR_USD", 50_000, 0, 0) // Buy 50,000 USD for 1000 EUR with no stop loss or take profit

View File

@ -57,6 +57,8 @@ type Position interface {
// - PositionClosed(Position) - Emitted after a position is closed either manually or automatically. // - PositionClosed(Position) - Emitted after a position is closed either manually or automatically.
type Broker interface { type Broker interface {
Signaler Signaler
Bid(symbol string) float64 // Bid returns the sell price of the symbol.
Ask(symbol string) float64 // Ask returns the buy price of the symbol, which is typically higher than the sell price.
// Candles returns a dataframe of candles for the given symbol, frequency, and count by querying the broker. // Candles returns a dataframe of candles for the given symbol, frequency, and count by querying the broker.
Candles(symbol, frequency string, count int) (*DataFrame, error) Candles(symbol, frequency string, count int) (*DataFrame, error)
MarketOrder(symbol string, units, stopLoss, takeProfit float64) (Order, error) MarketOrder(symbol string, units, stopLoss, takeProfit float64) (Order, error)

View File

@ -42,6 +42,14 @@ func NewOandaBroker(token, accountID string, practice bool) *OandaBroker {
} }
} }
func (b *OandaBroker) Bid(symbol string) float64 {
return 0
}
func (b *OandaBroker) Ask(symbol string) float64 {
return 0
}
func (b *OandaBroker) Candles(symbol, frequency string, count int) (*auto.DataFrame, error) { func (b *OandaBroker) Candles(symbol, frequency string, count int) (*auto.DataFrame, error) {
req, err := http.NewRequest("GET", b.baseUrl+"/v3/accounts/"+b.accountID+"/instruments/"+symbol+"/candles", nil) req, err := http.NewRequest("GET", b.baseUrl+"/v3/accounts/"+b.accountID+"/instruments/"+symbol+"/candles", nil)
if err != nil { if err != nil {

View File

@ -46,6 +46,7 @@ type Series interface {
Filter(f func(i int, val any) bool) Series // Where returns a new Series with only the values that return true for the given function. Filter(f func(i int, val any) bool) Series // Where returns a new Series with only the values that return true for the given function.
Map(f func(i int, val any) any) Series // Map returns a new Series with the values modified by the given function. Map(f func(i int, val any) any) Series // Map returns a new Series with the values modified by the given function.
MapReverse(f func(i int, val any) any) Series // MapReverse is the same as Map but it starts from the last item and works backwards. MapReverse(f func(i int, val any) any) Series // MapReverse is the same as Map but it starts from the last item and works backwards.
ForEach(f func(i int, val any)) Series // ForEach calls f for each item in the Series.
// Statistical functions. // Statistical functions.
@ -128,6 +129,11 @@ func (s *AppliedSeries) MapReverse(f func(i int, val any) any) Series {
return NewAppliedSeries(s.Series.MapReverse(f), s.apply) return NewAppliedSeries(s.Series.MapReverse(f), s.apply)
} }
func (s *AppliedSeries) ForEach(f func(i int, val any)) Series {
_ = s.Series.ForEach(f)
return s
}
func (s *AppliedSeries) WithValueFunc(value func(i int) any) Series { func (s *AppliedSeries) WithValueFunc(value func(i int) any) Series {
return &AppliedSeries{Series: s.Series.WithValueFunc(value), apply: s.apply} return &AppliedSeries{Series: s.Series.WithValueFunc(value), apply: s.apply}
} }
@ -189,6 +195,11 @@ func (s *RollingSeries) MapReverse(f func(i int, val any) any) Series {
return NewRollingSeries(s.Series.MapReverse(f), s.period) return NewRollingSeries(s.Series.MapReverse(f), s.period)
} }
func (s *RollingSeries) ForEach(f func(i int, val any)) Series {
_ = s.Series.ForEach(f)
return s
}
// Average is an alias for Mean. // Average is an alias for Mean.
func (s *RollingSeries) Average() *AppliedSeries { func (s *RollingSeries) Average() *AppliedSeries {
return s.Mean() return s.Mean()
@ -531,6 +542,13 @@ func (s *DataSeries) MapReverse(f func(i int, val any) any) Series {
return series return series
} }
func (s *DataSeries) ForEach(f func(i int, val any)) Series {
for i := 0; i < s.Len(); i++ {
f(i, s.value(i))
}
return s
}
func (s *DataSeries) Rolling(period int) *RollingSeries { func (s *DataSeries) Rolling(period int) *RollingSeries {
return NewRollingSeries(s, period) return NewRollingSeries(s, period)
} }