mirror of
https://github.com/lukewilson2002/autotrader.git
synced 2025-06-14 07:53:51 +00:00
Fixed so many IndexedSeries bugs...
This commit is contained in:
parent
c0de28664e
commit
46fd55ab8d
File diff suppressed because it is too large
Load Diff
@ -40,6 +40,7 @@ func Backtest(trader *Trader) {
|
||||
|
||||
log.Printf("Backtest completed on %d candles. Opening report...\n", trader.Stats().Dated.Len())
|
||||
stats := trader.Stats()
|
||||
// log.Println(trader.Stats().Dated.String())
|
||||
|
||||
// Divide net profit by maximum drawdown to get the profit factor.
|
||||
var maxDrawdown float64
|
||||
@ -204,13 +205,13 @@ func Backtest(trader *Trader) {
|
||||
}
|
||||
}
|
||||
|
||||
func newKline(dohlcv *Frame, trades *Series, dateLayout string) *charts.Kline {
|
||||
func newKline(dohlcv *IndexedFrame[UnixTime], trades *Series, dateLayout string) *charts.Kline {
|
||||
kline := charts.NewKLine()
|
||||
|
||||
x := make([]string, dohlcv.Len())
|
||||
y := make([]opts.KlineData, dohlcv.Len())
|
||||
for i := 0; i < dohlcv.Len(); i++ {
|
||||
x[i] = dohlcv.Date(i).Format(dateLayout)
|
||||
x[i] = dohlcv.Date(i).Time().Format(dateLayout)
|
||||
y[i] = opts.KlineData{Value: [4]float64{
|
||||
dohlcv.Open(i),
|
||||
dohlcv.Close(i),
|
||||
@ -325,7 +326,7 @@ func seriesStringArray(s *Series, dateLayout string) []string {
|
||||
type TestBroker struct {
|
||||
SignalManager
|
||||
DataBroker Broker
|
||||
Data *Frame
|
||||
Data *IndexedFrame[UnixTime]
|
||||
Cash float64
|
||||
Leverage float64
|
||||
Spread float64 // Number of pips to add to the price when buying and subtract when selling. (Forex)
|
||||
@ -337,7 +338,7 @@ type TestBroker struct {
|
||||
spreadCollectedUSD float64 // Total amount of spread collected from trades.
|
||||
}
|
||||
|
||||
func NewTestBroker(dataBroker Broker, data *Frame, cash, leverage, spread float64, startCandles int) *TestBroker {
|
||||
func NewTestBroker(dataBroker Broker, data *IndexedFrame[UnixTime], cash, leverage, spread float64, startCandles int) *TestBroker {
|
||||
return &TestBroker{
|
||||
DataBroker: dataBroker,
|
||||
Data: data,
|
||||
@ -445,7 +446,7 @@ func (b *TestBroker) Ask(_ string) float64 {
|
||||
// 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.
|
||||
func (b *TestBroker) Candles(symbol string, frequency string, count int) (*Frame, error) {
|
||||
func (b *TestBroker) Candles(symbol string, frequency string, count int) (*IndexedFrame[UnixTime], error) {
|
||||
start := Max(Max(b.candleCount, 1)-count, 0)
|
||||
adjCount := b.candleCount - start
|
||||
|
||||
|
@ -1,42 +1,49 @@
|
||||
package autotrader
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testDataCSV = `date,open,high,low,close,volume
|
||||
2022-01-01,1.1,1.2,1.0,1.15,100
|
||||
2022-01-02,1.15,1.2,1.1,1.2,110
|
||||
2022-01-03,1.2,1.3,1.15,1.25,120
|
||||
2022-01-04,1.25,1.3,1.0,1.1,130
|
||||
2022-01-05,1.1,1.2,1.0,1.15,110
|
||||
2022-01-06,1.15,1.2,1.1,1.2,120
|
||||
2022-01-07,1.2,1.3,1.15,1.25,140
|
||||
2022-01-08,1.25,1.3,1.0,1.1,150
|
||||
2022-01-09,1.1,1.4,1.0,1.3,220`
|
||||
|
||||
func newTestingDataframe() *Frame {
|
||||
data, err := DataFrameFromCSVReaderLayout(strings.NewReader(testDataCSV), DataCSVLayout{
|
||||
LatestFirst: false,
|
||||
DateFormat: "2006-01-02",
|
||||
Date: "date",
|
||||
Open: "open",
|
||||
High: "high",
|
||||
Low: "low",
|
||||
Close: "close",
|
||||
Volume: "volume",
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
var testData = func() *IndexedFrame[UnixTime] {
|
||||
type candlestick struct {
|
||||
Date time.Time
|
||||
Open float64
|
||||
High float64
|
||||
Low float64
|
||||
Close float64
|
||||
Volume float64
|
||||
}
|
||||
return data
|
||||
}
|
||||
candlesticks := []candlestick{
|
||||
{time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), 1.1, 1.2, 1.0, 1.15, 100},
|
||||
{time.Date(2022, 1, 2, 0, 0, 0, 0, time.UTC), 1.15, 1.2, 1.1, 1.2, 110},
|
||||
{time.Date(2022, 1, 3, 0, 0, 0, 0, time.UTC), 1.2, 1.3, 1.15, 1.25, 120},
|
||||
{time.Date(2022, 1, 4, 0, 0, 0, 0, time.UTC), 1.25, 1.3, 1.0, 1.1, 130},
|
||||
{time.Date(2022, 1, 5, 0, 0, 0, 0, time.UTC), 1.1, 1.2, 1.0, 1.15, 110},
|
||||
{time.Date(2022, 1, 6, 0, 0, 0, 0, time.UTC), 1.15, 1.2, 1.1, 1.2, 120},
|
||||
{time.Date(2022, 1, 7, 0, 0, 0, 0, time.UTC), 1.2, 1.3, 1.15, 1.25, 140},
|
||||
{time.Date(2022, 1, 8, 0, 0, 0, 0, time.UTC), 1.25, 1.3, 1.0, 1.1, 150},
|
||||
{time.Date(2022, 1, 9, 0, 0, 0, 0, time.UTC), 1.1, 1.4, 1.0, 1.3, 160},
|
||||
}
|
||||
frame := NewIndexedFrame(
|
||||
NewIndexedSeries[UnixTime, any]("Open", nil),
|
||||
NewIndexedSeries[UnixTime, any]("High", nil),
|
||||
NewIndexedSeries[UnixTime, any]("Low", nil),
|
||||
NewIndexedSeries[UnixTime, any]("Close", nil),
|
||||
NewIndexedSeries[UnixTime, any]("Volume", nil),
|
||||
)
|
||||
for _, c := range candlesticks {
|
||||
frame.Series("Open").Insert(UnixTime(c.Date.Unix()), c.Open)
|
||||
frame.Series("High").Insert(UnixTime(c.Date.Unix()), c.High)
|
||||
frame.Series("Low").Insert(UnixTime(c.Date.Unix()), c.Low)
|
||||
frame.Series("Close").Insert(UnixTime(c.Date.Unix()), c.Close)
|
||||
frame.Series("Volume").Insert(UnixTime(c.Date.Unix()), c.Volume)
|
||||
}
|
||||
return frame
|
||||
}()
|
||||
|
||||
func TestBacktestingBrokerCandles(t *testing.T) {
|
||||
data := newTestingDataframe()
|
||||
broker := NewTestBroker(nil, data, 0, 0, 0, 0)
|
||||
broker := NewTestBroker(nil, testData, 0, 0, 0, 0)
|
||||
|
||||
candles, err := broker.Candles("EUR_USD", "D", 3)
|
||||
if err != nil {
|
||||
@ -45,8 +52,9 @@ func TestBacktestingBrokerCandles(t *testing.T) {
|
||||
if candles.Len() != 1 {
|
||||
t.Errorf("Expected 1 candle, got %d", candles.Len())
|
||||
}
|
||||
if candles.Date(0) != time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC) {
|
||||
t.Errorf("Expected first candle to be 2022-01-01, got %s", candles.Date(0))
|
||||
expected := time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
if !candles.Date(0).Time().Equal(expected) {
|
||||
t.Errorf("Expected first candle to be %s, got %s", expected, candles.Date(0))
|
||||
}
|
||||
|
||||
broker.Advance()
|
||||
@ -57,11 +65,12 @@ func TestBacktestingBrokerCandles(t *testing.T) {
|
||||
if candles.Len() != 2 {
|
||||
t.Errorf("Expected 2 candles, got %d", candles.Len())
|
||||
}
|
||||
if candles.Date(1) != time.Date(2022, 1, 2, 0, 0, 0, 0, time.UTC) {
|
||||
t.Errorf("Expected second candle to be 2022-01-02, got %s", candles.Date(1))
|
||||
expected = time.Date(2022, 1, 2, 0, 0, 0, 0, time.UTC)
|
||||
if !candles.Date(1).Time().Equal(expected) {
|
||||
t.Errorf("Expected second candle to be %s, got %s", expected, candles.Date(1))
|
||||
}
|
||||
|
||||
for i := 0; i < 7; i++ { // 6 because we want to call broker.Candles 9 times total
|
||||
for i := 0; i < 7; i++ { // 6 because we want to call broker.Advance() 9 times total
|
||||
broker.Advance()
|
||||
candles, err = broker.Candles("EUR_USD", "D", 5)
|
||||
if err != nil && err != ErrEOF && i != 6 { // Allow ErrEOF on last iteration.
|
||||
@ -80,8 +89,7 @@ func TestBacktestingBrokerCandles(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBacktestingBrokerMarketOrders(t *testing.T) {
|
||||
data := newTestingDataframe()
|
||||
broker := NewTestBroker(nil, data, 100_000, 50, 0, 0)
|
||||
broker := NewTestBroker(nil, testData, 100_000, 50, 0, 0)
|
||||
broker.Slippage = 0
|
||||
|
||||
timeBeforeOrder := time.Now()
|
||||
@ -174,8 +182,7 @@ func TestBacktestingBrokerMarketOrders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBacktestingBrokerLimitOrders(t *testing.T) {
|
||||
data := newTestingDataframe()
|
||||
broker := NewTestBroker(nil, data, 100_000, 50, 0, 0)
|
||||
broker := NewTestBroker(nil, testData, 100_000, 50, 0, 0)
|
||||
broker.Slippage = 0
|
||||
|
||||
order, err := broker.Order(Limit, "EUR_USD", -50_000, 1.3, 1.35, 1.1) // Sell limit 50,000 USD for 1000 EUR
|
||||
@ -224,8 +231,7 @@ func TestBacktestingBrokerLimitOrders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBacktestingBrokerStopOrders(t *testing.T) {
|
||||
data := newTestingDataframe()
|
||||
broker := NewTestBroker(nil, data, 100_000, 50, 0, 0)
|
||||
broker := NewTestBroker(nil, testData, 100_000, 50, 0, 0)
|
||||
broker.Slippage = 0
|
||||
|
||||
order, err := broker.Order(Stop, "EUR_USD", 50_000, 1.2, 1, 1.3) // Buy stop 50,000 EUR for 1000 USD
|
||||
@ -273,8 +279,7 @@ func TestBacktestingBrokerStopOrders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBacktestingBrokerStopLossTakeProfit(t *testing.T) {
|
||||
data := newTestingDataframe()
|
||||
broker := NewTestBroker(nil, data, 100_000, 50, 0, 0)
|
||||
broker := NewTestBroker(nil, testData, 100_000, 50, 0, 0)
|
||||
broker.Slippage = 0
|
||||
|
||||
order, err := broker.Order(Market, "", 10_000, 0, 1.05, 1.25)
|
||||
|
@ -73,7 +73,7 @@ type Broker interface {
|
||||
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) (*Frame, error)
|
||||
Candles(symbol, frequency string, count int) (*IndexedFrame[UnixTime], error)
|
||||
// Order places an order with orderType for the given symbol and returns an error if it fails. A short position has negative units. If the orderType is Market, the price argument will be ignored and the order will be fulfilled at current price. Otherwise, price is used to set the target price for Stop and Limit orders. If stopLoss or takeProfit are zero, they will not be set. If the stopLoss is greater than the current price for a long position or less than the current price for a short position, the order will fail. Likewise for takeProfit. If the stopLoss is a negative number, it is used as a trailing stop loss to represent how many price points away the stop loss should be from the current price.
|
||||
Order(orderType OrderType, symbol string, units, price, stopLoss, takeProfit float64) (Order, error)
|
||||
NAV() float64 // NAV returns the net asset value of the account.
|
||||
|
35
cmd/ichimoku.go
Normal file
35
cmd/ichimoku.go
Normal file
@ -0,0 +1,35 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import auto "github.com/fivemoreminix/autotrader"
|
||||
|
||||
type IchimokuStrategy struct {
|
||||
convPeriod, basePeriod, leadingPeriods int
|
||||
}
|
||||
|
||||
func (s *IchimokuStrategy) Init(_ *auto.Trader) {
|
||||
}
|
||||
|
||||
func (s *IchimokuStrategy) Next(t *auto.Trader) {
|
||||
ichimoku := auto.Ichimoku(t.Data().Closes(), s.convPeriod, s.basePeriod, s.leadingPeriods)
|
||||
time := t.Data().Date(-1)
|
||||
// If the price crosses above the Conversion Line, buy.
|
||||
if auto.CrossoverIndex(*time, t.Data().Closes(), ichimoku.Series("Conversion")) {
|
||||
t.Buy(1000)
|
||||
}
|
||||
// If the price crosses below the Conversion Line, sell.
|
||||
if auto.CrossoverIndex(*time, ichimoku.Series("Conversion"), t.Data().Closes()) {
|
||||
t.Sell(1000)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
auto.Backtest(auto.NewTrader(auto.TraderConfig{
|
||||
Broker: auto.NewTestBroker(nil, nil, 10000, 50, 0.0002, 0),
|
||||
Strategy: &IchimokuStrategy{convPeriod: 9, basePeriod: 26, leadingPeriods: 52},
|
||||
Symbol: "EUR_USD",
|
||||
Frequency: "M15",
|
||||
CandlesToKeep: 2500,
|
||||
}))
|
||||
}
|
18
cmd/oanda_testing.go
Normal file
18
cmd/oanda_testing.go
Normal file
@ -0,0 +1,18 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/fivemoreminix/autotrader/oanda"
|
||||
)
|
||||
|
||||
func main() {
|
||||
broker := oanda.NewOandaBroker(os.Getenv("OANDA_TOKEN"), os.Getenv("OANDA_ACCOUNT_ID"), true)
|
||||
candles, err := broker.Candles("EUR_USD", "D", 100)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
println(candles.String())
|
||||
}
|
@ -1,7 +1,12 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
auto "github.com/fivemoreminix/autotrader"
|
||||
"github.com/fivemoreminix/autotrader/oanda"
|
||||
)
|
||||
|
||||
type SMAStrategy struct {
|
||||
@ -12,27 +17,29 @@ func (s *SMAStrategy) Init(_ *auto.Trader) {
|
||||
}
|
||||
|
||||
func (s *SMAStrategy) Next(t *auto.Trader) {
|
||||
sma1 := t.Data().Closes().Rolling(s.period1).Mean()
|
||||
sma2 := t.Data().Closes().Rolling(s.period2).Mean()
|
||||
sma1 := t.Data().Closes().Copy().Rolling(s.period1).Mean()
|
||||
sma2 := t.Data().Closes().Copy().Rolling(s.period2).Mean()
|
||||
// If the shorter SMA crosses above the longer SMA, buy.
|
||||
if auto.Crossover(sma1, sma2) {
|
||||
if auto.CrossoverIndex(*t.Data().Date(-1), sma1, sma2) {
|
||||
t.Buy(1000)
|
||||
} else if auto.Crossover(sma2, sma1) {
|
||||
} else if auto.CrossoverIndex(*t.Data().Date(-1), sma2, sma1) {
|
||||
t.Sell(1000)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
data, err := auto.EURUSD()
|
||||
/* data, err := auto.EURUSD()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
*/
|
||||
broker := oanda.NewOandaBroker(os.Getenv("OANDA_TOKEN"), os.Getenv("OANDA_ACCOUNT_ID"), true)
|
||||
|
||||
auto.Backtest(auto.NewTrader(auto.TraderConfig{
|
||||
Broker: auto.NewTestBroker(nil, data, 10000, 50, 0.0002, 0),
|
||||
Strategy: &SMAStrategy{period1: 20, period2: 40},
|
||||
Broker: auto.NewTestBroker(broker /* data, */, nil, 10000, 50, 0.0002, 0),
|
||||
Strategy: &SMAStrategy{period1: 10, period2: 25},
|
||||
Symbol: "EUR_USD",
|
||||
Frequency: "D",
|
||||
CandlesToKeep: 1000,
|
||||
Frequency: "M15",
|
||||
CandlesToKeep: 2500,
|
||||
}))
|
||||
}
|
||||
|
120
data.go
120
data.go
@ -1,120 +0,0 @@
|
||||
package autotrader
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DataCSVLayout struct {
|
||||
LatestFirst bool // Whether the latest data is first in the dataframe. If false, the latest data is last.
|
||||
DateFormat string // The format of the date column. Example: "03/22/2006". See https://pkg.go.dev/time#pkg-constants for more information.
|
||||
Date string
|
||||
Open string
|
||||
High string
|
||||
Low string
|
||||
Close string
|
||||
Volume string
|
||||
}
|
||||
|
||||
func EURUSD() (*Frame, error) {
|
||||
return DataFrameFromCSVLayout("./EUR_USD Historical Data.csv", DataCSVLayout{
|
||||
LatestFirst: true,
|
||||
DateFormat: "01/02/2006",
|
||||
Date: "\ufeff\"Date\"",
|
||||
Open: "Open",
|
||||
High: "High",
|
||||
Low: "Low",
|
||||
Close: "Price",
|
||||
Volume: "Vol.",
|
||||
})
|
||||
}
|
||||
|
||||
func DataFrameFromCSVLayout(path string, layout DataCSVLayout) (*Frame, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return DataFrameFromCSVReaderLayout(f, layout)
|
||||
}
|
||||
|
||||
func DataFrameFromCSVReaderLayout(r io.Reader, layout DataCSVLayout) (*Frame, error) {
|
||||
data, err := DataFrameFromCSVReader(r, layout.DateFormat, layout.LatestFirst)
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
// Rename the columns and remove any columns that are not needed.
|
||||
for _, name := range data.Names() {
|
||||
var newName string
|
||||
switch name {
|
||||
case layout.Date:
|
||||
newName = "Date"
|
||||
case layout.Open:
|
||||
newName = "Open"
|
||||
case layout.High:
|
||||
newName = "High"
|
||||
case layout.Low:
|
||||
newName = "Low"
|
||||
case layout.Close:
|
||||
newName = "Close"
|
||||
case layout.Volume:
|
||||
newName = "Volume"
|
||||
default:
|
||||
data.RemoveSeries(name)
|
||||
continue
|
||||
}
|
||||
data.Series(name).SetName(newName)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func DataFrameFromCSVReader(r io.Reader, dateLayout string, readReversed bool) (*Frame, error) {
|
||||
csv := csv.NewReader(r)
|
||||
csv.LazyQuotes = true
|
||||
|
||||
seriesSlice := make([]*Series, 0, 12)
|
||||
|
||||
// Read the CSV file.
|
||||
for {
|
||||
rec, err := csv.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create the columns needed.
|
||||
if len(seriesSlice) == 0 {
|
||||
for _, val := range rec {
|
||||
seriesSlice = append(seriesSlice, NewSeries(val))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Add rows to the series.
|
||||
for j, val := range rec {
|
||||
series := seriesSlice[j]
|
||||
if f, err := strconv.ParseFloat(val, 64); err == nil {
|
||||
series.Push(f)
|
||||
} else if t, err := time.Parse(dateLayout, val); err == nil {
|
||||
series.Push(t)
|
||||
} else {
|
||||
series.Push(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse the series if needed.
|
||||
if readReversed {
|
||||
for _, series := range seriesSlice {
|
||||
series.Reverse()
|
||||
}
|
||||
}
|
||||
|
||||
return NewFrame(seriesSlice...), nil
|
||||
}
|
42
data_test.go
42
data_test.go
@ -1,42 +0,0 @@
|
||||
package autotrader
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReadDataCSV(t *testing.T) {
|
||||
data, err := EURUSD()
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %s", err)
|
||||
}
|
||||
|
||||
if data.Len() != 2610 {
|
||||
t.Fatalf("Expected 2610 rows, got %d", data.Len())
|
||||
}
|
||||
if len(data.Names()) != 6 {
|
||||
t.Fatalf("Expected 6 columns, got %d", len(data.Names()))
|
||||
}
|
||||
if data.Series("Date") == nil {
|
||||
t.Fatalf("Expected Date column, got nil")
|
||||
}
|
||||
if data.Series("Open") == nil {
|
||||
t.Fatalf("Expected Open column, got nil")
|
||||
}
|
||||
if data.Series("High") == nil {
|
||||
t.Fatalf("Expected High column, got nil")
|
||||
}
|
||||
if data.Series("Low") == nil {
|
||||
t.Fatalf("Expected Low column, got nil")
|
||||
}
|
||||
if data.Series("Close") == nil {
|
||||
t.Fatalf("Expected Close column, got nil")
|
||||
}
|
||||
if data.Series("Volume") == nil {
|
||||
t.Fatalf("Expected Volume column, got nil")
|
||||
}
|
||||
|
||||
if data.Series("Date").Time(0).Equal(time.Time{}) {
|
||||
t.Fatalf("Expected Date column to have type time.Time, got %s", data.Value("Date", 0))
|
||||
}
|
||||
}
|
@ -10,30 +10,14 @@ import (
|
||||
"golang.org/x/exp/maps"
|
||||
)
|
||||
|
||||
type UnixTime int64
|
||||
|
||||
func (t UnixTime) Time() time.Time {
|
||||
return time.Unix(int64(t), 0)
|
||||
}
|
||||
|
||||
func (t UnixTime) String() string {
|
||||
return t.Time().String()
|
||||
}
|
||||
|
||||
func UnixTimeStep(frequency time.Duration) func(UnixTime, int) UnixTime {
|
||||
return func(t UnixTime, amt int) UnixTime {
|
||||
return UnixTime(t.Time().Add(frequency * time.Duration(amt)).Unix())
|
||||
}
|
||||
}
|
||||
|
||||
// It is worth mentioning that if you want to use time.Time as an index type, then you should use the public UnixTime as a Unix int64 time which can be converted back into a time.Time easily. See [time.Time](https://pkg.go.dev/time#Time) for more information on why you should not compare Time with == (or a map, which is what the IndexedFrame uses).
|
||||
type IndexedFrame[I comparable] struct {
|
||||
type IndexedFrame[I Index] struct {
|
||||
*SignalManager
|
||||
series map[string]*IndexedSeries[I]
|
||||
}
|
||||
|
||||
// It is worth mentioning that if you want to use time.Time as an index type, then you should use int64 as a Unix time. See [time.Time](https://pkg.go.dev/time#Time) for more information on why you should not compare Time with == (or a map, which is what the IndexedFrame uses).
|
||||
func NewIndexedFrame[I comparable](series ...*IndexedSeries[I]) *IndexedFrame[I] {
|
||||
func NewIndexedFrame[I Index](series ...*IndexedSeries[I]) *IndexedFrame[I] {
|
||||
f := &IndexedFrame[I]{
|
||||
&SignalManager{},
|
||||
make(map[string]*IndexedSeries[I], len(series)),
|
||||
@ -46,10 +30,10 @@ func NewIndexedFrame[I comparable](series ...*IndexedSeries[I]) *IndexedFrame[I]
|
||||
// Use the PushCandle method to add candlesticks in an easy and type-safe way.
|
||||
//
|
||||
// It is worth mentioning that if you want to use time.Time as an index type, then you should use int64 as a Unix time. See [time.Time](https://pkg.go.dev/time#Time) for more information on why you should not compare Time with == (or a map, which is what the IndexedFrame uses).
|
||||
func NewDOHLCVIndexedFrame[I comparable]() *IndexedFrame[I] {
|
||||
func NewDOHLCVIndexedFrame[I Index]() *IndexedFrame[I] {
|
||||
frame := NewIndexedFrame[I]()
|
||||
for _, name := range []string{"Open", "High", "Low", "Close", "Volume"} {
|
||||
frame.PushSeries(NewIndexedSeries[I](name, nil))
|
||||
frame.PushSeries(NewIndexedSeries[I, any](name, nil))
|
||||
}
|
||||
return frame
|
||||
}
|
||||
@ -140,7 +124,7 @@ func (f *IndexedFrame[I]) String() string {
|
||||
fmt.Fprintf(t, "%d\t%v\t%s\t\n", row, index, strings.Join(seriesVals, "\t"))
|
||||
}
|
||||
|
||||
indexes := maps.Keys(series[0].index)
|
||||
indexes := series[0].indexes
|
||||
// Print the first ten rows and the last ten rows if the IndexedFrame has more than 20 rows.
|
||||
if f.Len() > 20 {
|
||||
for i := 0; i < 10; i++ {
|
||||
@ -226,11 +210,6 @@ func (f *IndexedFrame[I]) VolumeIndex(index I) int {
|
||||
return f.IntIndex("Volume", index)
|
||||
}
|
||||
|
||||
// Dates returns a Series of all the dates in the IndexedFrame. This is equivalent to calling Series("Date").
|
||||
func (f *IndexedFrame[I]) Dates() *IndexedSeries[I] {
|
||||
return f.Series("Date")
|
||||
}
|
||||
|
||||
// Opens returns a FloatSeries of all the open prices in the IndexedFrame. This is equivalent to calling Series("Open").
|
||||
func (f *IndexedFrame[I]) Opens() *IndexedSeries[I] {
|
||||
return f.Series("Open")
|
||||
@ -276,11 +255,11 @@ func (f *IndexedFrame[I]) PushCandle(date I, open, high, low, close float64, vol
|
||||
if !f.ContainsDOHLCV() {
|
||||
return fmt.Errorf("IndexedFrame does not contain Open, High, Low, Close, Volume columns")
|
||||
}
|
||||
f.series["Open"].Push(date, open)
|
||||
f.series["High"].Push(date, high)
|
||||
f.series["Low"].Push(date, low)
|
||||
f.series["Close"].Push(date, close)
|
||||
f.series["Volume"].Push(date, volume)
|
||||
f.series["Open"].Insert(date, open)
|
||||
f.series["High"].Insert(date, high)
|
||||
f.series["Low"].Insert(date, low)
|
||||
f.series["Close"].Insert(date, close)
|
||||
f.series["Volume"].Insert(date, volume)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -96,11 +96,11 @@ func TestIndexedFrame(t *testing.T) {
|
||||
if data.Close(-1) != 1.0 {
|
||||
t.Fatalf("Expected latest close to be 1.0, got %f", data.Close(-1))
|
||||
}
|
||||
if !data.Date(0).Time().Equal(time.Date(2021, 5, 15, 0, 0, 0, 0, time.UTC)) {
|
||||
if !data.Date(-1).Time().Equal(time.Date(2021, 5, 15, 0, 0, 0, 0, time.UTC)) {
|
||||
t.Fatalf("Expected first date to be 2021-05-15, got %v", data.Date(0))
|
||||
}
|
||||
if index := UnixTime(time.Date(2021, 5, 15, 0, 0, 0, 0, time.UTC).Unix()); data.CloseIndex(index) != 1.4 {
|
||||
t.Fatalf("Expected close at 2021-05-15 to be 1.4, got %f", data.CloseIndex(index))
|
||||
if index := UnixTime(time.Date(2021, 5, 15, 0, 0, 0, 0, time.UTC).Unix()); data.CloseIndex(index) != 1.0 {
|
||||
t.Fatalf("Expected close at 2021-05-15 to be 1.0, got %f", data.CloseIndex(index))
|
||||
}
|
||||
|
||||
t.Log(data.String())
|
||||
|
@ -58,7 +58,7 @@ func (b *OandaBroker) Ask(symbol string) float64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (b *OandaBroker) Candles(symbol, frequency string, count int) (*auto.Frame, error) {
|
||||
func (b *OandaBroker) Candles(symbol, frequency string, count int) (*auto.IndexedFrame[auto.UnixTime], error) {
|
||||
req, err := http.NewRequest("GET", b.baseUrl+"/v3/accounts/"+b.accountID+"/instruments/"+symbol+"/candles", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -113,11 +113,11 @@ func (b *OandaBroker) Positions() []auto.Position {
|
||||
func (b *OandaBroker) fetchAccountUpdates() {
|
||||
}
|
||||
|
||||
func newDataframe(candles *CandlestickResponse) (*auto.Frame, error) {
|
||||
func newDataframe(candles *CandlestickResponse) (*auto.IndexedFrame[auto.UnixTime], error) {
|
||||
if candles == nil {
|
||||
return nil, fmt.Errorf("candles is nil or empty")
|
||||
}
|
||||
data := auto.NewDOHLCVFrame()
|
||||
data := auto.NewDOHLCVIndexedFrame[auto.UnixTime]()
|
||||
for _, candle := range candles.Candles {
|
||||
if candle.Mid == nil {
|
||||
return nil, fmt.Errorf("mid is nil or empty")
|
||||
@ -127,7 +127,7 @@ func newDataframe(candles *CandlestickResponse) (*auto.Frame, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing mid field of a candlestick: %w", err)
|
||||
}
|
||||
data.PushCandle(candle.Time, o, h, l, c, int64(candle.Volume))
|
||||
data.PushCandle(auto.UnixTime(candle.Time.Unix()), o, h, l, c, int64(candle.Volume))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
28
series.go
28
series.go
@ -108,6 +108,19 @@ func (s *Series) Reverse() *Series {
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Series) Insert(i int, value any) *Series {
|
||||
i = EasyIndex(i, s.Len()+1)
|
||||
if i < 0 {
|
||||
return s
|
||||
} else if i <= s.Len() { // Remember the length will grow by 1. We want to allow inserting at the end.
|
||||
s.data = slices.Insert(s.data, i, value)
|
||||
s.SignalEmit("LengthChanged", s.Len())
|
||||
} else {
|
||||
_ = s.Push(value) // Emits a LengthChanged signal
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Remove removes and returns the value at index i and emits a LengthChanged signal. If i is out of bounds then nil is returned.
|
||||
func (s *Series) Remove(i int) any {
|
||||
if i = EasyIndex(i, s.Len()); i < s.Len() && i >= 0 {
|
||||
@ -437,14 +450,17 @@ func NewRollingSeries(series *Series, period int) *RollingSeries {
|
||||
|
||||
// Period returns a slice of 'any' values with a length up to the period of the RollingSeries. The last item in the slice is the item at row. If row is out of bounds, nil is returned.
|
||||
func (s *RollingSeries) Period(row int) []any {
|
||||
items := make([]any, 0, s.period)
|
||||
row = EasyIndex(row, s.series.Len())
|
||||
if row < 0 || row >= s.series.Len() {
|
||||
return items
|
||||
}
|
||||
for j := row; j > row-s.period && j >= 0; j-- {
|
||||
items = slices.Insert(items, 0, s.series.Value(j))
|
||||
// Collect a valid range which is clamped between bounds for safety.
|
||||
start := Max(row-(s.period-1), 0) // Don't let the start go out of bounds.
|
||||
period := Min(s.period, row-start+1) // Maximum period we can get.
|
||||
start, end := s.series.Range(start, period) // Calculate start and end range within bounds.
|
||||
if start == end {
|
||||
return nil
|
||||
}
|
||||
count := end - start
|
||||
items := make([]any, count)
|
||||
copy(items, s.series.data[start:end])
|
||||
return items
|
||||
}
|
||||
|
||||
|
@ -1,9 +1,15 @@
|
||||
package autotrader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
anymath "github.com/spatialcurrent/go-math/pkg/math"
|
||||
"golang.org/x/exp/constraints"
|
||||
"golang.org/x/exp/maps"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
type ErrIndexExists struct {
|
||||
@ -14,26 +20,49 @@ func (e ErrIndexExists) Error() string {
|
||||
return fmt.Sprintf("index already exists: %v", e.any)
|
||||
}
|
||||
|
||||
// IndexedSeries is a Series with a custom index type.
|
||||
type IndexedSeries[I comparable] struct {
|
||||
*SignalManager
|
||||
series *Series
|
||||
index map[I]int
|
||||
// UnixTime is a wrapper over the number of milliseconds since January 1, 1970, AKA Unix time.
|
||||
type UnixTime int64
|
||||
|
||||
// Time converts the UnixTime to a time.Time.
|
||||
func (t UnixTime) Time() time.Time {
|
||||
return time.Unix(int64(t), 0)
|
||||
}
|
||||
|
||||
func NewIndexedSeries[I comparable](name string, vals map[I]any) *IndexedSeries[I] {
|
||||
// String returns the string representation of the UnixTime.
|
||||
func (t UnixTime) String() string {
|
||||
return t.Time().UTC().String()
|
||||
}
|
||||
|
||||
// UnixTimeStep returns a function that adds a number of increments to a UnixTime.
|
||||
func UnixTimeStep(frequency time.Duration) func(UnixTime, int) UnixTime {
|
||||
return func(t UnixTime, amt int) UnixTime {
|
||||
return UnixTime(t.Time().Add(frequency * time.Duration(amt)).Unix())
|
||||
}
|
||||
}
|
||||
|
||||
type Index interface {
|
||||
comparable
|
||||
constraints.Ordered
|
||||
}
|
||||
|
||||
// IndexedSeries is a Series with a custom index type.
|
||||
type IndexedSeries[I Index] struct {
|
||||
*SignalManager
|
||||
series *Series
|
||||
indexes []I // Sorted slice of indexes.
|
||||
index map[I]int
|
||||
}
|
||||
|
||||
// NewIndexedSeries returns a new IndexedSeries with the given name and index type.
|
||||
func NewIndexedSeries[I Index, V any](name string, vals map[I]V) *IndexedSeries[I] {
|
||||
out := &IndexedSeries[I]{
|
||||
&SignalManager{},
|
||||
NewSeries(name),
|
||||
make(map[I]int, len(vals)),
|
||||
make([]I, 0),
|
||||
make(map[I]int),
|
||||
}
|
||||
for key, val := range vals {
|
||||
// Check that the key is not already in the map.
|
||||
if _, ok := out.index[key]; ok {
|
||||
panic(ErrIndexExists{key})
|
||||
}
|
||||
out.index[key] = out.series.Len()
|
||||
out.series.Push(val)
|
||||
for index, val := range vals {
|
||||
out.Insert(index, val)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@ -53,19 +82,30 @@ func (s *IndexedSeries[I]) Add(other *IndexedSeries[I]) *IndexedSeries[I] {
|
||||
return s
|
||||
}
|
||||
|
||||
// Copy returns a copy of this series.
|
||||
func (s *IndexedSeries[I]) Copy() *IndexedSeries[I] {
|
||||
return s.CopyRange(0, -1)
|
||||
}
|
||||
|
||||
// CopyRange returns a copy of this series with the given range.
|
||||
func (s *IndexedSeries[I]) CopyRange(start, count int) *IndexedSeries[I] {
|
||||
start, end := s.series.Range(start, count)
|
||||
if start == end {
|
||||
return NewIndexedSeries[I, any](s.Name(), nil)
|
||||
}
|
||||
count = end - start
|
||||
|
||||
// Copy the index values over.
|
||||
index := make(map[I]int, len(s.index))
|
||||
for key, val := range s.index {
|
||||
index[key] = val
|
||||
indexes := make([]I, count)
|
||||
copy(indexes, s.indexes[start:end])
|
||||
index := make(map[I]int, count)
|
||||
for i, _index := range indexes {
|
||||
index[_index] = i
|
||||
}
|
||||
return &IndexedSeries[I]{
|
||||
&SignalManager{},
|
||||
s.series.CopyRange(start, count),
|
||||
indexes,
|
||||
index,
|
||||
}
|
||||
}
|
||||
@ -107,17 +147,19 @@ func (s *IndexedSeries[I]) ForEach(f func(i int, val any)) *IndexedSeries[I] {
|
||||
}
|
||||
|
||||
// Index returns the index of the given row or nil if the row is out of bounds. row is an EasyIndex.
|
||||
//
|
||||
// The performance of this operation is O(1).
|
||||
func (s *IndexedSeries[I]) Index(row int) *I {
|
||||
row = EasyIndex(row, s.series.Len())
|
||||
for key, val := range s.index {
|
||||
if val == row {
|
||||
return &key
|
||||
}
|
||||
if row < 0 || row >= len(s.indexes) {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
return &s.indexes[row]
|
||||
}
|
||||
|
||||
// Row returns the row of the given index or -1 if the index does not exist.
|
||||
//
|
||||
// The performance of this operation is O(1).
|
||||
func (s *IndexedSeries[I]) Row(index I) int {
|
||||
if i, ok := s.index[index]; ok {
|
||||
return i
|
||||
@ -165,30 +207,45 @@ func (s *IndexedSeries[I]) Name() string {
|
||||
return s.series.Name()
|
||||
}
|
||||
|
||||
// Push adds a value to the end of the series and returns the series or an error if the index already exists. The error is of type ErrIndexExists.
|
||||
func (s *IndexedSeries[I]) Push(index I, val any) (*IndexedSeries[I], error) {
|
||||
// Check that the key is not already in the map.
|
||||
if _, ok := s.index[index]; ok {
|
||||
return nil, ErrIndexExists{index}
|
||||
// insertIndex will insert the provided index somewhere in the sorted slice of indexes. If the index already exists, the existing index will be returned.
|
||||
func (s *IndexedSeries[I]) insertIndex(index I) (row int, exists bool) {
|
||||
// Sort the indexes.
|
||||
idx, found := slices.BinarySearch(s.indexes, index)
|
||||
if found {
|
||||
return idx, true
|
||||
}
|
||||
s.index[index] = s.series.Len()
|
||||
s.series.Push(val)
|
||||
return s, nil
|
||||
s.index[index] = idx // Create the index to row mapping.
|
||||
// Check if we're just appending the index. Just an optimization.
|
||||
if idx >= len(s.indexes) {
|
||||
s.indexes = append(s.indexes, index) // Append the index to our sorted slice of indexes.
|
||||
return idx, false
|
||||
}
|
||||
s.indexes = slices.Insert(s.indexes, idx, index)
|
||||
// Shift the row values of all indexes after the inserted index.
|
||||
for i := idx + 1; i < len(s.indexes); i++ {
|
||||
s.index[s.indexes[i]]++
|
||||
}
|
||||
return idx, false
|
||||
}
|
||||
|
||||
func (s *IndexedSeries[I]) Pop() any {
|
||||
return s.Remove(s.series.Len() - 1)
|
||||
// Insert adds a value to the series at the given index. If the index already exists, the value will be overwritten. The indexes are sorted using comparison operators.
|
||||
func (s *IndexedSeries[I]) Insert(index I, val any) *IndexedSeries[I] {
|
||||
row, exists := s.insertIndex(index)
|
||||
if exists {
|
||||
s.series.SetValue(row, val)
|
||||
return s
|
||||
}
|
||||
s.series.Insert(row, val)
|
||||
return s
|
||||
}
|
||||
|
||||
// Remove deletes the row at the given index and returns it.
|
||||
func (s *IndexedSeries[I]) Remove(row int) any {
|
||||
// Remove the index from the map.
|
||||
for index, j := range s.index {
|
||||
if j == row {
|
||||
delete(s.index, index)
|
||||
break
|
||||
}
|
||||
func (s *IndexedSeries[I]) Remove(index I) any {
|
||||
row, ok := s.index[index]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
delete(s.index, index)
|
||||
// Shift each index after the removed index down by one.
|
||||
for key, j := range s.index {
|
||||
if j > row {
|
||||
@ -199,15 +256,6 @@ func (s *IndexedSeries[I]) Remove(row int) any {
|
||||
return s.series.Remove(row)
|
||||
}
|
||||
|
||||
// RemoveIndex deletes the row at the given index and returns it. If index does not exist, nil is returned.
|
||||
func (s *IndexedSeries[I]) RemoveIndex(index I) any {
|
||||
// Check that the key is in the map.
|
||||
if i, ok := s.index[index]; ok {
|
||||
return s.Remove(i)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveRange deletes the rows in the given range and returns the series.
|
||||
//
|
||||
// The operation is O(n) where n is the number of rows in the series.
|
||||
@ -220,6 +268,8 @@ func (s *IndexedSeries[I]) RemoveRange(start, count int) *IndexedSeries[I] {
|
||||
// Remove the indexes from the map.
|
||||
for index, i := range s.index {
|
||||
if i >= start && i < end {
|
||||
idx := slices.Index(s.indexes, index)
|
||||
slices.Delete(s.indexes, idx, idx+1)
|
||||
delete(s.index, index)
|
||||
}
|
||||
}
|
||||
@ -236,22 +286,11 @@ func (s *IndexedSeries[I]) RemoveRange(start, count int) *IndexedSeries[I] {
|
||||
|
||||
// Reverse reverses the rows of the series.
|
||||
func (s *IndexedSeries[I]) Reverse() *IndexedSeries[I] {
|
||||
// Reverse the indexes.
|
||||
s.ReverseIndexes()
|
||||
// Reverse the values.
|
||||
_ = s.series.Reverse()
|
||||
return s
|
||||
}
|
||||
|
||||
// ReverseIndexes reverses the indexes of the series but not the rows.
|
||||
func (s *IndexedSeries[I]) ReverseIndexes() *IndexedSeries[I] {
|
||||
seriesLen := s.series.Len()
|
||||
for key, i := range s.index {
|
||||
s.index[key] = seriesLen - i - 1
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *IndexedSeries[I]) Rolling(period int) *IndexedRollingSeries[I] {
|
||||
return NewIndexedRollingSeries(s, period)
|
||||
}
|
||||
@ -284,6 +323,17 @@ func (s *IndexedSeries[I]) ShiftIndex(periods int, step func(prev I, amt int) I)
|
||||
if periods == 0 {
|
||||
return s
|
||||
}
|
||||
// Update the index values.
|
||||
for index, i := range s.index {
|
||||
s.indexes[i] = step(index, periods)
|
||||
}
|
||||
|
||||
// Reassign the index map.
|
||||
maps.Clear(s.index)
|
||||
for i, index := range s.indexes {
|
||||
s.index[index] = i
|
||||
}
|
||||
|
||||
// Shift the indexes.
|
||||
newIndexes := make(map[I]int, len(s.index))
|
||||
for index, i := range s.index {
|
||||
@ -293,6 +343,23 @@ func (s *IndexedSeries[I]) ShiftIndex(periods int, step func(prev I, amt int) I)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *IndexedSeries[I]) String() string {
|
||||
if s == nil {
|
||||
return fmt.Sprintf("%T[nil]", s)
|
||||
}
|
||||
|
||||
buffer := new(bytes.Buffer)
|
||||
t := tabwriter.NewWriter(buffer, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintf(t, "%T[%d]\n", s, s.Len())
|
||||
fmt.Fprintf(t, "[Row]\t[Index]\t%s\t\n", s.series.Name())
|
||||
|
||||
for i, index := range s.indexes {
|
||||
fmt.Fprintf(t, "%d\t%v\t%v\t\n", i, index, s.series.Value(i))
|
||||
}
|
||||
_ = t.Flush()
|
||||
return buffer.String()
|
||||
}
|
||||
|
||||
// Sub subtracts the other series values from this series values. The other series must have the same index type. The values are subtracted by comparing their indexes. For example, subtracting two IndexedSeries that share no indexes will result in no change of values.
|
||||
func (s *IndexedSeries[I]) Sub(other *IndexedSeries[I]) *IndexedSeries[I] {
|
||||
for index, row := range s.index {
|
||||
@ -331,12 +398,12 @@ func (s *IndexedSeries[I]) ValueRange(start, count int) []any {
|
||||
return s.series.ValueRange(start, count)
|
||||
}
|
||||
|
||||
type IndexedRollingSeries[I comparable] struct {
|
||||
type IndexedRollingSeries[I Index] struct {
|
||||
rolling *RollingSeries
|
||||
series *IndexedSeries[I]
|
||||
}
|
||||
|
||||
func NewIndexedRollingSeries[I comparable](series *IndexedSeries[I], period int) *IndexedRollingSeries[I] {
|
||||
func NewIndexedRollingSeries[I Index](series *IndexedSeries[I], period int) *IndexedRollingSeries[I] {
|
||||
return &IndexedRollingSeries[I]{NewRollingSeries(series.series, period), series}
|
||||
}
|
||||
|
||||
|
@ -151,8 +151,40 @@ func TestRollingSeries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexedSeriesInsert(t *testing.T) {
|
||||
indexed := NewIndexedSeries("test", map[UnixTime]float64{
|
||||
UnixTime(0): 1.0,
|
||||
UnixTime(2): 2.0,
|
||||
UnixTime(4): 3.0,
|
||||
UnixTime(6): 4.0,
|
||||
UnixTime(8): 5.0,
|
||||
UnixTime(10): 6.0,
|
||||
})
|
||||
if indexed.Len() != 6 {
|
||||
t.Fatalf("Expected 6 rows, got %d", indexed.Len())
|
||||
}
|
||||
for i := 0; i < 6; i++ {
|
||||
if val := indexed.Float(i); val != float64(i+1) {
|
||||
t.Errorf("(%d)\tExpected %f, got %v", i, float64(i+1), val)
|
||||
}
|
||||
}
|
||||
for i := 0; i < 6; i++ {
|
||||
index := UnixTime(i * 2)
|
||||
if val := indexed.ValueIndex(index); val != float64(i+1) {
|
||||
t.Errorf("(%v)\tExpected %f, got %v", index, float64(i+1), val)
|
||||
}
|
||||
}
|
||||
indexed.Insert(UnixTime(5), -1.0)
|
||||
if indexed.Len() != 7 {
|
||||
t.Fatalf("Expected 7 rows, got %d", indexed.Len())
|
||||
}
|
||||
if indexed.ValueIndex(UnixTime(5)) != -1.0 {
|
||||
t.Errorf("Expected value at index 5 to be -1.0, got %v", indexed.ValueIndex(UnixTime(5)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexedSeries(t *testing.T) {
|
||||
intIndexed := NewIndexedSeries("test", map[int]any{
|
||||
intIndexed := NewIndexedSeries("test", map[int]float64{
|
||||
0: 1.0,
|
||||
2: 2.0,
|
||||
4: 3.0,
|
||||
@ -160,6 +192,7 @@ func TestIndexedSeries(t *testing.T) {
|
||||
8: 5.0,
|
||||
10: 6.0,
|
||||
})
|
||||
|
||||
if intIndexed.Len() != 6 {
|
||||
t.Fatalf("Expected 6 rows, got %d", intIndexed.Len())
|
||||
}
|
||||
@ -167,10 +200,11 @@ func TestIndexedSeries(t *testing.T) {
|
||||
t.Errorf("Expected value at index 4 to be 3.0, got %v", intIndexed.ValueIndex(4))
|
||||
}
|
||||
|
||||
floatIndexed := NewIndexedSeries[float64]("test", nil)
|
||||
floatIndexed.Push(0.0, 1.0)
|
||||
floatIndexed.Push(2.0, 2.0)
|
||||
floatIndexed.Push(4.0, 3.0)
|
||||
floatIndexed := NewIndexedSeries("test", map[float64]float64{
|
||||
0.0: 1.0,
|
||||
2.0: 2.0,
|
||||
4.0: 3.0,
|
||||
})
|
||||
if floatIndexed.Len() != 3 {
|
||||
t.Fatalf("Expected 3 rows, got %d", floatIndexed.Len())
|
||||
}
|
||||
@ -178,15 +212,15 @@ func TestIndexedSeries(t *testing.T) {
|
||||
t.Errorf("Expected value at index 4.0 to be 3.0, got %v", floatIndexed.ValueIndex(4.0))
|
||||
}
|
||||
|
||||
timeIndexed := NewIndexedSeries("test", map[int64]any{
|
||||
time.Date(2018, 1, 1, 0, 0, 0, 0, time.UTC).Unix(): 1.0,
|
||||
time.Date(2018, 1, 2, 0, 0, 0, 0, time.UTC).Unix(): 2.0,
|
||||
time.Date(2018, 1, 3, 0, 0, 0, 0, time.UTC).Unix(): 3.0,
|
||||
timeIndexed := NewIndexedSeries("test", map[UnixTime]float64{
|
||||
UnixTime(time.Date(2018, 1, 1, 0, 0, 0, 0, time.UTC).Unix()): 1.0,
|
||||
UnixTime(time.Date(2018, 1, 2, 0, 0, 0, 0, time.UTC).Unix()): 2.0,
|
||||
UnixTime(time.Date(2018, 1, 3, 0, 0, 0, 0, time.UTC).Unix()): 3.0,
|
||||
})
|
||||
if timeIndexed.Len() != 3 {
|
||||
t.Fatalf("Expected 3 rows, got %d", timeIndexed.Len())
|
||||
}
|
||||
if index := time.Date(2018, 1, 2, 0, 0, 0, 0, time.UTC).Unix(); timeIndexed.ValueIndex(index).(float64) != 2.0 {
|
||||
if index := UnixTime(time.Date(2018, 1, 2, 0, 0, 0, 0, time.UTC).Unix()); timeIndexed.ValueIndex(index).(float64) != 2.0 {
|
||||
t.Errorf("Expected value at index 2018-01-02 to be 2.0, got %v", timeIndexed.ValueIndex(index))
|
||||
}
|
||||
|
||||
@ -194,38 +228,14 @@ func TestIndexedSeries(t *testing.T) {
|
||||
if doubledTimeIndexed.Len() != 3 {
|
||||
t.Fatalf("Expected 3 rows, got %d", doubledTimeIndexed.Len())
|
||||
}
|
||||
if index := time.Date(2018, 1, 2, 0, 0, 0, 0, time.UTC).Unix(); doubledTimeIndexed.ValueIndex(index).(float64) != 4.0 {
|
||||
if index := UnixTime(time.Date(2018, 1, 2, 0, 0, 0, 0, time.UTC).Unix()); doubledTimeIndexed.ValueIndex(index).(float64) != 4.0 {
|
||||
t.Errorf("Expected value at index 2018-01-02 to be 4.0, got %v", doubledTimeIndexed.ValueIndex(index))
|
||||
}
|
||||
|
||||
// Test that the Copy function works.
|
||||
index := time.Date(2018, 1, 1, 0, 0, 0, 0, time.UTC).Unix()
|
||||
index := UnixTime(time.Date(2018, 1, 1, 0, 0, 0, 0, time.UTC).Unix())
|
||||
doubledTimeIndexed.SetValueIndex(index, 100.0)
|
||||
if timeIndexed.ValueIndex(index).(float64) != 1.0 {
|
||||
t.Errorf("Expected value at index 2018-01-01 to be 1.0, got %v", timeIndexed.ValueIndex(index))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesEURUSD(t *testing.T) {
|
||||
data, err := EURUSD()
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %s", err)
|
||||
}
|
||||
|
||||
dates, closes := data.Dates(), data.Closes()
|
||||
|
||||
if dates.Len() != 2610 {
|
||||
t.Fatalf("Expected 2610 rows, got %d", dates.Len())
|
||||
}
|
||||
if closes.Len() != 2610 {
|
||||
t.Fatalf("Expected 2610 rows, got %d", closes.Len())
|
||||
}
|
||||
|
||||
sma10 := closes.Rolling(10).Mean()
|
||||
if sma10.Len() != 2610 {
|
||||
t.Fatalf("Expected 2610 rows, got %d", sma10.Len())
|
||||
}
|
||||
if !EqualApprox(sma10.Value(-1).(float64), 1.15878) { // Latest closing price averaged over 10 periods.
|
||||
t.Fatalf("Expected 1.10039, got %f", sma10.Value(-1))
|
||||
}
|
||||
}
|
||||
|
17
trader.go
17
trader.go
@ -23,12 +23,12 @@ type Trader struct {
|
||||
Log *log.Logger
|
||||
EOF bool
|
||||
|
||||
data *Frame
|
||||
data *IndexedFrame[UnixTime]
|
||||
sched *gocron.Scheduler
|
||||
stats *TraderStats
|
||||
}
|
||||
|
||||
func (t *Trader) Data() *Frame {
|
||||
func (t *Trader) Data() *IndexedFrame[UnixTime] {
|
||||
return t.data
|
||||
}
|
||||
|
||||
@ -107,13 +107,12 @@ func (t *Trader) Init() {
|
||||
|
||||
// Tick updates the current state of the market and runs the strategy.
|
||||
func (t *Trader) Tick() {
|
||||
t.fetchData() // Fetch the latest candlesticks from the broker.
|
||||
// t.Log.Println(t.data.Close(-1))
|
||||
t.fetchData() // Fetch the latest candlesticks from the broker.
|
||||
t.Strategy.Next(t) // Run the strategy.
|
||||
|
||||
// Update the stats.
|
||||
err := t.stats.Dated.PushValues(map[string]any{
|
||||
"Date": t.data.Date(-1),
|
||||
"Date": t.data.Date(-1).Time(),
|
||||
"Equity": t.Broker.NAV(),
|
||||
"Profit": t.Broker.PL(),
|
||||
"Drawdown": func() float64 {
|
||||
@ -164,14 +163,14 @@ func (t *Trader) fetchData() {
|
||||
|
||||
func (t *Trader) Buy(units float64) {
|
||||
t.closeOrdersAndPositions()
|
||||
t.Log.Printf("Buy %f units", units)
|
||||
t.Log.Printf("Buy %v units", units)
|
||||
t.Broker.Order(Market, t.Symbol, units, 0, 0, 0)
|
||||
t.stats.tradesThisCandle = append(t.stats.tradesThisCandle, TradeStat{units, false})
|
||||
}
|
||||
|
||||
func (t *Trader) Sell(units float64) {
|
||||
t.closeOrdersAndPositions()
|
||||
t.Log.Printf("Sell %f units", units)
|
||||
t.Log.Printf("Sell %v units", units)
|
||||
t.Broker.Order(Market, t.Symbol, -units, 0, 0, 0)
|
||||
t.stats.tradesThisCandle = append(t.stats.tradesThisCandle, TradeStat{-units, false})
|
||||
}
|
||||
@ -179,13 +178,13 @@ func (t *Trader) Sell(units float64) {
|
||||
func (t *Trader) closeOrdersAndPositions() {
|
||||
for _, order := range t.Broker.OpenOrders() {
|
||||
if order.Symbol() == t.Symbol {
|
||||
t.Log.Printf("Cancelling order %s (%f units)", order.Id(), order.Units())
|
||||
t.Log.Printf("Cancelling order: %v units", order.Units())
|
||||
order.Cancel()
|
||||
}
|
||||
}
|
||||
for _, position := range t.Broker.OpenPositions() {
|
||||
if position.Symbol() == t.Symbol {
|
||||
t.Log.Printf("Closing position %s (%f units, %f PL)", position.Id(), position.Units(), position.PL())
|
||||
t.Log.Printf("Closing position: %v units, $%.2f PL", position.Units(), position.PL())
|
||||
position.Close()
|
||||
t.stats.tradesThisCandle = append(t.stats.tradesThisCandle, TradeStat{position.Units(), true})
|
||||
}
|
||||
|
2
utils.go
2
utils.go
@ -18,7 +18,7 @@ func Crossover(a, b *Series) bool {
|
||||
return a.Float(-1) > b.Float(-1) && a.Float(-2) <= b.Float(-2)
|
||||
}
|
||||
|
||||
func CrossoverIndex[I comparable](index I, a, b *IndexedSeries[I]) bool {
|
||||
func CrossoverIndex[I Index](index I, a, b *IndexedSeries[I]) bool {
|
||||
aRow, bRow := a.Row(index), b.Row(index)
|
||||
if aRow < 1 || bRow < 1 {
|
||||
return false
|
||||
|
Loading…
x
Reference in New Issue
Block a user