diff --git a/cointop/chart.go b/cointop/chart.go index f0094c7..9c64a2a 100644 --- a/cointop/chart.go +++ b/cointop/chart.go @@ -2,17 +2,25 @@ package cointop import ( "fmt" + "math" "sort" "strings" "sync" "time" "github.com/miguelmota/cointop/pkg/chartplot" + "github.com/miguelmota/cointop/pkg/timedata" "github.com/miguelmota/cointop/pkg/timeutil" "github.com/miguelmota/cointop/pkg/ui" log "github.com/sirupsen/logrus" ) +// 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 @@ -121,7 +129,7 @@ func (ct *Cointop) ChartPoints(symbol string, name string) error { start := nowseconds - int64(rangeseconds.Seconds()) end := nowseconds - var data []float64 + var cacheData [][]float64 keyname := symbol if keyname == "" { @@ -132,21 +140,18 @@ func (ct *Cointop) ChartPoints(symbol string, name string) error { cached, found := ct.cache.Get(cachekey) if found { // cache hit - data, _ = cached.([]float64) + cacheData, _ = cached.([][]float64) log.Debug("ChartPoints() soft cache hit") } - if len(data) == 0 { + if len(cacheData) == 0 { if symbol == "" { convert := ct.State.currencyConversion graphData, err := ct.api.GetGlobalMarketGraphData(convert, start, end) if err != nil { return nil } - for i := range graphData.MarketCapByAvailableSupply { - price := graphData.MarketCapByAvailableSupply[i][1] - data = append(data, price) - } + cacheData = graphData.MarketCapByAvailableSupply } else { convert := ct.State.currencyConversion graphData, err := ct.api.GetCoinGraphData(convert, symbol, name, start, end) @@ -157,20 +162,37 @@ func (ct *Cointop) ChartPoints(symbol string, name string) error { sort.Slice(sorted[:], func(i, j int) bool { return sorted[i][0] < sorted[j][0] }) - for i := range sorted { - price := sorted[i][1] - data = append(data, price) - } + cacheData = sorted } - ct.cache.Set(cachekey, data, 10*time.Second) + ct.cache.Set(cachekey, cacheData, 10*time.Second) if ct.filecache != nil { go func() { - ct.filecache.Set(cachekey, data, 24*time.Hour) + ct.filecache.Set(cachekey, cacheData, 24*time.Hour) }() } } + // Resample cachedata + maxPoints := len(cacheData) + if maxPoints > 2*maxX { + maxPoints = 2 * maxX + } + timeQuantum := timedata.CalculateTimeQuantum(cacheData) + newStart := time.Unix(start, 0).Add(timeQuantum) + newEnd := time.Unix(end, 0).Add(-timeQuantum) + timeData := timedata.ResampleTimeSeriesData(cacheData, float64(newStart.UnixMilli()), float64(newEnd.UnixMilli()), maxPoints) + + // Extract just the values from the data + var data []float64 + for i := range timeData { + value := timeData[i][1] + if math.IsNaN(value) { + value = 0.0 + } + data = append(data, value) + } + chart.SetData(data) ct.State.chartPoints = chart.GetChartPoints(maxX) @@ -203,7 +225,7 @@ func (ct *Cointop) PortfolioChart() error { start := nowseconds - int64(rangeseconds.Seconds()) end := nowseconds - var data []float64 + var allCacheData []PriceData portfolio := ct.GetPortfolioSlice() chartname := ct.SelectedCoinName() for _, p := range portfolio { @@ -218,46 +240,76 @@ func (ct *Cointop) PortfolioChart() error { continue } - var graphData []float64 + var cacheData [][]float64 // [][time,value] cachekey := strings.ToLower(fmt.Sprintf("%s_%s_%s", p.Symbol, convert, strings.Replace(selectedChartRange, " ", "", -1))) cached, found := ct.cache.Get(cachekey) if found { // cache hit - graphData, _ = cached.([]float64) + cacheData, _ = cached.([][]float64) log.Debug("PortfolioChart() soft cache hit") } else { if ct.filecache != nil { - ct.filecache.Get(cachekey, &graphData) + ct.filecache.Get(cachekey, &cacheData) } - if len(graphData) == 0 { + 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 } - sorted := apiGraphData.Price - sort.Slice(sorted[:], func(i, j int) bool { - return sorted[i][0] < sorted[j][0] + + cacheData = apiGraphData.Price + sort.Slice(cacheData[:], func(i, j int) bool { + return cacheData[i][0] < cacheData[j][0] }) - for i := range sorted { - price := sorted[i][1] - graphData = append(graphData, price) - } } - ct.cache.Set(cachekey, graphData, 10*time.Second) + ct.cache.Set(cachekey, cacheData, 10*time.Second) if ct.filecache != nil { go func() { - ct.filecache.Set(cachekey, graphData, 24*time.Hour) + ct.filecache.Set(cachekey, cacheData, 24*time.Hour) }() } } - for i := range graphData { - price := graphData[i] - sum := p.Holdings * price + allCacheData = append(allCacheData, PriceData{p, cacheData}) + } + + // Calculate how many data points to provide to the chart. Limit maxPoints to 2*maxX + maxPoints := 0 + for _, cacheData := range allCacheData { + if len(cacheData.data) > maxPoints { + maxPoints = len(cacheData.data) + } + } + if maxPoints > 2*maxX { + maxPoints = 2 * maxX + } + + // 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 + } + } + newStart := time.Unix(start, 0).Add(timeQuantum) + newEnd := time.Unix(end, 0).Add(-timeQuantum) + + // Resample and sum data + var data []float64 + for _, cacheData := range allCacheData { + coinData := timedata.ResampleTimeSeriesData(cacheData.data, float64(newStart.UnixMilli()), float64(newEnd.UnixMilli()), maxPoints) + // 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 { diff --git a/pkg/timedata/timedata.go b/pkg/timedata/timedata.go new file mode 100644 index 0000000..c605fdc --- /dev/null +++ b/pkg/timedata/timedata.go @@ -0,0 +1,51 @@ +package timedata + +import ( + "math" + "sort" + "time" + + log "github.com/sirupsen/logrus" +) + +// Resample the [timestamp,value] data given to numsteps between start-end (returns numSteps+1 points). +// If the data does not extend past start/end then there will likely be NaN in the output data. +func ResampleTimeSeriesData(data [][]float64, start float64, end float64, numSteps int) [][]float64 { + var newData [][]float64 + l := len(data) + step := (end - start) / float64(numSteps) + for pos := start; pos <= end; pos += step { + idx := sort.Search(l, func(i int) bool { return data[i][0] >= pos }) + var val float64 + if idx == 0 { + val = math.NaN() // off the left + } else if idx == l { + val = math.NaN() // off the right + } else { + // between two points - linear interpolation + left := data[idx-1] + right := data[idx] + dvdt := (right[1] - left[1]) / (right[0] - left[0]) + val = left[1] + (pos-left[0])*dvdt + } + newData = append(newData, []float64{pos, val}) + } + return newData +} + +// Assuming that the [timestamp,value] data provided is roughly evenly spaced, calculate that interval. +func CalculateTimeQuantum(data [][]float64) time.Duration { + if len(data) > 1 { + minTime := time.UnixMilli(int64(data[0][0])) + maxTime := time.UnixMilli(int64(data[len(data)-1][0])) + return time.Duration(int64(maxTime.Sub(minTime)) / int64(len(data)-1)) + } + return 0 +} + +// Print out all the [timestamp,value] data provided +func DebugLogPriceData(data [][]float64) { + for i := range data { + log.Debugf("%s %.2f", time.Unix(int64(data[i][0]/1000), 0), data[i][1]) + } +}