Files
gocryptotrader/backtester/eventhandlers/exchange/exchange.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

375 lines
12 KiB
Go

package exchange
import (
"context"
"fmt"
"github.com/gofrs/uuid"
"github.com/shopspring/decimal"
"github.com/thrasher-corp/gocryptotrader/backtester/common"
"github.com/thrasher-corp/gocryptotrader/backtester/data"
"github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/exchange/slippage"
"github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/event"
"github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/fill"
"github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/order"
"github.com/thrasher-corp/gocryptotrader/backtester/funding"
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/engine"
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
gctorder "github.com/thrasher-corp/gocryptotrader/exchanges/order"
"github.com/thrasher-corp/gocryptotrader/exchanges/orderbook"
)
// Reset returns the exchange to initial settings
func (e *Exchange) Reset() {
*e = Exchange{}
}
// ExecuteOrder assesses the portfolio manager's order event and if it passes validation
// will send an order to the exchange/fake order manager to be stored and raise a fill event
func (e *Exchange) ExecuteOrder(o order.Event, data data.Handler, orderManager *engine.OrderManager, funds funding.IPairReleaser) (*fill.Fill, error) {
f := &fill.Fill{
Base: event.Base{
Offset: o.GetOffset(),
Exchange: o.GetExchange(),
Time: o.GetTime(),
CurrencyPair: o.Pair(),
AssetType: o.GetAssetType(),
Interval: o.GetInterval(),
Reason: o.GetReason(),
},
Direction: o.GetDirection(),
Amount: o.GetAmount(),
ClosePrice: data.Latest().ClosePrice(),
}
eventFunds := o.GetAllocatedFunds()
cs, err := e.GetCurrencySettings(o.GetExchange(), o.GetAssetType(), o.Pair())
if err != nil {
return f, err
}
f.ExchangeFee = cs.ExchangeFee // defaulting to just using taker fee right now without orderbook
f.Direction = o.GetDirection()
if o.GetDirection() != gctorder.Buy && o.GetDirection() != gctorder.Sell {
return f, nil
}
highStr := data.StreamHigh()
high := highStr[len(highStr)-1]
lowStr := data.StreamLow()
low := lowStr[len(lowStr)-1]
volStr := data.StreamVol()
volume := volStr[len(volStr)-1]
var adjustedPrice, amount decimal.Decimal
if cs.UseRealOrders {
// get current orderbook
var ob *orderbook.Base
ob, err = orderbook.Get(f.Exchange, f.CurrencyPair, f.AssetType)
if err != nil {
return f, err
}
// calculate an estimated slippage rate
adjustedPrice, amount = slippage.CalculateSlippageByOrderbook(ob, o.GetDirection(), eventFunds, f.ExchangeFee)
f.Slippage = adjustedPrice.Sub(f.ClosePrice).Div(f.ClosePrice).Mul(decimal.NewFromInt(100))
} else {
adjustedPrice, amount, err = e.sizeOfflineOrder(high, low, volume, &cs, f)
if err != nil {
switch f.GetDirection() {
case gctorder.Buy:
f.SetDirection(common.CouldNotBuy)
case gctorder.Sell:
f.SetDirection(common.CouldNotSell)
default:
f.SetDirection(common.DoNothing)
}
f.AppendReason(err.Error())
return f, err
}
}
portfolioLimitedAmount := reduceAmountToFitPortfolioLimit(adjustedPrice, amount, eventFunds, f.GetDirection())
if !portfolioLimitedAmount.Equal(amount) {
f.AppendReason(fmt.Sprintf("Order size shrunk from %v to %v to remain within portfolio limits", amount, portfolioLimitedAmount))
}
limitReducedAmount := portfolioLimitedAmount
if cs.CanUseExchangeLimits {
// Conforms the amount to the exchange order defined step amount
// reducing it when needed
limitReducedAmount = cs.Limits.ConformToDecimalAmount(portfolioLimitedAmount)
if !limitReducedAmount.Equal(portfolioLimitedAmount) {
f.AppendReason(fmt.Sprintf("Order size shrunk from %v to %v to remain within exchange step amount limits",
portfolioLimitedAmount,
limitReducedAmount))
}
}
err = verifyOrderWithinLimits(f, limitReducedAmount, &cs)
if err != nil {
return f, err
}
f.ExchangeFee = calculateExchangeFee(adjustedPrice, limitReducedAmount, cs.ExchangeFee)
orderID, err := e.placeOrder(context.TODO(), adjustedPrice, limitReducedAmount, cs.UseRealOrders, cs.CanUseExchangeLimits, f, orderManager)
if err != nil {
fundErr := funds.Release(eventFunds, eventFunds, f.GetDirection())
if fundErr != nil {
f.AppendReason(fundErr.Error())
}
if f.GetDirection() == gctorder.Buy {
f.SetDirection(common.CouldNotBuy)
} else if f.GetDirection() == gctorder.Sell {
f.SetDirection(common.CouldNotSell)
}
return f, err
}
switch f.GetDirection() {
case gctorder.Buy:
err = funds.Release(eventFunds, eventFunds.Sub(limitReducedAmount.Mul(adjustedPrice)), f.GetDirection())
if err != nil {
return f, err
}
funds.IncreaseAvailable(limitReducedAmount, f.GetDirection())
case gctorder.Sell:
err = funds.Release(eventFunds, eventFunds.Sub(limitReducedAmount), f.GetDirection())
if err != nil {
return f, err
}
funds.IncreaseAvailable(limitReducedAmount.Mul(adjustedPrice), f.GetDirection())
}
ords := orderManager.GetOrdersSnapshot("")
for i := range ords {
if ords[i].ID != orderID {
continue
}
ords[i].Date = o.GetTime()
ords[i].LastUpdated = o.GetTime()
ords[i].CloseTime = o.GetTime()
f.Order = &ords[i]
f.PurchasePrice = decimal.NewFromFloat(ords[i].Price)
f.Total = f.PurchasePrice.Mul(limitReducedAmount).Add(f.ExchangeFee)
}
if f.Order == nil {
return nil, fmt.Errorf("placed order %v not found in order manager", orderID)
}
return f, nil
}
// verifyOrderWithinLimits conforms the amount to fall into the minimum size and maximum size limit after reduced
func verifyOrderWithinLimits(f *fill.Fill, limitReducedAmount decimal.Decimal, cs *Settings) error {
if f == nil {
return common.ErrNilEvent
}
if cs == nil {
return errNilCurrencySettings
}
isBeyondLimit := false
var minMax MinMax
var direction gctorder.Side
switch f.GetDirection() {
case gctorder.Buy:
minMax = cs.BuySide
direction = common.CouldNotBuy
case gctorder.Sell:
minMax = cs.SellSide
direction = common.CouldNotSell
default:
direction = f.GetDirection()
f.SetDirection(common.DoNothing)
return fmt.Errorf("%w: %v", errInvalidDirection, direction)
}
var minOrMax, belowExceed string
var size decimal.Decimal
if limitReducedAmount.LessThan(minMax.MinimumSize) && minMax.MinimumSize.GreaterThan(decimal.Zero) {
isBeyondLimit = true
belowExceed = "below"
minOrMax = "minimum"
size = minMax.MinimumSize
}
if limitReducedAmount.GreaterThan(minMax.MaximumSize) && minMax.MaximumSize.GreaterThan(decimal.Zero) {
isBeyondLimit = true
belowExceed = "exceeded"
minOrMax = "maximum"
size = minMax.MaximumSize
}
if isBeyondLimit {
f.SetDirection(direction)
e := fmt.Sprintf("Order size %v %s %s size %v", limitReducedAmount, belowExceed, minOrMax, size)
f.AppendReason(e)
return fmt.Errorf("%w %v", errExceededPortfolioLimit, e)
}
return nil
}
func reduceAmountToFitPortfolioLimit(adjustedPrice, amount, sizedPortfolioTotal decimal.Decimal, side gctorder.Side) decimal.Decimal {
switch side {
case gctorder.Buy:
if adjustedPrice.Mul(amount).GreaterThan(sizedPortfolioTotal) {
// adjusted amounts exceeds portfolio manager's allowed funds
// the amount has to be reduced to equal the sizedPortfolioTotal
amount = sizedPortfolioTotal.Div(adjustedPrice)
}
case gctorder.Sell:
if amount.GreaterThan(sizedPortfolioTotal) {
amount = sizedPortfolioTotal
}
}
return amount
}
func (e *Exchange) placeOrder(ctx context.Context, price, amount decimal.Decimal, useRealOrders, useExchangeLimits bool, f *fill.Fill, orderManager *engine.OrderManager) (string, error) {
if f == nil {
return "", common.ErrNilEvent
}
u, err := uuid.NewV4()
if err != nil {
return "", err
}
var orderID string
p, _ := price.Float64()
a, _ := amount.Float64()
fee, _ := f.ExchangeFee.Float64()
o := &gctorder.Submit{
Price: p,
Amount: a,
Fee: fee,
Exchange: f.Exchange,
ID: u.String(),
Side: f.Direction,
AssetType: f.AssetType,
Date: f.GetTime(),
LastUpdated: f.GetTime(),
Pair: f.Pair(),
Type: gctorder.Market,
}
if useRealOrders {
resp, err := orderManager.Submit(ctx, o)
if resp != nil {
orderID = resp.OrderID
}
if err != nil {
return orderID, err
}
} else {
rate, _ := f.Amount.Float64()
submitResponse := gctorder.SubmitResponse{
IsOrderPlaced: true,
OrderID: u.String(),
Rate: rate,
Fee: fee,
Cost: p,
FullyMatched: true,
}
resp, err := orderManager.SubmitFakeOrder(o, submitResponse, useExchangeLimits)
if resp != nil {
orderID = resp.OrderID
}
if err != nil {
return orderID, err
}
}
return orderID, nil
}
func (e *Exchange) sizeOfflineOrder(high, low, volume decimal.Decimal, cs *Settings, f *fill.Fill) (adjustedPrice, adjustedAmount decimal.Decimal, err error) {
if cs == nil || f == nil {
return decimal.Zero, decimal.Zero, common.ErrNilArguments
}
// provide history and estimate volatility
slippageRate := slippage.EstimateSlippagePercentage(cs.MinimumSlippageRate, cs.MaximumSlippageRate)
if cs.SkipCandleVolumeFitting {
f.VolumeAdjustedPrice = f.ClosePrice
adjustedAmount = f.Amount
} else {
f.VolumeAdjustedPrice, adjustedAmount = ensureOrderFitsWithinHLV(f.ClosePrice, f.Amount, high, low, volume)
if !adjustedAmount.Equal(f.Amount) {
f.AppendReason(fmt.Sprintf("Order size shrunk from %v to %v to fit candle", f.Amount, adjustedAmount))
}
}
if adjustedAmount.LessThanOrEqual(decimal.Zero) && f.Amount.GreaterThan(decimal.Zero) {
return decimal.Zero, decimal.Zero, fmt.Errorf("amount set to 0, %w", errDataMayBeIncorrect)
}
adjustedPrice = applySlippageToPrice(f.GetDirection(), f.GetVolumeAdjustedPrice(), slippageRate)
f.Slippage = slippageRate.Mul(decimal.NewFromInt(100)).Sub(decimal.NewFromInt(100))
f.ExchangeFee = calculateExchangeFee(adjustedPrice, adjustedAmount, cs.TakerFee)
return adjustedPrice, adjustedAmount, nil
}
func applySlippageToPrice(direction gctorder.Side, price, slippageRate decimal.Decimal) decimal.Decimal {
adjustedPrice := price
if direction == gctorder.Buy {
adjustedPrice = price.Add(price.Mul(decimal.NewFromInt(1).Sub(slippageRate)))
} else if direction == gctorder.Sell {
adjustedPrice = price.Mul(slippageRate)
}
return adjustedPrice
}
// SetExchangeAssetCurrencySettings sets the settings for an exchange, asset, currency
func (e *Exchange) SetExchangeAssetCurrencySettings(exch string, a asset.Item, cp currency.Pair, c *Settings) {
if c.Exchange == "" ||
c.Asset == "" ||
c.Pair.IsEmpty() {
return
}
for i := range e.CurrencySettings {
if e.CurrencySettings[i].Pair == cp &&
e.CurrencySettings[i].Asset == a &&
exch == e.CurrencySettings[i].Exchange {
e.CurrencySettings[i] = *c
return
}
}
e.CurrencySettings = append(e.CurrencySettings, *c)
}
// GetCurrencySettings returns the settings for an exchange, asset currency
func (e *Exchange) GetCurrencySettings(exch string, a asset.Item, cp currency.Pair) (Settings, error) {
for i := range e.CurrencySettings {
if e.CurrencySettings[i].Pair.Equal(cp) {
if e.CurrencySettings[i].Asset == a {
if exch == e.CurrencySettings[i].Exchange {
return e.CurrencySettings[i], nil
}
}
}
}
return Settings{}, fmt.Errorf("no currency settings found for %v %v %v", exch, a, cp)
}
func ensureOrderFitsWithinHLV(slippagePrice, amount, high, low, volume decimal.Decimal) (adjustedPrice, adjustedAmount decimal.Decimal) {
adjustedPrice = slippagePrice
if adjustedPrice.LessThan(low) {
adjustedPrice = low
}
if adjustedPrice.GreaterThan(high) {
adjustedPrice = high
}
if volume.LessThanOrEqual(decimal.Zero) {
return adjustedPrice, adjustedAmount
}
currentVolume := amount.Mul(adjustedPrice)
if currentVolume.GreaterThan(volume) {
// reduce the volume to not exceed the total volume of the candle
// it is slightly less than the total to still allow for the illusion
// that open high low close values are valid with the remaining volume
// this is very opinionated
currentVolume = volume.Mul(decimal.NewFromFloat(0.99999999))
}
// extract the amount from the adjusted volume
adjustedAmount = currentVolume.Div(adjustedPrice)
return adjustedPrice, adjustedAmount
}
func calculateExchangeFee(price, amount, fee decimal.Decimal) decimal.Decimal {
return fee.Mul(price).Mul(amount)
}