package analytics import ( "context" "fmt" "math" "math/big" "github.com/jackc/pgx/v5/pgxpool" ) // TokenDistribution provides token distribution analytics type TokenDistribution struct { db *pgxpool.Pool chainID int } // NewTokenDistribution creates a new token distribution analyzer func NewTokenDistribution(db *pgxpool.Pool, chainID int) *TokenDistribution { return &TokenDistribution{ db: db, chainID: chainID, } } // DistributionStats represents token distribution statistics type DistributionStats struct { Contract string Symbol string TotalSupply string Holders int Distribution map[string]string TopHolders []HolderInfo } // HolderInfo represents holder information type HolderInfo struct { Address string Balance string Percentage string } // GetTokenDistribution gets token distribution for a contract func (td *TokenDistribution) GetTokenDistribution(ctx context.Context, contract string, topN int) (*DistributionStats, error) { // Refresh materialized view _, err := td.db.Exec(ctx, `REFRESH MATERIALIZED VIEW CONCURRENTLY token_distribution`) if err != nil { // Ignore error if view doesn't exist yet } // Get distribution from materialized view query := ` SELECT holder_count, total_balance FROM token_distribution WHERE token_contract = $1 AND chain_id = $2 ` var holders int var totalSupply string err = td.db.QueryRow(ctx, query, contract, td.chainID).Scan(&holders, &totalSupply) if err != nil { return nil, fmt.Errorf("failed to get distribution: %w", err) } // Get top holders topHoldersQuery := ` SELECT address, balance FROM token_balances WHERE token_contract = $1 AND chain_id = $2 AND balance > 0 ORDER BY balance DESC LIMIT $3 ` rows, err := td.db.Query(ctx, topHoldersQuery, contract, td.chainID, topN) if err != nil { return nil, fmt.Errorf("failed to get top holders: %w", err) } defer rows.Close() topHolders := []HolderInfo{} totalSupplyRat, ok := parseNumericString(totalSupply) if !ok || totalSupplyRat.Sign() <= 0 { totalSupplyRat = big.NewRat(0, 1) } for rows.Next() { var holder HolderInfo if err := rows.Scan(&holder.Address, &holder.Balance); err != nil { continue } holder.Percentage = formatPercentage(holder.Balance, totalSupplyRat, 4) topHolders = append(topHolders, holder) } stats := &DistributionStats{ Contract: contract, Holders: holders, TotalSupply: totalSupply, Distribution: make(map[string]string), TopHolders: topHolders, } balances, err := td.loadHolderBalances(ctx, contract) if err != nil { return nil, fmt.Errorf("failed to compute holder metrics: %w", err) } stats.Distribution["top_10_percent"] = concentrationPercent(balances, totalSupplyRat, 0.10) stats.Distribution["top_1_percent"] = concentrationPercent(balances, totalSupplyRat, 0.01) stats.Distribution["gini_coefficient"] = giniCoefficient(balances) return stats, nil } func (td *TokenDistribution) loadHolderBalances(ctx context.Context, contract string) ([]*big.Rat, error) { rows, err := td.db.Query(ctx, ` SELECT balance FROM token_balances WHERE token_contract = $1 AND chain_id = $2 AND balance > 0 ORDER BY balance DESC `, contract, td.chainID) if err != nil { return nil, err } defer rows.Close() balances := make([]*big.Rat, 0) for rows.Next() { var raw string if err := rows.Scan(&raw); err != nil { continue } if balance, ok := parseNumericString(raw); ok && balance.Sign() > 0 { balances = append(balances, balance) } } return balances, nil } func parseNumericString(raw string) (*big.Rat, bool) { value, ok := new(big.Rat).SetString(raw) return value, ok } func formatPercentage(raw string, total *big.Rat, decimals int) string { value, ok := parseNumericString(raw) if !ok { return "0" } return formatRatioAsPercent(value, total, decimals) } func concentrationPercent(balances []*big.Rat, total *big.Rat, percentile float64) string { if len(balances) == 0 { return "0" } count := int(math.Ceil(float64(len(balances)) * percentile)) if count < 1 { count = 1 } if count > len(balances) { count = len(balances) } sum := new(big.Rat) for i := 0; i < count; i++ { sum.Add(sum, balances[i]) } return formatRatioAsPercent(sum, total, 4) } func formatRatioAsPercent(value, total *big.Rat, decimals int) string { if value == nil || total == nil || total.Sign() <= 0 { return "0" } percent := new(big.Rat).Quo(value, total) percent.Mul(percent, big.NewRat(100, 1)) return formatRat(percent, decimals) } func giniCoefficient(balances []*big.Rat) string { if len(balances) == 0 { return "0" } total := new(big.Rat) for _, balance := range balances { total.Add(total, balance) } if total.Sign() <= 0 { return "0" } weightedSum := new(big.Rat) n := len(balances) for i := range balances { index := n - i weighted := new(big.Rat).Mul(balances[i], big.NewRat(int64(index), 1)) weightedSum.Add(weightedSum, weighted) } nRat := big.NewRat(int64(n), 1) numerator := new(big.Rat).Mul(weightedSum, big.NewRat(2, 1)) denominator := new(big.Rat).Mul(nRat, total) gini := new(big.Rat).Quo(numerator, denominator) gini.Sub(gini, new(big.Rat).Quo(big.NewRat(int64(n+1), 1), nRat)) if gini.Sign() < 0 { return "0" } return formatRat(gini, 6) } func formatRat(value *big.Rat, decimals int) string { if value == nil { return "0" } text := new(big.Float).SetPrec(256).SetRat(value).Text('f', decimals) for len(text) > 1 && text[len(text)-1] == '0' { text = text[:len(text)-1] } if len(text) > 1 && text[len(text)-1] == '.' { text = text[:len(text)-1] } return text }