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"
"strconv"
"strings"
"text/tabwriter"
"time"
"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())
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.
dateLayout := time.DateTime
if strings.Contains(trader.Frequency, "S") { // Seconds
@ -302,12 +326,25 @@ type TestBroker struct {
Cash float64
Leverage float64
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.
orders []Order
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.
func (b *TestBroker) CandleIndex() int {
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.
//
// 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
}
}
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{
id: strconv.Itoa(rand.Int()),
@ -436,17 +492,6 @@ func (b *TestBroker) Positions() []Position {
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 {
broker *TestBroker
closed bool
@ -466,8 +511,12 @@ func (p *TestPosition) Close() error {
return ErrPositionClosed
}
p.closed = true
p.closePrice = p.broker.Data.Close(p.broker.CandleIndex()) - p.broker.Spread // Get the last close price.
p.broker.Cash += p.Value() // Return the value of the position to the broker.
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.SignalEmit("PositionClosed", p)
return nil
}
@ -524,8 +573,13 @@ func (p *TestPosition) Value() float64 {
if p.closed {
return p.closePrice * p.units
}
bid := p.broker.Data.Close(p.broker.CandleIndex()) - p.broker.Spread
return bid * p.units
var price float64
if p.units < 0 {
price = p.broker.Ask("")
} else {
price = p.broker.Bid("")
}
return price * p.units
}
type TestOrder struct {

View File

@ -90,6 +90,7 @@ func TestBacktestingBrokerFunctions(t *testing.T) {
func TestBacktestingBrokerOrders(t *testing.T) {
data := newTestingDataframe()
broker := NewTestBroker(nil, data, 100_000, 50, 0, 0)
broker.Slippage = 0
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

View File

@ -57,6 +57,8 @@ type Position interface {
// - PositionClosed(Position) - Emitted after a position is closed either manually or automatically.
type Broker interface {
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(symbol, frequency string, count int) (*DataFrame, 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) {
req, err := http.NewRequest("GET", b.baseUrl+"/v3/accounts/"+b.accountID+"/instruments/"+symbol+"/candles", 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.
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.
ForEach(f func(i int, val any)) Series // ForEach calls f for each item in the Series.
// 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)
}
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 {
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)
}
func (s *RollingSeries) ForEach(f func(i int, val any)) Series {
_ = s.Series.ForEach(f)
return s
}
// Average is an alias for Mean.
func (s *RollingSeries) Average() *AppliedSeries {
return s.Mean()
@ -531,6 +542,13 @@ func (s *DataSeries) MapReverse(f func(i int, val any) any) 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 {
return NewRollingSeries(s, period)
}