Add support for purchase price/currency to portfolio (#243)

* Add support for declaring a BuyPrice and BuyCurrency in portfolio.
eg: ["Algorand", "125.4", "0.8", "USD"]

Add optional (default off) columns to portfolio:
"buy_price", "buy_currency", "profit", "profit_percent"

Note: there is no UI for entering this yet.
pull/250/head
Simon Roberts 3 years ago committed by GitHub
parent 0a5ba717d8
commit b5b68833f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -69,6 +69,9 @@ func ActionsMap() map[string]bool {
"move_down_or_next_page": true,
"show_price_alert_add_menu": true,
"sort_column_balance": true,
"sort_column_cost": true,
"sort_column_pnl": true,
"sort_column_pnl_percent": true,
}
}

@ -23,8 +23,10 @@ type Coin struct {
// for favorites
Favorite bool
// for portfolio
Holdings float64
Balance float64
Holdings float64
Balance float64
BuyPrice float64
BuyCurrency string
}
// AllCoins returns a slice of all the coins

@ -126,8 +126,10 @@ type Cointop struct {
// PortfolioEntry is portfolio entry
type PortfolioEntry struct {
Coin string
Holdings float64
Coin string
Holdings float64
BuyPrice float64
BuyCurrency string
}
// Portfolio is portfolio structure

@ -229,9 +229,12 @@ func (ct *Cointop) ConfigToToml() ([]byte, error) {
if !ok || entry.Coin == "" {
continue
}
amount := strconv.FormatFloat(entry.Holdings, 'f', -1, 64)
coinName := entry.Coin
tuple := []string{coinName, amount}
tuple := []string{
entry.Coin,
strconv.FormatFloat(entry.Holdings, 'f', -1, 64),
strconv.FormatFloat(entry.BuyPrice, 'f', -1, 64),
entry.BuyCurrency,
}
holdingsIfc = append(holdingsIfc, tuple)
}
sort.Slice(holdingsIfc, func(i, j int) bool {
@ -597,33 +600,7 @@ func (ct *Cointop) loadPortfolioFromConfig() error {
}
}
} else if key == "holdings" {
holdingsIfc, ok := valueIfc.([]interface{})
if !ok {
continue
}
for _, itemIfc := range holdingsIfc {
tupleIfc, ok := itemIfc.([]interface{})
if !ok {
continue
}
if len(tupleIfc) > 2 {
continue
}
name, ok := tupleIfc[0].(string)
if !ok {
continue
}
holdings, err := ct.InterfaceToFloat64(tupleIfc[1])
if err != nil {
return nil
}
if err := ct.SetPortfolioEntry(name, holdings); err != nil {
return err
}
}
// Defer until the end to work around premature-save issue
} else if key == "compact_notation" {
ct.State.portfolioCompactNotation = valueIfc.(bool)
} else {
@ -633,12 +610,64 @@ func (ct *Cointop) loadPortfolioFromConfig() error {
return err
}
if err := ct.SetPortfolioEntry(key, holdings); err != nil {
if err := ct.SetPortfolioEntry(key, holdings, 0.0, ""); err != nil {
return err
}
}
}
// Process holdings last because it causes a ct.Save()
if valueIfc, ok := ct.config.Portfolio["holdings"]; ok {
if holdingsIfc, ok := valueIfc.([]interface{}); ok {
ct.loadPortfolioHoldingsFromConfig(holdingsIfc)
}
}
return nil
}
func (ct *Cointop) loadPortfolioHoldingsFromConfig(holdingsIfc []interface{}) error {
for _, itemIfc := range holdingsIfc {
tupleIfc, ok := itemIfc.([]interface{})
if !ok {
continue
}
if len(tupleIfc) > 4 {
continue
}
name, ok := tupleIfc[0].(string)
if !ok {
continue // was not a string
}
holdings, err := ct.InterfaceToFloat64(tupleIfc[1])
if err != nil {
return err // was not a float64
}
buyPrice := 0.0
if len(tupleIfc) >= 3 {
if parsePrice, err := ct.InterfaceToFloat64(tupleIfc[2]); err != nil {
return err
} else {
buyPrice = parsePrice
}
}
buyCurrency := ""
if len(tupleIfc) >= 4 {
if parseCurrency, ok := tupleIfc[3].(string); !ok {
return err // was not a string
} else {
buyCurrency = parseCurrency
}
}
// Watch out - this calls ct.Save() which may save a half-loaded configuration
if err := ct.SetPortfolioEntry(name, holdings, buyPrice, buyCurrency); err != nil {
return err
}
}
return nil
}

