- SUMMARY.md with all accomplishments and deviation documentation - STATE.md updated: plan 8/14, 50% progress, decisions, session - ROADMAP.md updated: 7/14 plans complete - REQUIREMENTS.md: UI-01 through UI-04, UI-07, UI-08 marked complete Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
329 lines
9.5 KiB
Go
329 lines
9.5 KiB
Go
package financial
|
|
|
|
import (
|
|
"math/rand"
|
|
"testing"
|
|
|
|
"github.com/felt-app/felt/internal/template"
|
|
)
|
|
|
|
// TestCalculatePayoutsFromPool_Basic tests basic payout calculation.
|
|
func TestCalculatePayoutsFromPool_Basic(t *testing.T) {
|
|
tiers := []template.PayoutTier{
|
|
{Position: 1, PercentageBasisPoints: 5000}, // 50%
|
|
{Position: 2, PercentageBasisPoints: 3000}, // 30%
|
|
{Position: 3, PercentageBasisPoints: 2000}, // 20%
|
|
}
|
|
|
|
payouts, err := CalculatePayoutsFromPool(100000, tiers, 100) // 1000.00 pool, round to 1.00
|
|
if err != nil {
|
|
t.Fatalf("calculate payouts: %v", err)
|
|
}
|
|
|
|
if len(payouts) != 3 {
|
|
t.Fatalf("expected 3 payouts, got %d", len(payouts))
|
|
}
|
|
|
|
// Sum must equal prize pool
|
|
var sum int64
|
|
for _, p := range payouts {
|
|
sum += p.Amount
|
|
}
|
|
if sum != 100000 {
|
|
t.Errorf("sum mismatch: got %d, expected 100000", sum)
|
|
}
|
|
|
|
// Verify individual amounts
|
|
// 50% of 100000 = 50000, 30% = 30000, 20% = 20000
|
|
if payouts[0].Amount != 50000 {
|
|
t.Errorf("1st place: expected 50000, got %d", payouts[0].Amount)
|
|
}
|
|
if payouts[1].Amount != 30000 {
|
|
t.Errorf("2nd place: expected 30000, got %d", payouts[1].Amount)
|
|
}
|
|
if payouts[2].Amount != 20000 {
|
|
t.Errorf("3rd place: expected 20000, got %d", payouts[2].Amount)
|
|
}
|
|
}
|
|
|
|
// TestCalculatePayoutsFromPool_RoundingRemainder tests that rounding remainder goes to 1st.
|
|
func TestCalculatePayoutsFromPool_RoundingRemainder(t *testing.T) {
|
|
tiers := []template.PayoutTier{
|
|
{Position: 1, PercentageBasisPoints: 5000}, // 50%
|
|
{Position: 2, PercentageBasisPoints: 3000}, // 30%
|
|
{Position: 3, PercentageBasisPoints: 2000}, // 20%
|
|
}
|
|
|
|
// 333.33 pool with rounding to 5.00 -- this creates remainder
|
|
payouts, err := CalculatePayoutsFromPool(33333, tiers, 500)
|
|
if err != nil {
|
|
t.Fatalf("calculate payouts: %v", err)
|
|
}
|
|
|
|
var sum int64
|
|
for _, p := range payouts {
|
|
sum += p.Amount
|
|
}
|
|
if sum != 33333 {
|
|
t.Errorf("sum mismatch: got %d, expected 33333", sum)
|
|
}
|
|
|
|
// 50% of 33333 = 16666.5 -> floor to 16500 (round to 500)
|
|
// 30% of 33333 = 9999.9 -> floor to 9500
|
|
// 20% of 33333 = 6666.6 -> floor to 6500
|
|
// Total rounded = 16500 + 9500 + 6500 = 32500
|
|
// Remainder = 33333 - 32500 = 833 -> goes to 1st
|
|
// 1st gets: 16500 + 833 = 17333
|
|
if payouts[0].Amount != 17333 {
|
|
t.Errorf("1st place: expected 17333, got %d", payouts[0].Amount)
|
|
}
|
|
}
|
|
|
|
// TestCalculatePayoutsFromPool_NeverRoundsUp tests that rounding never creates money.
|
|
func TestCalculatePayoutsFromPool_NeverRoundsUp(t *testing.T) {
|
|
tiers := []template.PayoutTier{
|
|
{Position: 1, PercentageBasisPoints: 4000},
|
|
{Position: 2, PercentageBasisPoints: 3000},
|
|
{Position: 3, PercentageBasisPoints: 2000},
|
|
{Position: 4, PercentageBasisPoints: 1000},
|
|
}
|
|
|
|
denominations := []int64{100, 500, 1000, 5000}
|
|
|
|
for _, denom := range denominations {
|
|
payouts, err := CalculatePayoutsFromPool(77777, tiers, denom)
|
|
if err != nil {
|
|
t.Fatalf("denom %d: %v", denom, err)
|
|
}
|
|
|
|
var sum int64
|
|
for _, p := range payouts {
|
|
sum += p.Amount
|
|
// Each payout (except 1st which gets remainder) should be at most floor(raw)
|
|
if p.Position != 1 {
|
|
raw := int64(77777) * p.Percentage / 10000
|
|
if p.Amount > raw {
|
|
t.Errorf("denom %d, pos %d: amount %d > raw %d (rounding UP detected)",
|
|
denom, p.Position, p.Amount, raw)
|
|
}
|
|
}
|
|
}
|
|
if sum != 77777 {
|
|
t.Errorf("denom %d: sum %d != 77777", denom, sum)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCalculatePayoutsFromPool_CIGate is the CI gate test.
|
|
// It generates 10,000+ random prize pools and verifies sum(payouts) == prizePool
|
|
// in every case. This is the financial integrity gate.
|
|
func TestCalculatePayoutsFromPool_CIGate(t *testing.T) {
|
|
rng := rand.New(rand.NewSource(42)) // Deterministic for reproducibility
|
|
|
|
roundingDenominations := []int64{100, 500, 1000, 5000}
|
|
|
|
// Generate diverse payout structures (2-20 positions)
|
|
structures := generateRandomStructures(rng, 50)
|
|
|
|
iterations := 0
|
|
for _, denom := range roundingDenominations {
|
|
for _, structure := range structures {
|
|
for i := 0; i < 50; i++ {
|
|
// Random prize pool: 1000 cents (10.00) to 10,000,000 cents (100,000.00)
|
|
prizePool := int64(rng.Intn(9999000)) + 1000
|
|
|
|
payouts, err := CalculatePayoutsFromPool(prizePool, structure, denom)
|
|
if err != nil {
|
|
t.Fatalf("iteration %d: pool=%d denom=%d positions=%d: %v",
|
|
iterations, prizePool, denom, len(structure), err)
|
|
}
|
|
|
|
// CRITICAL ASSERTION: sum(payouts) == prizePool
|
|
var sum int64
|
|
for _, p := range payouts {
|
|
sum += p.Amount
|
|
// No negative payouts
|
|
if p.Amount < 0 {
|
|
t.Fatalf("iteration %d: negative payout: position=%d amount=%d",
|
|
iterations, p.Position, p.Amount)
|
|
}
|
|
}
|
|
|
|
if sum != prizePool {
|
|
t.Fatalf("CI GATE FAILURE at iteration %d: sum=%d prizePool=%d diff=%d (denom=%d, positions=%d)",
|
|
iterations, sum, prizePool, sum-prizePool, denom, len(structure))
|
|
}
|
|
|
|
iterations++
|
|
}
|
|
}
|
|
}
|
|
|
|
if iterations < 10000 {
|
|
t.Fatalf("CI gate: only ran %d iterations, expected at least 10000", iterations)
|
|
}
|
|
t.Logf("CI gate PASSED: %d iterations, zero sum deviations", iterations)
|
|
}
|
|
|
|
// TestCalculatePayoutsFromPool_GuaranteedPot tests guaranteed pot logic.
|
|
func TestCalculatePayoutsFromPool_GuaranteedPot(t *testing.T) {
|
|
// When pool < guarantee, house covers shortfall
|
|
// FinalPrizePool should be max(pool, guarantee)
|
|
tiers := []template.PayoutTier{
|
|
{Position: 1, PercentageBasisPoints: 6000},
|
|
{Position: 2, PercentageBasisPoints: 4000},
|
|
}
|
|
|
|
// If guarantee is 50000 but pool is only 30000, house contributes 20000
|
|
// FinalPrizePool = 50000
|
|
payouts, err := CalculatePayoutsFromPool(50000, tiers, 100)
|
|
if err != nil {
|
|
t.Fatalf("calculate payouts: %v", err)
|
|
}
|
|
|
|
var sum int64
|
|
for _, p := range payouts {
|
|
sum += p.Amount
|
|
}
|
|
if sum != 50000 {
|
|
t.Errorf("guaranteed pot: sum %d != 50000", sum)
|
|
}
|
|
}
|
|
|
|
// TestCalculatePayoutsFromPool_EntryCountBracketSelection tests unique entries bracket selection.
|
|
func TestCalculatePayoutsFromPool_EntryCountBracketSelection(t *testing.T) {
|
|
brackets := []template.PayoutBracket{
|
|
{MinEntries: 2, MaxEntries: 5, Tiers: []template.PayoutTier{
|
|
{Position: 1, PercentageBasisPoints: 10000},
|
|
}},
|
|
{MinEntries: 6, MaxEntries: 10, Tiers: []template.PayoutTier{
|
|
{Position: 1, PercentageBasisPoints: 6000},
|
|
{Position: 2, PercentageBasisPoints: 4000},
|
|
}},
|
|
{MinEntries: 11, MaxEntries: 20, Tiers: []template.PayoutTier{
|
|
{Position: 1, PercentageBasisPoints: 5000},
|
|
{Position: 2, PercentageBasisPoints: 3000},
|
|
{Position: 3, PercentageBasisPoints: 2000},
|
|
}},
|
|
}
|
|
|
|
// 3 entries -> first bracket (winner take all)
|
|
b := selectBracket(brackets, 3)
|
|
if b == nil {
|
|
t.Fatal("expected bracket for 3 entries")
|
|
}
|
|
if len(b.Tiers) != 1 {
|
|
t.Errorf("expected 1 tier for 3 entries, got %d", len(b.Tiers))
|
|
}
|
|
|
|
// 8 entries -> second bracket
|
|
b = selectBracket(brackets, 8)
|
|
if b == nil {
|
|
t.Fatal("expected bracket for 8 entries")
|
|
}
|
|
if len(b.Tiers) != 2 {
|
|
t.Errorf("expected 2 tiers for 8 entries, got %d", len(b.Tiers))
|
|
}
|
|
|
|
// 15 entries -> third bracket
|
|
b = selectBracket(brackets, 15)
|
|
if b == nil {
|
|
t.Fatal("expected bracket for 15 entries")
|
|
}
|
|
if len(b.Tiers) != 3 {
|
|
t.Errorf("expected 3 tiers for 15 entries, got %d", len(b.Tiers))
|
|
}
|
|
|
|
// 25 entries -> exceeds all brackets, use last
|
|
b = selectBracket(brackets, 25)
|
|
if b == nil {
|
|
t.Fatal("expected bracket for 25 entries (overflow)")
|
|
}
|
|
if len(b.Tiers) != 3 {
|
|
t.Errorf("expected 3 tiers for overflow, got %d", len(b.Tiers))
|
|
}
|
|
}
|
|
|
|
// TestRedistributeForBubble tests bubble prize redistribution.
|
|
func TestRedistributeForBubble(t *testing.T) {
|
|
payouts := []Payout{
|
|
{Position: 1, Amount: 50000},
|
|
{Position: 2, Amount: 30000},
|
|
{Position: 3, Amount: 20000},
|
|
}
|
|
|
|
funding, adjusted, err := redistributeForBubble(payouts, 5000)
|
|
if err != nil {
|
|
t.Fatalf("redistribute: %v", err)
|
|
}
|
|
|
|
// Verify funding sources exist
|
|
if len(funding) == 0 {
|
|
t.Fatal("expected funding sources")
|
|
}
|
|
|
|
// Verify total shaved equals bubble amount
|
|
var totalShaved int64
|
|
for _, f := range funding {
|
|
totalShaved += f.ReductionAmount
|
|
}
|
|
if totalShaved != 5000 {
|
|
t.Errorf("expected total shaved 5000, got %d", totalShaved)
|
|
}
|
|
|
|
// Verify adjusted payouts sum = original sum - bubble amount
|
|
var originalSum, adjustedSum int64
|
|
for _, p := range payouts {
|
|
originalSum += p.Amount
|
|
}
|
|
for _, p := range adjusted {
|
|
adjustedSum += p.Amount
|
|
}
|
|
if adjustedSum != originalSum-5000 {
|
|
t.Errorf("adjusted sum %d != original %d - bubble 5000 = %d", adjustedSum, originalSum, originalSum-5000)
|
|
}
|
|
}
|
|
|
|
// generateRandomStructures creates diverse payout structures for property testing.
|
|
func generateRandomStructures(rng *rand.Rand, count int) [][]template.PayoutTier {
|
|
structures := make([][]template.PayoutTier, 0, count)
|
|
|
|
for i := 0; i < count; i++ {
|
|
positions := rng.Intn(19) + 2 // 2-20 positions
|
|
tiers := make([]template.PayoutTier, positions)
|
|
|
|
// Generate random percentages that sum to 10000
|
|
remaining := int64(10000)
|
|
for j := 0; j < positions; j++ {
|
|
tiers[j].Position = j + 1
|
|
if j == positions-1 {
|
|
tiers[j].PercentageBasisPoints = remaining
|
|
} else {
|
|
// Min 1 basis point per remaining position
|
|
minPer := int64(positions - j)
|
|
maxPer := remaining - minPer
|
|
if maxPer < 1 {
|
|
maxPer = 1
|
|
}
|
|
// Weighted toward higher positions getting more
|
|
pct := int64(rng.Intn(int(maxPer/int64(positions-j)))) + 1
|
|
if pct > remaining-int64(positions-j-1) {
|
|
pct = remaining - int64(positions-j-1)
|
|
}
|
|
tiers[j].PercentageBasisPoints = pct
|
|
remaining -= pct
|
|
}
|
|
}
|
|
|
|
// Verify sum
|
|
var sum int64
|
|
for _, tier := range tiers {
|
|
sum += tier.PercentageBasisPoints
|
|
}
|
|
if sum == 10000 {
|
|
structures = append(structures, tiers)
|
|
}
|
|
}
|
|
|
|
return structures
|
|
}
|