aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--api/api.go12
-rw-r--r--api/const.go2
-rw-r--r--api/market_config.go75
-rw-r--r--api/portfolio.go219
-rw-r--r--api/routes.go17
-rw-r--r--cmd/web/Makefile2
-rw-r--r--cmd/web/js/account.jsx9
-rw-r--r--cmd/web/js/api.js17
-rw-r--r--cmd/web/js/balance.jsx99
-rw-r--r--cmd/web/js/poloniex.jsx112
-rw-r--r--cmd/web/package.json1
-rw-r--r--cmd/web/static/style.css38
-rw-r--r--cmd/web/yarn.lock4
-rw-r--r--db/report_lines.go140
-rw-r--r--markets/poloniex.go16
15 files changed, 651 insertions, 112 deletions
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 {
45 return "" 45 return ""
46} 46}
47 47
48func ErrorIs(err error, code ErrorCode) bool {
49 if err == nil {
50 return false
51 }
52
53 if apiError, ok := err.(*Error); !ok {
54 return false
55 } else {
56 return apiError.Code == code
57 }
58}
59
48func NewInternalError(err error) *Error { 60func NewInternalError(err error) *Error {
49 return &Error{InternalError, "internal error", err} 61 return &Error{InternalError, "internal error", err}
50} 62}
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"
6type Status uint32 6type Status uint32
7type ErrorCode uint32 7type ErrorCode uint32
8 8
9const EXTERNAL_SERVICE_TIMEOUT_SECONDS = 10 9const EXTERNAL_SERVICE_TIMEOUT_SECONDS = 20
10 10
11const ( 11const (
12 OK Status = iota 12 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 (
5 "strings" 5 "strings"
6 "time" 6 "time"
7 7
8 "immae.eu/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Front/markets"
9
10 "github.com/jloup/utils" 8 "github.com/jloup/utils"
11 "github.com/shopspring/decimal"
12 "immae.eu/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Front/db" 9 "immae.eu/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Front/db"
10 "immae.eu/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Front/markets"
13) 11)
14 12
15type MarketConfigQuery struct { 13type MarketConfigQuery struct {
@@ -56,45 +54,33 @@ func (q MarketConfigQuery) Run() (interface{}, *Error) {
56 return config.Config, nil 54 return config.Config, nil
57} 55}
58 56
59type MarketBalanceQuery struct { 57type TestMarketCredentialsQuery struct {
60 In struct { 58 In struct {
61 User db.User 59 User db.User
62 Market string 60 Market string
63 Currency string
64 } 61 }
65} 62}
66 63
67func (q MarketBalanceQuery) ValidateParams() *Error { 64func (q TestMarketCredentialsQuery) ValidateParams() *Error {
68 if q.In.Market != "poloniex" { 65 if q.In.Market != "poloniex" {
69 return &Error{BadRequest, "invalid market name", fmt.Errorf("'%v' is not a valid market name", q.In.Market)} 66 return &Error{BadRequest, "invalid market name", fmt.Errorf("'%v' is not a valid market name", q.In.Market)}
70 } 67 }
71 68
72 // TODO: we should request market for available currencies.
73 if q.In.Currency != "BTC" && q.In.Currency != "USDT" && q.In.Currency != "ETH" {
74 return &Error{BadRequest, "invalid currency, accept [BTC, USDT, ETH]", fmt.Errorf("'%v' is not a valid currency", q.In.Currency)}
75 }
76
77 return nil 69 return nil
78} 70}
79 71
80func (q MarketBalanceQuery) Run() (interface{}, *Error) { 72func (q TestMarketCredentialsQuery) Run() (interface{}, *Error) {
81 config, err := db.GetUserMarketConfig(q.In.User.Id, q.In.Market) 73 config, err := db.GetUserMarketConfig(q.In.User.Id, q.In.Market)
82 if err != nil { 74 if err != nil {
83 return nil, NewInternalError(err) 75 return nil, NewInternalError(err)
84 } 76 }
85 77
86 if config.Config["key"] == "" || config.Config["secret"] == "" { 78 if config.Config["key"] == "" || config.Config["secret"] == "" {
87 return nil, &Error{InvalidMarketCredentials, "your credentials for this market are not setup", fmt.Errorf("'%v' credentials are not setup", q.In.Market)} 79 return nil, &Error{InvalidMarketCredentials, "no market credentials", fmt.Errorf("market credentials are empty for marketId '%v'", q.In.Market)}
88 } 80 }
89 81
90 result := struct {
91 Value decimal.Decimal `json:"value"`
92 ValueCurrency string `json:"valueCurrency"`
93 Balance map[string]markets.Balance `json:"balance"`
94 }{}
95
96 resultErr := CallExternalService(fmt.Sprintf("'%s' GetBalanceValue", q.In.Market), EXTERNAL_SERVICE_TIMEOUT_SECONDS*time.Second, func() *Error { 82 resultErr := CallExternalService(fmt.Sprintf("'%s' GetBalanceValue", q.In.Market), EXTERNAL_SERVICE_TIMEOUT_SECONDS*time.Second, func() *Error {
97 balance, err := Poloniex.GetBalance(config.Config["key"], config.Config["secret"]) 83 err := Poloniex.TestCredentials(config.Config["key"], config.Config["secret"])
98 84
99 if utils.ErrIs(err, markets.InvalidCredentials) { 85 if utils.ErrIs(err, markets.InvalidCredentials) {
100 return &Error{InvalidMarketCredentials, "wrong market credentials", fmt.Errorf("wrong '%v' market credentials", q.In.Market)} 86 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) {
108 return NewInternalError(err) 94 return NewInternalError(err)
109 } 95 }
110 96
111 for currency, value := range balance.Balances {
112 if value.BTCValue.Abs().LessThan(decimal.NewFromFloat(0.0001)) {
113 delete(balance.Balances, currency)
114 }
115 }
116
117 result.Balance = balance.Balances
118 result.ValueCurrency = "BTC"
119 result.Value = balance.BTCValue.Round(8)
120
121 return nil 97 return nil
122 }) 98 })
123 99
@@ -125,7 +101,7 @@ func (q MarketBalanceQuery) Run() (interface{}, *Error) {
125 return nil, resultErr 101 return nil, resultErr
126 } 102 }
127 103
128 return &result, nil 104 return nil, nil
129} 105}
130 106
131type UpdateMarketConfigQuery struct { 107type UpdateMarketConfigQuery struct {
@@ -164,3 +140,36 @@ func (q UpdateMarketConfigQuery) Run() (interface{}, *Error) {
164 140
165 return nil, nil 141 return nil, nil
166} 142}
143
144type GetPortfolioQuery struct {
145 In struct {
146 User db.User
147 Market string
148 }
149}
150
151func (q GetPortfolioQuery) ValidateParams() *Error {
152 if q.In.Market != "poloniex" {
153 return &Error{BadRequest, "invalid market name", fmt.Errorf("'%v' is not a valid market name", q.In.Market)}
154 }
155
156 return nil
157}
158
159func (q GetPortfolioQuery) Run() (interface{}, *Error) {
160 marketConfig, err := db.GetUserMarketConfig(q.In.User.Id, q.In.Market)
161 if err != nil {
162 return nil, NewInternalError(err)
163 }
164
165 report, err := GetWeekPortfolio(*marketConfig)
166 if ErrorIs(err, NotFound) {
167 return nil, err.(*Error)
168 }
169
170 if err != nil {
171 return nil, NewInternalError(err)
172 }
173
174 return report.Round(), nil
175}
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 @@
1package api
2
3import (
4 "fmt"
5 "time"
6
7 "github.com/shopspring/decimal"
8 "immae.eu/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Front/db"
9)
10
11func init() {
12 decimal.MarshalJSONWithoutQuotes = true
13}
14
15const MARGIN_POSITION_SECURED_RATIO = 1.0
16
17const BTC_DIGITS = 3
18
19var MINIMAL_BTC_VALUE_TRESHOLD decimal.Decimal = decimal.NewFromFloat(10.0).Pow(decimal.NewFromFloat(-3.0))
20
21type ValuePerformance struct {
22 Value decimal.Decimal `json:"value"`
23 Variation decimal.Decimal `json:"variation"`
24 VariationP decimal.Decimal `json:"variationP"`
25}
26
27func NewValuePerformance(fromValue, toValue decimal.Decimal) ValuePerformance {
28 variation := toValue.Sub(fromValue)
29
30 return ValuePerformance{
31 Value: toValue,
32 Variation: variation,
33 VariationP: variation.Div(fromValue).Mul(decimal.NewFromFloat(100.0)),
34 }
35}
36
37type PositionType string
38
39const POSITION_SHORT PositionType = "short"
40const POSITION_LONG PositionType = "long"
41
42type PortfolioBalance struct {
43 PositionType PositionType `json:"positionType"`
44 Quantity decimal.Decimal `json:"quantity"`
45 QuantityLocked decimal.Decimal `json:"quantityLocked"`
46 BTCValue decimal.Decimal `json:"BTCValue"`
47 PositionPerformanceP decimal.Decimal `json:"positionPerformanceP"`
48 Weight decimal.Decimal `json:"weight"`
49}
50
51type Portfolio struct {
52 PeriodStart time.Time `json:"periodStart"`
53 PeriodEnd time.Time `json:"periodEnd"`
54 Balances map[string]PortfolioBalance `json:"balances"`
55 Value decimal.Decimal `json:"value"`
56 Performance ValuePerformance `json:"performance"`
57}
58
59func (p Portfolio) Round() Portfolio {
60 p.Value = p.Value.Round(BTC_DIGITS)
61 for currency := range p.Balances {
62 balance := p.Balances[currency]
63 balance.Quantity = balance.Quantity.Round(2)
64 balance.BTCValue = balance.BTCValue.Round(BTC_DIGITS)
65 balance.Weight = balance.Weight.Round(1)
66 balance.PositionPerformanceP = balance.PositionPerformanceP.Round(1)
67 p.Balances[currency] = balance
68 }
69
70 p.Performance.VariationP = p.Performance.VariationP.Round(1)
71 p.Performance.Variation = p.Performance.Variation.Round(BTC_DIGITS)
72 p.Performance.Value = p.Performance.Value.Round(BTC_DIGITS)
73 return p
74}
75
76func GetCurrenciesPerformance(from, to db.ReportTickers) map[string]ValuePerformance {
77 performances := make(map[string]ValuePerformance)
78 currencies := make(map[string]struct{})
79 for currency := range to.Balances {
80 if to.Balances[currency].Abs().LessThan(MINIMAL_BTC_VALUE_TRESHOLD) {
81 continue
82 }
83 currencies[currency] = struct{}{}
84 }
85
86 for currency := range currencies {
87 performances[currency] = NewValuePerformance(from.GetBTCRate(currency), to.GetBTCRate(currency))
88 }
89
90 return performances
91}
92
93func UserMovements(from db.Report, to db.Report) (decimal.Decimal, error) {
94 if from.Tag != db.BUY_END || (to.Tag != db.SELL_BEGIN && to.Tag != db.INTERMADIATE_STATE) {
95 return decimal.Zero, fmt.Errorf("cannot compare reports: '%s' -> '%s'", from.Tag, to.Tag)
96 }
97
98 var deltaBTC decimal.Decimal
99
100 currencies := make(map[string]struct{})
101 for currency := range to.Balances {
102 currencies[currency] = struct{}{}
103 }
104 for currency := range from.Balances {
105 currencies[currency] = struct{}{}
106 }
107
108 for currency := range currencies {
109 balanceFrom := from.Balances[currency]
110 balanceTo := to.Balances[currency]
111
112 delta := balanceTo.Total.Sub(balanceFrom.Total)
113 if !delta.Equals(decimal.Zero) {
114 deltaBTC = deltaBTC.Add(delta.Mul(to.Tickers.GetBTCRate(currency)).Neg())
115 }
116
117 }
118
119 return deltaBTC, nil
120}
121
122// Computes plus-value, ignoring positions took by users.
123func ComputePlusValue(from db.Report, to db.Report) (decimal.Decimal, error) {
124 if from.Tag != db.BUY_END || (to.Tag != db.SELL_BEGIN && to.Tag != db.INTERMADIATE_STATE) {
125 return decimal.Zero, fmt.Errorf("cannot compare reports: '%s' -> '%s'", from.Tag, to.Tag)
126 }
127
128 diff, err := UserMovements(from, to)
129 if err != nil {
130 return decimal.Zero, err
131 }
132
133 return to.Tickers.Total.Sub(from.Tickers.Total).Add(diff), nil
134}
135
136func ComputeWeights(report db.Report) map[string]decimal.Decimal {
137 weights := make(map[string]decimal.Decimal)
138
139 for currency := range report.Balances {
140
141 if report.Tickers.Balances[currency].Abs().LessThan(MINIMAL_BTC_VALUE_TRESHOLD) {
142 continue
143 }
144
145 quantityBlocked := report.Balances[currency].MarginInPosition.Mul(decimal.NewFromFloat(1.0 + MARGIN_POSITION_SECURED_RATIO))
146 weights[currency] = report.Tickers.Balances[currency].
147 Sub(quantityBlocked.Mul(report.Tickers.GetBTCRate(currency))).
148 Abs().
149 Div(report.Tickers.Total).
150 Mul(decimal.NewFromFloat(100))
151 }
152
153 return weights
154}
155
156func GetWeekPortfolio(marketConfig db.MarketConfig) (Portfolio, error) {
157 portfolio := Portfolio{
158 Balances: make(map[string]PortfolioBalance),
159 }
160
161 report, err := db.GetLastPortfolioBegin(marketConfig)
162 if err != nil {
163 return portfolio, err
164 }
165
166 if report == nil {
167 return portfolio, &Error{NotFound, "no report", fmt.Errorf("no reports for marketConfigId '%v'", marketConfig.Id)}
168 }
169
170 liveReport, err := db.GetLatestReport(marketConfig)
171 if err != nil {
172 return portfolio, err
173 }
174
175 weights := ComputeWeights(*report)
176 currenciesPerformances := GetCurrenciesPerformance(report.Tickers, liveReport.Tickers)
177
178 portfolio.PeriodStart = report.Date.Truncate(time.Second).UTC()
179 portfolio.PeriodEnd = liveReport.Date.Truncate(time.Second).UTC()
180
181 for currency := range liveReport.Balances {
182 balance := liveReport.Balances[currency]
183 btcTicker := liveReport.Tickers.Balances[currency]
184
185 if btcTicker.Abs().LessThan(MINIMAL_BTC_VALUE_TRESHOLD) {
186 continue
187 }
188
189 var positionType PositionType
190 var perfMul decimal.Decimal
191
192 if balance.Total.LessThan(decimal.Zero) {
193 positionType = POSITION_SHORT
194 perfMul = decimal.NewFromFloat(-1.0)
195 } else {
196 positionType = POSITION_LONG
197 perfMul = decimal.NewFromFloat(1.0)
198 }
199
200 portfolio.Balances[currency] = PortfolioBalance{
201 PositionType: positionType,
202 Quantity: balance.Total,
203 BTCValue: btcTicker,
204 QuantityLocked: balance.MarginInPosition.Mul(decimal.NewFromFloat(1.0 + MARGIN_POSITION_SECURED_RATIO)),
205 Weight: weights[currency],
206 PositionPerformanceP: currenciesPerformances[currency].VariationP.Mul(perfMul),
207 }
208 }
209
210 portfolio.Value = liveReport.Tickers.Total
211 plusValue, err := ComputePlusValue(*report, liveReport)
212 if err != nil {
213 return portfolio, err
214 }
215
216 portfolio.Performance = NewValuePerformance(liveReport.Tickers.Total.Sub(plusValue), liveReport.Tickers.Total)
217
218 return portfolio, nil
219}
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{
43 []Route{ 43 []Route{
44 {"GET", []gin.HandlerFunc{GetMarketConfig}, "/:name"}, 44 {"GET", []gin.HandlerFunc{GetMarketConfig}, "/:name"},
45 {"POST", []gin.HandlerFunc{UpdateMarketConfig}, "/:name/update"}, 45 {"POST", []gin.HandlerFunc{UpdateMarketConfig}, "/:name/update"},
46 {"GET", []gin.HandlerFunc{GetMarketBalance}, "/:name/balance"}, 46 {"GET", []gin.HandlerFunc{TestMarketCredentials}, "/:name/test-credentials"},
47 {"GET", []gin.HandlerFunc{GetPortfolio}, "/:name/portfolio"},
47 }, 48 },
48 }, 49 },
49} 50}
@@ -114,12 +115,20 @@ func GetMarketConfig(c *gin.Context) {
114 RunQuery(query, c) 115 RunQuery(query, c)
115} 116}
116 117
117func GetMarketBalance(c *gin.Context) { 118func TestMarketCredentials(c *gin.Context) {
118 query := &MarketBalanceQuery{} 119 query := &TestMarketCredentialsQuery{}
120
121 query.In.User = GetUser(c)
122 query.In.Market = c.Param("name")
123
124 RunQuery(query, c)
125}
126
127func GetPortfolio(c *gin.Context) {
128 query := &GetPortfolioQuery{}
119 129
120 query.In.User = GetUser(c) 130 query.In.User = GetUser(c)
121 query.In.Market = c.Param("name") 131 query.In.Market = c.Param("name")
122 query.In.Currency = c.Query("currency")
123 132
124 RunQuery(query, c) 133 RunQuery(query, c)
125} 134}
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
4 4
5SRC_DIR=js 5SRC_DIR=js
6BUILD_DIR=build/js 6BUILD_DIR=build/js
7JSX_SRC= header_footer.jsx main.jsx signup.jsx signin.jsx otp.jsx poloniex.jsx password_reset.jsx change_password.jsx account.jsx 7JSX_SRC= header_footer.jsx main.jsx signup.jsx signin.jsx otp.jsx poloniex.jsx password_reset.jsx change_password.jsx account.jsx balance.jsx
8JS_SRC= cookies.js app.js api.js 8JS_SRC= cookies.js app.js api.js
9STATIC_FILES= index.html style.css fontello.css 9STATIC_FILES= index.html style.css fontello.css
10STATIC_FILES+=$(addprefix fonts/, fontello.eot fontello.svg fontello.ttf fontello.woff fontello.woff2) 10STATIC_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 {
8 } 8 }
9 9
10 checkCredentials = () => { 10 checkCredentials = () => {
11 Api.Call('MARKET_BALANCE', {'name': 'poloniex', 'currency': 'BTC'}, function(err, status, data) { 11 Api.Call('MARKET_TEST_CREDENTIALS', {'name': 'poloniex'}, function(err, status, data) {
12 if (err) { 12 if (err) {
13 console.error(err, data); 13 console.error(err, data);
14 if (err.code === 'invalid_market_credentials') { 14 if (err.code === 'invalid_market_credentials') {
@@ -28,13 +28,6 @@ class PoloniexConfiguration extends React.Component {
28 } 28 }
29 29
30 handleCredentialsSubmit = () => { 30 handleCredentialsSubmit = () => {
31
32 /*
33 *If (!this.state.apiKey || !this.state.apiSecret) {
34 * return;
35 *}
36 */
37
38 Api.Call('UPDATE_MARKET', {'key': this.state.apiKey, 'secret': this.state.apiSecret, 'name': 'poloniex'}, function(err, status, data) { 31 Api.Call('UPDATE_MARKET', {'key': this.state.apiKey, 'secret': this.state.apiSecret, 'name': 'poloniex'}, function(err, status, data) {
39 if (err) { 32 if (err) {
40 console.error(err, data); 33 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 = {
74 return '/market/' + params.name; 74 return '/market/' + params.name;
75 } 75 }
76 }, 76 },
77 'MARKET_BALANCE': { 77 'MARKET_TEST_CREDENTIALS': {
78 'type': 'GET', 78 'type': 'GET',
79 'auth': true, 79 'auth': true,
80 'parameters': [ 80 'parameters': [
81 {'name': 'name', 'mandatory': true, 'inquery': false}, 81 {'name': 'name', 'mandatory': true, 'inquery': false},
82 {'name': 'currency', 'mandatory': true, 'inquery': true}, 82 ],
83 'buildUrl': function(params) {
84 return '/market/' + params.name + '/test-credentials';
85 }
86 },
87 'MARKET_GET_PORTFOLIO': {
88 'type': 'GET',
89 'auth': true,
90 'parameters': [
91 {'name': 'name', 'mandatory': true, 'inquery': false},
83 ], 92 ],
84 'buildUrl': function(params) { 93 'buildUrl': function(params) {
85 return '/market/' + params.name + '/balance'; 94 return '/market/' + params.name + '/portfolio';
86 } 95 }
87 }, 96 },
88 'UPDATE_MARKET': { 97 '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 @@
1import React from 'react';
2import moment from 'moment';
3
4class CurrencyLogo extends React.Component {
5 render = () => {
6 return <div className="d-inline-block h-100">
7 <img className="currency-logo align-top"
8 src={'/public/icons/black/' + this.props.currency.toLowerCase() + '.svg' }
9 title={this.props.currency}
10 alt={this.props.currency} />
11 </div>;
12 }
13 }
14
15 var formatVariation = (variation) => {
16 var variationAbs = Math.abs(variation);
17 if (variation === 0.0) {
18 return <span>{variationAbs}%</span>;
19 } else if (variation > 0) {
20 return <span className="performance-up">+{variationAbs}%</span>;
21 }
22 return <span className="performance-down">-{variationAbs}%</span>;
23};
24
25
26class CurrencyRateHeader extends React.Component {
27 render = () => {
28 return <React.Fragment>
29 <div className="row text-center">
30 <div className="d-inline col-2">Asset</div>
31 <div className="d-inline col-2">Position</div>
32 <div className="d-inline col-2">Qty</div>
33 <div className="d-inline col-2">Value (BTC)</div>
34 <div className="d-inline col-2">Weight</div>
35 <div className="d-inline col-2">Perf %</div>
36 </div>
37 </React.Fragment>;
38 }
39}
40
41class CurrencyRate extends React.Component {
42 render = () => {
43 return <React.Fragment>
44 <div className="row text-center">
45 <div className="d-inline col-2 text-left"><CurrencyLogo currency={this.props.currency} /><span>{this.props.currency}</span></div>
46 <div className="d-inline col-2">{this.props.positionType}</div>
47 <div className="d-inline col-2">{this.props.quantity}</div>
48 <div className="d-inline col-2">{this.props.BTCValue}</div>
49 <div className="d-inline col-2">{this.props.weight}%</div>
50 <div className="d-inline col-2">{formatVariation(this.props.positionPerformanceP)}</div>
51 </div>
52 </React.Fragment>;
53 }
54}
55
56class Assets extends React.Component {
57 render = () => {
58 var currencies = Object.keys(this.props.balances).map(function(currency) {
59 var balance = this.props.balances[currency];
60 balance.currency = currency;
61 return <div className="row" key={currency}>
62 <div className="col-12"><CurrencyRate {...balance} /></div>
63 </div>;
64 }.bind(this));
65
66 return <div className="assets">
67 <CurrencyRateHeader />
68 {currencies}
69 </div>;
70 }
71}
72
73class PFBalance extends React.Component {
74 render = () => {
75 var date = moment(this.props.periodStart).format('MMM Do, h:mma');
76 return <React.Fragment>
77 <div className="h-100 align-self-center balance">
78 <div className="row">
79 <div className="col-8">
80 Current balance
81 </div>
82 <div className="col-4 text-center h-100 btcValue">
83 <CurrencyLogo currency="BTC" /> <span className="h-100 align-middle"><strong>{this.props.balance}</strong></span>
84 </div>
85 </div>
86 <div className="row">
87 <div className="col-8">
88 <em>since {date}</em>
89 </div>
90 <div className="col-4 variation text-center">
91 <strong>{formatVariation(this.props.variationP)}</strong>
92 </div>
93 </div>
94 </div>
95 </React.Fragment>;
96 }
97}
98
99export {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 @@
1import Api from './api.js'; 1import Api from './api.js';
2import React from 'react'; 2import React from 'react';
3import {PFBalance, Assets} from './balance.js';
3 4
4class PoloniexController extends React.Component { 5class PoloniexController extends React.Component {
5 constructor(props) { 6 constructor(props) {
6 super(props); 7 super(props);
7 this.state = {'flag': 'loading', 'valueCurrency': null, 'balanceValue': null, 'balance': null}; 8 this.state = {'flag': 'loading', 'periodStart': null, 'variationP': null, 'balance': null, 'balances': null};
8 } 9 }
9 10
10 loadBalance = () => { 11 testCredentials = () => {
11 Api.Call('MARKET_BALANCE', {'name': 'poloniex', 'currency': 'BTC'}, function(err, status, data) { 12 Api.Call('MARKET_TEST_CREDENTIALS', {'name': 'poloniex'}, function(err, status, data) {
12 if (err) { 13 if (err) {
13 console.error(err, data); 14 console.error(err, data);
14 if (err.code === 'invalid_market_credentials') { 15 if (err.code === 'invalid_market_credentials') {
15 this.setState({'flag': 'invalidCredentials', 'valueCurrency': null, 'balanceValue': null, 'balance': null}); 16 this.setState({'flag': 'invalidCredentials', 'variationP': null, 'balance': null, 'balances': null, 'periodStart': null});
16 } else if (err.code === 'ip_restricted_api_key') { 17 } else if (err.code === 'ip_restricted_api_key') {
17 this.setState({'flag': 'ipRestricted', 'valueCurrency': null, 'balanceValue': null, 'balance': null}); 18 this.setState({'flag': 'ipRestricted', 'variationP': null, 'balance': null, 'balances': null, 'periodStart': null});
18 } 19 }
19 return; 20 return;
20 } 21 }
21 22
22 this.setState({'flag': 'ok', 'valueCurrency': data.valueCurrency, 'balanceValue': data.value, 'balance': data.balance}); 23 this.loadPortfolio();
24 }.bind(this));
25 }
26
27 loadPortfolio = () => {
28 Api.Call('MARKET_GET_PORTFOLIO', {'name': 'poloniex'}, function(err, status, data) {
29 if (err) {
30 console.error(err, data);
31 if (err.code === 'not_found') {
32 this.setState({'flag': 'noReport', 'variationP': null, 'balance': null, 'balances': null, 'periodStart': null});
33 }
34 return;
35 }
36
37 this.setState({'flag': 'ok', 'variationP': data.performance.variationP, 'balance': data.value, 'balances': data.balances, 'periodStart': data.periodStart});
23 }.bind(this)); 38 }.bind(this));
24 } 39 }
25 40
26 componentDidMount = () => { 41 componentDidMount = () => {
27 this.loadBalance(); 42 this.testCredentials();
28 } 43 }
29 44
30 render = () => { 45 render = () => {
@@ -33,10 +48,13 @@ class PoloniexController extends React.Component {
33 case 'loading': 48 case 'loading':
34 displayText = 'Loading data from poloniex...'; 49 displayText = 'Loading data from poloniex...';
35 break; 50 break;
51 case 'noReport':
52 displayText = 'Your account is setup ! Reporting will start next Monday !';
53 break;
36 case 'invalidCredentials': 54 case 'invalidCredentials':
37 case 'ipRestricted': 55 case 'ipRestricted':
38 case 'emptyCredentials': 56 case 'emptyCredentials':
39 displayText = <div>Please provide poloniex credentials in <a href="/account">Account</a> page.</div>; 57 displayText = <div>Please provide poloniex credentials in <a href="/account"><u>Account</u></a> page.</div>;
40 break; 58 break;
41 default: 59 default:
42 displayText = null; 60 displayText = null;
@@ -44,59 +62,55 @@ class PoloniexController extends React.Component {
44 return ( 62 return (
45 <div> 63 <div>
46 <PoloniexBalance balanceCurrency={this.state.valueCurrency} 64 <PoloniexBalance balanceCurrency={this.state.valueCurrency}
47 balanceValue={this.state.balanceValue} 65 variationP={this.state.variationP}
66 periodStart={this.state.periodStart}
48 balance={this.state.balance} 67 balance={this.state.balance}
68 balances={this.state.balances}
49 displayText={displayText}/> 69 displayText={displayText}/>
50 </div> 70 </div>
51 ); 71 );
52 } 72 }
53} 73}
54 74
55class CurrencyLogo extends React.Component { 75class Panel extends React.Component {
56 render = () => { 76 render = () => {
57 return <img className="currency-logo" 77 if (this.props.component === null) {
58 src={'/public/icons/black/' + this.props.currency.toLowerCase() + '.svg' } 78 return <div></div>;
59 title={this.props.currency} 79 }
60 alt={this.props.currency} />; 80
81 return (
82 <div className="row">
83 <div className="box col-12">
84 <div className="row">
85 <div className="col-4">{this.props.title}</div>
86 </div>
87 <hr/>
88 {this.props.component}
89 </div>
90 </div>);
61 } 91 }
62} 92}
63 93
64class PoloniexBalance extends React.Component { 94class PoloniexBalance extends React.Component {
65 constructor(props) {
66 super(props);
67 this.state = {'hideMsg': true, 'msg': '', 'msgOk': false};
68 }
69
70 computeCurrencyRatio = (currency) => {
71 return (parseFloat(this.props.balance[currency].btcValue) / parseFloat(this.props.balanceValue) * 100.0).toFixed(1);
72 }
73 95
74 render = () => { 96 render = () => {
75 var dashboard = null; 97 var balancePanel = null;
76 98 var assetsPanel = null;
77 if (this.props.balanceValue !== null) { 99 var messagePanel = null;
78 100
79 var balance = Object.keys(this.props.balance).map(function(currency) { 101 if (this.props.variationP !== null) {
80 return <div key={currency}> 102 balancePanel =
81 <div>
82 <CurrencyLogo currency={currency} /> {this.props.balance[currency].amount} {currency} ({this.computeCurrencyRatio(currency)}%)
83 </div>
84 </div>;
85 }.bind(this));
86
87 dashboard =
88 <div className="row"> 103 <div className="row">
89 <div className="col-6 align-self-center h-100 balances"> 104 <div className="col-12 offset-md-1 col-md-10 align-self-center h-100 balances">
90 {balance} 105 <PFBalance variationP={this.props.variationP} balance={this.props.balance} periodStart={this.props.periodStart}/>
91 </div>
92 <div className="offset-1 col-5 h-100 align-self-center">
93 <div className="text-center">
94 Balance ({this.props.balanceCurrency}): <span>{this.props.balanceValue}</span><CurrencyLogo currency={this.props.balanceCurrency} />
95 </div>
96 </div> 106 </div>
97 </div>; 107 </div>;
108
109 assetsPanel =
110 <Assets balances={this.props.balances} />;
111
98 } else { 112 } else {
99 dashboard = 113 messagePanel =
100 <div className="row"> 114 <div className="row">
101 <div className="col-12 text-center"> 115 <div className="col-12 text-center">
102 <span>{this.props.displayText}</span> 116 <span>{this.props.displayText}</span>
@@ -105,15 +119,11 @@ class PoloniexBalance extends React.Component {
105 } 119 }
106 120
107 return ( 121 return (
108 <div className="row"> 122 <React.Fragment>
109 <div className="box offset-2 col-8 portfolio"> 123 <Panel title="Balance" component={balancePanel}/>
110 <div className="row"> 124 <Panel title="Assets" component={assetsPanel}/>
111 <div className="col-4">Portfolio</div> 125 <Panel title="Balance" component={messagePanel}/>
112 </div> 126 </React.Fragment>
113 <hr/>
114 {dashboard}
115 </div>
116 </div>
117 ); 127 );
118 } 128 }
119} 129}
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 @@
12 "classnames": "^2.2.5", 12 "classnames": "^2.2.5",
13 "debowerify": "^1.3.1", 13 "debowerify": "^1.3.1",
14 "localenvify": "^1.0.1", 14 "localenvify": "^1.0.1",
15 "moment": "^2.22.1",
15 "page": "^1.8.3", 16 "page": "^1.8.3",
16 "path-to-regexp": "^1.2.1", 17 "path-to-regexp": "^1.2.1",
17 "qs": "^6.5.1", 18 "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 {
54 margin-top: 5px; 54 margin-top: 5px;
55} 55}
56 56
57.performance-up {
58 color: green;
59}
60
61.performance-down {
62 color: red;
63}
64
57.sign-in .form-control { 65.sign-in .form-control {
58 margin-bottom: 20px; 66 margin-bottom: 20px;
59 border-radius: 2px; 67 border-radius: 2px;
@@ -63,16 +71,12 @@ h1 {
63 71
64.config-status { 72.config-status {
65 margin-bottom: 10px; 73 margin-bottom: 10px;
66 font-size: 0.9em;
67} 74}
68 75
69.config-status .icon-cancel-circled { 76.config-status .icon-cancel-circled {}
70 color: red; 77
71} 78.config-status .icon-ok-circled {}
72 79
73.config-status .icon-ok-circled {
74 color: green;
75}
76.config-status i { 80.config-status i {
77 font-size: 1.2em; 81 font-size: 1.2em;
78} 82}
@@ -123,13 +127,27 @@ h1 {
123 display: inline-block; 127 display: inline-block;
124 margin-left: 5px; 128 margin-left: 5px;
125 margin-right: 5px; 129 margin-right: 5px;
126} 130
127
128.portfolio .currency-logo {
129 height: 24px; 131 height: 24px;
130 width: 24px; 132 width: 24px;
131} 133}
132 134
135.balance > .btcValue {
136 font-size: 1.2em;
137}
138
139.balance > .variation {
140 font-size: 1.2em;
141}
142
143.balance > .row {
144 margin-bottom: 10px;
145}
146
147.assets > .row {
148 margin-bottom: 10px;
149}
150
133.balances > div { 151.balances > div {
134 margin-bottom: 5px; 152 margin-bottom: 5px;
135} 153}
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:
3078 through2 "^2.0.0" 3078 through2 "^2.0.0"
3079 xtend "^4.0.0" 3079 xtend "^4.0.0"
3080 3080
3081moment@^2.22.1:
3082 version "2.22.1"
3083 resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.1.tgz#529a2e9bf973f259c9643d237fda84de3a26e8ad"
3084
3081mout@~0.9.0: 3085mout@~0.9.0:
3082 version "0.9.1" 3086 version "0.9.1"
3083 resolved "https://registry.yarnpkg.com/mout/-/mout-0.9.1.tgz#84f0f3fd6acc7317f63de2affdcc0cee009b0477" 3087 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 @@
1package db
2
3import (
4 "encoding/json"
5 "fmt"
6 "time"
7
8 "github.com/go-redis/redis"
9 "github.com/shopspring/decimal"
10)
11
12type PortfolioStep string
13
14const SELL_BEGIN PortfolioStep = "process_sell_all__1_all_sell_begin"
15const SELL_END PortfolioStep = "process_sell_all__1_all_sell_end"
16const BUY_BEGIN PortfolioStep = "process_sell_all__3_all_buy_begin"
17const BUY_END PortfolioStep = "process_sell_all__3_all_buy_end"
18const INTERMADIATE_STATE PortfolioStep = "process_print_balances__1_print_balances_begin"
19
20type ViewBalances struct {
21 Id int64
22 Date time.Time
23 ReportId int64
24 MarketId int64
25 Payload struct {
26 Report
27 }
28}
29
30type Report struct {
31 Tag PortfolioStep
32 Date time.Time
33 Balances ReportBalances
34 Tickers ReportTickers
35}
36
37type ReportTickers struct {
38 Currency string
39 Balances map[string]decimal.Decimal
40 Rates BTCRates
41 Total decimal.Decimal
42}
43
44func (r ReportTickers) GetBTCRate(currency string) decimal.Decimal {
45 if currency == "BTC" {
46 return decimal.NewFromFloat(1.0)
47 }
48
49 return r.Rates[currency]
50}
51
52type BTCRates map[string]decimal.Decimal
53
54func (b BTCRates) Rate(currency string) decimal.Decimal {
55 if currency == "BTC" {
56 return decimal.NewFromFloat(1.0)
57 }
58
59 return b[currency]
60}
61
62type ReportBalances map[string]struct {
63 Total decimal.Decimal
64 MarginTotal decimal.Decimal `json:"margin_total"`
65 MarginInPosition decimal.Decimal `json:"margin_in_position"`
66 MarginAvailable decimal.Decimal `json:"margin_available"`
67}
68
69func RedisReportKey(marketConfigId int64, timestamp, entity string) string {
70 return fmt.Sprintf("/cryptoportfolio/%v/%v/%v", marketConfigId, timestamp, entity)
71}
72
73func GetLatestReport(marketConfig MarketConfig) (Report, error) {
74 var reportPayload Report
75 var err error
76 var key string
77
78 // Get balance.
79 key = RedisReportKey(marketConfig.Id, "latest", "balance")
80
81 payload, err := Redis.Get(key).Bytes()
82 if err == redis.Nil {
83 return Report{}, fmt.Errorf("cannot find '%s' redis key", key)
84 } else if err != nil {
85 return Report{}, err
86 }
87
88 // Get date.
89 key = RedisReportKey(marketConfig.Id, "latest", "date")
90 dateString, err := Redis.Get(key).Result()
91
92 if err == redis.Nil {
93 return Report{}, fmt.Errorf("cannot find '%s' redis key", key)
94 } else if err != nil {
95 return Report{}, err
96 }
97
98 reportPayload.Date, err = time.Parse("2006-01-02T15:04:05", dateString)
99 if err != nil {
100 return Report{}, err
101 }
102
103 err = json.Unmarshal(payload, &reportPayload)
104
105 return reportPayload, err
106}
107
108func GetPortfolioMilestones(marketConfig MarketConfig, step PortfolioStep, limit int) ([]Report, error) {
109 viewBalances := make([]ViewBalances, 0)
110 reports := make([]Report, 0)
111
112 err := DB.
113 Model(&viewBalances).
114 Where("market_id = ?", marketConfig.Id).
115 Where("payload @> ?", fmt.Sprintf(`{"tag": "%s", "checkpoint": "%s"}`, step, "begin")).
116 OrderExpr("date DESC").Limit(limit).Select()
117 if err != nil {
118 return nil, err
119 }
120
121 for _, reportLine := range viewBalances {
122 reportLine.Payload.Report.Date = reportLine.Date
123 reports = append(reports, reportLine.Payload.Report)
124 }
125
126 return reports, nil
127}
128
129func GetLastPortfolioBegin(marketConfig MarketConfig) (*Report, error) {
130 reports, err := GetPortfolioMilestones(marketConfig, BUY_END, 1)
131 if err != nil {
132 return nil, nil
133 }
134
135 if len(reports) == 0 {
136 return nil, nil
137 }
138
139 return &reports[0], nil
140}
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 {
52 } 52 }
53} 53}
54 54
55func (p *Poloniex) TestCredentials(apiKey, apiSecret string) error {
56 client, _ := poloniex.NewClient(apiKey, apiSecret)
57
58 _, err := client.TradeReturnDepositAdresses()
59
60 if poloniexInvalidCredentialsError(err) {
61 return utils.Error{InvalidCredentials, "invalid poloniex credentials"}
62 }
63
64 if poloniexRestrictedIPError(err) {
65 return utils.Error{IPRestricted, "IP restricted api key"}
66 }
67
68 return nil
69}
70
55func (p *Poloniex) GetBalance(apiKey, apiSecret string) (Summary, error) { 71func (p *Poloniex) GetBalance(apiKey, apiSecret string) (Summary, error) {
56 client, _ := poloniex.NewClient(apiKey, apiSecret) 72 client, _ := poloniex.NewClient(apiKey, apiSecret)
57 var summary Summary 73 var summary Summary