@ -12,7 +12,7 @@ import (
log "github.com/sirupsen/logrus"
)
// FiatCurrencyNames is a mpa of currency symbols to names.
// FiatCurrencyNames is a map of currency symbols to names.
// Keep these in alphabetical order.
var FiatCurrencyNames = map[string]string{
"AUD": "Australian Dollar",
@ -301,3 +301,20 @@ func CurrencySymbol(currency string) string {
return "?"
}
func (ct *Cointop) Convert(convertFrom string, convertTo string, amount float64) (float64, error) {
convertFrom = strings.ToLower(convertFrom)
convertTo = strings.ToLower(convertTo)
var rate float64
if convertFrom == convertTo {
rate = 1.0
} else {
crate, err := ct.api.GetExchangeRate(convertFrom, convertTo, true)
if err != nil {
return 0, err
}
rate = crate
}
return rate * amount, nil
}

@ -85,5 +85,8 @@ func DefaultShortcuts() map[string]string {
"<": "scroll_left",
"+": "show_price_alert_add_menu",
"\\\\": "toggle_table_fullscreen",
"!": "sort_column_cost",
"@": "sort_column_pnl",
"#": "sort_column_pnl_percent",
}
}

@ -325,6 +325,12 @@ func (ct *Cointop) SetKeybindingAction(shortcutKey string, action string) error
fn = ct.Keyfn(ct.CursorDownOrNextPage)
case "move_up_or_previous_page":
fn = ct.Keyfn(ct.CursorUpOrPreviousPage)
case "sort_column_cost":
fn = ct.Sortfn("cost", true)
case "sort_column_pnl":
fn = ct.Sortfn("profit", true)
case "sort_column_pnl_percent":
fn = ct.Sortfn("profit_percent", true)
default:
fn = ct.Keyfn(ct.Noop)
}

