From 24e4797900b3d2edf642fdb547bc22357a5b39ad Mon Sep 17 00:00:00 2001 From: jloup Date: Wed, 9 May 2018 19:44:17 +0200 Subject: Refactor Portfolio balance. --- api/api.go | 12 +++ api/const.go | 2 +- api/market_config.go | 75 ++++++++++-------- api/portfolio.go | 219 +++++++++++++++++++++++++++++++++++++++++++++++++++ api/routes.go | 17 +++- 5 files changed, 287 insertions(+), 38 deletions(-) create mode 100644 api/portfolio.go (limited to 'api') diff --git a/api/api.go b/api/api.go index 42b9923..ece2a26 100644 --- a/api/api.go +++ b/api/api.go @@ -45,6 +45,18 @@ func (e Error) Error() string { return "" } +func ErrorIs(err error, code ErrorCode) bool { + if err == nil { + return false + } + + if apiError, ok := err.(*Error); !ok { + return false + } else { + return apiError.Code == code + } +} + func NewInternalError(err error) *Error { return &Error{InternalError, "internal error", err} } diff --git a/api/const.go b/api/const.go index d3e5f42..1f15c6e 100644 --- a/api/const.go +++ b/api/const.go @@ -6,7 +6,7 @@ import "net/http" type Status uint32 type ErrorCode uint32 -const EXTERNAL_SERVICE_TIMEOUT_SECONDS = 10 +const EXTERNAL_SERVICE_TIMEOUT_SECONDS = 20 const ( OK Status = iota diff --git a/api/market_config.go b/api/market_config.go index c2248b3..81a92d1 100644 --- a/api/market_config.go +++ b/api/market_config.go @@ -5,11 +5,9 @@ import ( "strings" "time" - "immae.eu/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Front/markets" - "github.com/jloup/utils" - "github.com/shopspring/decimal" "immae.eu/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Front/db" + "immae.eu/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Front/markets" ) type MarketConfigQuery struct { @@ -56,45 +54,33 @@ func (q MarketConfigQuery) Run() (interface{}, *Error) { return config.Config, nil } -type MarketBalanceQuery struct { +type TestMarketCredentialsQuery struct { In struct { - User db.User - Market string - Currency string + User db.User + Market string } } -func (q MarketBalanceQuery) ValidateParams() *Error { +func (q TestMarketCredentialsQuery) ValidateParams() *Error { if q.In.Market != "poloniex" { return &Error{BadRequest, "invalid market name", fmt.Errorf("'%v' is not a valid market name", q.In.Market)} } - // TODO: we should request market for available currencies. - if q.In.Currency != "BTC" && q.In.Currency != "USDT" && q.In.Currency != "ETH" { - return &Error{BadRequest, "invalid currency, accept [BTC, USDT, ETH]", fmt.Errorf("'%v' is not a valid currency", q.In.Currency)} - } - return nil } -func (q MarketBalanceQuery) Run() (interface{}, *Error) { +func (q TestMarketCredentialsQuery) Run() (interface{}, *Error) { config, err := db.GetUserMarketConfig(q.In.User.Id, q.In.Market) if err != nil { return nil, NewInternalError(err) } if config.Config["key"] == "" || config.Config["secret"] == "" { - return nil, &Error{InvalidMarketCredentials, "your credentials for this market are not setup", fmt.Errorf("'%v' credentials are not setup", q.In.Market)} + return nil, &Error{InvalidMarketCredentials, "no market credentials", fmt.Errorf("market credentials are empty for marketId '%v'", q.In.Market)} } - result := struct { - Value decimal.Decimal `json:"value"` - ValueCurrency string `json:"valueCurrency"` - Balance map[string]markets.Balance `json:"balance"` - }{} - resultErr := CallExternalService(fmt.Sprintf("'%s' GetBalanceValue", q.In.Market), EXTERNAL_SERVICE_TIMEOUT_SECONDS*time.Second, func() *Error { - balance, err := Poloniex.GetBalance(config.Config["key"], config.Config["secret"]) + err := Poloniex.TestCredentials(config.Config["key"], config.Config["secret"]) if utils.ErrIs(err, markets.InvalidCredentials) { return &Error{InvalidMarketCredentials, "wrong market credentials", fmt.Errorf("wrong '%v' market credentials", q.In.Market)} @@ -108,16 +94,6 @@ func (q MarketBalanceQuery) Run() (interface{}, *Error) { return NewInternalError(err) } - for currency, value := range balance.Balances { - if value.BTCValue.Abs().LessThan(decimal.NewFromFloat(0.0001)) { - delete(balance.Balances, currency) - } - } - - result.Balance = balance.Balances - result.ValueCurrency = "BTC" - result.Value = balance.BTCValue.Round(8) - return nil }) @@ -125,7 +101,7 @@ func (q MarketBalanceQuery) Run() (interface{}, *Error) { return nil, resultErr } - return &result, nil + return nil, nil } type UpdateMarketConfigQuery struct { @@ -164,3 +140,36 @@ func (q UpdateMarketConfigQuery) Run() (interface{}, *Error) { return nil, nil } + +type GetPortfolioQuery struct { + In struct { + User db.User + Market string + } +} + +func (q GetPortfolioQuery) ValidateParams() *Error { + if q.In.Market != "poloniex" { + return &Error{BadRequest, "invalid market name", fmt.Errorf("'%v' is not a valid market name", q.In.Market)} + } + + return nil +} + +func (q GetPortfolioQuery) Run() (interface{}, *Error) { + marketConfig, err := db.GetUserMarketConfig(q.In.User.Id, q.In.Market) + if err != nil { + return nil, NewInternalError(err) + } + + report, err := GetWeekPortfolio(*marketConfig) + if ErrorIs(err, NotFound) { + return nil, err.(*Error) + } + + if err != nil { + return nil, NewInternalError(err) + } + + return report.Round(), nil +} diff --git a/api/portfolio.go b/api/portfolio.go new file mode 100644 index 0000000..0fef94c --- /dev/null +++ b/api/portfolio.go @@ -0,0 +1,219 @@ +package api + +import ( + "fmt" + "time" + + "github.com/shopspring/decimal" + "immae.eu/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Front/db" +) + +func init() { + decimal.MarshalJSONWithoutQuotes = true +} + +const MARGIN_POSITION_SECURED_RATIO = 1.0 + +const BTC_DIGITS = 3 + +var MINIMAL_BTC_VALUE_TRESHOLD decimal.Decimal = decimal.NewFromFloat(10.0).Pow(decimal.NewFromFloat(-3.0)) + +type ValuePerformance struct { + Value decimal.Decimal `json:"value"` + Variation decimal.Decimal `json:"variation"` + VariationP decimal.Decimal `json:"variationP"` +} + +func NewValuePerformance(fromValue, toValue decimal.Decimal) ValuePerformance { + variation := toValue.Sub(fromValue) + + return ValuePerformance{ + Value: toValue, + Variation: variation, + VariationP: variation.Div(fromValue).Mul(decimal.NewFromFloat(100.0)), + } +} + +type PositionType string + +const POSITION_SHORT PositionType = "short" +const POSITION_LONG PositionType = "long" + +type PortfolioBalance struct { + PositionType PositionType `json:"positionType"` + Quantity decimal.Decimal `json:"quantity"` + QuantityLocked decimal.Decimal `json:"quantityLocked"` + BTCValue decimal.Decimal `json:"BTCValue"` + PositionPerformanceP decimal.Decimal `json:"positionPerformanceP"` + Weight decimal.Decimal `json:"weight"` +} + +type Portfolio struct { + PeriodStart time.Time `json:"periodStart"` + PeriodEnd time.Time `json:"periodEnd"` + Balances map[string]PortfolioBalance `json:"balances"` + Value decimal.Decimal `json:"value"` + Performance ValuePerformance `json:"performance"` +} + +func (p Portfolio) Round() Portfolio { + p.Value = p.Value.Round(BTC_DIGITS) + for currency := range p.Balances { + balance := p.Balances[currency] + balance.Quantity = balance.Quantity.Round(2) + balance.BTCValue = balance.BTCValue.Round(BTC_DIGITS) + balance.Weight = balance.Weight.Round(1) + balance.PositionPerformanceP = balance.PositionPerformanceP.Round(1) + p.Balances[currency] = balance + } + + p.Performance.VariationP = p.Performance.VariationP.Round(1) + p.Performance.Variation = p.Performance.Variation.Round(BTC_DIGITS) + p.Performance.Value = p.Performance.Value.Round(BTC_DIGITS) + return p +} + +func GetCurrenciesPerformance(from, to db.ReportTickers) map[string]ValuePerformance { + performances := make(map[string]ValuePerformance) + currencies := make(map[string]struct{}) + for currency := range to.Balances { + if to.Balances[currency].Abs().LessThan(MINIMAL_BTC_VALUE_TRESHOLD) { + continue + } + currencies[currency] = struct{}{} + } + + for currency := range currencies { + performances[currency] = NewValuePerformance(from.GetBTCRate(currency), to.GetBTCRate(currency)) + } + + return performances +} + +func UserMovements(from db.Report, to db.Report) (decimal.Decimal, error) { + if from.Tag != db.BUY_END || (to.Tag != db.SELL_BEGIN && to.Tag != db.INTERMADIATE_STATE) { + return decimal.Zero, fmt.Errorf("cannot compare reports: '%s' -> '%s'", from.Tag, to.Tag) + } + + var deltaBTC decimal.Decimal + + currencies := make(map[string]struct{}) + for currency := range to.Balances { + currencies[currency] = struct{}{} + } + for currency := range from.Balances { + currencies[currency] = struct{}{} + } + + for currency := range currencies { + balanceFrom := from.Balances[currency] + balanceTo := to.Balances[currency] + + delta := balanceTo.Total.Sub(balanceFrom.Total) + if !delta.Equals(decimal.Zero) { + deltaBTC = deltaBTC.Add(delta.Mul(to.Tickers.GetBTCRate(currency)).Neg()) + } + + } + + return deltaBTC, nil +} + +// Computes plus-value, ignoring positions took by users. +func ComputePlusValue(from db.Report, to db.Report) (decimal.Decimal, error) { + if from.Tag != db.BUY_END || (to.Tag != db.SELL_BEGIN && to.Tag != db.INTERMADIATE_STATE) { + return decimal.Zero, fmt.Errorf("cannot compare reports: '%s' -> '%s'", from.Tag, to.Tag) + } + + diff, err := UserMovements(from, to) + if err != nil { + return decimal.Zero, err + } + + return to.Tickers.Total.Sub(from.Tickers.Total).Add(diff), nil +} + +func ComputeWeights(report db.Report) map[string]decimal.Decimal { + weights := make(map[string]decimal.Decimal) + + for currency := range report.Balances { + + if report.Tickers.Balances[currency].Abs().LessThan(MINIMAL_BTC_VALUE_TRESHOLD) { + continue + } + + quantityBlocked := report.Balances[currency].MarginInPosition.Mul(decimal.NewFromFloat(1.0 + MARGIN_POSITION_SECURED_RATIO)) + weights[currency] = report.Tickers.Balances[currency]. + Sub(quantityBlocked.Mul(report.Tickers.GetBTCRate(currency))). + Abs(). + Div(report.Tickers.Total). + Mul(decimal.NewFromFloat(100)) + } + + return weights +} + +func GetWeekPortfolio(marketConfig db.MarketConfig) (Portfolio, error) { + portfolio := Portfolio{ + Balances: make(map[string]PortfolioBalance), + } + + report, err := db.GetLastPortfolioBegin(marketConfig) + if err != nil { + return portfolio, err + } + + if report == nil { + return portfolio, &Error{NotFound, "no report", fmt.Errorf("no reports for marketConfigId '%v'", marketConfig.Id)} + } + + liveReport, err := db.GetLatestReport(marketConfig) + if err != nil { + return portfolio, err + } + + weights := ComputeWeights(*report) + currenciesPerformances := GetCurrenciesPerformance(report.Tickers, liveReport.Tickers) + + portfolio.PeriodStart = report.Date.Truncate(time.Second).UTC() + portfolio.PeriodEnd = liveReport.Date.Truncate(time.Second).UTC() + + for currency := range liveReport.Balances { + balance := liveReport.Balances[currency] + btcTicker := liveReport.Tickers.Balances[currency] + + if btcTicker.Abs().LessThan(MINIMAL_BTC_VALUE_TRESHOLD) { + continue + } + + var positionType PositionType + var perfMul decimal.Decimal + + if balance.Total.LessThan(decimal.Zero) { + positionType = POSITION_SHORT + perfMul = decimal.NewFromFloat(-1.0) + } else { + positionType = POSITION_LONG + perfMul = decimal.NewFromFloat(1.0) + } + + portfolio.Balances[currency] = PortfolioBalance{ + PositionType: positionType, + Quantity: balance.Total, + BTCValue: btcTicker, + QuantityLocked: balance.MarginInPosition.Mul(decimal.NewFromFloat(1.0 + MARGIN_POSITION_SECURED_RATIO)), + Weight: weights[currency], + PositionPerformanceP: currenciesPerformances[currency].VariationP.Mul(perfMul), + } + } + + portfolio.Value = liveReport.Tickers.Total + plusValue, err := ComputePlusValue(*report, liveReport) + if err != nil { + return portfolio, err + } + + portfolio.Performance = NewValuePerformance(liveReport.Tickers.Total.Sub(plusValue), liveReport.Tickers.Total) + + return portfolio, nil +} diff --git a/api/routes.go b/api/routes.go index 22af0e7..d7b316d 100644 --- a/api/routes.go +++ b/api/routes.go @@ -43,7 +43,8 @@ var Groups = []Group{ []Route{ {"GET", []gin.HandlerFunc{GetMarketConfig}, "/:name"}, {"POST", []gin.HandlerFunc{UpdateMarketConfig}, "/:name/update"}, - {"GET", []gin.HandlerFunc{GetMarketBalance}, "/:name/balance"}, + {"GET", []gin.HandlerFunc{TestMarketCredentials}, "/:name/test-credentials"}, + {"GET", []gin.HandlerFunc{GetPortfolio}, "/:name/portfolio"}, }, }, } @@ -114,12 +115,20 @@ func GetMarketConfig(c *gin.Context) { RunQuery(query, c) } -func GetMarketBalance(c *gin.Context) { - query := &MarketBalanceQuery{} +func TestMarketCredentials(c *gin.Context) { + query := &TestMarketCredentialsQuery{} + + query.In.User = GetUser(c) + query.In.Market = c.Param("name") + + RunQuery(query, c) +} + +func GetPortfolio(c *gin.Context) { + query := &GetPortfolioQuery{} query.In.User = GetUser(c) query.In.Market = c.Param("name") - query.In.Currency = c.Query("currency") RunQuery(query, c) } -- cgit v1.2.3