From 24e4797900b3d2edf642fdb547bc22357a5b39ad Mon Sep 17 00:00:00 2001 From: jloup Date: Wed, 9 May 2018 19:44:17 +0200 Subject: [PATCH] Refactor Portfolio balance. --- api/api.go | 12 +++ api/const.go | 2 +- api/market_config.go | 75 ++++++++------ api/portfolio.go | 219 +++++++++++++++++++++++++++++++++++++++ api/routes.go | 17 ++- cmd/web/Makefile | 2 +- cmd/web/js/account.jsx | 9 +- cmd/web/js/api.js | 17 ++- cmd/web/js/balance.jsx | 99 ++++++++++++++++++ cmd/web/js/poloniex.jsx | 112 +++++++++++--------- cmd/web/package.json | 1 + cmd/web/static/style.css | 38 +++++-- cmd/web/yarn.lock | 4 + db/report_lines.go | 140 +++++++++++++++++++++++++ markets/poloniex.go | 16 +++ 15 files changed, 651 insertions(+), 112 deletions(-) create mode 100644 api/portfolio.go create mode 100644 cmd/web/js/balance.jsx create mode 100644 db/report_lines.go 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) } diff --git a/cmd/web/Makefile b/cmd/web/Makefile index 52e2289..7315d5e 100644 --- a/cmd/web/Makefile +++ b/cmd/web/Makefile @@ -4,7 +4,7 @@ export PATH := $(PATH):./node_modules/.bin SRC_DIR=js BUILD_DIR=build/js -JSX_SRC= header_footer.jsx main.jsx signup.jsx signin.jsx otp.jsx poloniex.jsx password_reset.jsx change_password.jsx account.jsx +JSX_SRC= header_footer.jsx main.jsx signup.jsx signin.jsx otp.jsx poloniex.jsx password_reset.jsx change_password.jsx account.jsx balance.jsx JS_SRC= cookies.js app.js api.js STATIC_FILES= index.html style.css fontello.css STATIC_FILES+=$(addprefix fonts/, fontello.eot fontello.svg fontello.ttf fontello.woff fontello.woff2) diff --git a/cmd/web/js/account.jsx b/cmd/web/js/account.jsx index 43e7083..d30abe7 100644 --- a/cmd/web/js/account.jsx +++ b/cmd/web/js/account.jsx @@ -8,7 +8,7 @@ class PoloniexConfiguration extends React.Component { } checkCredentials = () => { - Api.Call('MARKET_BALANCE', {'name': 'poloniex', 'currency': 'BTC'}, function(err, status, data) { + Api.Call('MARKET_TEST_CREDENTIALS', {'name': 'poloniex'}, function(err, status, data) { if (err) { console.error(err, data); if (err.code === 'invalid_market_credentials') { @@ -28,13 +28,6 @@ class PoloniexConfiguration extends React.Component { } handleCredentialsSubmit = () => { - - /* - *If (!this.state.apiKey || !this.state.apiSecret) { - * return; - *} - */ - Api.Call('UPDATE_MARKET', {'key': this.state.apiKey, 'secret': this.state.apiSecret, 'name': 'poloniex'}, function(err, status, data) { if (err) { console.error(err, data); diff --git a/cmd/web/js/api.js b/cmd/web/js/api.js index c9b4ef5..f892a6b 100644 --- a/cmd/web/js/api.js +++ b/cmd/web/js/api.js @@ -74,15 +74,24 @@ var ApiEndpoints = { return '/market/' + params.name; } }, - 'MARKET_BALANCE': { + 'MARKET_TEST_CREDENTIALS': { 'type': 'GET', 'auth': true, 'parameters': [ - {'name': 'name', 'mandatory': true, 'inquery': false}, - {'name': 'currency', 'mandatory': true, 'inquery': true}, + {'name': 'name', 'mandatory': true, 'inquery': false}, + ], + 'buildUrl': function(params) { + return '/market/' + params.name + '/test-credentials'; + } + }, + 'MARKET_GET_PORTFOLIO': { + 'type': 'GET', + 'auth': true, + 'parameters': [ + {'name': 'name', 'mandatory': true, 'inquery': false}, ], 'buildUrl': function(params) { - return '/market/' + params.name + '/balance'; + return '/market/' + params.name + '/portfolio'; } }, 'UPDATE_MARKET': { diff --git a/cmd/web/js/balance.jsx b/cmd/web/js/balance.jsx new file mode 100644 index 0000000..d141aa8 --- /dev/null +++ b/cmd/web/js/balance.jsx @@ -0,0 +1,99 @@ +import React from 'react'; +import moment from 'moment'; + +class CurrencyLogo extends React.Component { + render = () => { + return
+ {this.props.currency} +
; + } + } + + var formatVariation = (variation) => { + var variationAbs = Math.abs(variation); + if (variation === 0.0) { + return {variationAbs}%; + } else if (variation > 0) { + return +{variationAbs}%; + } + return -{variationAbs}%; +}; + + +class CurrencyRateHeader extends React.Component { + render = () => { + return +
+
Asset
+
Position
+
Qty
+
Value (BTC)
+
Weight
+
Perf %
+
+
; + } +} + +class CurrencyRate extends React.Component { + render = () => { + return +
+
{this.props.currency}
+
{this.props.positionType}
+
{this.props.quantity}
+
{this.props.BTCValue}
+
{this.props.weight}%
+
{formatVariation(this.props.positionPerformanceP)}
+
+
; + } +} + +class Assets extends React.Component { + render = () => { + var currencies = Object.keys(this.props.balances).map(function(currency) { + var balance = this.props.balances[currency]; + balance.currency = currency; + return
+
+
; + }.bind(this)); + + return
+ + {currencies} +
; + } +} + +class PFBalance extends React.Component { + render = () => { + var date = moment(this.props.periodStart).format('MMM Do, h:mma'); + return +
+
+
+ Current balance +
+
+ {this.props.balance} +
+
+
+
+ since {date} +
+
+ {formatVariation(this.props.variationP)} +
+
+
+
; + } +} + +export {PFBalance, Assets}; diff --git a/cmd/web/js/poloniex.jsx b/cmd/web/js/poloniex.jsx index edac368..6019ef8 100644 --- a/cmd/web/js/poloniex.jsx +++ b/cmd/web/js/poloniex.jsx @@ -1,30 +1,45 @@ import Api from './api.js'; import React from 'react'; +import {PFBalance, Assets} from './balance.js'; class PoloniexController extends React.Component { constructor(props) { super(props); - this.state = {'flag': 'loading', 'valueCurrency': null, 'balanceValue': null, 'balance': null}; + this.state = {'flag': 'loading', 'periodStart': null, 'variationP': null, 'balance': null, 'balances': null}; } - loadBalance = () => { - Api.Call('MARKET_BALANCE', {'name': 'poloniex', 'currency': 'BTC'}, function(err, status, data) { + testCredentials = () => { + Api.Call('MARKET_TEST_CREDENTIALS', {'name': 'poloniex'}, function(err, status, data) { if (err) { console.error(err, data); if (err.code === 'invalid_market_credentials') { - this.setState({'flag': 'invalidCredentials', 'valueCurrency': null, 'balanceValue': null, 'balance': null}); + this.setState({'flag': 'invalidCredentials', 'variationP': null, 'balance': null, 'balances': null, 'periodStart': null}); } else if (err.code === 'ip_restricted_api_key') { - this.setState({'flag': 'ipRestricted', 'valueCurrency': null, 'balanceValue': null, 'balance': null}); + this.setState({'flag': 'ipRestricted', 'variationP': null, 'balance': null, 'balances': null, 'periodStart': null}); } return; } - this.setState({'flag': 'ok', 'valueCurrency': data.valueCurrency, 'balanceValue': data.value, 'balance': data.balance}); + this.loadPortfolio(); + }.bind(this)); + } + + loadPortfolio = () => { + Api.Call('MARKET_GET_PORTFOLIO', {'name': 'poloniex'}, function(err, status, data) { + if (err) { + console.error(err, data); + if (err.code === 'not_found') { + this.setState({'flag': 'noReport', 'variationP': null, 'balance': null, 'balances': null, 'periodStart': null}); + } + return; + } + + this.setState({'flag': 'ok', 'variationP': data.performance.variationP, 'balance': data.value, 'balances': data.balances, 'periodStart': data.periodStart}); }.bind(this)); } componentDidMount = () => { - this.loadBalance(); + this.testCredentials(); } render = () => { @@ -33,10 +48,13 @@ class PoloniexController extends React.Component { case 'loading': displayText = 'Loading data from poloniex...'; break; + case 'noReport': + displayText = 'Your account is setup ! Reporting will start next Monday !'; + break; case 'invalidCredentials': case 'ipRestricted': case 'emptyCredentials': - displayText =
Please provide poloniex credentials in Account page.
; + displayText =
Please provide poloniex credentials in Account page.
; break; default: displayText = null; @@ -44,59 +62,55 @@ class PoloniexController extends React.Component { return (
); } } -class CurrencyLogo extends React.Component { +class Panel extends React.Component { render = () => { - return {this.props.currency}; + if (this.props.component === null) { + return
; + } + + return ( +
+
+
+
{this.props.title}
+
+
+ {this.props.component} +
+
); } } class PoloniexBalance extends React.Component { - constructor(props) { - super(props); - this.state = {'hideMsg': true, 'msg': '', 'msgOk': false}; - } - - computeCurrencyRatio = (currency) => { - return (parseFloat(this.props.balance[currency].btcValue) / parseFloat(this.props.balanceValue) * 100.0).toFixed(1); - } render = () => { - var dashboard = null; - - if (this.props.balanceValue !== null) { + var balancePanel = null; + var assetsPanel = null; + var messagePanel = null; - var balance = Object.keys(this.props.balance).map(function(currency) { - return
-
- {this.props.balance[currency].amount} {currency} ({this.computeCurrencyRatio(currency)}%) -
-
; - }.bind(this)); - - dashboard = + if (this.props.variationP !== null) { + balancePanel =
-
- {balance} -
-
-
- Balance ({this.props.balanceCurrency}): {this.props.balanceValue} -
+
+
; + + assetsPanel = + ; + } else { - dashboard = + messagePanel =
{this.props.displayText} @@ -105,15 +119,11 @@ class PoloniexBalance extends React.Component { } return ( -
-
-
-
Portfolio
-
-
- {dashboard} -
-
+ + + + + ); } } diff --git a/cmd/web/package.json b/cmd/web/package.json index a867313..0bfb4e9 100644 --- a/cmd/web/package.json +++ b/cmd/web/package.json @@ -12,6 +12,7 @@ "classnames": "^2.2.5", "debowerify": "^1.3.1", "localenvify": "^1.0.1", + "moment": "^2.22.1", "page": "^1.8.3", "path-to-regexp": "^1.2.1", "qs": "^6.5.1", diff --git a/cmd/web/static/style.css b/cmd/web/static/style.css index 2212168..e4da176 100644 --- a/cmd/web/static/style.css +++ b/cmd/web/static/style.css @@ -54,6 +54,14 @@ h1 { margin-top: 5px; } +.performance-up { + color: green; +} + +.performance-down { + color: red; +} + .sign-in .form-control { margin-bottom: 20px; border-radius: 2px; @@ -63,16 +71,12 @@ h1 { .config-status { margin-bottom: 10px; - font-size: 0.9em; } -.config-status .icon-cancel-circled { - color: red; -} +.config-status .icon-cancel-circled {} + +.config-status .icon-ok-circled {} -.config-status .icon-ok-circled { - color: green; -} .config-status i { font-size: 1.2em; } @@ -123,13 +127,27 @@ h1 { display: inline-block; margin-left: 5px; margin-right: 5px; -} - -.portfolio .currency-logo { + height: 24px; width: 24px; } +.balance > .btcValue { + font-size: 1.2em; +} + +.balance > .variation { + font-size: 1.2em; +} + +.balance > .row { + margin-bottom: 10px; +} + +.assets > .row { + margin-bottom: 10px; +} + .balances > div { margin-bottom: 5px; } diff --git a/cmd/web/yarn.lock b/cmd/web/yarn.lock index b4a18f4..366391a 100644 --- a/cmd/web/yarn.lock +++ b/cmd/web/yarn.lock @@ -3078,6 +3078,10 @@ module-deps@^5.0.1: through2 "^2.0.0" xtend "^4.0.0" +moment@^2.22.1: + version "2.22.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.1.tgz#529a2e9bf973f259c9643d237fda84de3a26e8ad" + mout@~0.9.0: version "0.9.1" resolved "https://registry.yarnpkg.com/mout/-/mout-0.9.1.tgz#84f0f3fd6acc7317f63de2affdcc0cee009b0477" diff --git a/db/report_lines.go b/db/report_lines.go new file mode 100644 index 0000000..274be57 --- /dev/null +++ b/db/report_lines.go @@ -0,0 +1,140 @@ +package db + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/go-redis/redis" + "github.com/shopspring/decimal" +) + +type PortfolioStep string + +const SELL_BEGIN PortfolioStep = "process_sell_all__1_all_sell_begin" +const SELL_END PortfolioStep = "process_sell_all__1_all_sell_end" +const BUY_BEGIN PortfolioStep = "process_sell_all__3_all_buy_begin" +const BUY_END PortfolioStep = "process_sell_all__3_all_buy_end" +const INTERMADIATE_STATE PortfolioStep = "process_print_balances__1_print_balances_begin" + +type ViewBalances struct { + Id int64 + Date time.Time + ReportId int64 + MarketId int64 + Payload struct { + Report + } +} + +type Report struct { + Tag PortfolioStep + Date time.Time + Balances ReportBalances + Tickers ReportTickers +} + +type ReportTickers struct { + Currency string + Balances map[string]decimal.Decimal + Rates BTCRates + Total decimal.Decimal +} + +func (r ReportTickers) GetBTCRate(currency string) decimal.Decimal { + if currency == "BTC" { + return decimal.NewFromFloat(1.0) + } + + return r.Rates[currency] +} + +type BTCRates map[string]decimal.Decimal + +func (b BTCRates) Rate(currency string) decimal.Decimal { + if currency == "BTC" { + return decimal.NewFromFloat(1.0) + } + + return b[currency] +} + +type ReportBalances map[string]struct { + Total decimal.Decimal + MarginTotal decimal.Decimal `json:"margin_total"` + MarginInPosition decimal.Decimal `json:"margin_in_position"` + MarginAvailable decimal.Decimal `json:"margin_available"` +} + +func RedisReportKey(marketConfigId int64, timestamp, entity string) string { + return fmt.Sprintf("/cryptoportfolio/%v/%v/%v", marketConfigId, timestamp, entity) +} + +func GetLatestReport(marketConfig MarketConfig) (Report, error) { + var reportPayload Report + var err error + var key string + + // Get balance. + key = RedisReportKey(marketConfig.Id, "latest", "balance") + + payload, err := Redis.Get(key).Bytes() + if err == redis.Nil { + return Report{}, fmt.Errorf("cannot find '%s' redis key", key) + } else if err != nil { + return Report{}, err + } + + // Get date. + key = RedisReportKey(marketConfig.Id, "latest", "date") + dateString, err := Redis.Get(key).Result() + + if err == redis.Nil { + return Report{}, fmt.Errorf("cannot find '%s' redis key", key) + } else if err != nil { + return Report{}, err + } + + reportPayload.Date, err = time.Parse("2006-01-02T15:04:05", dateString) + if err != nil { + return Report{}, err + } + + err = json.Unmarshal(payload, &reportPayload) + + return reportPayload, err +} + +func GetPortfolioMilestones(marketConfig MarketConfig, step PortfolioStep, limit int) ([]Report, error) { + viewBalances := make([]ViewBalances, 0) + reports := make([]Report, 0) + + err := DB. + Model(&viewBalances). + Where("market_id = ?", marketConfig.Id). + Where("payload @> ?", fmt.Sprintf(`{"tag": "%s", "checkpoint": "%s"}`, step, "begin")). + OrderExpr("date DESC").Limit(limit).Select() + if err != nil { + return nil, err + } + + for _, reportLine := range viewBalances { + reportLine.Payload.Report.Date = reportLine.Date + reports = append(reports, reportLine.Payload.Report) + } + + return reports, nil +} + +func GetLastPortfolioBegin(marketConfig MarketConfig) (*Report, error) { + reports, err := GetPortfolioMilestones(marketConfig, BUY_END, 1) + if err != nil { + return nil, nil + } + + if len(reports) == 0 { + return nil, nil + } + + return &reports[0], nil +} diff --git a/markets/poloniex.go b/markets/poloniex.go index 9aaeafb..58645b2 100644 --- a/markets/poloniex.go +++ b/markets/poloniex.go @@ -52,6 +52,22 @@ func NewPoloniex() *Poloniex { } } +func (p *Poloniex) TestCredentials(apiKey, apiSecret string) error { + client, _ := poloniex.NewClient(apiKey, apiSecret) + + _, err := client.TradeReturnDepositAdresses() + + if poloniexInvalidCredentialsError(err) { + return utils.Error{InvalidCredentials, "invalid poloniex credentials"} + } + + if poloniexRestrictedIPError(err) { + return utils.Error{IPRestricted, "IP restricted api key"} + } + + return nil +} + func (p *Poloniex) GetBalance(apiKey, apiSecret string) (Summary, error) { client, _ := poloniex.NewClient(apiKey, apiSecret) var summary Summary -- 2.41.0