github.com/shoshinnikita/budget-manager@v0.7.1-0.20220131195411-8c46ff1c6778/internal/web/pages/statistics/cost_intervals.go (about)

     1  package statistics
     2  
     3  import (
     4  	"math"
     5  	"sort"
     6  
     7  	"github.com/ShoshinNikita/budget-manager/internal/db"
     8  	"github.com/ShoshinNikita/budget-manager/internal/pkg/money"
     9  )
    10  
    11  type CostInterval struct {
    12  	From money.Money `json:"from"`
    13  	To   money.Money `json:"to"`
    14  
    15  	Count int         `json:"count"`
    16  	Total money.Money `json:"total"`
    17  }
    18  
    19  func CalculateCostIntervals(spends []db.Spend, maxIntervalNumber int) (intervals []CostInterval) {
    20  	if len(spends) == 0 {
    21  		return nil
    22  	}
    23  
    24  	costs := extractSortedCosts(spends)
    25  	for intervalNumber := maxIntervalNumber; intervalNumber > 0; intervalNumber-- {
    26  		intervals = prepareIntervals(costs, intervalNumber)
    27  		intervals = fillIntervals(costs, intervals)
    28  
    29  		if last := intervals[len(intervals)-1]; last.From >= last.To {
    30  			// Sometimes there can be a situation when 'from' >= 'to' because we round the interval. Just try
    31  			// we the lower interval
    32  			continue
    33  		}
    34  
    35  		var c int
    36  		for i := range intervals {
    37  			if intervals[i].Total != 0 {
    38  				c++
    39  			}
    40  		}
    41  		if c > len(intervals)/2 {
    42  			break
    43  		}
    44  	}
    45  
    46  	// Round interval totals for more beautiful view
    47  	for i := range intervals {
    48  		intervals[i].Total = intervals[i].Total.Round()
    49  	}
    50  
    51  	return intervals
    52  }
    53  
    54  // extractSortedCosts returns a sorted slice of Spend costs
    55  func extractSortedCosts(spends []db.Spend) []money.Money {
    56  	res := make([]money.Money, 0, len(spends))
    57  	for _, s := range spends {
    58  		res = append(res, s.Cost)
    59  	}
    60  	sort.Slice(res, func(i, j int) bool {
    61  		return res[i] < res[j]
    62  	})
    63  	return res
    64  }
    65  
    66  // prepareIntervals prepares cost intervals excluding costs less than p5 and greater than p95 (p - percentile)
    67  func prepareIntervals(costs []money.Money, intervalNumber int) []CostInterval {
    68  	min := getPercentileValue(costs, 5).Floor()
    69  	max := getPercentileValue(costs, 95).Ceil()
    70  
    71  	delta := max.Sub(min)
    72  	interval := delta.Div(int64(intervalNumber)).Round()
    73  
    74  	intervals := make([]CostInterval, 0, intervalNumber)
    75  	next := min
    76  	for i := 0; i < intervalNumber; i++ {
    77  		from := next
    78  		next = next.Add(interval)
    79  		to := biggestValueBefore(next)
    80  		if i+1 == intervalNumber {
    81  			to = max
    82  		}
    83  
    84  		intervals = append(intervals, CostInterval{From: from, To: to})
    85  	}
    86  
    87  	return intervals
    88  }
    89  
    90  // getPercentileValue returns a value at the nth percentile. It uses the nearest rank method to
    91  // find the percentile rank - https://en.wikipedia.org/wiki/Percentile#The_nearest-rank_method
    92  func getPercentileValue(costs []money.Money, n int) money.Money {
    93  	i := float64(n) / 100 * float64(len(costs))
    94  	index := int(math.Ceil(i)) - 1
    95  	switch {
    96  	case index < 0:
    97  		index = 0
    98  	case index >= len(costs):
    99  		index = len(costs) - 1
   100  	}
   101  
   102  	return costs[index]
   103  }
   104  
   105  // biggestValueBefore returns the biggest value before 'm'. It can be used to represent open intervals - (a, b)
   106  // For example, if max money precision is 2, it will return 'm-0.01'. If precision is 3 - 'm-0.001' and so one
   107  func biggestValueBefore(m money.Money) money.Money {
   108  	return m - 1
   109  }
   110  
   111  func fillIntervals(costs []money.Money, intervals []CostInterval) []CostInterval {
   112  	for _, cost := range costs {
   113  		for i := range intervals {
   114  			if intervals[i].From <= cost && cost <= intervals[i].To {
   115  				intervals[i].Count++
   116  				intervals[i].Total = intervals[i].Total.Add(cost)
   117  				break
   118  			}
   119  		}
   120  	}
   121  	return intervals
   122  }