package cointop import ( "fmt" "math" "sort" "sync" "time" "github.com/cointop-sh/cointop/pkg/chartplot" "github.com/cointop-sh/cointop/pkg/timedata" "github.com/cointop-sh/cointop/pkg/timeutil" "github.com/cointop-sh/cointop/pkg/ui" log "github.com/sirupsen/logrus" ) // PriceData is the time-series data for a Coin used when building a Portfolio view for chart type PriceData struct { coin *Coin data [][]float64 } // ChartView is structure for chart view type ChartView = ui.View // NewChartView returns a new chart view func NewChartView() *ChartView { return ui.NewView("chart") } var chartLock sync.Mutex var chartPointsLock sync.Mutex // ChartRanges returns list of chart ranges available func ChartRanges() []string { return []string{ "24H", "3D", "7D", "1M", "3M", "6M", "1Y", "YTD", "All Time", } } // ChartRangesMap returns map of chart range time ranges func ChartRangesMap() map[string]time.Duration { return map[string]time.Duration{ "All Time": 10 * 365 * 24 * time.Hour, "YTD": 1 * time.Second, // this will be calculated "1Y": 365 * 24 * time.Hour, "6M": 365 / 2 * 24 * time.Hour, "3M": 365 / 4 * 24 * time.Hour, "1M": 365 / 12 * 24 * time.Hour, "7D": 24 * 7 * time.Hour, "3D": 24 * 3 * time.Hour, "24H": 24 * time.Hour, "6H": 6 * time.Hour, "1H": 1 * time.Hour, } } // UpdateChart updates the chart view func (ct *Cointop) UpdateChart() error { log.Debug("UpdateChart()") chartLock.Lock() defer chartLock.Unlock() if ct.IsPortfolioVisible() { if err := ct.PortfolioChart(); err != nil { return err } } else { symbol := ct.SelectedCoinSymbol() name := ct.SelectedCoinName() ct.ChartPoints(symbol, name) } var body string if len(ct.State.chartPoints) == 0 { body = "\n\n\n\n\nnot enough data for chart" } else { for i := range ct.State.chartPoints { var s string for j := range ct.State.chartPoints[i] { p := ct.State.chartPoints[i][j] s = fmt.Sprintf("%s%c", s, p) } body = fmt.Sprintf("%s%s\n", body, s) } } ct.UpdateUI(func() error { ct.Views.Chart.Clear() return ct.Views.Chart.Update(ct.colorscheme.Chart(body)) }) return nil } // ChartPoints calculates the chart points func (ct *Cointop) ChartPoints(symbol string, name string) error { log.Debug("ChartPoints()") maxX := ct.ChartWidth() chartPointsLock.Lock() defer chartPointsLock.Unlock() // TODO: not do this (SoC) go ct.UpdateMarketbar() chart := chartplot.NewChartPlot() chart.SetHeight(ct.State.chartHeight) rangeseconds := ct.chartRangesMap[ct.State.selectedChartRange] if ct.State.selectedChartRange == "YTD" { ytd := time.Now().Unix() - timeutil.BeginningOfYear().Unix() rangeseconds = time.Duration(ytd) * time.Second } now := time.Now() nowseconds := now.Unix() start := nowseconds - int64(rangeseconds.Seconds()) end := nowseconds var cacheData [][]float64 keyname := symbol if keyname == "" { keyname = "globaldata" } cachekey := ct.CompositeCacheKey(keyname, name, ct.State.currencyConversion, ct.State.selectedChartRange) cached, found := ct.cache.Get(cachekey) if found { // cache hit cacheData, _ = cached.([][]float64) log.Debug("ChartPoints() soft cache hit") } if len(cacheData) == 0 { if symbol == "" { convert := ct.State.currencyConversion graphData, err := ct.api.GetGlobalMarketGraphData(convert, start, end) if err != nil { return nil } cacheData = graphData.MarketCapByAvailableSupply } else { convert := ct.State.currencyConversion graphData, err := ct.api.GetCoinGraphData(convert, symbol, name, start, end) if err != nil { return nil } sorted := graphData.Price sort.Slice(sorted[:], func(i, j int) bool { return sorted[i][0] < sorted[j][0] }) cacheData = sorted } ct.cache.Set(cachekey, cacheData, 10*time.Second) if ct.filecache != nil { go func() { ct.filecache.Set(cachekey, cacheData, 24*time.Hour) }() } } var labels []string var data []float64 timeQuantum := timedata.CalculateTimeQuantum(cacheData) // will be 0 if <2 points if timeQuantum > 0 { // Resample cachedata newStart := cacheData[0][0] // use the first data point newEnd := time.Unix(end, 0).Add(-timeQuantum) timeData := timedata.ResampleTimeSeriesData(cacheData, newStart, float64(newEnd.UnixMilli()), chart.GetChartDataSize(maxX)) labels = timedata.BuildTimeSeriesLabels(timeData) // Extract just the values from the data for i := range timeData { value := timeData[i][1] if math.IsNaN(value) { value = 0.0 } data = append(data, value) } } chart.SetData(data) chart.SetDataLabels(labels) ct.State.chartPoints = chart.GetChartPoints(maxX) return nil } // PortfolioChart renders the portfolio chart func (ct *Cointop) PortfolioChart() error { log.Debug("PortfolioChart()") maxX := ct.ChartWidth() chartPointsLock.Lock() defer chartPointsLock.Unlock() // TODO: not do this (SoC) go ct.UpdateMarketbar() chart := chartplot.NewChartPlot() chart.SetHeight(ct.State.chartHeight) convert := ct.State.currencyConversion // cache here selectedChartRange := ct.State.selectedChartRange // cache here rangeseconds := ct.chartRangesMap[selectedChartRange] if selectedChartRange == "YTD" { ytd := time.Now().Unix() - timeutil.BeginningOfYear().Unix() rangeseconds = time.Duration(ytd) * time.Second } now := time.Now() nowseconds := now.Unix() start := nowseconds - int64(rangeseconds.Seconds()) end := nowseconds var allCacheData []PriceData portfolio := ct.GetPortfolioSlice() chartname := ct.SelectedCoinName() for _, p := range portfolio { // filter by selected chart if selected if chartname != "" { if chartname != p.Name { continue } } if p.Holdings <= 0 { continue } var cacheData [][]float64 // [][time,value] cachekey := ct.CompositeCacheKey(p.Symbol, p.Name, convert, selectedChartRange) cached, found := ct.cache.Get(cachekey) if found { // cache hit cacheData, _ = cached.([][]float64) log.Debug("PortfolioChart() soft cache hit") } else { if ct.filecache != nil { ct.filecache.Get(cachekey, &cacheData) } if len(cacheData) == 0 { time.Sleep(2 * time.Second) apiGraphData, err := ct.api.GetCoinGraphData(convert, p.Symbol, p.Name, start, end) if err != nil { return err } cacheData = apiGraphData.Price sort.Slice(cacheData[:], func(i, j int) bool { return cacheData[i][0] < cacheData[j][0] }) } ct.cache.Set(cachekey, cacheData, 10*time.Second) if ct.filecache != nil { go func() { ct.filecache.Set(cachekey, cacheData, 24*time.Hour) }() } } allCacheData = append(allCacheData, PriceData{p, cacheData}) } // Use the gap between price samples to adjust start/end in by one interval var timeQuantum time.Duration for _, cacheData := range allCacheData { timeQuantum = timedata.CalculateTimeQuantum(cacheData.data) if timeQuantum != 0 { break // use the first one } } // If there is data, resample and sum var data []float64 var labels []string if timeQuantum > 0 { newStart := time.Unix(start, 0).Add(timeQuantum) newEnd := time.Unix(end, 0).Add(-timeQuantum) // Resample and sum data for i, cacheData := range allCacheData { coinData := timedata.ResampleTimeSeriesData(cacheData.data, float64(newStart.UnixMilli()), float64(newEnd.UnixMilli()), chart.GetChartDataSize(maxX)) if i == 0 { labels = timedata.BuildTimeSeriesLabels(coinData) } // sum (excluding NaN) for i := range coinData { price := coinData[i][1] if math.IsNaN(price) { price = 0.0 } sum := cacheData.coin.Holdings * price if i < len(data) { data[i] += sum } else { data = append(data, sum) } } } // Scale Portfolio Balances to hide value if ct.State.hidePortfolioBalances { var lastPrice = data[len(data)-1] if lastPrice > 0.0 { for i, price := range data { data[i] = 100 * price / lastPrice } } } } chart.SetData(data) chart.SetDataLabels(labels) ct.State.chartPoints = chart.GetChartPoints(maxX) return nil } // ShortenChart decreases the chart height by one row func (ct *Cointop) ShortenChart() error { log.Debug("ShortenChart()") candidate := ct.State.chartHeight - 1 if candidate < 5 { return nil } ct.State.chartHeight = candidate ct.State.lastChartHeight = ct.State.chartHeight if err := ct.Save(); err != nil { return err } go ct.UpdateChart() return nil } // EnlargeChart increases the chart height by one row func (ct *Cointop) EnlargeChart() error { log.Debug("EnlargeChart()") candidate := ct.State.lastChartHeight + 1 if candidate > 30 { return nil } ct.State.chartHeight = candidate ct.State.lastChartHeight = ct.State.chartHeight if err := ct.Save(); err != nil { return err } go ct.UpdateChart() return nil } // NextChartRange sets the chart to the next range option func (ct *Cointop) NextChartRange() error { log.Debug("NextChartRange()") sel := 0 max := len(ct.chartRanges) for i, k := range ct.chartRanges { if k == ct.State.selectedChartRange { sel = i + 1 break } } if sel > max-1 { sel = 0 } ct.State.selectedChartRange = ct.chartRanges[sel] go ct.UpdateChart() return nil } // PrevChartRange sets the chart to the prevous range option func (ct *Cointop) PrevChartRange() error { log.Debug("PrevChartRange()") sel := 0 for i, k := range ct.chartRanges { if k == ct.State.selectedChartRange { sel = i - 1 break } } if sel < 0 { sel = len(ct.chartRanges) - 1 } ct.State.selectedChartRange = ct.chartRanges[sel] go ct.UpdateChart() return nil } // FirstChartRange sets the chart to the first range option func (ct *Cointop) FirstChartRange() error { log.Debug("FirstChartRange()") ct.State.selectedChartRange = ct.chartRanges[0] go ct.UpdateChart() return nil } // LastChartRange sets the chart to the last range option func (ct *Cointop) LastChartRange() error { log.Debug("LastChartRange()") ct.State.selectedChartRange = ct.chartRanges[len(ct.chartRanges)-1] go ct.UpdateChart() return nil } // ToggleCoinChart toggles between the global chart and the coin chart func (ct *Cointop) ToggleCoinChart() error { log.Debug("ToggleCoinChart()") highlightedcoin := ct.HighlightedRowCoin() if ct.State.selectedCoin == highlightedcoin { ct.State.selectedCoin = nil } else { ct.State.selectedCoin = highlightedcoin } go func() { // keep these two synchronous to avoid race conditions ct.ShowChartLoader() ct.UpdateChart() }() // TODO: not do this (SoC) go ct.UpdateMarketbar() return nil } // ShowChartLoader shows chart loading indicator func (ct *Cointop) ShowChartLoader() error { log.Debug("ShowChartLoader()") ct.UpdateUI(func() error { content := "\n\nLoading..." return ct.Views.Chart.Update(ct.colorscheme.Chart(content)) }) return nil } // ChartWidth returns the width for chart func (ct *Cointop) ChartWidth() int { log.Debug("ChartWidth()") w := ct.Width() max := ct.State.maxChartWidth if max > 0 && w > max { return max } return w } // ToggleChartFullscreen toggles the chart fullscreen mode func (ct *Cointop) ToggleChartFullscreen() error { log.Debug("ToggleChartFullscreen()") ct.State.onlyChart = !ct.State.onlyChart ct.State.onlyTable = false if !ct.State.onlyChart { // NOTE: cached values are initial config settings. // If the only-chart config was set then toggle // all other initial hidden views. onlyChart, _ := ct.cache.Get("onlyChart") if onlyChart.(bool) { ct.State.hideMarketbar = false ct.State.hideChart = false ct.State.hideTable = false ct.State.hideStatusbar = false } else { // NOTE: cached values store initial hidden views preferences. hideMarketbar, _ := ct.cache.Get("hideMarketbar") ct.State.hideMarketbar = hideMarketbar.(bool) hideChart, _ := ct.cache.Get("hideChart") ct.State.hideChart = hideChart.(bool) hideTable, _ := ct.cache.Get("hideTable") ct.State.hideTable = hideTable.(bool) hideStatusbar, _ := ct.cache.Get("hideStatusbar") ct.State.hideStatusbar = hideStatusbar.(bool) } } go func() { ct.UpdateTable() ct.UpdateChart() }() return nil }