Files
gocryptotrader/exchanges/kline/kline.go
Scott 017cdf1384 Backtester: Live trading upgrades (#1023)
* Modifications for a smoother live run

* Fixes data appending

* Successfully allows multi-currency live trading. Adds multiple currencies to live DCA strategy

* Attempting to get cash and carry working

* Poor attempts at sorting out data and appending it properly with USD in mind

* =designs new live data handler

* Updates cash and carry strat to work

* adds test coverage. begins closeallpositions function

* Updates cash and carry to work live

* New kline.Event type. Cancels orders on close. Rn types

* =Fixes USD funding issue

* =fixes tests

* fixes tests AGAIN

* adds coverage to close all orders

* crummy tests, should override

* more tests

* more tests

* more coverage

* removes scourge of currency.Pair maps. More tests

* missed currency stuff

* Fixes USD data issue & collateral issue. Needs to close ALL orders

* Now triggers updates on the very first data entry

* All my problems are solved now????

* fixes tests, extends coverage

* there is some really funky candle stuff going on

* my brain is melting

* better shutdown management, fixes freezing bug

* fixes data duplication issues, adds retries to requests

* reduces logging, adds verbose options

* expands coverage over all new functionality

* fixes fun bug from curr == curr to curr.Equal(curr)

* fixes setup issues and tests

* starts adding external wallet amounts for funding

* more setup for assets

* setup live fund calcs and placing orders

* successfully performs automated cash and carry

* merge fixes

* funding properly set at all times

* fixes some bugs, need to address currencystatistics still

* adds 'appeneded' trait, attempts to fix some stats

* fixes stat bugs, adds cool new fetchfees feature

* fixes terrible processing bugs

* tightens realorder stats, sadly loses some live stats

* this actually sets everything correctly for bothcd ..cd ..cd ..cd ..cd ..!

* fix tests

* coverage

* beautiful new test coverage

* docs

* adds new fee getter delayer

* commits from the correct directory

* Lint

* adds verbose to fund manager

* Fix bug in t2b2 strat. Update dca live config. Docs

* go mod tidy

* update buf

* buf + test improvement

* Post merge fixes

* fixes surprise offset bug

* fix sizing restrictions for cash and carry

* fix server lints

* merge fixes

* test fixesss

* lintle fixles

* slowloris

* rn run to task, bug fixes, close all on close

* rpc lint and fixes

* bugfix: order manager not processing orders properly

* somewhat addresses nits

* absolutely broken end of day commit

* absolutely massive knockon effects from nits

* massive knockon effects continue

* fixes things

* address remaining nits

* jk now fixes things

* addresses the easier nits

* more nit fixers

* more niterinos addressederinos

* refactors holdings and does some nits

* so buf

* addresses some nits, fixes holdings bugs

* cleanup

* attempts to fix alert chans to prevent many chans waiting?

* terrible code, will revert

* to be reviewed in detail tomorrow

* Fixes up channel system

* smashes those nits

* fixes extra candles, fixes collateral bug, tests

* fixes data races, introduces reflection

* more checks n tests

* Fixes cash and carry issues. Fixes more cool bugs

* fixes ~typer~ typo

* replace spot strats from ftx to binance

* fixes all the tests I just destroyed

* removes example path, rm verbose

* 1) what 2) removes FTX references from the Backtester

* renamed, non-working strategies

* Removes FTX references almost as fast as sbf removes funds

* regen docs, add contrib names,sort contrib names

* fixes merge renamings

* Addresses nits. Fixes setting API credentials. Fixes Binance limit retrieval

* Fixes live order bugs with real orders and without

* Apply suggestions from code review

Co-authored-by: Adrian Gallagher <adrian.gallagher@thrasher.io>

* Update backtester/engine/live.go

Co-authored-by: Adrian Gallagher <adrian.gallagher@thrasher.io>

* Update backtester/engine/live.go

Co-authored-by: Adrian Gallagher <adrian.gallagher@thrasher.io>

* Update backtester/config/strategyconfigbuilder/main.go

Co-authored-by: Adrian Gallagher <adrian.gallagher@thrasher.io>

* updates docs

* even better docs

Co-authored-by: Adrian Gallagher <adrian.gallagher@thrasher.io>
2023-01-05 13:03:17 +11:00

583 lines
16 KiB
Go

