diff --git a/backtesting.go b/backtesting.go index 3ec9320..d5f9f8a 100644 --- a/backtesting.go +++ b/backtesting.go @@ -21,6 +21,8 @@ var ( ErrPositionClosed = errors.New("position closed") ) +var _ Broker = (*TestBroker)(nil) // Compile-time interface check. + func Backtest(trader *Trader) { switch broker := trader.Broker.(type) { case *TestBroker: diff --git a/broker.go b/broker.go index 1a10e29..a601712 100644 --- a/broker.go +++ b/broker.go @@ -58,8 +58,8 @@ type Position interface { type Broker interface { Signaler // Candles returns a dataframe of candles for the given symbol, frequency, and count by querying the broker. - Candles(symbol string, frequency string, count int) (*DataFrame, error) - MarketOrder(symbol string, units float64, stopLoss, takeProfit float64) (Order, error) + Candles(symbol, frequency string, count int) (*DataFrame, error) + MarketOrder(symbol string, units, stopLoss, takeProfit float64) (Order, error) NAV() float64 // NAV returns the net asset value of the account. PL() float64 // PL returns the profit or loss of the account. OpenOrders() []Order diff --git a/data.go b/data.go index 8ed7ad7..cff56dc 100644 --- a/data.go +++ b/data.go @@ -78,7 +78,7 @@ type Frame interface { Lows() Series Closes() Series Volumes() Series - PushCandle(date time.Time, open, high, low, close, volume float64) error + PushCandle(date time.Time, open, high, low, close float64, volume int64) error } // AppliedSeries is like Series, but it applies a function to each row of data before returning it. @@ -439,6 +439,25 @@ type DataFrame struct { // data *df.DataFrame // DataFrame with a Date, Open, High, Low, Close, and Volume column. } +func NewDataFrame(series ...Series) *DataFrame { + d := &DataFrame{} + d.PushSeries(series...) + return d +} + +// NewDOHLCVDataFrame returns a DataFrame with empty Date, Open, High, Low, Close, and Volume columns. +// Use the PushCandle method to add candlesticks in an easy and type-safe way. +func NewDOHLCVDataFrame() *DataFrame { + return NewDataFrame( + NewDataSeries(df.NewSeriesTime("Date", nil)), + NewDataSeries(df.NewSeriesFloat64("Open", nil)), + NewDataSeries(df.NewSeriesFloat64("High", nil)), + NewDataSeries(df.NewSeriesFloat64("Low", nil)), + NewDataSeries(df.NewSeriesFloat64("Close", nil)), + NewDataSeries(df.NewSeriesInt64("Volume", nil)), + ) +} + // Copy copies the DataFrame from start to end (inclusive). If end is -1, it will copy to the end of the DataFrame. If start is out of bounds, nil is returned. func (d *DataFrame) Copy(start, end int) Frame { out := &DataFrame{} @@ -468,6 +487,9 @@ func (d *DataFrame) Len() int { } func (d *DataFrame) String() string { + if d == nil { + return fmt.Sprintf("%T[nil]", d) + } names := d.Names() // Defines the order of the columns. series := make([]Series, len(names)) for i, name := range names { @@ -596,16 +618,16 @@ func (d *DataFrame) ContainsDOHLCV() bool { return d.Contains("Date", "Open", "High", "Low", "Close", "Volume") } -func (d *DataFrame) PushCandle(date time.Time, open, high, low, close, volume float64) error { +func (d *DataFrame) PushCandle(date time.Time, open, high, low, close float64, volume int64) error { if len(d.series) == 0 { - d.PushSeries([]Series{ + d.PushSeries( NewDataSeries(df.NewSeriesTime("Date", nil, date)), NewDataSeries(df.NewSeriesFloat64("Open", nil, open)), NewDataSeries(df.NewSeriesFloat64("High", nil, high)), NewDataSeries(df.NewSeriesFloat64("Low", nil, low)), NewDataSeries(df.NewSeriesFloat64("Close", nil, close)), - NewDataSeries(df.NewSeriesFloat64("Volume", nil, volume)), - }...) + NewDataSeries(df.NewSeriesInt64("Volume", nil, volume)), + ) return nil } if !d.ContainsDOHLCV() { @@ -773,12 +795,6 @@ func (d *DataFrame) Time(column string, i int) time.Time { } } -func NewDataFrame(series ...Series) *DataFrame { - d := &DataFrame{} - d.PushSeries(series...) - return d -} - 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. diff --git a/oanda/defs.go b/oanda/defs.go new file mode 100644 index 0000000..ab7cd80 --- /dev/null +++ b/oanda/defs.go @@ -0,0 +1,57 @@ +package oanda + +import ( + "fmt" + "strconv" + "time" +) + +// CandlestickResponse represents the response from the Oanda API for a request for candlestick data. +type CandlestickResponse struct { + Instrument string `json:"instrument"` // The instrument whose Prices are represented by the candlesticks. + Granularity string `json:"granularity"` // The granularity of the candlesticks provided. + Candles []Candlestick `json:"candles"` // The list of candlesticks that satisfy the request. +} + +// Candlestick represents a single candlestick. +type Candlestick struct { + Time time.Time `json:"time"` // The start time of the candlestick. + Bid *CandlestickData `json:"bid"` // The candlestick data based on bids. Only provided if bid-based candles were requested. + Ask *CandlestickData `json:"ask"` // The candlestick data based on asks. Only provided if ask-based candles were requested. + Mid *CandlestickData `json:"mid"` // The candlestick data based on midpoints. Only provided if midpoint-based candles were requested. + Volume int `json:"volume"` // The number of prices created during the time-range represented by the candlestick. + Complete bool `json:"complete"` // A flag indicating if the candlestick is complete. A complete candlestick is one whose ending time is not in the future. +} + +// CandlestickData represents the price information for a candlestick. +type CandlestickData struct { + // The first (open) price in the time-range represented by the candlestick. + O string `json:"o"` + // The highest price in the time-range represented by the candlestick. + H string `json:"h"` + // The lowest price in the time-range represented by the candlestick. + L string `json:"l"` + // The last (closing) price in the time-range represented by the candlestick. + C string `json:"c"` +} + +func (d CandlestickData) Parse(o, h, l, c *float64) error { + var err error + *o, err = strconv.ParseFloat(d.O, 64) + if err != nil { + return fmt.Errorf("error parsing O field of CandlestickData: %w", err) + } + *h, err = strconv.ParseFloat(d.H, 64) + if err != nil { + return fmt.Errorf("error parsing H field of CandlestickData: %w", err) + } + *l, err = strconv.ParseFloat(d.L, 64) + if err != nil { + return fmt.Errorf("error parsing L field of CandlestickData: %w", err) + } + *c, err = strconv.ParseFloat(d.C, 64) + if err != nil { + return fmt.Errorf("error parsing C field of CandlestickData: %w", err) + } + return nil +} diff --git a/oanda/endpoints.go b/oanda/endpoints.go new file mode 100644 index 0000000..cb4cf4f --- /dev/null +++ b/oanda/endpoints.go @@ -0,0 +1 @@ +package oanda diff --git a/oanda/go.mod b/oanda/go.mod new file mode 100644 index 0000000..229440c --- /dev/null +++ b/oanda/go.mod @@ -0,0 +1,3 @@ +module github.com/fivemoreminix/autotrader/oanda + +go 1.20 diff --git a/oanda/oanda.go b/oanda/oanda.go new file mode 100644 index 0000000..a6d681d --- /dev/null +++ b/oanda/oanda.go @@ -0,0 +1,117 @@ +package oanda + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + auto "github.com/fivemoreminix/autotrader" +) + +const ( + oandaLiveURL = "https://api-fxtrade.oanda.com" + oandaPracticeURL = "https://api-fxpractice.oanda.com" + TimeLayout = time.RFC3339 +) + +var _ auto.Broker = (*OandaBroker)(nil) // Compile-time interface check. + +type OandaBroker struct { + *auto.SignalManager + client *http.Client + token string + accountID string + baseUrl string // Either oandaLiveURL or oandaPracticeURL. +} + +func NewOandaBroker(token, accountID string, practice bool) *OandaBroker { + var baseUrl string + if practice { + baseUrl = oandaPracticeURL + } else { + baseUrl = oandaLiveURL + } + return &OandaBroker{ + SignalManager: &auto.SignalManager{}, + client: &http.Client{}, + token: token, + accountID: accountID, + baseUrl: baseUrl, + } +} + +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 { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+b.token) + q := req.URL.Query() + q.Add("granularity", frequency) + q.Add("count", strconv.Itoa(auto.Min(count, 5000))) // API says max is 5000. + req.URL.RawQuery = q.Encode() + resp, err := b.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var candlestickResponse *CandlestickResponse + if err := json.NewDecoder(resp.Body).Decode(&candlestickResponse); err != nil { + return nil, err + } + + return newDataframe(candlestickResponse) +} + +func (b *OandaBroker) MarketOrder(symbol string, units, stopLoss, takeProfit float64) (auto.Order, error) { + return nil, nil +} + +func (b *OandaBroker) NAV() float64 { + return 0 +} + +func (b *OandaBroker) PL() float64 { + return 0 +} + +func (b *OandaBroker) OpenOrders() []auto.Order { + return nil +} + +func (b *OandaBroker) OpenPositions() []auto.Position { + return nil +} + +func (b *OandaBroker) Orders() []auto.Order { + return nil +} + +func (b *OandaBroker) Positions() []auto.Position { + return nil +} + +func (b *OandaBroker) fetchAccountUpdates() { +} + +func newDataframe(candles *CandlestickResponse) (*auto.DataFrame, error) { + if candles == nil { + return nil, fmt.Errorf("candles is nil or empty") + } + data := auto.NewDOHLCVDataFrame() + for _, candle := range candles.Candles { + if candle.Mid == nil { + return nil, fmt.Errorf("mid is nil or empty") + } + var o, h, l, c float64 + err := candle.Mid.Parse(&o, &h, &l, &c) + 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)) + } + return data, nil +}