| package currencyx |
|
|
| import ( |
| "cmp" |
| "errors" |
| "fmt" |
| "slices" |
|
|
| "github.com/alpacahq/alpacadecimal" |
| ) |
|
|
| |
| |
| |
| type WeightedAllocationItem[T any] struct { |
| Key T |
| Weight alpacadecimal.Decimal |
| } |
|
|
| |
| type WeightedAllocation[T any] struct { |
| Key T |
| Amount alpacadecimal.Decimal |
| } |
|
|
| |
| |
| |
| type AmountAllocationItem[T any] struct { |
| Key T |
| Amount alpacadecimal.Decimal |
| } |
|
|
| |
| type AmountAllocation[T any] struct { |
| Key T |
| Amount alpacadecimal.Decimal |
| } |
|
|
| |
| type WeightedAllocationInput[T any] struct { |
| Amount alpacadecimal.Decimal |
| Items []WeightedAllocationItem[T] |
|
|
| |
| |
| CompareKey func(left, right T) int |
| } |
|
|
| |
| |
| type AmountAllocationInput[T any] struct { |
| Amount alpacadecimal.Decimal |
| Items []AmountAllocationItem[T] |
|
|
| |
| |
| CompareKey func(left, right T) int |
| } |
|
|
| |
| |
| func AllocateByWeight[T any](currency Currency, input WeightedAllocationInput[T]) ([]WeightedAllocation[T], error) { |
| if err := validateWeightedAllocationInput(currency, input); err != nil { |
| return nil, err |
| } |
|
|
| if input.Amount.IsZero() { |
| return nil, nil |
| } |
|
|
| totalWeight := alpacadecimal.Zero |
| for _, item := range input.Items { |
| totalWeight = totalWeight.Add(item.Weight) |
| } |
|
|
| type allocationCandidate struct { |
| index int |
| key T |
| amount alpacadecimal.Decimal |
| remainder alpacadecimal.Decimal |
| } |
|
|
| candidates := make([]allocationCandidate, 0, len(input.Items)) |
| allocated := alpacadecimal.Zero |
| for i, item := range input.Items { |
| share := input.Amount.Mul(item.Weight).Div(totalWeight) |
| amount := currency.RoundDown(share) |
|
|
| candidates = append(candidates, allocationCandidate{ |
| index: i, |
| key: item.Key, |
| amount: amount, |
| remainder: share.Sub(amount), |
| }) |
| allocated = allocated.Add(amount) |
| } |
|
|
| slices.SortStableFunc(candidates, func(left, right allocationCandidate) int { |
| if remainderCmp := right.remainder.Cmp(left.remainder); remainderCmp != 0 { |
| return remainderCmp |
| } |
|
|
| if input.CompareKey != nil { |
| if keyCmp := input.CompareKey(left.key, right.key); keyCmp != 0 { |
| return keyCmp |
| } |
| } |
|
|
| return cmp.Compare(left.index, right.index) |
| }) |
|
|
| unit := currency.Unit() |
| remaining := input.Amount.Sub(allocated) |
| for i := range candidates { |
| if remaining.LessThan(unit) { |
| break |
| } |
|
|
| candidates[i].amount = candidates[i].amount.Add(unit) |
| remaining = remaining.Sub(unit) |
| } |
|
|
| slices.SortFunc(candidates, func(left, right allocationCandidate) int { |
| return cmp.Compare(left.index, right.index) |
| }) |
|
|
| allocations := make([]WeightedAllocation[T], 0, len(candidates)) |
| for _, candidate := range candidates { |
| if candidate.amount.IsZero() { |
| continue |
| } |
|
|
| allocations = append(allocations, WeightedAllocation[T]{ |
| Key: candidate.key, |
| Amount: candidate.amount, |
| }) |
| } |
|
|
| return allocations, nil |
| } |
|
|
| |
| |
| |
| func AllocateByAmount[T any](currency Currency, input AmountAllocationInput[T]) ([]AmountAllocation[T], error) { |
| if currency == nil { |
| return nil, errors.New("currency is required") |
| } |
|
|
| if err := validateAmountAllocationInput(currency, input); err != nil { |
| return nil, err |
| } |
|
|
| if input.Amount.IsZero() { |
| return nil, nil |
| } |
|
|
| totalAmount := alpacadecimal.Zero |
| for _, item := range input.Items { |
| totalAmount = totalAmount.Add(item.Amount) |
| } |
|
|
| type allocationCandidate struct { |
| index int |
| key T |
| amount alpacadecimal.Decimal |
| allocated alpacadecimal.Decimal |
| remainder alpacadecimal.Decimal |
| } |
|
|
| candidates := make([]allocationCandidate, 0, len(input.Items)) |
| allocated := alpacadecimal.Zero |
| for i, item := range input.Items { |
| share := input.Amount.Mul(item.Amount).Div(totalAmount) |
| floor := currency.RoundDown(share) |
|
|
| candidates = append(candidates, allocationCandidate{ |
| index: i, |
| key: item.Key, |
| amount: item.Amount, |
| allocated: floor, |
| remainder: share.Sub(floor), |
| }) |
| allocated = allocated.Add(floor) |
| } |
|
|
| slices.SortStableFunc(candidates, func(left, right allocationCandidate) int { |
| if remainderCmp := right.remainder.Cmp(left.remainder); remainderCmp != 0 { |
| return remainderCmp |
| } |
|
|
| if input.CompareKey != nil { |
| if keyCmp := input.CompareKey(left.key, right.key); keyCmp != 0 { |
| return keyCmp |
| } |
| } |
|
|
| return cmp.Compare(left.index, right.index) |
| }) |
|
|
| unit := currency.Unit() |
| remaining := input.Amount.Sub(allocated) |
| for remaining.GreaterThanOrEqual(unit) { |
| distributed := false |
|
|
| for i := range candidates { |
| if remaining.LessThan(unit) { |
| break |
| } |
|
|
| next := candidates[i].allocated.Add(unit) |
| if next.GreaterThan(candidates[i].amount) { |
| continue |
| } |
|
|
| candidates[i].allocated = next |
| remaining = remaining.Sub(unit) |
| distributed = true |
| } |
|
|
| if !distributed { |
| return nil, errors.New("cannot distribute remaining allocation without exceeding item amounts") |
| } |
| } |
|
|
| slices.SortFunc(candidates, func(left, right allocationCandidate) int { |
| return cmp.Compare(left.index, right.index) |
| }) |
|
|
| allocations := make([]AmountAllocation[T], 0, len(candidates)) |
| for _, candidate := range candidates { |
| if candidate.allocated.IsZero() { |
| continue |
| } |
|
|
| allocations = append(allocations, AmountAllocation[T]{ |
| Key: candidate.key, |
| Amount: candidate.allocated, |
| }) |
| } |
|
|
| return allocations, nil |
| } |
|
|
| func validateWeightedAllocationInput[T any](currency Currency, input WeightedAllocationInput[T]) error { |
| if currency == nil { |
| return errors.New("currency is required") |
| } |
|
|
| var errs []error |
|
|
| if err := currency.Validate(); err != nil { |
| errs = append(errs, fmt.Errorf("invalid currency: %w", err)) |
| } |
|
|
| if !currency.IsRoundedToPrecision(input.Amount) { |
| errs = append(errs, errors.New("amount must be rounded to currency precision")) |
| } |
|
|
| if input.Amount.Sign() < 0 { |
| errs = append(errs, errors.New("amount must be non-negative")) |
| } |
|
|
| if len(input.Items) == 0 && !input.Amount.IsZero() { |
| errs = append(errs, errors.New("items are required for a non-zero amount")) |
| } |
|
|
| totalWeight := alpacadecimal.Zero |
| for i, item := range input.Items { |
| if item.Weight.Sign() <= 0 { |
| errs = append(errs, fmt.Errorf("items[%d].weight must be positive", i)) |
| continue |
| } |
|
|
| totalWeight = totalWeight.Add(item.Weight) |
| } |
|
|
| return errors.Join(errs...) |
| } |
|
|
| func validateAmountAllocationInput[T any](currency Currency, input AmountAllocationInput[T]) error { |
| if currency == nil { |
| return errors.New("currency is required") |
| } |
|
|
| var errs []error |
|
|
| if input.Amount.Sign() < 0 { |
| errs = append(errs, errors.New("amount must be non-negative")) |
| } |
|
|
| if !currency.IsRoundedToPrecision(input.Amount) { |
| errs = append(errs, errors.New("amount must be rounded to currency precision")) |
| } |
|
|
| if len(input.Items) == 0 && !input.Amount.IsZero() { |
| errs = append(errs, errors.New("items are required for a non-zero amount")) |
| } |
|
|
| totalAmount := alpacadecimal.Zero |
| for i, item := range input.Items { |
| if item.Amount.Sign() <= 0 { |
| errs = append(errs, fmt.Errorf("items[%d].amount must be positive", i)) |
| continue |
| } |
|
|
| if !currency.IsRoundedToPrecision(item.Amount) { |
| errs = append(errs, fmt.Errorf("items[%d].amount must be rounded to currency precision", i)) |
| } |
|
|
| totalAmount = totalAmount.Add(item.Amount) |
| } |
|
|
| if input.Amount.GreaterThan(totalAmount) { |
| errs = append(errs, errors.New("amount must not exceed total item amount")) |
| } |
|
|
| return errors.Join(errs...) |
| } |
|
|