package kline
import (
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/thrasher-corp/gocryptotrader/common"
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
"github.com/thrasher-corp/gocryptotrader/exchanges/order"
)
// CreateKline creates candles out of trade history data for a set time interval
func CreateKline(trades []order.TradeHistory, interval Interval, p currency.Pair, a asset.Item, exchange string) (Item, error) {
if interval.Duration() < time.Minute {
return Item{}, fmt.Errorf("invalid time interval: [%s]", interval)
}
if err := validateData(trades); err != nil {
return Item{}, err
}
timeIntervalStart := trades[0].Timestamp.Truncate(interval.Duration())
timeIntervalEnd := trades[len(trades)-1].Timestamp
// Adds time interval buffer zones
var timeIntervalCache [][]order.TradeHistory
var candleStart []time.Time
for t := timeIntervalStart; t.Before(timeIntervalEnd); t = t.Add(interval.Duration()) {
timeBufferEnd := t.Add(interval.Duration())
insertionCount := 0
var zonedTradeHistory []order.TradeHistory
for i := 0; i < len(trades); i++ {
if (trades[i].Timestamp.After(t) ||
trades[i].Timestamp.Equal(t)) &&
(trades[i].Timestamp.Before(timeBufferEnd) ||
trades[i].Timestamp.Equal(timeBufferEnd)) {
zonedTradeHistory = append(zonedTradeHistory, trades[i])
insertionCount++
continue
}
trades = trades[i:]
break
}
candleStart = append(candleStart, t)
// Insert dummy in time period when there is no price action
if insertionCount == 0 {
timeIntervalCache = append(timeIntervalCache, []order.TradeHistory{})
continue
}
timeIntervalCache = append(timeIntervalCache, zonedTradeHistory)
}
if candleStart == nil {
return Item{}, errors.New("candle start cannot be nil")
}
var candles = Item{
Exchange: exchange,
Pair: p,
Asset: a,
Interval: interval,
}
var closePriceOfLast float64
for x := range timeIntervalCache {
if len(timeIntervalCache[x]) == 0 {
candles.Candles = append(candles.Candles, Candle{
Time: candleStart[x],
High: closePriceOfLast,
Low: closePriceOfLast,
Close: closePriceOfLast,
Open: closePriceOfLast})
continue
}
var newCandle = Candle{
Open: timeIntervalCache[x][0].Price,
Time: candleStart[x],
}
for y := range timeIntervalCache[x] {
if y == len(timeIntervalCache[x])-1 {
newCandle.Close = timeIntervalCache[x][y].Price
closePriceOfLast = timeIntervalCache[x][y].Price
}
if newCandle.High < timeIntervalCache[x][y].Price {
newCandle.High = timeIntervalCache[x][y].Price
}
if newCandle.Low > timeIntervalCache[x][y].Price || newCandle.Low == 0 {
newCandle.Low = timeIntervalCache[x][y].Price
}
newCandle.Volume += timeIntervalCache[x][y].Amount
}
candles.Candles = append(candles.Candles, newCandle)
}
return candles, nil
}
// validateData checks for zero values on data and sorts before turning
// converting into OHLC
func validateData(trades []order.TradeHistory) error {
if len(trades) < 2 {
return errors.New("insufficient data")
}
for i := range trades {
if trades[i].Timestamp.IsZero() ||
trades[i].Timestamp.Unix() == 0 {
return fmt.Errorf("timestamp not set for element %d", i)
}
if trades[i].Amount == 0 {
return fmt.Errorf("amount not set for element %d", i)
}
if trades[i].Price == 0 {
return fmt.Errorf("price not set for element %d", i)
}
}
sort.Slice(trades, func(i, j int) bool {
return trades[i].Timestamp.Before(trades[j].Timestamp)
})
return nil
}
// String returns numeric string
func (i Interval) String() string {
return i.Duration().String()
}
// Word returns text version of Interval
func (i Interval) Word() string {
return durationToWord(i)
}
// Duration returns interval casted as time.Duration for compatibility
func (i Interval) Duration() time.Duration {
return time.Duration(i)
}
// Short returns short string version of interval
func (i Interval) Short() string {
s := i.String()
if strings.HasSuffix(s, "m0s") {
s = s[:len(s)-2]
}
if strings.HasSuffix(s, "h0m") {
s = s[:len(s)-2]
}
return s
}
// FillMissingDataWithEmptyEntries amends a kline item to have candle entries
// for every interval between its start and end dates derived from ranges
func (k *Item) FillMissingDataWithEmptyEntries(i *IntervalRangeHolder) {
var anyChanges bool
for x := range i.Ranges {
for y := range i.Ranges[x].Intervals {
if !i.Ranges[x].Intervals[y].HasData {
for z := range k.Candles {
if i.Ranges[x].Intervals[y].Start.Equal(k.Candles[z].Time) {
break
}
}
anyChanges = true
k.Candles = append(k.Candles, Candle{
Time: i.Ranges[x].Intervals[y].Start.Time,
})
}
}
}
if anyChanges {
k.SortCandlesByTimestamp(false)
}
}
// RemoveDuplicateCandlesByTime removes any duplicate candles
func (k *Item) RemoveDuplicateCandlesByTime() {
var newCandles []Candle
candleMap := make(map[int64]struct{})
for x := range k.Candles {
if _, ok := candleMap[k.Candles[x].Time.UnixNano()]; !ok {
newCandles = append(newCandles, k.Candles[x])
candleMap[k.Candles[x].Time.UnixNano()] = struct{}{}
}
}
k.Candles = newCandles
}
// RemoveOutsideRange removes any candles outside the start and end date
func (k *Item) RemoveOutsideRange(start, end time.Time) {
var newCandles []Candle
for i := range k.Candles {
if k.Candles[i].Time.Equal(start) ||
(k.Candles[i].Time.After(start) && k.Candles[i].Time.Before(end)) {
newCandles = append(newCandles, k.Candles[i])
}
}
k.Candles = newCandles
}
// SortCandlesByTimestamp sorts candles by timestamp
func (k *Item) SortCandlesByTimestamp(desc bool) {
sort.Slice(k.Candles, func(i, j int) bool {
if desc {
return k.Candles[i].Time.After(k.Candles[j].Time)
}
return k.Candles[i].Time.Before(k.Candles[j].Time)
})
}
// FormatDates converts all dates to UTC time
func (k *Item) FormatDates() {
for x := range k.Candles {
k.Candles[x].Time = k.Candles[x].Time.UTC()
}
}
// durationToWord returns english version of interval
func durationToWord(in Interval) string {
switch in {
case FifteenSecond:
return "fifteensecond"
case OneMin:
return "onemin"
case ThreeMin:
return "threemin"
case FiveMin:
return "fivemin"
case TenMin:
return "tenmin"
case FifteenMin:
return "fifteenmin"
case ThirtyMin:
return "thirtymin"
case OneHour:
return "onehour"
case TwoHour:
return "twohour"
case FourHour:
return "fourhour"
case SixHour:
return "sixhour"
case EightHour:
return "eighthour"
case TwelveHour:
return "twelvehour"
case OneDay:
return "oneday"
case ThreeDay:
return "threeday"
case FifteenDay:
return "fifteenday"
case OneWeek:
return "oneweek"
case TwoWeek:
return "twoweek"
case OneMonth:
return "onemonth"
case OneYear:
return "oneyear"
default:
return "notfound"
}
}
// TotalCandlesPerInterval turns total candles per period for interval
func TotalCandlesPerInterval(start, end time.Time, interval Interval) (out float64) {
switch interval {
case FifteenSecond:
return end.Sub(start).Seconds() / 15
case OneMin:
return end.Sub(start).Minutes()
case ThreeMin:
return end.Sub(start).Minutes() / 3
case FiveMin:
return end.Sub(start).Minutes() / 5
case TenMin:
return end.Sub(start).Minutes() / 10
case FifteenMin:
return end.Sub(start).Minutes() / 15
case ThirtyMin:
return end.Sub(start).Minutes() / 30
case OneHour:
return end.Sub(start).Hours()
case TwoHour:
return end.Sub(start).Hours() / 2
case FourHour:
return end.Sub(start).Hours() / 4
case SixHour:
return end.Sub(start).Hours() / 6
case EightHour:
return end.Sub(start).Hours() / 8
case TwelveHour:
return end.Sub(start).Hours() / 12
case OneDay:
return end.Sub(start).Hours() / 24
case ThreeDay:
return end.Sub(start).Hours() / 72
case FifteenDay:
return end.Sub(start).Hours() / (24 * 15)
case OneWeek:
return end.Sub(start).Hours() / (24 * 7)
case TwoWeek:
return end.Sub(start).Hours() / (24 * 14)
case OneMonth:
return end.Sub(start).Hours() / (24 * 30)
case OneYear:
return end.Sub(start).Hours() / 8760
}
return -1
}
var oneYearDurationInNano = float64(OneYear.Duration().Nanoseconds())
// IntervalsPerYear helps determine the number of intervals in a year
// used in CAGR calculation to know the amount of time of an interval in a year
func (i *Interval) IntervalsPerYear() float64 {
if i.Duration() == 0 {
return 0
}
return oneYearDurationInNano / float64(i.Duration().Nanoseconds())
}
// ConvertToNewInterval allows the scaling of candles to larger candles
// eg convert OneDay candles to ThreeDay candles, if there are adequate candles
// incomplete candles are NOT converted
// eg an 4 OneDay candles will convert to one ThreeDay candle, skipping the fourth
func ConvertToNewInterval(item *Item, newInterval Interval) (*Item, error) {
if item == nil {
return nil, errNilKline
}
if newInterval <= 0 {
return nil, ErrUnsetInterval
}
if newInterval.Duration() <= item.Interval.Duration() {
return nil, ErrCanOnlyDownscaleCandles
}
if newInterval.Duration()%item.Interval.Duration() != 0 {
return nil, ErrWholeNumberScaling
}
oldIntervalsPerNewCandle := int64(newInterval / item.Interval)
var candleBundles [][]Candle
candles := make([]Candle, 0, oldIntervalsPerNewCandle)
for i := range item.Candles {
candles = append(candles, item.Candles[i])
intervalCount := int64(i + 1)
if oldIntervalsPerNewCandle == intervalCount {
candleBundles = append(candleBundles, candles)
candles = candles[:0]
}
}
responseCandle := &Item{
Exchange: item.Exchange,
Pair: item.Pair,
Asset: item.Asset,
Interval: newInterval,
}
for i := range candleBundles {
var lowest, highest, volume float64
lowest = candleBundles[i][0].Low
highest = candleBundles[i][0].High
for j := range candleBundles[i] {
volume += candleBundles[i][j].Volume
if candleBundles[i][j].Low < lowest {
lowest = candleBundles[i][j].Low
}
if candleBundles[i][j].High > highest {
lowest = candleBundles[i][j].High
}
volume += candleBundles[i][j].Volume
}
responseCandle.Candles = append(responseCandle.Candles, Candle{
Time: candleBundles[i][0].Time,
Open: candleBundles[i][0].Open,
High: highest,
Low: lowest,
Close: candleBundles[i][len(candleBundles[i])-1].Close,
Volume: volume,
})
}
return responseCandle, nil
}
// CalculateCandleDateRanges will calculate the expected candle data in intervals in a date range
// If an API is limited in the amount of candles it can make in a request, it will automatically separate
// ranges into the limit
func CalculateCandleDateRanges(start, end time.Time, interval Interval, limit uint32) (*IntervalRangeHolder, error) {
if err := common.StartEndTimeCheck(start, end); err != nil && !errors.Is(err, common.ErrStartAfterTimeNow) {
return nil, err
}
if interval <= 0 {
return nil, ErrUnsetInterval
}
start = start.Round(interval.Duration())
end = end.Round(interval.Duration())
resp := &IntervalRangeHolder{
Start: CreateIntervalTime(start),
End: CreateIntervalTime(end),
}
var intervalsInWholePeriod []IntervalData
for i := start; i.Before(end) && !i.Equal(end); i = i.Add(interval.Duration()) {
intervalsInWholePeriod = append(intervalsInWholePeriod, IntervalData{
Start: CreateIntervalTime(i.Round(interval.Duration())),
End: CreateIntervalTime(i.Round(interval.Duration()).Add(interval.Duration())),
})
}
if len(intervalsInWholePeriod) < int(limit) || limit == 0 {
resp.Ranges = []IntervalRange{{
Start: CreateIntervalTime(start),
End: CreateIntervalTime(end),
Intervals: intervalsInWholePeriod,
}}
return resp, nil
}
var intervals []IntervalData
splitIntervalsByLimit := make([][]IntervalData, 0, len(intervalsInWholePeriod)/int(limit)+1)
for len(intervalsInWholePeriod) >= int(limit) {
intervals, intervalsInWholePeriod = intervalsInWholePeriod[:limit], intervalsInWholePeriod[limit:]
splitIntervalsByLimit = append(splitIntervalsByLimit, intervals)
}
if len(intervalsInWholePeriod) > 0 {
splitIntervalsByLimit = append(splitIntervalsByLimit, intervalsInWholePeriod)
}
for x := range splitIntervalsByLimit {
resp.Ranges = append(resp.Ranges, IntervalRange{
Start: splitIntervalsByLimit[x][0].Start,
End: splitIntervalsByLimit[x][len(splitIntervalsByLimit[x])-1].End,
Intervals: splitIntervalsByLimit[x],
})
}
return resp, nil
}
// HasDataAtDate determines whether a there is any data at a set
// date inside the existing limits
func (h *IntervalRangeHolder) HasDataAtDate(t time.Time) bool {
tu := t.Unix()
if tu < h.Start.Ticks || tu > h.End.Ticks {
return false
}
for i := range h.Ranges {
if tu >= h.Ranges[i].Start.Ticks && tu <= h.Ranges[i].End.Ticks {
for j := range h.Ranges[i].Intervals {
if tu >= h.Ranges[i].Intervals[j].Start.Ticks && tu < h.Ranges[i].Intervals[j].End.Ticks {
return h.Ranges[i].Intervals[j].HasData
}
if j == len(h.Ranges[i].Intervals)-1 {
if tu == h.Ranges[i].Start.Ticks {
return h.Ranges[i].Intervals[j].HasData
}
}
}
}
}
return false
}
// GetClosePriceAtTime returns the close price of a candle
// at a given time
func (k *Item) GetClosePriceAtTime(t time.Time) (float64, error) {
for i := range k.Candles {
if k.Candles[i].Time.Equal(t) {
return k.Candles[i].Close, nil
}
}
return -1, fmt.Errorf("%w at %v", ErrNotFoundAtTime, t)
}
// SetHasDataFromCandles will calculate whether there is data in each candle
// allowing any missing data from an API request to be highlighted
func (h *IntervalRangeHolder) SetHasDataFromCandles(c []Candle) {
for x := range h.Ranges {
intervals:
for y := range h.Ranges[x].Intervals {
for z := range c {
cu := c[z].Time.Unix()
if cu >= h.Ranges[x].Intervals[y].Start.Ticks && cu < h.Ranges[x].Intervals[y].End.Ticks {
h.Ranges[x].Intervals[y].HasData = true
continue intervals
}
}
h.Ranges[x].Intervals[y].HasData = false
}
}
}
// DataSummary returns a summary of a data range to highlight where data is missing
func (h *IntervalRangeHolder) DataSummary(includeHasData bool) []string {
var (
rangeStart, rangeEnd, prevStart, prevEnd time.Time
rangeHasData bool
rangeTexts []string
)
rangeStart = h.Start.Time
for i := range h.Ranges {
for j := range h.Ranges[i].Intervals {
if h.Ranges[i].Intervals[j].HasData {
if !rangeHasData && !rangeEnd.IsZero() {
rangeTexts = append(rangeTexts, h.createDateSummaryRange(rangeStart, rangeEnd, rangeHasData))
prevStart = rangeStart
prevEnd = rangeEnd
rangeStart = h.Ranges[i].Intervals[j].Start.Time
}
rangeHasData = true
} else {
if rangeHasData && !rangeEnd.IsZero() {
if includeHasData {
rangeTexts = append(rangeTexts, h.createDateSummaryRange(rangeStart, rangeEnd, rangeHasData))
}
prevStart = rangeStart
prevEnd = rangeEnd
rangeStart = h.Ranges[i].Intervals[j].Start.Time
}
rangeHasData = false
}
rangeEnd = h.Ranges[i].Intervals[j].End.Time
}
}
if !rangeStart.Equal(prevStart) || !rangeEnd.Equal(prevEnd) {
if (rangeHasData && includeHasData) || !rangeHasData {
rangeTexts = append(rangeTexts, h.createDateSummaryRange(rangeStart, rangeEnd, rangeHasData))
}
}
return rangeTexts
}
func (h *IntervalRangeHolder) createDateSummaryRange(start, end time.Time, hasData bool) string {
dataString := "missing"
if hasData {
dataString = "has"
}
return fmt.Sprintf("%s data between %s and %s",
dataString,
start.Format(common.SimpleTimeFormat),
end.Format(common.SimpleTimeFormat))
}
// CreateIntervalTime is a simple helper function to set the time twice
func CreateIntervalTime(tt time.Time) IntervalTime {
return IntervalTime{Time: tt, Ticks: tt.Unix()}
}
// Equal allows for easier unix comparison
func (i *IntervalTime) Equal(tt time.Time) bool {
return tt.Unix() == i.Ticks
}
// EqualSource checks whether two sets of candles
// come from the same data source
func (k *Item) EqualSource(i *Item) error {
if k == nil || i == nil {
return common.ErrNilPointer
}
if k.Exchange != i.Exchange ||
k.Asset != i.Asset ||
!k.Pair.Equal(i.Pair) {
return fmt.Errorf("%v %v %v %w %v %v %v", k.Exchange, k.Asset, k.Pair, ErrItemNotEqual, i.Exchange, i.Asset, i.Pair)
}
if !k.UnderlyingPair.IsEmpty() && !i.UnderlyingPair.IsEmpty() && !k.UnderlyingPair.Equal(i.UnderlyingPair) {
return fmt.Errorf("%w %v %v", ErrItemUnderlyingNotEqual, k.UnderlyingPair, i.UnderlyingPair)
}
return nil
}