@ -35,6 +35,10 @@ var SupportedPortfolioTableHeaders = []string{
"1y_change",
"percent_holdings",
"last_updated",
"cost_price",
"cost",
"profit",
"profit_percent",
}
// DefaultPortfolioTableHeaders are the default portfolio table header columns
@ -301,6 +305,118 @@ func (ct *Cointop) GetPortfolioTable() *table.Table {
Color: ct.colorscheme.TableRow,
Text: lastUpdated,
})
case "cost_price":
text := fmt.Sprintf("%s %s", coin.BuyCurrency, ct.FormatPrice(coin.BuyPrice))
if ct.State.hidePortfolioBalances {
text = HiddenBalanceChars
}
if coin.BuyPrice == 0.0 || coin.BuyCurrency == "" {
text = ""
}
symbolPadding := 1
ct.SetTableColumnWidth(header, utf8.RuneCountInString(text)+symbolPadding)
ct.SetTableColumnAlignLeft(header, false)
rowCells = append(rowCells,
&table.RowCell{
LeftMargin: leftMargin,
RightMargin: rightMargin,
LeftAlign: false,
Color: ct.colorscheme.TableRow,
Text: text,
})
case "cost":
cost := 0.0
if coin.BuyPrice > 0 && coin.BuyCurrency != "" {
costPrice, err := ct.Convert(coin.BuyCurrency, ct.State.currencyConversion, coin.BuyPrice)
if err == nil {
cost = costPrice * coin.Holdings
}
}
// text := ct.FormatPrice(cost)
text := humanize.FixedMonetaryf(cost, 2)
if ct.State.hidePortfolioBalances {
text = HiddenBalanceChars
}
if coin.BuyPrice == 0.0 {
text = ""
}
symbolPadding := 1
ct.SetTableColumnWidth(header, utf8.RuneCountInString(text)+symbolPadding)
ct.SetTableColumnAlignLeft(header, false)
rowCells = append(rowCells,
&table.RowCell{
LeftMargin: leftMargin,
RightMargin: rightMargin,
LeftAlign: false,
Color: ct.colorscheme.TableColumnPrice,
Text: text,
})
case "profit":
text := ""
colorProfit := ct.colorscheme.TableColumnChange
if coin.BuyPrice > 0 && coin.BuyCurrency != "" {
costPrice, err := ct.Convert(coin.BuyCurrency, ct.State.currencyConversion, coin.BuyPrice)
if err == nil {
profit := (coin.Price - costPrice) * coin.Holdings
text = humanize.FixedMonetaryf(profit, 2)
if profit > 0 {
colorProfit = ct.colorscheme.TableColumnChangeUp
} else if profit < 0 {
colorProfit = ct.colorscheme.TableColumnChangeDown
}
} else {
text = "?"
}
}
if ct.State.hidePortfolioBalances {
text = HiddenBalanceChars
colorProfit = ct.colorscheme.TableColumnChange
}
symbolPadding := 1
ct.SetTableColumnWidth(header, utf8.RuneCountInString(text)+symbolPadding)
ct.SetTableColumnAlignLeft(header, false)
rowCells = append(rowCells,
&table.RowCell{
LeftMargin: leftMargin,
RightMargin: rightMargin,
LeftAlign: false,
Color: colorProfit,
Text: text,
})
case "profit_percent":
profitPercent := 0.0
if coin.BuyPrice > 0 && coin.BuyCurrency != "" {
costPrice, err := ct.Convert(coin.BuyCurrency, ct.State.currencyConversion, coin.BuyPrice)
if err == nil {
profitPercent = 100 * (coin.Price/costPrice - 1)
}
}
colorProfit := ct.colorscheme.TableColumnChange
if profitPercent > 0 {
colorProfit = ct.colorscheme.TableColumnChangeUp
} else if profitPercent < 0 {
colorProfit = ct.colorscheme.TableColumnChangeDown
}
text := fmt.Sprintf("%.2f%%", profitPercent)
if ct.State.hidePortfolioBalances {
text = HiddenBalanceChars
colorProfit = ct.colorscheme.TableColumnChange
}
if coin.BuyPrice == 0.0 {
text = ""
}
ct.SetTableColumnWidthFromString(header, text)
ct.SetTableColumnAlignLeft(header, false)
rowCells = append(rowCells,
&table.RowCell{
LeftMargin: leftMargin,
RightMargin: rightMargin,
LeftAlign: false,
Color: colorProfit,
Text: text,
})
}
}
@ -456,8 +572,12 @@ func (ct *Cointop) SetPortfolioHoldings() error {
}
shouldDelete := holdings == 0
// TODO: add fields to form, parse here
buyPrice := 0.0
buyCurrency := ""
idx := ct.GetPortfolioCoinIndex(coin)
if err := ct.SetPortfolioEntry(coin.Name, holdings); err != nil {
if err := ct.SetPortfolioEntry(coin.Name, holdings, buyPrice, buyCurrency); err != nil {
return err
}
@ -503,7 +623,7 @@ func (ct *Cointop) PortfolioEntry(c *Coin) (*PortfolioEntry, bool) {
}
// SetPortfolioEntry sets a portfolio entry
func (ct *Cointop) SetPortfolioEntry(coin string, holdings float64) error {
func (ct *Cointop) SetPortfolioEntry(coin string, holdings float64, buyPrice float64, buyCurrency string) error {
log.Debug("SetPortfolioEntry()")
ic, _ := ct.State.allCoinsSlugMap.Load(strings.ToLower(coin))
c, _ := ic.(*Coin)
@ -511,8 +631,10 @@ func (ct *Cointop) SetPortfolioEntry(coin string, holdings float64) error {
if isNew {
key := strings.ToLower(coin)
ct.State.portfolio.Entries[key] = &PortfolioEntry{
Coin: coin,
Holdings: holdings,
Coin: coin,
Holdings: holdings,
BuyPrice: buyPrice,
BuyCurrency: buyCurrency,
}
} else {
p.Holdings = holdings
@ -564,6 +686,8 @@ func (ct *Cointop) GetPortfolioSlice() []*Coin {
continue
}
coin.Holdings = p.Holdings
coin.BuyPrice = p.BuyPrice
coin.BuyCurrency = p.BuyCurrency
balance := coin.Price * p.Holdings
balancestr := fmt.Sprintf("%.2f", balance)
if ct.State.currencyConversion == "ETH" || ct.State.currencyConversion == "BTC" {
@ -688,6 +812,7 @@ func (ct *Cointop) PrintHoldingsTable(options *TablePrintOptions) error {
records := make([][]string, len(holdings))
symbol := ct.CurrencySymbol()
// TODO: buy_price, buy_currency, profit, profit_percent, etc
headers := []string{"name", "symbol", "price", "holdings", "balance", "24h%", "%holdings"}
if len(filterCols) > 0 {
for _, col := range filterCols {

@ -68,6 +68,14 @@ func (ct *Cointop) Sort(sortBy string, desc bool, list []*Coin, renderHeaders bo
return a.AvailableSupply < b.AvailableSupply
case "last_updated":
return a.LastUpdated < b.LastUpdated
case "cost_price":
return a.BuyPrice < b.BuyPrice
case "cost":
return (a.BuyPrice * a.Holdings) < (b.BuyPrice * b.Holdings) // TODO: convert?
case "profit":
return (a.Price - a.BuyPrice) < (b.Price - b.BuyPrice)
case "profit_percent":
return (a.Price - a.BuyPrice) < (b.Price - b.BuyPrice)
default:
return a.Rank < b.Rank
}

@ -126,6 +126,26 @@ var HeaderColumns = map[string]*HeaderColumn{
Label: "last [u]pdated",
PlainLabel: "last updated",
},
"cost_price": {
Slug: "cost_price",
Label: "cost price",
PlainLabel: "cost price",
},
"cost": {
Slug: "cost",
Label: "cost[!]",
PlainLabel: "cost",
},
"profit": {
Slug: "profit",
Label: "PNL[@]",
PlainLabel: "PNL",
},
"profit_percent": {
Slug: "profit_percent",
Label: "PNL%[#]",
PlainLabel: "PNL%",
},
}
// GetLabel fetch the label to use for the heading (depends on configuration)
@ -211,7 +231,7 @@ func (ct *Cointop) UpdateTableHeader() error {
}
leftAlign := ct.GetTableColumnAlignLeft(col)
switch col {
case "price", "balance":
case "price", "balance", "profit", "cost":
label = fmt.Sprintf("%s%s", ct.CurrencySymbol(), label)
}
if leftAlign {
@ -265,6 +285,9 @@ func (ct *Cointop) SetTableColumnWidth(header string, width int) {
prev = prevIfc.(int)
} else {
hc := HeaderColumns[header]
if hc == nil {
log.Warnf("SetTableColumnWidth(%s) not found", header)
}
prev = utf8.RuneCountInString(ct.GetLabel(hc)) + 1
switch header {
case "price", "balance":

@ -184,6 +184,29 @@ draft: false
Your portfolio is autosaved after you edit holdings. You can also press <kbd>ctrl</kbd>+<kbd>s</kbd> to manually save your portfolio holdings to the config file.
## How do I include buy/cost price in my portfolio?
Currently there is no UI for this. If you want to include the cost of your coins in the Portfolio screen, you will need to edit your config.toml
Each coin consists of four values: coin name, coin amount, cost-price, cost-currency.
For example, the following configuration includes 100 ALGO at USD1.95 each; and 0.1 BTC at AUD50100.83 each.
```toml
holdings = [["Algorand", "100", "1.95", "USD"], ["Bitcoin", "0.1", "50100.83", "AUD"]]
```
With this configuration, four new columns are useful:
- `cost_price` the price and currency that the coins were purchased at
- `cost` the cost (in the current currency) of the coins
- `profit` the PNL of the coins (current value vs original cost)
- `profit_percent` the PNL of the coins as a fraction of the original cost
With the holdings above, and the currency set to GBP (British Pounds) cointop will look something like this:
![Screen Shot 2021-10-22 at 8 41 21 am](https://user-images.githubusercontent.com/122371/138361142-8e1f32b5-ca24-471d-a628-06968f07c65f.png)
## How do I hide my portfolio balances (private mode)?
You can run cointop with the `--hide-portfolio-balances` flag to hide portfolio balances or use the keyboard shortcut <kbd>Ctrl</kbd>+<kbd>space</kbd> on the portfolio page to toggle hide/show.
@ -505,4 +528,4 @@ draft: false
DEBUG=1 DEBUG_HTTP=1 cointop
```
If you set environment variable `DEBUG_FILE` you can explicitly provide a logfile location, rather than `/tmp/cointop.log`
If you set environment variable `DEBUG_FILE` you can explicitly provide a logfile location, rather than `/tmp/cointop.log`

@ -12,6 +12,7 @@ import (
apitypes "github.com/cointop-sh/cointop/pkg/api/types"
"github.com/cointop-sh/cointop/pkg/api/util"
gecko "github.com/cointop-sh/cointop/pkg/api/vendors/coingecko/v3"
"github.com/cointop-sh/cointop/pkg/api/vendors/coingecko/v3/types"
geckoTypes "github.com/cointop-sh/cointop/pkg/api/vendors/coingecko/v3/types"
)
@ -33,6 +34,7 @@ type Service struct {
maxResultsPerPage uint
maxPages uint
cacheMap sync.Map
cachedRates *types.ExchangeRatesItem
}
// NewCoinGecko new service
@ -146,6 +148,45 @@ func (s *Service) GetCoinGraphData(convert, symbol, name string, start, end int6
return ret, nil
}
// GetCachedExchangeRates returns an indefinitely cached set of exchange rates
func (s *Service) GetExchangeRates(cached bool) (*types.ExchangeRatesItem, error) {
if s.cachedRates == nil || !cached {
rates, err := s.client.ExchangeRates()
if err != nil {
return nil, err
}
s.cachedRates = rates
}
return s.cachedRates, nil
}
// GetExchangeRate gets the current excange rate between two currencies
func (s *Service) GetExchangeRate(convertFrom string, convertTo string, cached bool) (float64, error) {
convertFrom = strings.ToLower(convertFrom)
convertTo = strings.ToLower(convertTo)
if convertFrom == convertTo {
return 1.0, nil
}
rates, err := s.GetExchangeRates(cached)
if err != nil {
return 0, err
}
if rates == nil {
return 0, fmt.Errorf("expected rates, received nil")
}
// Combined rate is convertFrom->BTC->convertTo
fromRate, found := (*rates)[convertFrom]
if !found {
return 0, fmt.Errorf("unsupported currency conversion: %s", convertFrom)
}
toRate, found := (*rates)[convertTo]
if !found {
return 0, fmt.Errorf("unsupported currency conversion: %s", convertTo)
}
rate := toRate.Value / fromRate.Value
return rate, nil
}
// GetGlobalMarketGraphData gets global market graph data
func (s *Service) GetGlobalMarketGraphData(convert string, start int64, end int64) (apitypes.MarketGraph, error) {
days := strconv.Itoa(util.CalcDays(start, end))
@ -160,25 +201,10 @@ func (s *Service) GetGlobalMarketGraphData(convert string, start int64, end int6
}
// This API does not appear to support vs_currency and only returns USD, so use ExchangeRates to convert
rate := 1.0
if convertTo != "usd" {
rates, err := s.client.ExchangeRates()
if err != nil {
return ret, err
}
if rates == nil {
return ret, fmt.Errorf("expected rates, received nil")
}
// Combined rate is USD->BTC->other
btcRate, found := (*rates)[convertTo]
if !found {
return ret, fmt.Errorf("unsupported currency conversion: %s", convertTo)
}
usdRate, found := (*rates)["usd"]
if !found {
return ret, fmt.Errorf("unsupported currency conversion: usd")
}
rate = btcRate.Value / usdRate.Value
// TODO: watch out - this is not cached, so we hit the backend every time!
rate, err := s.GetExchangeRate("usd", convertTo, true)
if err != nil {
return ret, err
}
var marketCapUSD [][]float64

@ -430,3 +430,11 @@ func getChartInterval(start, end int64) string {
}
return interval
}
// GetExchangeRate gets the current excange rate between two currencies
func (s *Service) GetExchangeRate(convertFrom string, convertTo string, cached bool) (float64, error) {
if convertFrom == convertTo {
return 1.0, nil
}
return 0, fmt.Errorf("unsupported currency conversion: %s => %s", convertFrom, convertTo)
}

@ -16,4 +16,5 @@ type Interface interface {
CoinLink(name string) string
SupportedCurrencies() []string
Price(name string, convert string) (float64, error)
GetExchangeRate(convertFrom string, convertTo string, cached bool) (float64, error) // I don't love this caching
}

@ -34,6 +34,11 @@ func Monetaryf(value float64, precision int) string {
return f(value, precision, "LC_MONETARY", false)
}
// FixedMonetaryf produces a fixed-precision monetary-value string. See Monetaryf.
func FixedMonetaryf(value float64, precision int) string {
return f(value, precision, "LC_MONETARY", true)
}
// borrowed from go-locale/util.go
func splitLocale(locale string) (string, string) {
// Remove the encoding, if present

Loading…
Cancel
Save