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 }