backtester: Futures handling & FTX Cash and Carry example strategy (#930)

* implements futures functions and GRPC functions on new branch

* lint and test fixes

* Fix uneven split pnl. Adds collateral weight test. docs. New clear func

* Test protection if someone has zero collateral

* Uses string instead of double for accuracy

* Fixes old code panic

* context, match, docs

* Addresses Shazniterinos, var names, expanded tests

* Returns subaccount name, provides USD values when offlinecalc

* Fixes oopsie

* Fixes cool bug which allowed made up subaccount results

* Subaccount override on FTX, subaccount results for collateral

* Strenghten collateral account info checks. Improve FTX test

* English is my first language

* Fixes oopsies

* Adds some conceptual futures order details to track PNL

* Initial design of future order processing in the backtester

* Introduces futures concept for collateral and spot/futures config diffs

* Fixes most tests

* Simple designs for collateral funding pair concept

* Expands interface use so much it hurts

* Implements more collateral interfaces

* Adds liquidation, adds strategy, struggles with Binance

* Attempts at getting FTX to work

* Adds calculatePNL as a wrapper function and adds an `IsFutures` asset check

* Successfully loads backtester with collateral currency

* Fails to really get much going for supporting futures

* Merges master changes

* Fleshes out how FTX processes collateral

* Further FTX collateral workings

* hooks up more ftx collateral and pnl calculations

* more funcs to flesh out handling

* Adds more links, just can't fit the pieces together :(

* Greatly expands futures order processing

* Fleshes out position tracker to also handle asset and exchange +testing

* RM linkedOrderID. rn positioncontroller, unexport

* Successfully tracks futures order positions

* Fails to calculate PNL

* Calculates pnl from orders accurately with exception to flipping orders

* Calculates PNL from orders

* Adds another controller layer to make it ez from orderstore

* Backtester now compiles. Adds test coverage

* labels things add scaling collateral test

* Calculates pnl in line with fees

* Mostly accurate PNL, with exception to appending with diff prices

* Adds locks, adds rpc function

* grpc implementations

* Gracefully handles rpc function

* beautiful tests!

* rejiggles tests to polish

* Finishes FTX testing, adds comments

* Exposes collateral calculations to rpc

* Adds commands and testing for rpcserver.go functions

* Increase testing and fix up backtester code

* Returns cool changes to original branch

* end of day fixes

* Fixing some tests

* Fixing tests 🎉

* Fixes all the tests

* Splits the backtester setup and running into different files

* Merge, minor fixes

* Messing with some strategy updates

* Failed understanding at collateral usage

* Begins the creation of cash and carry strategy

* Adds underlying pair, adds filldependentevent for futures

* Completes fill prerequsite event implementation. Can't short though

* Some bug fixes

* investigating funds

* CAN NOW CREATE A SHORT ORDER

* Minor change in short size

* Fixes for unrealised PNL & collateral rendering

* Fixes lint and tests

* Adds some verbosity

* Updates to pnl calc

* Tracks pnl for short orders, minor update to strategy

* Close and open event based on conditions

* Adds pnl data for currency statistics

* Working through PNL calculation automatically. Now panics

* Adds tracking, is blocked from design

* Work to flesh out closing a position

* vain attempts at tracking zeroing out bugs

* woww, super fun new subloggers 🎉

* Begins attempt at automatically handling contracts and collateral based on direction

* Merge master + fixes

* Investigating issues with pnl and holdings

* Minor pnl fixes

* Fixes future position sizing, needs contract sizing

* Can render pnl results, focussing on funding statistics

* tracking candles for futures, but why not btc

* Improves funding statistics

* Colours and stats

* Fixes collateral and snapshot bugs

* Completes test

* Fixes totals bug

* Fix double buy, expand stats, fixes usd totals, introduce interface

* Begins report formatting and calculations

* Appends pnl to receiving curr. Fixes map[time]. accurate USD

* Improves report output rendering

* PNL stats in report. New tests for futures

* Fixes existing tests before adding new coverage

* Test coverage

* Completes portfolio coverage

* Increase coverage exchange, portfolio. fix size bug. NEW CHART

* WHAT IS GOING ON WITH PNL

* Fixes PNL calculation. Adds ability to skip om futures tracking

* minor commit before merge

* Adds basic liquidation to backtester

* Changes liquidation to order based

* Liquidationnnnnn

* Further fleshes out liquidations

* Completes liquidations in a honorable manner. Adds AppendReasonf

* Beginnings of spot futures gap chart. Needs to link currencies to render difference

* Removes fake liquidation. Adds cool new chart

* Fixes somet tests,allows for zero fee value v nil distinction,New tests

* Some annoying test fixes that took too long

* portfolio coverage

* holding coverage, privatisation funding

* Testwork

* boring tests

* engine coverage

* More backtesting coverage

* Funding, strategy, report test coverage

* Completes coverage of report package

* Documentation, fixes some assumptions on asset errors

* Changes before master merge

* Lint and Tests

* defaults to non-coloured rendering

* Chart rendering

* Fixes surprise non-local-lints

* Niterinos to the extremeos

* Fixes merge problems

* The linter splintered across the glinting plinths

* Many nits addressed. Now sells spot position on final candle

* Adds forgotten coverage

* Adds ability to size futures contracts to match spot positions.

* fixes order sell sizing

* Adds tests to sizing. Fixes charting issue

* clint splintered the linters with flint

* Improves stats, stat rendering

* minifix

* Fixes tests and fee bug

* Merge fixeroos

* Microfixes

* Updates orderPNL on first Correctly utilises fees. Adds committed funds

* New base funcs. New order summary

* Fun test updates

* Fix logo colouring

* Fixes niteroonies

* Fix report

* BAD COMMIT

* Fixes funding issues.Updates default fee rates.Combines cashcarry case

* doc regen

* Now returns err

* Fixes sizing bug issue introduced in PR

* Fixes fun fee/total US value bug

* Fix chart bug. Show log charts with disclaimer

* sellside fee

* fixes fee and slippage view

* Fixed slippage price issue

* Fixes calculation and removes rendering

* Fixes stats and some rendering

* Merge fix

* Fixes merge issues

* go mod tidy, lint updates

* New linter attempt

* Version bump in appveyor and makefile

* Regex filename, config fixes, template h2 fixes

* Removes bad stats.

* neatens config builder. Moves filename generator

* Fixes issue where linter wants to fix my spelling

* Fixes pointers and starts
This commit is contained in:
Scott
2022-06-30 15:43:41 +10:00
committed by GitHub
parent d3339ad0b8
commit f929b4d51e
161 changed files with 15137 additions and 7292 deletions

View File

@@ -2838,7 +2838,7 @@ func TestFetchSpotExchangeLimits(t *testing.T) {
t.Parallel()
limits, err := b.FetchSpotExchangeLimits(context.Background())
if !errors.Is(err, nil) {
t.Errorf("received '%v', epected '%v'", err, nil)
t.Errorf("received '%v', expected '%v'", err, nil)
}
if len(limits) == 0 {
t.Error("expected a response")

View File

@@ -1278,7 +1278,7 @@ func (b *Base) GetAvailableTransferChains(_ context.Context, _ currency.Code) ([
// CalculatePNL is an overridable function to allow PNL to be calculated on an
// open position
// It will also determine whether the position is considered to be liquidated
// For live trading, an overrided function may wish to confirm the liquidation by
// For live trading, an overriding function may wish to confirm the liquidation by
// requesting the status of the asset
func (b *Base) CalculatePNL(context.Context, *order.PNLCalculatorRequest) (*order.PNLResult, error) {
return nil, common.ErrNotYetImplemented
@@ -1301,6 +1301,18 @@ func (b *Base) GetFuturesPositions(context.Context, asset.Item, currency.Pair, t
return nil, common.ErrNotYetImplemented
}
// GetCollateralCurrencyForContract returns the collateral currency for an asset and contract pair
func (b *Base) GetCollateralCurrencyForContract(asset.Item, currency.Pair) (currency.Code, asset.Item, error) {
return currency.Code{}, asset.Empty, common.ErrNotYetImplemented
}
// GetCurrencyForRealisedPNL returns where to put realised PNL
// example 1: FTX PNL is paid out in USD to your spot wallet
// example 2: Binance coin margined futures pays returns using the same currency eg BTC
func (b *Base) GetCurrencyForRealisedPNL(_ asset.Item, _ currency.Pair) (currency.Code, asset.Item, error) {
return currency.Code{}, asset.Empty, common.ErrNotYetImplemented
}
// HasAssetTypeAccountSegregation returns if the accounts are divided into asset
// types instead of just being denoted as spot holdings.
func (b *Base) HasAssetTypeAccountSegregation() bool {

View File

@@ -1847,12 +1847,11 @@ func TestScaleCollateral(t *testing.T) {
Asset: asset.Spot,
Side: order.Buy,
FreeCollateral: decimal.NewFromFloat(v[v2].Total),
USDPrice: decimal.Zero,
IsLiquidating: true,
CalculateOffline: true,
})
if !errors.Is(err, order.ErrUSDValueRequired) {
t.Errorf("received '%v' exepected '%v'", err, order.ErrUSDValueRequired)
t.Errorf("received '%v' expected '%v'", err, order.ErrUSDValueRequired)
}
_, err = f.ScaleCollateral(

View File

@@ -1290,7 +1290,8 @@ func (f *FTX) CalculatePNL(ctx context.Context, pnl *order.PNLCalculatorRequest)
return nil, fmt.Errorf("%v %w", f.Name, order.ErrNilPNLCalculator)
}
result := &order.PNLResult{
Time: pnl.Time,
Time: pnl.Time,
IsOrder: true,
}
creds, err := f.GetCredentials(ctx)
if err != nil {
@@ -1310,7 +1311,7 @@ func (f *FTX) CalculatePNL(ctx context.Context, pnl *order.PNLCalculatorRequest)
if err != nil {
return nil, err
}
if info.Liquidating || info.Collateral == 0 {
if info.Liquidating || info.Collateral <= 0 {
result.IsLiquidated = true
return result, fmt.Errorf("%s %s %w", f.Name, creds.SubAccount, order.ErrPositionLiquidated)
}
@@ -1682,3 +1683,13 @@ func (f *FTX) GetFuturesPositions(ctx context.Context, a asset.Item, cp currency
return resp, nil
}
// GetCollateralCurrencyForContract returns the collateral currency for an asset and contract pair
func (f *FTX) GetCollateralCurrencyForContract(_ asset.Item, _ currency.Pair) (currency.Code, asset.Item, error) {
return currency.USD, asset.Futures, nil
}
// GetCurrencyForRealisedPNL returns where to put realised PNL
func (f *FTX) GetCurrencyForRealisedPNL(_ asset.Item, _ currency.Pair) (currency.Code, asset.Item, error) {
return currency.USD, asset.Spot, nil
}

View File

@@ -82,6 +82,7 @@ var (
type Item struct {
Exchange string
Pair currency.Pair
UnderlyingPair currency.Pair
Asset asset.Item
Interval Interval
Candles []Candle

View File

@@ -179,7 +179,7 @@ func (l *LocalBitcoins) Getads(ctx context.Context, args ...string) (AdData, err
//
// params - see localbitcoins_types.go AdEdit for reference
// adID - string for the ad you already created
// TODO
// TODO use parameter
func (l *LocalBitcoins) EditAd(ctx context.Context, _ *AdEdit, adID string) error {
resp := struct {
Data AdData `json:"data"`
@@ -209,7 +209,7 @@ func (l *LocalBitcoins) EditAd(ctx context.Context, _ *AdEdit, adID string) erro
// CreateAd creates a new advertisement
//
// params - see localbitcoins_types.go AdCreate for reference
// TODO
// TODO use parameter
func (l *LocalBitcoins) CreateAd(ctx context.Context, _ *AdCreate) error {
return l.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, localbitcoinsAPIAdCreate, nil, nil)
}
@@ -220,7 +220,7 @@ func (l *LocalBitcoins) CreateAd(ctx context.Context, _ *AdCreate) error {
//
// equation - string of equation
// adID - string of specific ad identification
// TODO
// TODO use parameter
func (l *LocalBitcoins) UpdatePriceEquation(ctx context.Context, adID string) error {
return l.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, localbitcoinsAPIUpdateEquation+adID, nil, nil)
}
@@ -228,7 +228,6 @@ func (l *LocalBitcoins) UpdatePriceEquation(ctx context.Context, adID string) er
// DeleteAd deletes the advertisement by adID.
//
// adID - string of specific ad identification
// TODO
func (l *LocalBitcoins) DeleteAd(ctx context.Context, adID string) error {
resp := struct {
Error struct {
@@ -263,7 +262,6 @@ func (l *LocalBitcoins) ReleaseFunds(ctx context.Context, contactID string) erro
// ReleaseFundsByPin releases Bitcoin trades specified by ID {contact_id}. if
// the current pincode is provided. If the release was successful a message is
// returned on the data key.
// TODO
func (l *LocalBitcoins) ReleaseFundsByPin(ctx context.Context, contactID string) error {
return l.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, localbitcoinsAPIReleaseByPin+contactID, nil, nil)
}
@@ -286,7 +284,6 @@ func (l *LocalBitcoins) GetMessages(ctx context.Context, contactID string) (Mess
// SendMessage posts a message and/or uploads an image to the trade. Encode
// images with multipart/form-data encoding.
// TODO
func (l *LocalBitcoins) SendMessage(ctx context.Context, contactID string) error {
return l.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, localbitcoinsAPISendMessage+contactID, nil, nil)
}
@@ -295,7 +292,7 @@ func (l *LocalBitcoins) SendMessage(ctx context.Context, contactID string) error
// starting the dispute has been fulfilled.
//
// topic - [optional] String Short description of issue to LocalBitcoins customer support.
// TODO
// TODO use parameter
func (l *LocalBitcoins) Dispute(ctx context.Context, _, contactID string) error {
return l.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, localbitcoinsAPIDispute+contactID, nil, nil)
}
@@ -325,7 +322,6 @@ func (l *LocalBitcoins) VerifyIdentity(ctx context.Context, contactID string) er
// InitiateTrade sttempts to start a Bitcoin trade from the specified
// advertisement ID.
// TODO
func (l *LocalBitcoins) InitiateTrade(ctx context.Context, adID string) error {
return l.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, localbitcoinsAPIInitiateTrade+adID, nil, nil)
}
@@ -422,7 +418,7 @@ func (l *LocalBitcoins) GetDashboardClosedTrades(ctx context.Context) ([]DashBoa
// msg - [optional] Feedback message displayed alongside feedback on receivers
// profile page.
// username - username of trade contact
// TODO
// TODO add support
func (l *LocalBitcoins) SetFeedback(ctx context.Context) error {
return l.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, localbitcoinsAPIFeedback, nil, nil)
}
@@ -435,14 +431,14 @@ func (l *LocalBitcoins) Logout(ctx context.Context) error {
}
// CreateNewInvoice creates a new invoice.
// TODO
// TODO add support
func (l *LocalBitcoins) CreateNewInvoice(ctx context.Context) error {
return l.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, localbitcoinsAPICreateInvoice, nil, nil)
}
// GetInvoice returns information about a specific invoice created by the token
// owner.
// TODO
// TODO add support
func (l *LocalBitcoins) GetInvoice(ctx context.Context) (Invoice, error) {
resp := Invoice{}
return resp, l.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, localbitcoinsAPICreateInvoice, nil, &resp)
@@ -452,7 +448,7 @@ func (l *LocalBitcoins) GetInvoice(ctx context.Context) (Invoice, error) {
// it is sure that receiver cannot accidentally pay the invoice at the same time
// as the merchant is deleting it. You can use the API request
// /api/merchant/invoice/{invoice_id}/ to check if deleting is possible.
// TODO
// TODO add support
func (l *LocalBitcoins) DeleteInvoice(ctx context.Context) (Invoice, error) {
resp := Invoice{}
return resp, l.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, localbitcoinsAPICreateInvoice, nil, &resp)
@@ -465,7 +461,7 @@ func (l *LocalBitcoins) GetNotifications(ctx context.Context) ([]NotificationInf
}
// MarkNotifications marks a specific notification as read.
// TODO
// TODO add support
func (l *LocalBitcoins) MarkNotifications(ctx context.Context) error {
return l.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, localbitcoinsAPIMarkNotification, nil, nil)
}
@@ -513,7 +509,7 @@ func (l *LocalBitcoins) CheckPincode(ctx context.Context, pin int) (bool, error)
// GetPlaces Looks up places near lat, lon and provides full URLs to buy and
// sell listings for each.
// TODO
// TODO add support
func (l *LocalBitcoins) GetPlaces(ctx context.Context) error {
return l.SendHTTPRequest(ctx, exchange.RestSpot, localbitcoinsAPIPlaces, nil, request.Unset)
}
@@ -527,7 +523,7 @@ func (l *LocalBitcoins) VerifyUsername(ctx context.Context) error {
// GetRecentMessages returns maximum of 25 newest trade messages. Does not
// return messages older than one month. Messages are ordered by sending time,
// and the newest one is first.
// TODO
// TODO add support
func (l *LocalBitcoins) GetRecentMessages(ctx context.Context) error {
return l.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, http.MethodPost, localbitcoinsAPIVerifyUsername, nil, nil)
}
@@ -641,13 +637,13 @@ func (l *LocalBitcoins) GetWalletAddress(ctx context.Context) (string, error) {
}
// GetBitcoinsWithCashAd returns buy or sell as cash local advertisements.
// TODO
// TODO add support
func (l *LocalBitcoins) GetBitcoinsWithCashAd(ctx context.Context) error {
return l.SendHTTPRequest(ctx, exchange.RestSpot, localbitcoinsAPICashBuy, nil, request.Unset)
}
// GetBitcoinsOnlineAd this API returns buy or sell Bitcoin online ads.
// TODO
// TODO add support
func (l *LocalBitcoins) GetBitcoinsOnlineAd(ctx context.Context) error {
return l.SendHTTPRequest(ctx, exchange.RestSpot, localbitcoinsAPIOnlineBuy, nil, request.Unset)
}

View File

@@ -18,7 +18,7 @@ import (
// to track futures orders
func SetupPositionController() *PositionController {
return &PositionController{
positionTrackerControllers: make(map[string]map[asset.Item]map[currency.Pair]*MultiPositionTracker),
multiPositionTrackers: make(map[string]map[asset.Item]map[currency.Pair]*MultiPositionTracker),
}
}
@@ -38,10 +38,10 @@ func (c *PositionController) TrackNewOrder(d *Detail) error {
}
c.m.Lock()
defer c.m.Unlock()
exchM, ok := c.positionTrackerControllers[strings.ToLower(d.Exchange)]
exchM, ok := c.multiPositionTrackers[strings.ToLower(d.Exchange)]
if !ok {
exchM = make(map[asset.Item]map[currency.Pair]*MultiPositionTracker)
c.positionTrackerControllers[strings.ToLower(d.Exchange)] = exchM
c.multiPositionTrackers[strings.ToLower(d.Exchange)] = exchM
}
itemM, ok := exchM[d.AssetType]
if !ok {
@@ -65,6 +65,44 @@ func (c *PositionController) TrackNewOrder(d *Detail) error {
return multiPositionTracker.TrackNewOrder(d)
}
// SetCollateralCurrency allows the setting of a collateral currency to all child trackers
// when using position controller for futures orders tracking
func (c *PositionController) SetCollateralCurrency(exch string, item asset.Item, pair currency.Pair, collateralCurrency currency.Code) error {
if c == nil {
return fmt.Errorf("position controller %w", common.ErrNilPointer)
}
c.m.Lock()
defer c.m.Unlock()
if !item.IsFutures() {
return fmt.Errorf("%v %v %v %w", exch, item, pair, ErrNotFuturesAsset)
}
exchM, ok := c.multiPositionTrackers[strings.ToLower(exch)]
if !ok {
return fmt.Errorf("cannot set collateral %v for %v %v %v %w", collateralCurrency, exch, item, pair, ErrPositionsNotLoadedForExchange)
}
itemM, ok := exchM[item]
if !ok {
return fmt.Errorf("cannot set collateral %v for %v %v %v %w", collateralCurrency, exch, item, pair, ErrPositionsNotLoadedForAsset)
}
multiPositionTracker, ok := itemM[pair]
if !ok {
return fmt.Errorf("cannot set collateral %v for %v %v %v %w", collateralCurrency, exch, item, pair, ErrPositionsNotLoadedForPair)
}
if multiPositionTracker == nil {
return fmt.Errorf("cannot set collateral %v for %v %v %v %w", collateralCurrency, exch, item, pair, common.ErrNilPointer)
}
multiPositionTracker.m.Lock()
multiPositionTracker.collateralCurrency = collateralCurrency
for i := range multiPositionTracker.positions {
multiPositionTracker.positions[i].m.Lock()
multiPositionTracker.positions[i].collateralCurrency = collateralCurrency
multiPositionTracker.positions[i].m.Unlock()
}
multiPositionTracker.m.Unlock()
return nil
}
// GetPositionsForExchange returns all positions for an
// exchange, asset pair that is stored in the position controller
func (c *PositionController) GetPositionsForExchange(exch string, item asset.Item, pair currency.Pair) ([]PositionStats, error) {
@@ -76,7 +114,7 @@ func (c *PositionController) GetPositionsForExchange(exch string, item asset.Ite
if !item.IsFutures() {
return nil, fmt.Errorf("%v %v %v %w", exch, item, pair, ErrNotFuturesAsset)
}
exchM, ok := c.positionTrackerControllers[strings.ToLower(exch)]
exchM, ok := c.multiPositionTrackers[strings.ToLower(exch)]
if !ok {
return nil, fmt.Errorf("%v %v %v %w", exch, item, pair, ErrPositionsNotLoadedForExchange)
}
@@ -105,7 +143,7 @@ func (c *PositionController) UpdateOpenPositionUnrealisedPNL(exch string, item a
c.m.Lock()
defer c.m.Unlock()
exchM, ok := c.positionTrackerControllers[strings.ToLower(exch)]
exchM, ok := c.multiPositionTrackers[strings.ToLower(exch)]
if !ok {
return decimal.Zero, fmt.Errorf("%v %v %v %w", exch, item, pair, ErrPositionsNotLoadedForExchange)
}
@@ -137,45 +175,6 @@ func (c *PositionController) UpdateOpenPositionUnrealisedPNL(exch string, item a
return latestPos.unrealisedPNL, nil
}
// ClearPositionsForExchange resets positions for an
// exchange, asset, pair that has been stored
func (c *PositionController) ClearPositionsForExchange(exch string, item asset.Item, pair currency.Pair) error {
if c == nil {
return fmt.Errorf("position controller %w", common.ErrNilPointer)
}
c.m.Lock()
defer c.m.Unlock()
if !item.IsFutures() {
return fmt.Errorf("%v %v %v %w", exch, item, pair, ErrNotFuturesAsset)
}
exchM, ok := c.positionTrackerControllers[strings.ToLower(exch)]
if !ok {
return fmt.Errorf("%v %v %v %w", exch, item, pair, ErrPositionsNotLoadedForExchange)
}
itemM, ok := exchM[item]
if !ok {
return fmt.Errorf("%v %v %v %w", exch, item, pair, ErrPositionsNotLoadedForAsset)
}
multiPositionTracker, ok := itemM[pair]
if !ok {
return fmt.Errorf("%v %v %v %w", exch, item, pair, ErrPositionsNotLoadedForPair)
}
newMPT, err := SetupMultiPositionTracker(&MultiPositionTrackerSetup{
Exchange: exch,
Asset: item,
Pair: pair,
Underlying: multiPositionTracker.underlying,
OfflineCalculation: multiPositionTracker.offlinePNLCalculation,
UseExchangePNLCalculation: multiPositionTracker.useExchangePNLCalculations,
ExchangePNLCalculation: multiPositionTracker.exchangePNLCalculation,
})
if err != nil {
return err
}
itemM[pair] = newMPT
return nil
}
// SetupMultiPositionTracker creates a futures order tracker for a specific exchange
func SetupMultiPositionTracker(setup *MultiPositionTrackerSetup) (*MultiPositionTracker, error) {
if setup == nil {
@@ -205,85 +204,17 @@ func SetupMultiPositionTracker(setup *MultiPositionTrackerSetup) (*MultiPosition
orderPositions: make(map[string]*PositionTracker),
useExchangePNLCalculations: setup.UseExchangePNLCalculation,
exchangePNLCalculation: setup.ExchangePNLCalculation,
collateralCurrency: setup.CollateralCurrency,
}, nil
}
// GetPositions returns all positions
func (e *MultiPositionTracker) GetPositions() []PositionStats {
if e == nil {
return nil
}
e.m.Lock()
defer e.m.Unlock()
resp := make([]PositionStats, len(e.positions))
for i := range e.positions {
resp[i] = e.positions[i].GetStats()
}
return resp
}
// TrackNewOrder upserts an order to the tracker and updates position
// status and exposure. PNL is calculated separately as it requires mark prices
func (e *MultiPositionTracker) TrackNewOrder(d *Detail) error {
if e == nil {
return fmt.Errorf("multi-position tracker %w", common.ErrNilPointer)
}
if d == nil {
return ErrSubmissionIsNil
}
e.m.Lock()
defer e.m.Unlock()
if d.AssetType != e.asset {
return errAssetMismatch
}
if tracker, ok := e.orderPositions[d.OrderID]; ok {
// this has already been associated
// update the tracker
return tracker.TrackNewOrder(d)
}
if len(e.positions) > 0 {
for i := range e.positions {
if e.positions[i].status == Open && i != len(e.positions)-1 {
return fmt.Errorf("%w %v at position %v/%v", errPositionDiscrepancy, e.positions[i], i, len(e.positions)-1)
}
}
if e.positions[len(e.positions)-1].status == Open {
err := e.positions[len(e.positions)-1].TrackNewOrder(d)
if err != nil && !errors.Is(err, ErrPositionClosed) {
return err
}
e.orderPositions[d.OrderID] = e.positions[len(e.positions)-1]
return nil
}
}
setup := &PositionTrackerSetup{
Pair: d.Pair,
EntryPrice: decimal.NewFromFloat(d.Price),
Underlying: d.Pair.Base,
Asset: d.AssetType,
Side: d.Side,
UseExchangePNLCalculation: e.useExchangePNLCalculations,
}
tracker, err := e.SetupPositionTracker(setup)
if err != nil {
return err
}
e.positions = append(e.positions, tracker)
err = tracker.TrackNewOrder(d)
if err != nil {
return err
}
e.orderPositions[d.OrderID] = tracker
return nil
}
// SetupPositionTracker creates a new position tracker to track n futures orders
// until the position(s) are closed
func (e *MultiPositionTracker) SetupPositionTracker(setup *PositionTrackerSetup) (*PositionTracker, error) {
if e == nil {
func (m *MultiPositionTracker) SetupPositionTracker(setup *PositionTrackerSetup) (*PositionTracker, error) {
if m == nil {
return nil, fmt.Errorf("multi-position tracker %w", common.ErrNilPointer)
}
if e.exchange == "" {
if m.exchange == "" {
return nil, errExchangeNameEmpty
}
if setup == nil {
@@ -297,7 +228,7 @@ func (e *MultiPositionTracker) SetupPositionTracker(setup *PositionTrackerSetup)
}
resp := &PositionTracker{
exchange: strings.ToLower(e.exchange),
exchange: strings.ToLower(m.exchange),
asset: setup.Asset,
contractPair: setup.Pair,
underlyingAsset: setup.Underlying,
@@ -306,20 +237,167 @@ func (e *MultiPositionTracker) SetupPositionTracker(setup *PositionTrackerSetup)
currentDirection: setup.Side,
openingDirection: setup.Side,
useExchangePNLCalculation: setup.UseExchangePNLCalculation,
offlinePNLCalculation: e.offlinePNLCalculation,
collateralCurrency: setup.CollateralCurrency,
offlinePNLCalculation: m.offlinePNLCalculation,
}
if !setup.UseExchangePNLCalculation {
// use position tracker's pnl calculation by default
resp.PNLCalculation = &PNLCalculator{}
} else {
if e.exchangePNLCalculation == nil {
if m.exchangePNLCalculation == nil {
return nil, ErrNilPNLCalculator
}
resp.PNLCalculation = e.exchangePNLCalculation
resp.PNLCalculation = m.exchangePNLCalculation
}
return resp, nil
}
// UpdateOpenPositionUnrealisedPNL updates the pnl for the latest open position
// based on the last price and the time
func (m *MultiPositionTracker) UpdateOpenPositionUnrealisedPNL(last float64, updated time.Time) (decimal.Decimal, error) {
m.m.Lock()
defer m.m.Unlock()
pos := m.positions
if len(pos) == 0 {
return decimal.Zero, fmt.Errorf("%v %v %v %w", m.exchange, m.asset, m.pair, ErrPositionsNotLoadedForPair)
}
latestPos := pos[len(pos)-1]
if latestPos.status.IsInactive() {
return decimal.Zero, fmt.Errorf("%v %v %v %w", m.exchange, m.asset, m.pair, ErrPositionClosed)
}
err := latestPos.TrackPNLByTime(updated, last)
if err != nil {
return decimal.Zero, fmt.Errorf("%w for position %v %v %v", err, m.exchange, m.asset, m.pair)
}
latestPos.m.Lock()
defer latestPos.m.Unlock()
return latestPos.unrealisedPNL, nil
}
// ClearPositionsForExchange resets positions for an
// exchange, asset, pair that has been stored
func (c *PositionController) ClearPositionsForExchange(exch string, item asset.Item, pair currency.Pair) error {
if c == nil {
return fmt.Errorf("position controller %w", common.ErrNilPointer)
}
c.m.Lock()
defer c.m.Unlock()
if !item.IsFutures() {
return fmt.Errorf("%v %v %v %w", exch, item, pair, ErrNotFuturesAsset)
}
exchM, ok := c.multiPositionTrackers[strings.ToLower(exch)]
if !ok {
return fmt.Errorf("%v %v %v %w", exch, item, pair, ErrPositionsNotLoadedForExchange)
}
itemM, ok := exchM[item]
if !ok {
return fmt.Errorf("%v %v %v %w", exch, item, pair, ErrPositionsNotLoadedForAsset)
}
multiPositionTracker, ok := itemM[pair]
if !ok {
return fmt.Errorf("%v %v %v %w", exch, item, pair, ErrPositionsNotLoadedForPair)
}
newMPT, err := SetupMultiPositionTracker(&MultiPositionTrackerSetup{
Exchange: exch,
Asset: item,
Pair: pair,
Underlying: multiPositionTracker.underlying,
OfflineCalculation: multiPositionTracker.offlinePNLCalculation,
UseExchangePNLCalculation: multiPositionTracker.useExchangePNLCalculations,
ExchangePNLCalculation: multiPositionTracker.exchangePNLCalculation,
CollateralCurrency: multiPositionTracker.collateralCurrency,
})
if err != nil {
return err
}
itemM[pair] = newMPT
return nil
}
// GetPositions returns all positions
func (m *MultiPositionTracker) GetPositions() []PositionStats {
if m == nil {
return nil
}
m.m.Lock()
defer m.m.Unlock()
resp := make([]PositionStats, len(m.positions))
for i := range m.positions {
resp[i] = m.positions[i].GetStats()
}
return resp
}
// TrackNewOrder upserts an order to the tracker and updates position
// status and exposure. PNL is calculated separately as it requires mark prices
func (m *MultiPositionTracker) TrackNewOrder(d *Detail) error {
if m == nil {
return fmt.Errorf("multi-position tracker %w", common.ErrNilPointer)
}
if d == nil {
return ErrSubmissionIsNil
}
m.m.Lock()
defer m.m.Unlock()
if d.AssetType != m.asset {
return errAssetMismatch
}
if tracker, ok := m.orderPositions[d.OrderID]; ok {
// this has already been associated
// update the tracker
return tracker.TrackNewOrder(d, false)
}
if len(m.positions) > 0 {
for i := range m.positions {
if m.positions[i].status == Open && i != len(m.positions)-1 {
return fmt.Errorf("%w %v at position %v/%v", errPositionDiscrepancy, m.positions[i], i, len(m.positions)-1)
}
}
if m.positions[len(m.positions)-1].status == Open {
err := m.positions[len(m.positions)-1].TrackNewOrder(d, false)
if err != nil && !errors.Is(err, ErrPositionClosed) {
return err
}
m.orderPositions[d.OrderID] = m.positions[len(m.positions)-1]
return nil
}
}
setup := &PositionTrackerSetup{
Pair: d.Pair,
EntryPrice: decimal.NewFromFloat(d.Price),
Underlying: d.Pair.Base,
Asset: d.AssetType,
Side: d.Side,
UseExchangePNLCalculation: m.useExchangePNLCalculations,
CollateralCurrency: m.collateralCurrency,
}
tracker, err := m.SetupPositionTracker(setup)
if err != nil {
return err
}
m.positions = append(m.positions, tracker)
err = tracker.TrackNewOrder(d, true)
if err != nil {
return err
}
m.orderPositions[d.OrderID] = tracker
return nil
}
// Liquidate will update the latest open position's
// to reflect its liquidated status
func (m *MultiPositionTracker) Liquidate(price decimal.Decimal, t time.Time) error {
if m == nil {
return fmt.Errorf("multi-position tracker %w", common.ErrNilPointer)
}
m.m.Lock()
defer m.m.Unlock()
if len(m.positions) == 0 {
return fmt.Errorf("%v %v %v %w", m.exchange, m.asset, m.pair, ErrPositionsNotLoadedForPair)
}
return m.positions[len(m.positions)-1].Liquidate(price, t)
}
// GetStats returns a summary of a future position
func (p *PositionTracker) GetStats() PositionStats {
if p == nil {
@@ -330,20 +408,23 @@ func (p *PositionTracker) GetStats() PositionStats {
var orders []Detail
orders = append(orders, p.longPositions...)
orders = append(orders, p.shortPositions...)
return PositionStats{
Exchange: p.exchange,
Asset: p.asset,
Pair: p.contractPair,
Underlying: p.underlyingAsset,
Status: p.status,
Orders: orders,
RealisedPNL: p.realisedPNL,
UnrealisedPNL: p.unrealisedPNL,
LatestDirection: p.currentDirection,
OpeningDirection: p.openingDirection,
OpeningPrice: p.entryPrice,
LatestPrice: p.latestPrice,
PNLHistory: p.pnlHistory,
Exchange: p.exchange,
Asset: p.asset,
Pair: p.contractPair,
Underlying: p.underlyingAsset,
CollateralCurrency: p.collateralCurrency,
Status: p.status,
Orders: orders,
RealisedPNL: p.realisedPNL,
UnrealisedPNL: p.unrealisedPNL,
LatestDirection: p.currentDirection,
OpeningDirection: p.openingDirection,
OpeningPrice: p.entryPrice,
LatestPrice: p.latestPrice,
PNLHistory: p.pnlHistory,
Exposure: p.exposure,
}
}
@@ -360,8 +441,9 @@ func (p *PositionTracker) TrackPNLByTime(t time.Time, currentPrice float64) erro
}()
price := decimal.NewFromFloat(currentPrice)
result := &PNLResult{
Time: t,
Price: price,
Time: t,
Price: price,
Status: p.status,
}
if p.currentDirection.IsLong() {
diff := price.Sub(p.entryPrice)
@@ -371,8 +453,12 @@ func (p *PositionTracker) TrackPNLByTime(t time.Time, currentPrice float64) erro
result.UnrealisedPNL = p.exposure.Mul(diff)
}
if len(p.pnlHistory) > 0 {
result.RealisedPNLBeforeFees = p.pnlHistory[len(p.pnlHistory)-1].RealisedPNLBeforeFees
result.Exposure = p.pnlHistory[len(p.pnlHistory)-1].Exposure
latest := p.pnlHistory[len(p.pnlHistory)-1]
result.RealisedPNLBeforeFees = latest.RealisedPNLBeforeFees
result.Exposure = latest.Exposure
result.Direction = latest.Direction
result.RealisedPNL = latest.RealisedPNL
result.IsLiquidated = latest.IsLiquidated
}
var err error
p.pnlHistory, err = upsertPNLEntry(p.pnlHistory, result)
@@ -391,6 +477,37 @@ func (p *PositionTracker) GetRealisedPNL() decimal.Decimal {
return calculateRealisedPNL(p.pnlHistory)
}
// Liquidate will update the positions stats to reflect its liquidation
func (p *PositionTracker) Liquidate(price decimal.Decimal, t time.Time) error {
if p == nil {
return fmt.Errorf("position tracker %w", common.ErrNilPointer)
}
p.m.Lock()
defer p.m.Unlock()
latest, err := p.GetLatestPNLSnapshot()
if err != nil {
return err
}
if !latest.Time.Equal(t) {
return fmt.Errorf("%w cannot liquidate from a different time. PNL snapshot %v. Liquidation request on %v Status: %v", errCannotLiquidate, latest.Time, t, p.status)
}
p.status = Liquidated
p.currentDirection = ClosePosition
p.exposure = decimal.Zero
p.realisedPNL = decimal.Zero
p.unrealisedPNL = decimal.Zero
_, err = upsertPNLEntry(p.pnlHistory, &PNLResult{
Time: t,
Price: price,
Direction: ClosePosition,
IsLiquidated: true,
IsOrder: true,
Status: p.status,
})
return err
}
// GetLatestPNLSnapshot takes the latest pnl history value
// and returns it
func (p *PositionTracker) GetLatestPNLSnapshot() (PNLResult, error) {
@@ -402,13 +519,16 @@ func (p *PositionTracker) GetLatestPNLSnapshot() (PNLResult, error) {
// TrackNewOrder knows how things are going for a given
// futures contract
func (p *PositionTracker) TrackNewOrder(d *Detail) error {
func (p *PositionTracker) TrackNewOrder(d *Detail, isInitialOrder bool) error {
if p == nil {
return fmt.Errorf("position tracker %w", common.ErrNilPointer)
}
p.m.Lock()
defer p.m.Unlock()
if p.status == Closed {
if isInitialOrder && len(p.pnlHistory) > 0 {
return fmt.Errorf("%w received isInitialOrder = true with existing position", errCannotTrackInvalidParams)
}
if p.status.IsInactive() {
return ErrPositionClosed
}
if d == nil {
@@ -426,6 +546,7 @@ func (p *PositionTracker) TrackNewOrder(d *Detail) error {
return fmt.Errorf("%w asset '%v' received: '%v'",
errOrderNotEqualToTracker, d.AssetType, p.asset)
}
if d.Side == UnknownSide {
return ErrSideIsInvalid
}
@@ -484,7 +605,8 @@ func (p *PositionTracker) TrackNewOrder(d *Detail) error {
longSide = longSide.Add(decimal.NewFromFloat(p.longPositions[i].Amount))
}
if p.currentDirection == UnknownSide {
if isInitialOrder {
p.openingDirection = d.Side
p.currentDirection = d.Side
}
@@ -514,8 +636,18 @@ func (p *PositionTracker) TrackNewOrder(d *Detail) error {
if len(p.pnlHistory) != 0 {
cal.PreviousPrice = p.pnlHistory[len(p.pnlHistory)-1].Price
}
if (cal.OrderDirection.IsShort() && cal.CurrentDirection.IsLong() || cal.OrderDirection.IsLong() && cal.CurrentDirection.IsShort()) &&
cal.Exposure.LessThan(amount) {
switch {
case isInitialOrder:
result = &PNLResult{
IsOrder: true,
Time: cal.Time,
Price: cal.CurrentPrice,
Exposure: cal.Amount,
Fee: cal.Fee,
Direction: cal.OpeningDirection,
UnrealisedPNL: cal.Fee.Neg(),
}
case (cal.OrderDirection.IsShort() && cal.CurrentDirection.IsLong() || cal.OrderDirection.IsLong() && cal.CurrentDirection.IsShort()) && cal.Exposure.LessThan(amount):
// latest order swaps directions!
// split the order to calculate PNL from each direction
first := cal.Exposure
@@ -527,6 +659,7 @@ func (p *PositionTracker) TrackNewOrder(d *Detail) error {
if err != nil {
return err
}
result.Status = p.status
p.pnlHistory, err = upsertPNLEntry(cal.PNLHistory, result)
if err != nil {
return err
@@ -548,7 +681,7 @@ func (p *PositionTracker) TrackNewOrder(d *Detail) error {
cal.Time = cal.Time.Add(1)
cal.PNLHistory = p.pnlHistory
result, err = p.PNLCalculation.CalculatePNL(context.TODO(), cal)
} else {
default:
result, err = p.PNLCalculation.CalculatePNL(context.TODO(), cal)
}
if err != nil {
@@ -559,6 +692,7 @@ func (p *PositionTracker) TrackNewOrder(d *Detail) error {
result.RealisedPNLBeforeFees = decimal.Zero
p.status = Closed
}
result.Status = p.status
p.pnlHistory, err = upsertPNLEntry(p.pnlHistory, result)
if err != nil {
return err
@@ -571,7 +705,7 @@ func (p *PositionTracker) TrackNewOrder(d *Detail) error {
case shortSide.GreaterThan(longSide):
p.currentDirection = Short
default:
p.currentDirection = UnknownSide
p.currentDirection = ClosePosition
}
if p.currentDirection.IsLong() {
@@ -585,6 +719,9 @@ func (p *PositionTracker) TrackNewOrder(d *Detail) error {
p.closingPrice = decimal.NewFromFloat(d.Price)
p.realisedPNL = calculateRealisedPNL(p.pnlHistory)
p.unrealisedPNL = decimal.Zero
p.pnlHistory[len(p.pnlHistory)-1].RealisedPNL = p.realisedPNL
p.pnlHistory[len(p.pnlHistory)-1].UnrealisedPNL = p.unrealisedPNL
p.pnlHistory[len(p.pnlHistory)-1].Direction = p.currentDirection
} else if p.exposure.IsNegative() {
if p.currentDirection.IsLong() {
p.currentDirection = Short
@@ -596,6 +733,12 @@ func (p *PositionTracker) TrackNewOrder(d *Detail) error {
return nil
}
// GetCurrencyForRealisedPNL is a generic handling of determining the asset
// to assign realised PNL into, which is just itself
func (p *PNLCalculator) GetCurrencyForRealisedPNL(realisedAsset asset.Item, realisedPair currency.Pair) (currency.Code, asset.Item, error) {
return realisedPair.Base, realisedAsset, nil
}
// CalculatePNL this is a localised generic way of calculating open
// positions' worth, it is an implementation of the PNLCalculation interface
func (p *PNLCalculator) CalculatePNL(_ context.Context, calc *PNLCalculatorRequest) (*PNLResult, error) {
@@ -604,7 +747,13 @@ func (p *PNLCalculator) CalculatePNL(_ context.Context, calc *PNLCalculatorReque
}
var previousPNL *PNLResult
if len(calc.PNLHistory) > 0 {
previousPNL = &calc.PNLHistory[len(calc.PNLHistory)-1]
for i := len(calc.PNLHistory) - 1; i >= 0; i-- {
if calc.PNLHistory[i].Time.Equal(calc.Time) || !calc.PNLHistory[i].IsOrder {
continue
}
previousPNL = &calc.PNLHistory[i]
break
}
}
var prevExposure decimal.Decimal
if previousPNL != nil {
@@ -646,13 +795,16 @@ func (p *PNLCalculator) CalculatePNL(_ context.Context, calc *PNLCalculatorReque
}
response := &PNLResult{
IsOrder: true,
Time: calc.Time,
UnrealisedPNL: unrealisedPNL,
RealisedPNLBeforeFees: realisedPNL,
Price: calc.CurrentPrice,
Exposure: currentExposure,
Fee: calc.Fee,
Direction: calc.CurrentDirection,
}
return response, nil
}
@@ -661,6 +813,9 @@ func (p *PNLCalculator) CalculatePNL(_ context.Context, calc *PNLCalculatorReque
func calculateRealisedPNL(pnlHistory []PNLResult) decimal.Decimal {
var realisedPNL, totalFees decimal.Decimal
for i := range pnlHistory {
if !pnlHistory[i].IsOrder {
continue
}
realisedPNL = realisedPNL.Add(pnlHistory[i].RealisedPNLBeforeFees)
totalFees = totalFees.Add(pnlHistory[i].Fee)
}
@@ -674,10 +829,24 @@ func upsertPNLEntry(pnlHistory []PNLResult, entry *PNLResult) ([]PNLResult, erro
return nil, errTimeUnset
}
for i := range pnlHistory {
if entry.Time.Equal(pnlHistory[i].Time) {
pnlHistory[i] = *entry
return pnlHistory, nil
if !entry.Time.Equal(pnlHistory[i].Time) {
continue
}
pnlHistory[i].UnrealisedPNL = entry.UnrealisedPNL
pnlHistory[i].RealisedPNL = entry.RealisedPNL
pnlHistory[i].RealisedPNLBeforeFees = entry.RealisedPNLBeforeFees
pnlHistory[i].Exposure = entry.Exposure
pnlHistory[i].Direction = entry.Direction
pnlHistory[i].Price = entry.Price
pnlHistory[i].Status = entry.Status
pnlHistory[i].Fee = entry.Fee
if entry.IsOrder {
pnlHistory[i].IsOrder = true
}
if entry.IsLiquidated {
pnlHistory[i].IsLiquidated = true
}
return pnlHistory, nil
}
pnlHistory = append(pnlHistory, *entry)
sort.Slice(pnlHistory, func(i, j int) bool {

View File

@@ -28,10 +28,20 @@ func (f *FakePNL) CalculatePNL(context.Context, *PNLCalculatorRequest) (*PNLResu
return f.result, nil
}
// GetCurrencyForRealisedPNL overrides default pnl calculations
func (f *FakePNL) GetCurrencyForRealisedPNL(realisedAsset asset.Item, realisedPair currency.Pair) (currency.Code, asset.Item, error) {
if f.err != nil {
return realisedPair.Base, asset.Empty, f.err
}
return realisedPair.Base, realisedAsset, nil
}
func TestUpsertPNLEntry(t *testing.T) {
t.Parallel()
var results []PNLResult
result := &PNLResult{}
result := &PNLResult{
IsOrder: true,
}
_, err := upsertPNLEntry(results, result)
if !errors.Is(err, errTimeUnset) {
t.Error(err)
@@ -79,11 +89,11 @@ func TestTrackNewOrder(t *testing.T) {
t.Error(err)
}
err = f.TrackNewOrder(nil)
err = f.TrackNewOrder(nil, false)
if !errors.Is(err, ErrSubmissionIsNil) {
t.Error(err)
}
err = f.TrackNewOrder(&Detail{})
err = f.TrackNewOrder(&Detail{}, false)
if !errors.Is(err, errOrderNotEqualToTracker) {
t.Error(err)
}
@@ -95,7 +105,7 @@ func TestTrackNewOrder(t *testing.T) {
OrderID: "1",
Price: 1337,
}
err = f.TrackNewOrder(od)
err = f.TrackNewOrder(od, false)
if !errors.Is(err, ErrSideIsInvalid) {
t.Error(err)
}
@@ -103,13 +113,13 @@ func TestTrackNewOrder(t *testing.T) {
od.Side = Long
od.Amount = 1
od.OrderID = "2"
err = f.TrackNewOrder(od)
err = f.TrackNewOrder(od, false)
if !errors.Is(err, errTimeUnset) {
t.Error(err)
}
f.openingDirection = Long
od.Date = time.Now()
err = f.TrackNewOrder(od)
err = f.TrackNewOrder(od, false)
if !errors.Is(err, nil) {
t.Error(err)
}
@@ -130,7 +140,7 @@ func TestTrackNewOrder(t *testing.T) {
od.Amount = 0.4
od.Side = Short
od.OrderID = "3"
err = f.TrackNewOrder(od)
err = f.TrackNewOrder(od, false)
if !errors.Is(err, nil) {
t.Error(err)
}
@@ -149,7 +159,7 @@ func TestTrackNewOrder(t *testing.T) {
od.Side = Short
od.OrderID = "4"
od.Fee = 0.1
err = f.TrackNewOrder(od)
err = f.TrackNewOrder(od, false)
if !errors.Is(err, nil) {
t.Error(err)
}
@@ -164,27 +174,41 @@ func TestTrackNewOrder(t *testing.T) {
od.OrderID = "5"
od.Side = Long
od.Amount = 0.2
err = f.TrackNewOrder(od)
err = f.TrackNewOrder(od, false)
if !errors.Is(err, nil) {
t.Error(err)
}
if f.currentDirection != UnknownSide {
if f.currentDirection != ClosePosition {
t.Errorf("expected recognition that its unknown, received '%v'", f.currentDirection)
}
if f.status != Closed {
t.Errorf("expected recognition that its closed, received '%v'", f.status)
}
err = f.TrackNewOrder(od)
err = f.TrackNewOrder(od, false)
if !errors.Is(err, ErrPositionClosed) {
t.Error(err)
}
if f.currentDirection != UnknownSide {
if f.currentDirection != ClosePosition {
t.Errorf("expected recognition that its unknown, received '%v'", f.currentDirection)
}
if f.status != Closed {
t.Errorf("expected recognition that its closed, received '%v'", f.status)
}
err = f.TrackNewOrder(od, true)
if !errors.Is(err, errCannotTrackInvalidParams) {
t.Error(err)
}
f, err = e.SetupPositionTracker(setup)
if !errors.Is(err, nil) {
t.Error(err)
}
err = f.TrackNewOrder(od, true)
if !errors.Is(err, nil) {
t.Error(err)
}
}
func TestSetupMultiPositionTracker(t *testing.T) {
@@ -359,7 +383,7 @@ func TestExchangeTrackNewOrder(t *testing.T) {
func TestSetupPositionControllerReal(t *testing.T) {
t.Parallel()
pc := SetupPositionController()
if pc.positionTrackerControllers == nil {
if pc.multiPositionTrackers == nil {
t.Error("unexpected nil")
}
}
@@ -489,14 +513,14 @@ func TestGetPositionsForExchange(t *testing.T) {
if len(pos) != 0 {
t.Error("expected zero")
}
c.positionTrackerControllers = make(map[string]map[asset.Item]map[currency.Pair]*MultiPositionTracker)
c.positionTrackerControllers[testExchange] = nil
c.multiPositionTrackers = make(map[string]map[asset.Item]map[currency.Pair]*MultiPositionTracker)
c.multiPositionTrackers[testExchange] = nil
_, err = c.GetPositionsForExchange(testExchange, asset.Futures, p)
if !errors.Is(err, ErrPositionsNotLoadedForAsset) {
t.Errorf("received '%v' expected '%v", err, ErrPositionsNotLoadedForExchange)
}
c.positionTrackerControllers[testExchange] = make(map[asset.Item]map[currency.Pair]*MultiPositionTracker)
c.positionTrackerControllers[testExchange][asset.Futures] = nil
c.multiPositionTrackers[testExchange] = make(map[asset.Item]map[currency.Pair]*MultiPositionTracker)
c.multiPositionTrackers[testExchange][asset.Futures] = nil
_, err = c.GetPositionsForExchange(testExchange, asset.Futures, p)
if !errors.Is(err, ErrPositionsNotLoadedForPair) {
t.Errorf("received '%v' expected '%v", err, ErrPositionsNotLoadedForPair)
@@ -506,8 +530,8 @@ func TestGetPositionsForExchange(t *testing.T) {
t.Errorf("received '%v' expected '%v", err, ErrNotFuturesAsset)
}
c.positionTrackerControllers[testExchange][asset.Futures] = make(map[currency.Pair]*MultiPositionTracker)
c.positionTrackerControllers[testExchange][asset.Futures][p] = &MultiPositionTracker{
c.multiPositionTrackers[testExchange][asset.Futures] = make(map[currency.Pair]*MultiPositionTracker)
c.multiPositionTrackers[testExchange][asset.Futures][p] = &MultiPositionTracker{
exchange: testExchange,
}
@@ -518,7 +542,7 @@ func TestGetPositionsForExchange(t *testing.T) {
if len(pos) != 0 {
t.Fatal("expected zero")
}
c.positionTrackerControllers[testExchange][asset.Futures][p] = &MultiPositionTracker{
c.multiPositionTrackers[testExchange][asset.Futures][p] = &MultiPositionTracker{
exchange: testExchange,
positions: []*PositionTracker{
{
@@ -551,14 +575,14 @@ func TestClearPositionsForExchange(t *testing.T) {
if !errors.Is(err, ErrPositionsNotLoadedForExchange) {
t.Errorf("received '%v' expected '%v", err, ErrPositionsNotLoadedForExchange)
}
c.positionTrackerControllers = make(map[string]map[asset.Item]map[currency.Pair]*MultiPositionTracker)
c.positionTrackerControllers[testExchange] = nil
c.multiPositionTrackers = make(map[string]map[asset.Item]map[currency.Pair]*MultiPositionTracker)
c.multiPositionTrackers[testExchange] = nil
err = c.ClearPositionsForExchange(testExchange, asset.Futures, p)
if !errors.Is(err, ErrPositionsNotLoadedForAsset) {
t.Errorf("received '%v' expected '%v", err, ErrPositionsNotLoadedForExchange)
}
c.positionTrackerControllers[testExchange] = make(map[asset.Item]map[currency.Pair]*MultiPositionTracker)
c.positionTrackerControllers[testExchange][asset.Futures] = nil
c.multiPositionTrackers[testExchange] = make(map[asset.Item]map[currency.Pair]*MultiPositionTracker)
c.multiPositionTrackers[testExchange][asset.Futures] = nil
err = c.ClearPositionsForExchange(testExchange, asset.Futures, p)
if !errors.Is(err, ErrPositionsNotLoadedForPair) {
t.Errorf("received '%v' expected '%v", err, ErrPositionsNotLoadedForPair)
@@ -568,11 +592,11 @@ func TestClearPositionsForExchange(t *testing.T) {
t.Errorf("received '%v' expected '%v", err, ErrNotFuturesAsset)
}
c.positionTrackerControllers[testExchange][asset.Futures] = make(map[currency.Pair]*MultiPositionTracker)
c.positionTrackerControllers[testExchange][asset.Futures][p] = &MultiPositionTracker{
c.multiPositionTrackers[testExchange][asset.Futures] = make(map[currency.Pair]*MultiPositionTracker)
c.multiPositionTrackers[testExchange][asset.Futures][p] = &MultiPositionTracker{
exchange: testExchange,
}
c.positionTrackerControllers[testExchange][asset.Futures][p] = &MultiPositionTracker{
c.multiPositionTrackers[testExchange][asset.Futures][p] = &MultiPositionTracker{
exchange: testExchange,
underlying: currency.DOGE,
positions: []*PositionTracker{
@@ -585,7 +609,7 @@ func TestClearPositionsForExchange(t *testing.T) {
if !errors.Is(err, nil) {
t.Errorf("received '%v' expected '%v", err, nil)
}
if len(c.positionTrackerControllers[testExchange][asset.Futures][p].positions) != 0 {
if len(c.multiPositionTrackers[testExchange][asset.Futures][p].positions) != 0 {
t.Fatal("expected 0")
}
c = nil
@@ -599,29 +623,32 @@ func TestCalculateRealisedPNL(t *testing.T) {
t.Parallel()
result := calculateRealisedPNL(nil)
if !result.IsZero() {
t.Error("expected zero")
t.Errorf("received '%v' expected '0'", result)
}
result = calculateRealisedPNL([]PNLResult{
{
IsOrder: true,
RealisedPNLBeforeFees: decimal.NewFromInt(1337),
},
})
if !result.Equal(decimal.NewFromInt(1337)) {
t.Error("expected 1337")
t.Errorf("received '%v' expected '1337'", result)
}
result = calculateRealisedPNL([]PNLResult{
{
IsOrder: true,
RealisedPNLBeforeFees: decimal.NewFromInt(1339),
Fee: decimal.NewFromInt(2),
},
{
IsOrder: true,
RealisedPNLBeforeFees: decimal.NewFromInt(2),
Fee: decimal.NewFromInt(2),
},
})
if !result.Equal(decimal.NewFromInt(1337)) {
t.Error("expected 1337")
t.Errorf("received '%v' expected '1337'", result)
}
}
@@ -802,3 +829,245 @@ func TestUpdateOpenPositionUnrealisedPNL(t *testing.T) {
t.Errorf("received '%v' expected '%v", err, common.ErrNilPointer)
}
}
func TestSetCollateralCurrency(t *testing.T) {
t.Parallel()
var expectedError = ErrNotFuturesAsset
pc := SetupPositionController()
err := pc.SetCollateralCurrency("hi", asset.Spot, currency.Pair{}, currency.Code{})
if !errors.Is(err, expectedError) {
t.Errorf("received '%v' expected '%v", err, expectedError)
}
cp := currency.NewPair(currency.BTC, currency.USDT)
pc.multiPositionTrackers = make(map[string]map[asset.Item]map[currency.Pair]*MultiPositionTracker)
err = pc.SetCollateralCurrency("hi", asset.Futures, cp, currency.DOGE)
expectedError = ErrPositionsNotLoadedForExchange
if !errors.Is(err, expectedError) {
t.Fatalf("received '%v' expected '%v", err, expectedError)
}
pc.multiPositionTrackers["hi"] = make(map[asset.Item]map[currency.Pair]*MultiPositionTracker)
err = pc.SetCollateralCurrency("hi", asset.Futures, cp, currency.DOGE)
expectedError = ErrPositionsNotLoadedForAsset
if !errors.Is(err, expectedError) {
t.Fatalf("received '%v' expected '%v", err, expectedError)
}
pc.multiPositionTrackers["hi"][asset.Futures] = make(map[currency.Pair]*MultiPositionTracker)
err = pc.SetCollateralCurrency("hi", asset.Futures, cp, currency.DOGE)
expectedError = ErrPositionsNotLoadedForPair
if !errors.Is(err, expectedError) {
t.Fatalf("received '%v' expected '%v", err, expectedError)
}
pc.multiPositionTrackers["hi"][asset.Futures][cp] = nil
err = pc.SetCollateralCurrency("hi", asset.Futures, cp, currency.DOGE)
expectedError = common.ErrNilPointer
if !errors.Is(err, expectedError) {
t.Fatalf("received '%v' expected '%v", err, expectedError)
}
pc.multiPositionTrackers["hi"][asset.Futures][cp] = &MultiPositionTracker{
exchange: "hi",
asset: asset.Futures,
pair: cp,
orderPositions: make(map[string]*PositionTracker),
}
err = pc.TrackNewOrder(&Detail{
Date: time.Now(),
Exchange: "hi",
Pair: cp,
AssetType: asset.Futures,
Side: Long,
OrderID: "lol",
Price: 1,
Amount: 1,
})
if !errors.Is(err, nil) {
t.Fatalf("received '%v' expected '%v", err, nil)
}
err = pc.SetCollateralCurrency("hi", asset.Futures, cp, currency.DOGE)
expectedError = nil
if !errors.Is(err, expectedError) {
t.Fatalf("received '%v' expected '%v", err, expectedError)
}
if !pc.multiPositionTrackers["hi"][asset.Futures][cp].collateralCurrency.Equal(currency.DOGE) {
t.Errorf("received '%v' expected '%v'", pc.multiPositionTrackers["hi"][asset.Futures][cp].collateralCurrency, currency.DOGE)
}
if !pc.multiPositionTrackers["hi"][asset.Futures][cp].positions[0].collateralCurrency.Equal(currency.DOGE) {
t.Errorf("received '%v' expected '%v'", pc.multiPositionTrackers["hi"][asset.Futures][cp].positions[0].collateralCurrency, currency.DOGE)
}
pc = nil
err = pc.SetCollateralCurrency("hi", asset.Spot, currency.Pair{}, currency.Code{})
expectedError = common.ErrNilPointer
if !errors.Is(err, expectedError) {
t.Errorf("received '%v' expected '%v", err, expectedError)
}
}
func TestMPTUpdateOpenPositionUnrealisedPNL(t *testing.T) {
t.Parallel()
var err, expectedError error
expectedError = nil
cp := currency.NewPair(currency.BTC, currency.USDT)
pc := SetupPositionController()
err = pc.TrackNewOrder(&Detail{
Date: time.Now(),
Exchange: "hi",
Pair: cp,
AssetType: asset.Futures,
Side: Long,
OrderID: "lol",
Price: 1,
Amount: 1,
})
if !errors.Is(err, expectedError) {
t.Fatalf("received '%v' expected '%v", err, expectedError)
}
result, err := pc.multiPositionTrackers["hi"][asset.Futures][cp].UpdateOpenPositionUnrealisedPNL(1337, time.Now())
if !errors.Is(err, expectedError) {
t.Fatalf("received '%v' expected '%v", err, expectedError)
}
if result.Equal(decimal.NewFromInt(1337)) {
t.Error("")
}
expectedError = ErrPositionClosed
pc.multiPositionTrackers["hi"][asset.Futures][cp].positions[0].status = Closed
_, err = pc.multiPositionTrackers["hi"][asset.Futures][cp].UpdateOpenPositionUnrealisedPNL(1337, time.Now())
if !errors.Is(err, expectedError) {
t.Fatalf("received '%v' expected '%v", err, expectedError)
}
expectedError = ErrPositionsNotLoadedForPair
pc.multiPositionTrackers["hi"][asset.Futures][cp].positions = nil
_, err = pc.multiPositionTrackers["hi"][asset.Futures][cp].UpdateOpenPositionUnrealisedPNL(1337, time.Now())
if !errors.Is(err, expectedError) {
t.Fatalf("received '%v' expected '%v", err, expectedError)
}
}
func TestMPTLiquidate(t *testing.T) {
t.Parallel()
item := asset.Futures
pair, err := currency.NewPairFromStrings("BTC", "1231")
if !errors.Is(err, nil) {
t.Error(err)
}
e := &MultiPositionTracker{
exchange: testExchange,
exchangePNLCalculation: &FakePNL{},
asset: item,
orderPositions: make(map[string]*PositionTracker),
}
err = e.Liquidate(decimal.Zero, time.Time{})
if !errors.Is(err, ErrPositionsNotLoadedForPair) {
t.Error(err)
}
setup := &PositionTrackerSetup{
Pair: pair,
Asset: item,
}
_, err = e.SetupPositionTracker(setup)
if !errors.Is(err, nil) {
t.Error(err)
}
tt := time.Now()
err = e.TrackNewOrder(&Detail{
Date: tt,
Exchange: testExchange,
Pair: pair,
AssetType: item,
Side: Long,
OrderID: "lol",
Price: 1,
Amount: 1,
})
if !errors.Is(err, nil) {
t.Error(err)
}
err = e.Liquidate(decimal.Zero, time.Time{})
if !errors.Is(err, errCannotLiquidate) {
t.Error(err)
}
err = e.Liquidate(decimal.Zero, tt)
if !errors.Is(err, nil) {
t.Error(err)
}
if e.positions[0].status != Liquidated {
t.Errorf("received '%v' expected '%v'", e.positions[0].status, Liquidated)
}
if !e.positions[0].exposure.IsZero() {
t.Errorf("received '%v' expected '%v'", e.positions[0].exposure, 0)
}
e = nil
err = e.Liquidate(decimal.Zero, tt)
if !errors.Is(err, common.ErrNilPointer) {
t.Error(err)
}
}
func TestPositionLiquidate(t *testing.T) {
t.Parallel()
item := asset.Futures
pair, err := currency.NewPairFromStrings("BTC", "1231")
if !errors.Is(err, nil) {
t.Error(err)
}
p := &PositionTracker{
contractPair: pair,
asset: item,
exchange: testExchange,
PNLCalculation: &PNLCalculator{},
status: Open,
openingDirection: Long,
}
tt := time.Now()
err = p.TrackNewOrder(&Detail{
Date: tt,
Exchange: testExchange,
Pair: pair,
AssetType: item,
Side: Long,
OrderID: "lol",
Price: 1,
Amount: 1,
}, false)
if !errors.Is(err, nil) {
t.Error(err)
}
err = p.Liquidate(decimal.Zero, time.Time{})
if !errors.Is(err, errCannotLiquidate) {
t.Error(err)
}
err = p.Liquidate(decimal.Zero, tt)
if !errors.Is(err, nil) {
t.Error(err)
}
if p.status != Liquidated {
t.Errorf("received '%v' expected '%v'", p.status, Liquidated)
}
if !p.exposure.IsZero() {
t.Errorf("received '%v' expected '%v'", p.exposure, 0)
}
p = nil
err = p.Liquidate(decimal.Zero, tt)
if !errors.Is(err, common.ErrNilPointer) {
t.Error(err)
}
}

View File

@@ -44,18 +44,21 @@ var (
errNilOrder = errors.New("nil order received")
errNoPNLHistory = errors.New("no pnl history")
errCannotCalculateUnrealisedPNL = errors.New("cannot calculate unrealised PNL")
errCannotTrackInvalidParams = errors.New("parameters set incorrectly, cannot track")
)
// PNLCalculation is an interface to allow multiple
// ways of calculating PNL to be used for futures positions
type PNLCalculation interface {
CalculatePNL(context.Context, *PNLCalculatorRequest) (*PNLResult, error)
GetCurrencyForRealisedPNL(realisedAsset asset.Item, realisedPair currency.Pair) (currency.Code, asset.Item, error)
}
// CollateralManagement is an interface that allows
// multiple ways of calculating the size of collateral
// on an exchange
type CollateralManagement interface {
GetCollateralCurrencyForContract(asset.Item, currency.Pair) (currency.Code, asset.Item, error)
ScaleCollateral(ctx context.Context, calculator *CollateralCalculator) (*CollateralByCurrency, error)
CalculateTotalCollateral(context.Context, *TotalCollateralCalculator) (*TotalCollateralResponse, error)
}
@@ -125,8 +128,8 @@ type UsedCollateralBreakdown struct {
// and so all you need to do is send all orders to
// the position controller and its all tracked happily
type PositionController struct {
m sync.Mutex
positionTrackerControllers map[string]map[asset.Item]map[currency.Pair]*MultiPositionTracker
m sync.Mutex
multiPositionTrackers map[string]map[asset.Item]map[currency.Pair]*MultiPositionTracker
}
// MultiPositionTracker will track the performance of
@@ -134,12 +137,13 @@ type PositionController struct {
// is closed, then the position controller will create a new one
// to track the current positions
type MultiPositionTracker struct {
m sync.Mutex
exchange string
asset asset.Item
pair currency.Pair
underlying currency.Code
positions []*PositionTracker
m sync.Mutex
exchange string
asset asset.Item
pair currency.Pair
underlying currency.Code
collateralCurrency currency.Code
positions []*PositionTracker
// order positions allows for an easier time knowing which order is
// part of which position tracker
orderPositions map[string]*PositionTracker
@@ -155,6 +159,7 @@ type MultiPositionTrackerSetup struct {
Asset asset.Item
Pair currency.Pair
Underlying currency.Code
CollateralCurrency currency.Code
OfflineCalculation bool
UseExchangePNLCalculation bool
ExchangePNLCalculation PNLCalculation
@@ -173,6 +178,7 @@ type PositionTracker struct {
asset asset.Item
contractPair currency.Pair
underlyingAsset currency.Code
collateralCurrency currency.Code
exposure decimal.Decimal
currentDirection Side
openingDirection Side
@@ -196,6 +202,7 @@ type PositionTrackerSetup struct {
Pair currency.Pair
EntryPrice decimal.Decimal
Underlying currency.Code
CollateralCurrency currency.Code
Asset asset.Item
Side Side
UseExchangePNLCalculation bool
@@ -256,29 +263,36 @@ type PNLCalculatorRequest struct {
// PNLResult stores a PNL result from a point in time
type PNLResult struct {
Status Status
Time time.Time
UnrealisedPNL decimal.Decimal
RealisedPNLBeforeFees decimal.Decimal
RealisedPNL decimal.Decimal
Price decimal.Decimal
Exposure decimal.Decimal
Direction Side
Fee decimal.Decimal
IsLiquidated bool
// Is event is supposed to show that something has happened and it isnt just tracking in time
IsOrder bool
}
// PositionStats is a basic holder
// for position information
type PositionStats struct {
Exchange string
Asset asset.Item
Pair currency.Pair
Underlying currency.Code
Orders []Detail
RealisedPNL decimal.Decimal
UnrealisedPNL decimal.Decimal
LatestDirection Side
Status Status
OpeningDirection Side
OpeningPrice decimal.Decimal
LatestPrice decimal.Decimal
PNLHistory []PNLResult
Exchange string
Asset asset.Item
Pair currency.Pair
Underlying currency.Code
CollateralCurrency currency.Code
Orders []Detail
RealisedPNL decimal.Decimal
UnrealisedPNL decimal.Decimal
Exposure decimal.Decimal
LatestDirection Side
Status Status
OpeningDirection Side
OpeningPrice decimal.Decimal
LatestPrice decimal.Decimal
PNLHistory []PNLResult
}

View File

@@ -23,6 +23,7 @@ var (
ErrAmountIsInvalid = errors.New("order amount is equal or less than zero")
ErrPriceMustBeSetIfLimitOrder = errors.New("order price must be set if limit order type is desired")
ErrOrderIDNotSet = errors.New("order id or client order id is not set")
errCannotLiquidate = errors.New("cannot liquidate position")
)
// Submit contains all properties of an order that may be required
@@ -278,6 +279,7 @@ const (
Closed
Pending
Cancelling
Liquidated
)
// Type enforces a standard for order types across the code base
@@ -304,7 +306,7 @@ const (
)
// Side enforces a standard for order sides across the code base
type Side uint16
type Side uint32
// Order side types
const (
@@ -316,11 +318,16 @@ const (
AnySide
Long
Short
ClosePosition
// Backtester signal types
DoNothing
TransferredFunds
CouldNotBuy
CouldNotSell
CouldNotShort
CouldNotLong
CouldNotCloseShort
CouldNotCloseLong
MissingData
)

View File

@@ -18,7 +18,7 @@ const (
orderSubmissionValidSides = Buy | Sell | Bid | Ask | Long | Short
shortSide = Short | Sell | Ask
longSide = Long | Buy | Bid
inactiveStatuses = Filled | Cancelled | InsufficientBalance | MarketUnavailable | Rejected | PartiallyCancelled | Expired | Closed | AnyStatus | Cancelling
inactiveStatuses = Filled | Cancelled | InsufficientBalance | MarketUnavailable | Rejected | PartiallyCancelled | Expired | Closed | AnyStatus | Cancelling | Liquidated
activeStatuses = Active | Open | PartiallyFilled | New | PendingCancel | Hidden | AutoDeleverage | Pending
notPlaced = InsufficientBalance | MarketUnavailable | Rejected
)
@@ -375,7 +375,13 @@ func (d *Detail) IsActive() bool {
func (d *Detail) IsInactive() bool {
return d.Amount <= 0 ||
d.Amount <= d.ExecutedAmount ||
inactiveStatuses&d.Status == d.Status
d.Status.IsInactive()
}
// IsInactive returns true if the status indicates it is
// currently not available on the exchange
func (s Status) IsInactive() bool {
return inactiveStatuses&s == s
}
// WasOrderPlaced returns true if an order has a status that indicates that it
@@ -636,6 +642,8 @@ func (s Side) String() string {
return "SHORT"
case AnySide:
return "ANY"
case ClosePosition:
return "CLOSE POSITION"
// Backtester signal types below.
case DoNothing:
return "DO NOTHING"
@@ -645,6 +653,14 @@ func (s Side) String() string {
return "COULD NOT BUY"
case CouldNotSell:
return "COULD NOT SELL"
case CouldNotShort:
return "COULD NOT SHORT"
case CouldNotLong:
return "COULD NOT LONG"
case CouldNotCloseShort:
return "COULD NOT CLOSE SHORT"
case CouldNotCloseLong:
return "COULD NOT CLOSE LONG"
case MissingData:
return "MISSING DATA"
default: