2
0
mirror of https://github.com/miguelmota/cointop synced 2024-11-10 13:10:26 +00:00

Merge branch 'lyricnz-feature/portfolio-buy2'

This commit is contained in:
Miguel Mota 2021-10-24 21:58:16 -07:00
commit b921c091d6
No known key found for this signature in database
GPG Key ID: 67EC1161588A00F9
16 changed files with 456 additions and 67 deletions

View File

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

View File

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

View File

@ -92,6 +92,7 @@ type State struct {
tableCompactNotation bool tableCompactNotation bool
favoritesCompactNotation bool favoritesCompactNotation bool
portfolioCompactNotation bool portfolioCompactNotation bool
enableMouse bool
} }
// Cointop cointop // Cointop cointop
@ -125,8 +126,10 @@ type Cointop struct {
// PortfolioEntry is portfolio entry // PortfolioEntry is portfolio entry
type PortfolioEntry struct { type PortfolioEntry struct {
Coin string Coin string
Holdings float64 Holdings float64
BuyPrice float64
BuyCurrency string
} }
// Portfolio is portfolio structure // Portfolio is portfolio structure
@ -187,6 +190,9 @@ var DefaultChartRange = "1Y"
// DefaultCompactNotation ... // DefaultCompactNotation ...
var DefaultCompactNotation = false var DefaultCompactNotation = false
// DefaultEnableMouse ...
var DefaultEnableMouse = true
// DefaultMaxChartWidth ... // DefaultMaxChartWidth ...
var DefaultMaxChartWidth = 175 var DefaultMaxChartWidth = 175
@ -296,6 +302,7 @@ func NewCointop(config *Config) (*Cointop, error) {
SoundEnabled: true, SoundEnabled: true,
}, },
compactNotation: DefaultCompactNotation, compactNotation: DefaultCompactNotation,
enableMouse: DefaultEnableMouse,
tableCompactNotation: DefaultCompactNotation, tableCompactNotation: DefaultCompactNotation,
favoritesCompactNotation: DefaultCompactNotation, favoritesCompactNotation: DefaultCompactNotation,
portfolioCompactNotation: DefaultCompactNotation, portfolioCompactNotation: DefaultCompactNotation,
@ -488,7 +495,7 @@ func (ct *Cointop) Run() error {
defer ui.Close() defer ui.Close()
ui.SetInputEsc(true) ui.SetInputEsc(true)
ui.SetMouse(true) ui.SetMouse(ct.State.enableMouse)
ui.SetHighlight(true) ui.SetHighlight(true)
ui.SetManagerFunc(ct.layout) ui.SetManagerFunc(ct.layout)
if err := ct.SetKeybindings(); err != nil { if err := ct.SetKeybindings(); err != nil {

View File

@ -49,6 +49,7 @@ type ConfigFileConfig struct {
RefreshRate interface{} `toml:"refresh_rate"` RefreshRate interface{} `toml:"refresh_rate"`
CacheDir interface{} `toml:"cache_dir"` CacheDir interface{} `toml:"cache_dir"`
CompactNotation interface{} `toml:"compact_notation"` CompactNotation interface{} `toml:"compact_notation"`
EnableMouse interface{} `toml:"enable_mouse"`
Table map[string]interface{} `toml:"table"` Table map[string]interface{} `toml:"table"`
Chart map[string]interface{} `toml:"chart"` Chart map[string]interface{} `toml:"chart"`
} }
@ -72,6 +73,7 @@ func (ct *Cointop) SetupConfig() error {
ct.loadRefreshRateFromConfig, ct.loadRefreshRateFromConfig,
ct.loadCacheDirFromConfig, ct.loadCacheDirFromConfig,
ct.loadCompactNotationFromConfig, ct.loadCompactNotationFromConfig,
ct.loadEnableMouseFromConfig,
ct.loadPriceAlertsFromConfig, ct.loadPriceAlertsFromConfig,
ct.loadPortfolioFromConfig, ct.loadPortfolioFromConfig,
} }
@ -227,9 +229,12 @@ func (ct *Cointop) ConfigToToml() ([]byte, error) {
if !ok || entry.Coin == "" { if !ok || entry.Coin == "" {
continue continue
} }
amount := strconv.FormatFloat(entry.Holdings, 'f', -1, 64) tuple := []string{
coinName := entry.Coin entry.Coin,
tuple := []string{coinName, amount} strconv.FormatFloat(entry.Holdings, 'f', -1, 64),
strconv.FormatFloat(entry.BuyPrice, 'f', -1, 64),
entry.BuyCurrency,
}
holdingsIfc = append(holdingsIfc, tuple) holdingsIfc = append(holdingsIfc, tuple)
} }
sort.Slice(holdingsIfc, func(i, j int) bool { sort.Slice(holdingsIfc, func(i, j int) bool {
@ -289,6 +294,7 @@ func (ct *Cointop) ConfigToToml() ([]byte, error) {
Table: tableMapIfc, Table: tableMapIfc,
Chart: chartMapIfc, Chart: chartMapIfc,
CompactNotation: ct.State.compactNotation, CompactNotation: ct.State.compactNotation,
EnableMouse: ct.State.enableMouse,
} }
var b bytes.Buffer var b bytes.Buffer
@ -506,6 +512,16 @@ func (ct *Cointop) loadCompactNotationFromConfig() error {
return nil return nil
} }
// loadCompactNotationFromConfig loads compact-notation setting from config file to struct
func (ct *Cointop) loadEnableMouseFromConfig() error {
log.Debug("loadEnableMouseFromConfig()")
if enableMouse, ok := ct.config.EnableMouse.(bool); ok {
ct.State.enableMouse = enableMouse
}
return nil
}
// LoadAPIChoiceFromConfig loads API choices from config file to struct // LoadAPIChoiceFromConfig loads API choices from config file to struct
func (ct *Cointop) loadAPIChoiceFromConfig() error { func (ct *Cointop) loadAPIChoiceFromConfig() error {
log.Debug("loadAPIKeysFromConfig()") log.Debug("loadAPIKeysFromConfig()")
@ -584,33 +600,7 @@ func (ct *Cointop) loadPortfolioFromConfig() error {
} }
} }
} else if key == "holdings" { } else if key == "holdings" {
holdingsIfc, ok := valueIfc.([]interface{}) // Defer until the end to work around premature-save issue
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
}
}
} else if key == "compact_notation" { } else if key == "compact_notation" {
ct.State.portfolioCompactNotation = valueIfc.(bool) ct.State.portfolioCompactNotation = valueIfc.(bool)
} else { } else {
@ -620,12 +610,62 @@ func (ct *Cointop) loadPortfolioFromConfig() error {
return err return err
} }
if err := ct.SetPortfolioEntry(key, holdings); err != nil { if err := ct.SetPortfolioEntry(key, holdings, 0.0, ""); err != nil {
return err 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 buyPrice, err = ct.InterfaceToFloat64(tupleIfc[2]); err != nil {
return err
}
}
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 return nil
} }

View File

@ -12,7 +12,7 @@ import (
log "github.com/sirupsen/logrus" 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. // Keep these in alphabetical order.
var FiatCurrencyNames = map[string]string{ var FiatCurrencyNames = map[string]string{
"AUD": "Australian Dollar", "AUD": "Australian Dollar",
@ -301,3 +301,20 @@ func CurrencySymbol(currency string) string {
return "?" return "?"
} }
// Convert converts an amount to another currency type
func (ct *Cointop) Convert(convertFrom, convertTo string, amount float64) (float64, error) {
convertFrom = strings.ToLower(convertFrom)
convertTo = strings.ToLower(convertTo)
if convertFrom == convertTo {
return amount, nil
}
rate, err := ct.api.GetExchangeRate(convertFrom, convertTo, true)
if err != nil {
return 0, err
}
return rate * amount, nil
}

View File

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

View File

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

View File

@ -35,6 +35,10 @@ var SupportedPortfolioTableHeaders = []string{
"1y_change", "1y_change",
"percent_holdings", "percent_holdings",
"last_updated", "last_updated",
"cost_price",
"cost",
"pnl",
"pnl_percent",
} }
// DefaultPortfolioTableHeaders are the default portfolio table header columns // DefaultPortfolioTableHeaders are the default portfolio table header columns
@ -49,12 +53,23 @@ var DefaultPortfolioTableHeaders = []string{
"24h_change", "24h_change",
"7d_change", "7d_change",
"percent_holdings", "percent_holdings",
"cost_price",
"cost",
"pnl",
"pnl_percent",
"last_updated", "last_updated",
} }
// HiddenBalanceChars are the characters to show when hidding balances // HiddenBalanceChars are the characters to show when hidding balances
var HiddenBalanceChars = "********" var HiddenBalanceChars = "********"
var costColumns = map[string]bool{
"cost_price": true,
"cost": true,
"pnl": true,
"pnl_percent": true,
}
// ValidPortfolioTableHeader returns the portfolio table headers // ValidPortfolioTableHeader returns the portfolio table headers
func (ct *Cointop) ValidPortfolioTableHeader(name string) bool { func (ct *Cointop) ValidPortfolioTableHeader(name string) bool {
for _, v := range SupportedPortfolioTableHeaders { for _, v := range SupportedPortfolioTableHeaders {
@ -80,6 +95,25 @@ func (ct *Cointop) GetPortfolioTable() *table.Table {
headers := ct.GetPortfolioTableHeaders() headers := ct.GetPortfolioTableHeaders()
ct.ClearSyncMap(&ct.State.tableColumnWidths) ct.ClearSyncMap(&ct.State.tableColumnWidths)
ct.ClearSyncMap(&ct.State.tableColumnAlignLeft) ct.ClearSyncMap(&ct.State.tableColumnAlignLeft)
displayCostColumns := false
for _, coin := range ct.State.coins {
if coin.BuyPrice > 0 && coin.BuyCurrency != "" {
displayCostColumns = true
break
}
}
if !displayCostColumns {
filtered := make([]string, 0)
for _, header := range headers {
if _, ok := costColumns[header]; !ok {
filtered = append(filtered, header)
}
}
headers = filtered
}
for _, coin := range ct.State.coins { for _, coin := range ct.State.coins {
leftMargin := 1 leftMargin := 1
rightMargin := 1 rightMargin := 1
@ -301,6 +335,117 @@ func (ct *Cointop) GetPortfolioTable() *table.Table {
Color: ct.colorscheme.TableRow, Color: ct.colorscheme.TableRow,
Text: lastUpdated, Text: lastUpdated,
}) })
case "cost_price":
text := fmt.Sprintf("%s %s", coin.BuyCurrency, ct.FormatPrice(coin.BuyPrice))
if coin.BuyPrice == 0.0 || coin.BuyCurrency == "" {
text = ""
}
if ct.State.hidePortfolioBalances {
text = HiddenBalanceChars
}
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 := humanize.FixedMonetaryf(cost, 2)
if coin.BuyPrice == 0.0 {
text = ""
}
if ct.State.hidePortfolioBalances {
text = HiddenBalanceChars
}
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 "pnl":
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 "pnl_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 coin.BuyPrice == 0.0 {
text = ""
}
if ct.State.hidePortfolioBalances {
text = HiddenBalanceChars
colorProfit = ct.colorscheme.TableColumnChange
}
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 +601,12 @@ func (ct *Cointop) SetPortfolioHoldings() error {
} }
shouldDelete := holdings == 0 shouldDelete := holdings == 0
// TODO: add fields to form, parse here
buyPrice := 0.0
buyCurrency := ""
idx := ct.GetPortfolioCoinIndex(coin) 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 return err
} }
@ -503,7 +652,7 @@ func (ct *Cointop) PortfolioEntry(c *Coin) (*PortfolioEntry, bool) {
} }
// SetPortfolioEntry sets a portfolio entry // 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()") log.Debug("SetPortfolioEntry()")
ic, _ := ct.State.allCoinsSlugMap.Load(strings.ToLower(coin)) ic, _ := ct.State.allCoinsSlugMap.Load(strings.ToLower(coin))
c, _ := ic.(*Coin) c, _ := ic.(*Coin)
@ -511,8 +660,10 @@ func (ct *Cointop) SetPortfolioEntry(coin string, holdings float64) error {
if isNew { if isNew {
key := strings.ToLower(coin) key := strings.ToLower(coin)
ct.State.portfolio.Entries[key] = &PortfolioEntry{ ct.State.portfolio.Entries[key] = &PortfolioEntry{
Coin: coin, Coin: coin,
Holdings: holdings, Holdings: holdings,
BuyPrice: buyPrice,
BuyCurrency: buyCurrency,
} }
} else { } else {
p.Holdings = holdings p.Holdings = holdings
@ -564,6 +715,8 @@ func (ct *Cointop) GetPortfolioSlice() []*Coin {
continue continue
} }
coin.Holdings = p.Holdings coin.Holdings = p.Holdings
coin.BuyPrice = p.BuyPrice
coin.BuyCurrency = p.BuyCurrency
balance := coin.Price * p.Holdings balance := coin.Price * p.Holdings
balancestr := fmt.Sprintf("%.2f", balance) balancestr := fmt.Sprintf("%.2f", balance)
if ct.State.currencyConversion == "ETH" || ct.State.currencyConversion == "BTC" { if ct.State.currencyConversion == "ETH" || ct.State.currencyConversion == "BTC" {
@ -688,7 +841,7 @@ func (ct *Cointop) PrintHoldingsTable(options *TablePrintOptions) error {
records := make([][]string, len(holdings)) records := make([][]string, len(holdings))
symbol := ct.CurrencySymbol() symbol := ct.CurrencySymbol()
headers := []string{"name", "symbol", "price", "holdings", "balance", "24h%", "%holdings"} headers := []string{"name", "symbol", "price", "holdings", "balance", "24h%", "%holdings", "cost_price", "cost", "pnl", "pnl_percent"}
if len(filterCols) > 0 { if len(filterCols) > 0 {
for _, col := range filterCols { for _, col := range filterCols {
valid := false valid := false
@ -785,6 +938,70 @@ func (ct *Cointop) PrintHoldingsTable(options *TablePrintOptions) error {
if hideBalances { if hideBalances {
item[i] = HiddenBalanceChars item[i] = HiddenBalanceChars
} }
case "cost_price":
if entry.BuyPrice > 0 && entry.BuyCurrency != "" {
if humanReadable {
item[i] = fmt.Sprintf("%s %s", entry.BuyCurrency, ct.FormatPrice(entry.BuyPrice))
} else {
item[i] = fmt.Sprintf("%s %s", entry.BuyCurrency, strconv.FormatFloat(entry.BuyPrice, 'f', -1, 64))
}
}
if hideBalances {
item[i] = HiddenBalanceChars
}
case "cost":
if entry.BuyPrice > 0 && entry.BuyCurrency != "" {
costPrice, err := ct.Convert(entry.BuyCurrency, ct.State.currencyConversion, entry.BuyPrice)
if err == nil {
cost := costPrice * entry.Holdings
if humanReadable {
item[i] = fmt.Sprintf("%s%s", symbol, humanize.FixedMonetaryf(cost, 2))
} else {
item[i] = strconv.FormatFloat(cost, 'f', -1, 64)
}
} else {
item[i] = "?" // error
}
}
if hideBalances {
item[i] = HiddenBalanceChars
}
case "pnl":
if entry.BuyPrice > 0 && entry.BuyCurrency != "" {
costPrice, err := ct.Convert(entry.BuyCurrency, ct.State.currencyConversion, entry.BuyPrice)
if err == nil {
profit := (entry.Price - costPrice) * entry.Holdings
if humanReadable {
// TODO: if <0 "£-3.71" should be "-£3.71"?
item[i] = fmt.Sprintf("%s%s", symbol, humanize.FixedMonetaryf(profit, 2))
} else {
item[i] = strconv.FormatFloat(profit, 'f', -1, 64)
}
} else {
item[i] = "?" // error
}
}
if hideBalances {
item[i] = HiddenBalanceChars
}
case "pnl_percent":
if entry.BuyPrice > 0 && entry.BuyCurrency != "" {
costPrice, err := ct.Convert(entry.BuyCurrency, ct.State.currencyConversion, entry.BuyPrice)
if err == nil {
profitPercent := 100 * (entry.Price/costPrice - 1)
if humanReadable {
item[i] = fmt.Sprintf("%s%%", humanize.Numericf(profitPercent, 2))
} else {
item[i] = fmt.Sprintf("%.2f", profitPercent)
}
} else {
item[i] = "?" // error
}
}
if hideBalances {
item[i] = HiddenBalanceChars
}
} }
} }
records[i] = item records[i] = item

View File

@ -68,6 +68,14 @@ func (ct *Cointop) Sort(sortBy string, desc bool, list []*Coin, renderHeaders bo
return a.AvailableSupply < b.AvailableSupply return a.AvailableSupply < b.AvailableSupply
case "last_updated": case "last_updated":
return a.LastUpdated < b.LastUpdated 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 "pnl":
return (a.Price - a.BuyPrice) < (b.Price - b.BuyPrice)
case "pnl_percent":
return (a.Price - a.BuyPrice) < (b.Price - b.BuyPrice)
default: default:
return a.Rank < b.Rank return a.Rank < b.Rank
} }

View File

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

View File

@ -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. 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
- `pnl` the PNL of the coins (current value vs original cost)
- `pnl_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:
![portfolio profit and loss](https://user-images.githubusercontent.com/122371/138361142-8e1f32b5-ca24-471d-a628-06968f07c65f.png)
## How do I hide my portfolio balances (private mode)? ## 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. 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.

View File

@ -12,6 +12,7 @@ import (
apitypes "github.com/cointop-sh/cointop/pkg/api/types" apitypes "github.com/cointop-sh/cointop/pkg/api/types"
"github.com/cointop-sh/cointop/pkg/api/util" "github.com/cointop-sh/cointop/pkg/api/util"
gecko "github.com/cointop-sh/cointop/pkg/api/vendors/coingecko/v3" 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" geckoTypes "github.com/cointop-sh/cointop/pkg/api/vendors/coingecko/v3/types"
) )
@ -33,6 +34,7 @@ type Service struct {
maxResultsPerPage uint maxResultsPerPage uint
maxPages uint maxPages uint
cacheMap sync.Map cacheMap sync.Map
cachedRates *types.ExchangeRatesItem
} }
// NewCoinGecko new service // NewCoinGecko new service
@ -146,6 +148,45 @@ func (s *Service) GetCoinGraphData(convert, symbol, name string, start, end int6
return ret, nil 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, 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 // GetGlobalMarketGraphData gets global market graph data
func (s *Service) GetGlobalMarketGraphData(convert string, start int64, end int64) (apitypes.MarketGraph, error) { func (s *Service) GetGlobalMarketGraphData(convert string, start int64, end int64) (apitypes.MarketGraph, error) {
days := strconv.Itoa(util.CalcDays(start, end)) 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 // This API does not appear to support vs_currency and only returns USD, so use ExchangeRates to convert
rate := 1.0 // TODO: watch out - this is not cached, so we hit the backend every time!
if convertTo != "usd" { rate, err := s.GetExchangeRate("usd", convertTo, true)
rates, err := s.client.ExchangeRates() if err != nil {
if err != nil { return ret, err
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
} }
var marketCapUSD [][]float64 var marketCapUSD [][]float64

View File

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

View File

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

View File

@ -34,6 +34,11 @@ func Monetaryf(value float64, precision int) string {
return f(value, precision, "LC_MONETARY", false) 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 // borrowed from go-locale/util.go
func splitLocale(locale string) (string, string) { func splitLocale(locale string) (string, string) {
// Remove the encoding, if present // Remove the encoding, if present

View File

@ -38,12 +38,12 @@ func (ui *UI) SetBgColor(bgColor gocui.Attribute) {
// SetInputEsc enables the escape key // SetInputEsc enables the escape key
func (ui *UI) SetInputEsc(enabled bool) { func (ui *UI) SetInputEsc(enabled bool) {
ui.g.InputEsc = true ui.g.InputEsc = enabled
} }
// SetMouse enables the mouse // SetMouse enables the mouse
func (ui *UI) SetMouse(enabled bool) { func (ui *UI) SetMouse(enabled bool) {
ui.g.Mouse = true ui.g.Mouse = enabled
} }
// SetCursor enables the input field cursor // SetCursor enables the input field cursor
@ -53,7 +53,7 @@ func (ui *UI) SetCursor(enabled bool) {
// SetHighlight enables the highlight active state // SetHighlight enables the highlight active state
func (ui *UI) SetHighlight(enabled bool) { func (ui *UI) SetHighlight(enabled bool) {
ui.g.Highlight = true ui.g.Highlight = enabled
} }
// SetManagerFunc sets the function to call for rendering UI // SetManagerFunc sets the function to call for rendering UI