Files
gocryptotrader/backtester/funding/funding.go
Scott 6eaa2e4073 Backtester: USD tracking (#818)
* Initial concept for creating price tracking pairs

* Completes coverage, even with a slow test

* I dont know what point to hook this stuff up

* Bit of a broken way of handling tracking pairs

* Correctly calculates USD rates against all currencies

* Removes dependency on GCT config

* Failed currency statistics redesign

* initial Update chart to use highcharts

* Minor changes to stats

* Creats funding stats to handle the stat calculations. Needs more work

* tracks USD snapshots and BREAKS THINGS FURTHER

* Fixed!

* Adds ratio calculations and such, but its WRONG. do it at totals level dummy

* End of day basic lint

* Remaining lints

* USD totals statistics

* Minor panic fixes

* Printing of funding stats, but its bad

* Properly calculates overall benchmark, moves funding stat output

* Adds some template charge, removes duplicate fields

* New charts!

* Darkcharts. funding protection when disabled

* Now works with usd tracking/funding disabled!

* Attempting to only show working stats based on settings.

* Spruces up the goose/reporting

* Completes report HTML rendering

* lint and test fixes

* funding statistics testing

* slightly more test coverage

* Test coverage

* Initial documentation

* Fixes tests

* Database testing and rendering improvements and breakages

* report and cmd rendering, linting. fix comma output. rm gct cfg

* PR mode 🎉 Path field, config builder support,testing,linting,docs

* minor calculation improvement

* Secret lint that did not show up locally

* Disable USD tracking for example configs

* ShazNitNoScope

* Forgotten errors

* ""

* literally Logarithmically logically renders the date 👀

* Fixes typos, fixes parallel test, fixes chart gui and exporting
2021-11-08 12:10:15 +11:00

552 lines
17 KiB
Go

package funding
import (
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/shopspring/decimal"
"github.com/thrasher-corp/gocryptotrader/backtester/common"
"github.com/thrasher-corp/gocryptotrader/backtester/data/kline"
"github.com/thrasher-corp/gocryptotrader/backtester/funding/trackingcurrencies"
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
gctkline "github.com/thrasher-corp/gocryptotrader/exchanges/kline"
"github.com/thrasher-corp/gocryptotrader/exchanges/order"
)
var (
// ErrFundsNotFound used when funds are requested but the funding is not found in the manager
ErrFundsNotFound = errors.New("funding not found")
// ErrAlreadyExists used when a matching item or pair is already in the funding manager
ErrAlreadyExists = errors.New("funding already exists")
// ErrUSDTrackingDisabled used when attempting to track USD values when disabled
ErrUSDTrackingDisabled = errors.New("USD tracking disabled")
errCannotAllocate = errors.New("cannot allocate funds")
errZeroAmountReceived = errors.New("amount received less than or equal to zero")
errNegativeAmountReceived = errors.New("received negative decimal")
errNotEnoughFunds = errors.New("not enough funds")
errCannotTransferToSameFunds = errors.New("cannot send funds to self")
errTransferMustBeSameCurrency = errors.New("cannot transfer to different currency")
errCannotMatchTrackingToItem = errors.New("cannot match tracking data to funding items")
)
// SetupFundingManager creates the funding holder. It carries knowledge about levels of funding
// across all execution handlers and enables fund transfers
func SetupFundingManager(usingExchangeLevelFunding, disableUSDTracking bool) *FundManager {
return &FundManager{
usingExchangeLevelFunding: usingExchangeLevelFunding,
disableUSDTracking: disableUSDTracking,
}
}
// CreateItem creates a new funding item
func CreateItem(exch string, a asset.Item, ci currency.Code, initialFunds, transferFee decimal.Decimal) (*Item, error) {
if initialFunds.IsNegative() {
return nil, fmt.Errorf("%v %v %v %w initial funds: %v", exch, a, ci, errNegativeAmountReceived, initialFunds)
}
if transferFee.IsNegative() {
return nil, fmt.Errorf("%v %v %v %w transfer fee: %v", exch, a, ci, errNegativeAmountReceived, transferFee)
}
return &Item{
exchange: exch,
asset: a,
currency: ci,
initialFunds: initialFunds,
available: initialFunds,
transferFee: transferFee,
snapshot: make(map[time.Time]ItemSnapshot),
}, nil
}
// CreateSnapshot creates a Snapshot for an event's point in time
// as funding.snapshots is a map, it allows for the last event
// in the chronological list to establish the canon at X time
func (f *FundManager) CreateSnapshot(t time.Time) {
for i := range f.items {
if f.items[i].snapshot == nil {
f.items[i].snapshot = make(map[time.Time]ItemSnapshot)
}
iss := ItemSnapshot{
Available: f.items[i].available,
Time: t,
}
if !f.disableUSDTracking {
var usdClosePrice decimal.Decimal
if f.items[i].usdTrackingCandles == nil {
continue
}
usdCandles := f.items[i].usdTrackingCandles.GetStream()
for j := range usdCandles {
if usdCandles[j].GetTime().Equal(t) {
usdClosePrice = usdCandles[j].ClosePrice()
break
}
}
iss.USDClosePrice = usdClosePrice
iss.USDValue = usdClosePrice.Mul(f.items[i].available)
}
f.items[i].snapshot[t] = iss
}
}
// AddUSDTrackingData adds USD tracking data to a funding item
// only in the event that it is not USD and there is data
func (f *FundManager) AddUSDTrackingData(k *kline.DataFromKline) error {
if f == nil || f.items == nil {
return common.ErrNilArguments
}
if f.disableUSDTracking {
return ErrUSDTrackingDisabled
}
baseSet := false
quoteSet := false
for i := range f.items {
if baseSet && quoteSet {
return nil
}
if strings.EqualFold(f.items[i].exchange, k.Item.Exchange) &&
f.items[i].asset == k.Item.Asset {
if f.items[i].currency == k.Item.Pair.Base {
if f.items[i].usdTrackingCandles == nil &&
trackingcurrencies.CurrencyIsUSDTracked(k.Item.Pair.Quote) {
f.items[i].usdTrackingCandles = k
}
baseSet = true
}
if trackingcurrencies.CurrencyIsUSDTracked(f.items[i].currency) {
if f.items[i].usdTrackingCandles == nil {
usdCandles := gctkline.Item{
Exchange: k.Item.Exchange,
Pair: currency.Pair{Delimiter: k.Item.Pair.Delimiter, Base: f.items[i].currency, Quote: currency.USD},
Asset: k.Item.Asset,
Interval: k.Item.Interval,
Candles: make([]gctkline.Candle, len(k.Item.Candles)),
}
copy(usdCandles.Candles, k.Item.Candles)
for j := range usdCandles.Candles {
// usd stablecoins do not always match in value,
// this is a simplified implementation that can allow
// USD tracking for many different currencies across many exchanges
// without retrieving n candle history and exchange rates
usdCandles.Candles[j].Open = 1
usdCandles.Candles[j].High = 1
usdCandles.Candles[j].Low = 1
usdCandles.Candles[j].Close = 1
}
cpy := *k
cpy.Item = usdCandles
if err := cpy.Load(); err != nil {
return err
}
f.items[i].usdTrackingCandles = &cpy
}
quoteSet = true
}
}
}
if baseSet {
return nil
}
return fmt.Errorf("%w %v %v %v", errCannotMatchTrackingToItem, k.Item.Exchange, k.Item.Asset, k.Item.Pair)
}
// CreatePair adds two funding items and associates them with one another
// the association allows for the same currency to be used multiple times when
// usingExchangeLevelFunding is false. eg BTC-USDT and LTC-USDT do not share the same
// USDT level funding
func CreatePair(base, quote *Item) (*Pair, error) {
if base == nil {
return nil, fmt.Errorf("base %w", common.ErrNilArguments)
}
if quote == nil {
return nil, fmt.Errorf("quote %w", common.ErrNilArguments)
}
// copy to prevent the off chance of sending in the same base OR quote
// to create a new pair with a new base OR quote
bCopy := *base
qCopy := *quote
bCopy.pairedWith = &qCopy
qCopy.pairedWith = &bCopy
return &Pair{Base: &bCopy, Quote: &qCopy}, nil
}
// Reset clears all settings
func (f *FundManager) Reset() {
*f = FundManager{}
}
// USDTrackingDisabled clears all settings
func (f *FundManager) USDTrackingDisabled() bool {
return f.disableUSDTracking
}
// GenerateReport builds report data for result HTML report
func (f *FundManager) GenerateReport() *Report {
report := Report{
USDTotalsOverTime: make(map[time.Time]ItemSnapshot),
UsingExchangeLevelFunding: f.usingExchangeLevelFunding,
DisableUSDTracking: f.disableUSDTracking,
}
var items []ReportItem
for i := range f.items {
item := ReportItem{
Exchange: f.items[i].exchange,
Asset: f.items[i].asset,
Currency: f.items[i].currency,
InitialFunds: f.items[i].initialFunds,
TransferFee: f.items[i].transferFee,
FinalFunds: f.items[i].available,
}
if !f.disableUSDTracking &&
f.items[i].usdTrackingCandles != nil {
usdStream := f.items[i].usdTrackingCandles.GetStream()
item.USDInitialFunds = f.items[i].initialFunds.Mul(usdStream[0].ClosePrice())
item.USDFinalFunds = f.items[i].available.Mul(usdStream[len(usdStream)-1].ClosePrice())
item.USDInitialCostForOne = usdStream[0].ClosePrice()
item.USDFinalCostForOne = usdStream[len(usdStream)-1].ClosePrice()
item.USDPairCandle = f.items[i].usdTrackingCandles
}
var pricingOverTime []ItemSnapshot
for _, v := range f.items[i].snapshot {
pricingOverTime = append(pricingOverTime, v)
if !f.disableUSDTracking {
usdTotalForPeriod := report.USDTotalsOverTime[v.Time]
usdTotalForPeriod.Time = v.Time
usdTotalForPeriod.USDValue = usdTotalForPeriod.USDValue.Add(v.USDValue)
report.USDTotalsOverTime[v.Time] = usdTotalForPeriod
}
}
sort.Slice(pricingOverTime, func(i, j int) bool {
return pricingOverTime[i].Time.Before(pricingOverTime[j].Time)
})
item.Snapshots = pricingOverTime
if f.items[i].initialFunds.IsZero() {
item.ShowInfinite = true
} else {
item.Difference = f.items[i].available.Sub(f.items[i].initialFunds).Div(f.items[i].initialFunds).Mul(decimal.NewFromInt(100))
}
if f.items[i].pairedWith != nil {
item.PairedWith = f.items[i].pairedWith.currency
}
items = append(items, item)
}
report.Items = items
return &report
}
// Transfer allows transferring funds from one pretend exchange to another
func (f *FundManager) Transfer(amount decimal.Decimal, sender, receiver *Item, inclusiveFee bool) error {
if sender == nil || receiver == nil {
return common.ErrNilArguments
}
if amount.LessThanOrEqual(decimal.Zero) {
return errZeroAmountReceived
}
if inclusiveFee {
if sender.available.LessThan(amount) {
return fmt.Errorf("%w for %v", errNotEnoughFunds, sender.currency)
}
} else {
if sender.available.LessThan(amount.Add(sender.transferFee)) {
return fmt.Errorf("%w for %v", errNotEnoughFunds, sender.currency)
}
}
if sender.currency != receiver.currency {
return errTransferMustBeSameCurrency
}
if sender.currency == receiver.currency &&
sender.exchange == receiver.exchange &&
sender.asset == receiver.asset {
return fmt.Errorf("%v %v %v %w", sender.exchange, sender.asset, sender.currency, errCannotTransferToSameFunds)
}
sendAmount := amount
receiveAmount := amount
if inclusiveFee {
receiveAmount = amount.Sub(sender.transferFee)
} else {
sendAmount = amount.Add(sender.transferFee)
}
err := sender.Reserve(sendAmount)
if err != nil {
return err
}
receiver.IncreaseAvailable(receiveAmount)
return sender.Release(sendAmount, decimal.Zero)
}
// AddItem appends a new funding item. Will reject if exists by exchange asset currency
func (f *FundManager) AddItem(item *Item) error {
if f.Exists(item) {
return fmt.Errorf("cannot add item %v %v %v %w", item.exchange, item.asset, item.currency, ErrAlreadyExists)
}
f.items = append(f.items, item)
return nil
}
// Exists verifies whether there is a funding item that exists
// with the same exchange, asset and currency
func (f *FundManager) Exists(item *Item) bool {
for i := range f.items {
if f.items[i].Equal(item) {
return true
}
}
return false
}
// AddPair adds a pair to the fund manager if it does not exist
func (f *FundManager) AddPair(p *Pair) error {
if f.Exists(p.Base) {
return fmt.Errorf("%w %v", ErrAlreadyExists, p.Base)
}
if f.Exists(p.Quote) {
return fmt.Errorf("%w %v", ErrAlreadyExists, p.Quote)
}
f.items = append(f.items, p.Base, p.Quote)
return nil
}
// IsUsingExchangeLevelFunding returns if using usingExchangeLevelFunding
func (f *FundManager) IsUsingExchangeLevelFunding() bool {
return f.usingExchangeLevelFunding
}
// GetFundingForEvent This will construct a funding based on a backtesting event
func (f *FundManager) GetFundingForEvent(ev common.EventHandler) (*Pair, error) {
return f.GetFundingForEAP(ev.GetExchange(), ev.GetAssetType(), ev.Pair())
}
// GetFundingForEAC This will construct a funding based on the exchange, asset, currency code
func (f *FundManager) GetFundingForEAC(exch string, a asset.Item, c currency.Code) (*Item, error) {
for i := range f.items {
if f.items[i].BasicEqual(exch, a, c, currency.Code{}) {
return f.items[i], nil
}
}
return nil, ErrFundsNotFound
}
// GetFundingForEAP This will construct a funding based on the exchange, asset, currency pair
func (f *FundManager) GetFundingForEAP(exch string, a asset.Item, p currency.Pair) (*Pair, error) {
var resp Pair
for i := range f.items {
if f.items[i].BasicEqual(exch, a, p.Base, p.Quote) {
resp.Base = f.items[i]
continue
}
if f.items[i].BasicEqual(exch, a, p.Quote, p.Base) {
resp.Quote = f.items[i]
}
}
if resp.Base == nil {
return nil, fmt.Errorf("base %w", ErrFundsNotFound)
}
if resp.Quote == nil {
return nil, fmt.Errorf("quote %w", ErrFundsNotFound)
}
return &resp, nil
}
// BaseInitialFunds returns the initial funds
// from the base in a currency pair
func (p *Pair) BaseInitialFunds() decimal.Decimal {
return p.Base.initialFunds
}
// QuoteInitialFunds returns the initial funds
// from the quote in a currency pair
func (p *Pair) QuoteInitialFunds() decimal.Decimal {
return p.Quote.initialFunds
}
// BaseAvailable returns the available funds
// from the base in a currency pair
func (p *Pair) BaseAvailable() decimal.Decimal {
return p.Base.available
}
// QuoteAvailable returns the available funds
// from the quote in a currency pair
func (p *Pair) QuoteAvailable() decimal.Decimal {
return p.Quote.available
}
// Reserve allocates an amount of funds to be used at a later time
// it prevents multiple events from claiming the same resource
// changes which currency to affect based on the order side
func (p *Pair) Reserve(amount decimal.Decimal, side order.Side) error {
switch side {
case order.Buy:
return p.Quote.Reserve(amount)
case order.Sell:
return p.Base.Reserve(amount)
default:
return fmt.Errorf("%w for %v %v %v. Unknown side %v",
errCannotAllocate,
p.Base.exchange,
p.Base.asset,
p.Base.currency,
side)
}
}
// Release reduces the amount of funding reserved and adds any difference
// back to the available amount
// changes which currency to affect based on the order side
func (p *Pair) Release(amount, diff decimal.Decimal, side order.Side) error {
switch side {
case order.Buy:
return p.Quote.Release(amount, diff)
case order.Sell:
return p.Base.Release(amount, diff)
default:
return fmt.Errorf("%w for %v %v %v. Unknown side %v",
errCannotAllocate,
p.Base.exchange,
p.Base.asset,
p.Base.currency,
side)
}
}
// IncreaseAvailable adds funding to the available amount
// changes which currency to affect based on the order side
func (p *Pair) IncreaseAvailable(amount decimal.Decimal, side order.Side) {
switch side {
case order.Buy:
p.Base.IncreaseAvailable(amount)
case order.Sell:
p.Quote.IncreaseAvailable(amount)
}
}
// CanPlaceOrder does a > 0 check to see if there are any funds
// to place an order with
// changes which currency to affect based on the order side
func (p *Pair) CanPlaceOrder(side order.Side) bool {
switch side {
case order.Buy:
return p.Quote.CanPlaceOrder()
case order.Sell:
return p.Base.CanPlaceOrder()
}
return false
}
// Reserve allocates an amount of funds to be used at a later time
// it prevents multiple events from claiming the same resource
func (i *Item) Reserve(amount decimal.Decimal) error {
if amount.LessThanOrEqual(decimal.Zero) {
return errZeroAmountReceived
}
if amount.GreaterThan(i.available) {
return fmt.Errorf("%w for %v %v %v. Requested %v Available: %v",
errCannotAllocate,
i.exchange,
i.asset,
i.currency,
amount,
i.available)
}
i.available = i.available.Sub(amount)
i.reserved = i.reserved.Add(amount)
return nil
}
// Release reduces the amount of funding reserved and adds any difference
// back to the available amount
func (i *Item) Release(amount, diff decimal.Decimal) error {
if amount.LessThanOrEqual(decimal.Zero) {
return errZeroAmountReceived
}
if diff.IsNegative() {
return fmt.Errorf("%w diff", errNegativeAmountReceived)
}
if amount.GreaterThan(i.reserved) {
return fmt.Errorf("%w for %v %v %v. Requested %v Reserved: %v",
errCannotAllocate,
i.exchange,
i.asset,
i.currency,
amount,
i.reserved)
}
i.reserved = i.reserved.Sub(amount)
i.available = i.available.Add(diff)
return nil
}
// IncreaseAvailable adds funding to the available amount
func (i *Item) IncreaseAvailable(amount decimal.Decimal) {
if amount.IsNegative() || amount.IsZero() {
return
}
i.available = i.available.Add(amount)
}
// CanPlaceOrder checks if the item has any funds available
func (i *Item) CanPlaceOrder() bool {
return i.available.GreaterThan(decimal.Zero)
}
// Equal checks for equality via an Item to compare to
func (i *Item) Equal(item *Item) bool {
if i == nil && item == nil {
return true
}
if item == nil || i == nil {
return false
}
if i.currency == item.currency &&
i.asset == item.asset &&
i.exchange == item.exchange {
if i.pairedWith == nil && item.pairedWith == nil {
return true
}
if i.pairedWith == nil || item.pairedWith == nil {
return false
}
if i.pairedWith.currency == item.pairedWith.currency &&
i.pairedWith.asset == item.pairedWith.asset &&
i.pairedWith.exchange == item.pairedWith.exchange {
return true
}
}
return false
}
// BasicEqual checks for equality via passed in values
func (i *Item) BasicEqual(exch string, a asset.Item, currency, pairedCurrency currency.Code) bool {
return i != nil &&
i.exchange == exch &&
i.asset == a &&
i.currency == currency &&
(i.pairedWith == nil ||
(i.pairedWith != nil && i.pairedWith.currency == pairedCurrency))
}
// MatchesCurrency checks that an item's currency is equal
func (i *Item) MatchesCurrency(c currency.Code) bool {
return i != nil && i.currency == c
}
// MatchesItemCurrency checks that an item's currency is equal
func (i *Item) MatchesItemCurrency(item *Item) bool {
return i != nil && item != nil && i.currency == item.currency
}
// MatchesExchange checks that an item's exchange is equal
func (i *Item) MatchesExchange(item *Item) bool {
return i != nil && item != nil && i.exchange == item.exchange
}