feat: add unit tests for DCA calculations with empirical Lamassu data
Some checks are pending
CI / lint (push) Waiting to run
CI / tests (3.10) (push) Blocked by required conditions
CI / tests (3.9) (push) Blocked by required conditions

- Extract pure calculation functions to calculations.py (no lnbits deps)
- transaction_processor.py now imports from calculations.py (DRY)
- Add 22 tests covering commission, distribution, and fiat round-trip
- Include real Lamassu transaction data (8.75%, 5.5% commission rates)
- Test edge cases: discounts (90%, 100%), zero commission, small amounts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
padreug 2026-01-11 14:14:18 +01:00
parent 8d94dcc2b7
commit 397fd4b002
3 changed files with 560 additions and 84 deletions

147
calculations.py Normal file
View file

@ -0,0 +1,147 @@
"""
Pure calculation functions for DCA transaction processing.
These functions have no external dependencies (no lnbits, no database)
and can be easily tested in isolation.
"""
from typing import Dict, Tuple
def calculate_commission(
crypto_atoms: int,
commission_percentage: float,
discount: float = 0.0
) -> Tuple[int, int, float]:
"""
Calculate commission split from a Lamassu transaction.
The crypto_atoms from Lamassu already includes the commission baked in.
This function extracts the base amount (for DCA distribution) and
commission amount (for commission wallet).
Formula:
effective_commission = commission_percentage * (100 - discount) / 100
base_amount = round(crypto_atoms / (1 + effective_commission))
commission_amount = crypto_atoms - base_amount
Args:
crypto_atoms: Total sats from Lamassu (includes commission)
commission_percentage: Commission rate as decimal (e.g., 0.03 for 3%)
discount: Discount percentage on commission (e.g., 10.0 for 10% off)
Returns:
Tuple of (base_amount_sats, commission_amount_sats, effective_commission_rate)
Example:
>>> calculate_commission(266800, 0.03, 0.0)
(259029, 7771, 0.03)
"""
if commission_percentage > 0:
effective_commission = commission_percentage * (100 - discount) / 100
base_crypto_atoms = round(crypto_atoms / (1 + effective_commission))
commission_amount_sats = crypto_atoms - base_crypto_atoms
else:
effective_commission = 0.0
base_crypto_atoms = crypto_atoms
commission_amount_sats = 0
return base_crypto_atoms, commission_amount_sats, effective_commission
def calculate_distribution(
base_amount_sats: int,
client_balances: Dict[str, float],
min_balance_threshold: float = 0.01
) -> Dict[str, int]:
"""
Calculate proportional distribution of sats to clients based on their fiat balances.
Uses proportional allocation with remainder distribution to ensure
the total distributed equals exactly the base amount.
Args:
base_amount_sats: Total sats to distribute (after commission)
client_balances: Dict of {client_id: remaining_balance_fiat}
min_balance_threshold: Minimum balance to be included (default 0.01)
Returns:
Dict of {client_id: allocated_sats}
Example:
>>> calculate_distribution(100000, {"a": 500.0, "b": 500.0})
{"a": 50000, "b": 50000}
"""
# Filter out clients with balance below threshold
active_balances = {
client_id: balance
for client_id, balance in client_balances.items()
if balance >= min_balance_threshold
}
if not active_balances:
return {}
total_balance = sum(active_balances.values())
if total_balance == 0:
return {}
# First pass: calculate base allocations and track for remainder distribution
client_calculations = []
distributed_sats = 0
for client_id, balance in active_balances.items():
proportion = balance / total_balance
exact_share = base_amount_sats * proportion
allocated_sats = round(exact_share)
client_calculations.append({
'client_id': client_id,
'proportion': proportion,
'exact_share': exact_share,
'allocated_sats': allocated_sats,
})
distributed_sats += allocated_sats
# Handle remainder due to rounding
remainder = base_amount_sats - distributed_sats
if remainder != 0:
# Sort by largest fractional remainder to distribute fairly
client_calculations.sort(
key=lambda x: x['exact_share'] - x['allocated_sats'],
reverse=True
)
# Distribute remainder one sat at a time
for i in range(abs(remainder)):
idx = i % len(client_calculations)
if remainder > 0:
client_calculations[idx]['allocated_sats'] += 1
else:
client_calculations[idx]['allocated_sats'] -= 1
# Build final distributions dict
distributions = {
calc['client_id']: calc['allocated_sats']
for calc in client_calculations
}
return distributions
def calculate_exchange_rate(base_crypto_atoms: int, fiat_amount: float) -> float:
"""
Calculate exchange rate in sats per fiat unit.
Args:
base_crypto_atoms: Base amount in sats (after commission)
fiat_amount: Fiat amount dispensed
Returns:
Exchange rate as sats per fiat unit
"""
if fiat_amount <= 0:
return 0.0
return base_crypto_atoms / fiat_amount

381
tests/test_calculations.py Normal file
View file

@ -0,0 +1,381 @@
"""
Tests for DCA transaction calculations using empirical data.
These tests verify commission and distribution calculations against
real Lamassu transaction data to ensure the math is correct.
"""
import pytest
from decimal import Decimal
from typing import Dict, List, Tuple
# Import from the parent package (following lnurlp pattern)
from ..calculations import calculate_commission, calculate_distribution, calculate_exchange_rate
# =============================================================================
# COMMISSION CALCULATION TESTS
# =============================================================================
class TestCommissionCalculation:
"""Tests for commission calculation logic."""
# Empirical test cases: (crypto_atoms, commission%, discount%, expected_base, expected_commission)
# Formula: base = round(crypto_atoms / (1 + effective_commission))
# Where: effective_commission = commission_percentage * (100 - discount) / 100
EMPIRICAL_COMMISSION_CASES = [
# =============================================================
# REAL LAMASSU TRANSACTIONS (extracted from production database)
# =============================================================
# 8.75% commission, no discount - small transaction
# 15600 / 1.0875 = 14344.827... → 14345
(15600, 0.0875, 0.0, 14345, 1255),
# 8.75% commission, no discount - large transaction
# 309200 / 1.0875 = 284322.298... → 284322
(309200, 0.0875, 0.0, 284322, 24878),
# 5.5% commission, no discount
# 309500 / 1.055 = 293364.928... → 293365
(309500, 0.055, 0.0, 293365, 16135),
# 5.5% commission with 100% discount (no commission charged)
# effective = 0.055 * (100-100)/100 = 0
(292400, 0.055, 100.0, 292400, 0),
# 5.5% commission with 90% discount
# effective = 0.055 * (100-90)/100 = 0.0055
# 115000 / 1.0055 = 114370.96... → 114371
(115000, 0.055, 90.0, 114371, 629),
# 5.5% commission, no discount - 1300 GTQ transaction
# 205600 / 1.055 = 194881.516... → 194882
# Note: This tx showed 0.01 GTQ rounding discrepancy in per-client fiat
(205600, 0.055, 0.0, 194882, 10718),
# =============================================================
# SYNTHETIC TEST CASES (edge cases)
# =============================================================
# Zero commission - all goes to base
(100000, 0.0, 0.0, 100000, 0),
# Small amount edge case (1 sat minimum)
(100, 0.03, 0.0, 97, 3),
]
@pytest.mark.parametrize(
"crypto_atoms,commission_pct,discount,expected_base,expected_commission",
EMPIRICAL_COMMISSION_CASES,
ids=[
"lamassu_8.75pct_small",
"lamassu_8.75pct_large",
"lamassu_5.5pct_no_discount",
"lamassu_5.5pct_100pct_discount",
"lamassu_5.5pct_90pct_discount",
"lamassu_5.5pct_1300gtq",
"zero_commission",
"small_amount_100sats",
]
)
def test_commission_calculation(
self,
crypto_atoms: int,
commission_pct: float,
discount: float,
expected_base: int,
expected_commission: int
):
"""Test commission calculation against empirical data."""
base, commission, _ = calculate_commission(crypto_atoms, commission_pct, discount)
assert base == expected_base, f"Base amount mismatch: got {base}, expected {expected_base}"
assert commission == expected_commission, f"Commission mismatch: got {commission}, expected {expected_commission}"
# Invariant: base + commission must equal total
assert base + commission == crypto_atoms, "Base + commission must equal total crypto_atoms"
def test_commission_invariant_always_sums_to_total(self):
"""Commission + base must always equal the original amount."""
test_values = [1, 100, 1000, 10000, 100000, 266800, 1000000]
commission_rates = [0.0, 0.01, 0.03, 0.05, 0.10]
discounts = [0.0, 10.0, 25.0, 50.0]
for crypto_atoms in test_values:
for comm_rate in commission_rates:
for discount in discounts:
base, commission, _ = calculate_commission(crypto_atoms, comm_rate, discount)
assert base + commission == crypto_atoms, \
f"Invariant failed: {base} + {commission} != {crypto_atoms} " \
f"(rate={comm_rate}, discount={discount})"
# =============================================================================
# DISTRIBUTION CALCULATION TESTS
# =============================================================================
class TestDistributionCalculation:
"""Tests for proportional distribution logic."""
def test_single_client_gets_all(self):
"""Single client should receive entire distribution."""
distributions = calculate_distribution(
base_amount_sats=100000,
client_balances={"client_a": 500.00}
)
assert distributions == {"client_a": 100000}
def test_two_clients_equal_balance(self):
"""Two clients with equal balance should split evenly."""
distributions = calculate_distribution(
base_amount_sats=100000,
client_balances={
"client_a": 500.00,
"client_b": 500.00
}
)
assert distributions["client_a"] == 50000
assert distributions["client_b"] == 50000
assert sum(distributions.values()) == 100000
def test_two_clients_unequal_balance(self):
"""Two clients with 75/25 balance split."""
distributions = calculate_distribution(
base_amount_sats=100000,
client_balances={
"client_a": 750.00,
"client_b": 250.00
}
)
assert distributions["client_a"] == 75000
assert distributions["client_b"] == 25000
assert sum(distributions.values()) == 100000
def test_distribution_invariant_sums_to_total(self):
"""Total distributed sats must always equal base amount."""
# Test with various client configurations
test_cases = [
{"a": 100.0},
{"a": 100.0, "b": 100.0},
{"a": 100.0, "b": 200.0, "c": 300.0},
{"a": 33.33, "b": 33.33, "c": 33.34}, # Tricky rounding case
{"a": 1000.0, "b": 1.0}, # Large imbalance
]
for client_balances in test_cases:
for base_amount in [100, 1000, 10000, 100000, 258835]:
distributions = calculate_distribution(base_amount, client_balances)
total_distributed = sum(distributions.values())
assert total_distributed == base_amount, \
f"Distribution sum {total_distributed} != base {base_amount} " \
f"for balances {client_balances}"
def test_zero_balance_client_excluded(self):
"""Clients with zero balance should be excluded."""
distributions = calculate_distribution(
base_amount_sats=100000,
client_balances={
"client_a": 500.00,
"client_b": 0.0,
"client_c": 500.00
}
)
assert "client_b" not in distributions
assert distributions["client_a"] == 50000
assert distributions["client_c"] == 50000
def test_tiny_balance_excluded(self):
"""Clients with balance < 0.01 should be excluded."""
distributions = calculate_distribution(
base_amount_sats=100000,
client_balances={
"client_a": 500.00,
"client_b": 0.005, # Less than threshold
}
)
assert "client_b" not in distributions
assert distributions["client_a"] == 100000
def test_no_eligible_clients_returns_empty(self):
"""If no clients have balance, return empty distribution."""
distributions = calculate_distribution(
base_amount_sats=100000,
client_balances={
"client_a": 0.0,
"client_b": 0.0,
}
)
assert distributions == {}
def test_fiat_round_trip_invariant(self):
"""
Verify that distributed sats convert back to original fiat amount.
The sum of each client's fiat equivalent should equal the original
fiat amount (within rounding tolerance).
"""
# Use real Lamassu transaction data
test_cases = [
# (crypto_atoms, fiat_amount, commission_pct, discount, client_balances)
(309200, 2000.0, 0.0875, 0.0, {"a": 1000.0, "b": 1000.0}),
(309500, 2000.0, 0.055, 0.0, {"a": 500.0, "b": 1000.0, "c": 500.0}),
(292400, 2000.0, 0.055, 100.0, {"a": 800.0, "b": 1200.0}),
(115000, 800.0, 0.055, 90.0, {"a": 400.0, "b": 400.0}),
# Transaction that showed 0.01 GTQ rounding discrepancy with 4 clients
(205600, 1300.0, 0.055, 0.0, {"a": 1.0, "b": 986.0, "c": 14.0, "d": 4.0}),
]
for crypto_atoms, fiat_amount, comm_pct, discount, client_balances in test_cases:
# Calculate commission and base amount
base_sats, _, _ = calculate_commission(crypto_atoms, comm_pct, discount)
# Calculate exchange rate
exchange_rate = calculate_exchange_rate(base_sats, fiat_amount)
# Distribute sats to clients
distributions = calculate_distribution(base_sats, client_balances)
# Convert each client's sats back to fiat
total_fiat_distributed = sum(
sats / exchange_rate for sats in distributions.values()
)
# Should equal original fiat amount (within small rounding tolerance)
assert abs(total_fiat_distributed - fiat_amount) < 0.01, \
f"Fiat round-trip failed: {total_fiat_distributed:.2f} != {fiat_amount:.2f} " \
f"(crypto={crypto_atoms}, comm={comm_pct}, discount={discount})"
# =============================================================================
# EMPIRICAL END-TO-END TESTS
# =============================================================================
class TestEmpiricalTransactions:
"""
End-to-end tests using real Lamassu transaction data.
Add your empirical test cases here! Each case should include:
- Transaction details (crypto_atoms, fiat, commission, discount)
- Client balances at time of transaction
- Expected distribution outcome
"""
# TODO: Add your empirical data here
# Example structure:
EMPIRICAL_SCENARIOS = [
{
"name": "real_tx_266800sats_two_equal_clients",
"transaction": {
"crypto_atoms": 266800,
"fiat_amount": 2000,
"commission_percentage": 0.03,
"discount": 0.0,
},
"client_balances": {
"client_a": 1000.00, # 50% of total
"client_b": 1000.00, # 50% of total
},
# 266800 / 1.03 = 259029
"expected_base_sats": 259029,
"expected_commission_sats": 7771,
"expected_distributions": {
# 259029 / 2 = 129514.5 → both get 129514 or 129515
# With banker's rounding: 129514.5 → 129514 (even)
# Remainder of 1 sat goes to first client by fractional sort
"client_a": 129515,
"client_b": 129514,
},
},
# Add more scenarios from your real data!
]
@pytest.mark.parametrize(
"scenario",
EMPIRICAL_SCENARIOS,
ids=[s["name"] for s in EMPIRICAL_SCENARIOS]
)
def test_empirical_scenario(self, scenario):
"""Test full transaction flow against empirical data."""
tx = scenario["transaction"]
# Calculate commission
base, commission, _ = calculate_commission(
tx["crypto_atoms"],
tx["commission_percentage"],
tx["discount"]
)
assert base == scenario["expected_base_sats"], \
f"Base amount mismatch in {scenario['name']}"
assert commission == scenario["expected_commission_sats"], \
f"Commission mismatch in {scenario['name']}"
# Calculate distribution
distributions = calculate_distribution(
base,
scenario["client_balances"]
)
# Verify each client's allocation
for client_id, expected_sats in scenario["expected_distributions"].items():
actual_sats = distributions.get(client_id, 0)
assert actual_sats == expected_sats, \
f"Distribution mismatch for {client_id} in {scenario['name']}: " \
f"got {actual_sats}, expected {expected_sats}"
# Verify total distribution equals base
assert sum(distributions.values()) == base, \
f"Total distribution doesn't match base in {scenario['name']}"
# =============================================================================
# EDGE CASE TESTS
# =============================================================================
class TestEdgeCases:
"""Tests for edge cases and boundary conditions."""
def test_minimum_amount_1_sat(self):
"""Test with minimum possible amount (1 sat)."""
base, commission, _ = calculate_commission(1, 0.03, 0.0)
# With 3% commission on 1 sat, base rounds to 1, commission to 0
assert base + commission == 1
def test_large_transaction(self):
"""Test with large transaction (100 BTC worth of sats)."""
crypto_atoms = 10_000_000_000 # 100 BTC in sats
base, commission, _ = calculate_commission(crypto_atoms, 0.03, 0.0)
assert base + commission == crypto_atoms
assert commission > 0
def test_100_percent_discount(self):
"""100% discount should result in zero commission."""
base, commission, effective = calculate_commission(100000, 0.03, 100.0)
assert effective == 0.0
assert commission == 0
assert base == 100000
def test_many_clients_distribution(self):
"""Test distribution with many clients."""
# 10 clients with varying balances
client_balances = {f"client_{i}": float(i * 100) for i in range(1, 11)}
distributions = calculate_distribution(1000000, client_balances)
assert len(distributions) == 10
assert sum(distributions.values()) == 1000000
# Verify proportionality (client_10 should get ~18% with balance 1000)
# Total balance = 100+200+...+1000 = 5500
# client_10 proportion = 1000/5500 ≈ 18.18%
assert distributions["client_10"] > distributions["client_1"]

View file

@ -26,6 +26,7 @@ from lnbits.core.crud.wallets import get_wallet
from lnbits.core.services import update_wallet_balance from lnbits.core.services import update_wallet_balance
from lnbits.settings import settings from lnbits.settings import settings
from .calculations import calculate_commission, calculate_distribution, calculate_exchange_rate
from .crud import ( from .crud import (
get_flow_mode_clients, get_flow_mode_clients,
get_payments_by_lamassu_transaction, get_payments_by_lamassu_transaction,
@ -715,21 +716,13 @@ class LamassuTransactionProcessor:
# Could use current time as fallback, but this indicates a data issue # Could use current time as fallback, but this indicates a data issue
# transaction_time = datetime.now(timezone.utc) # transaction_time = datetime.now(timezone.utc)
# Calculate effective commission percentage after discount (following the reference logic) # Calculate commission split using the extracted pure function
if commission_percentage > 0: base_crypto_atoms, commission_amount_sats, effective_commission = calculate_commission(
effective_commission = commission_percentage * (100 - discount) / 100 crypto_atoms, commission_percentage, discount
# Since crypto_atoms already includes commission, we need to extract the base amount )
# Formula: crypto_atoms = base_amount * (1 + effective_commission)
# Therefore: base_amount = crypto_atoms / (1 + effective_commission)
base_crypto_atoms = round(crypto_atoms / (1 + effective_commission))
commission_amount_sats = crypto_atoms - base_crypto_atoms
else:
effective_commission = 0.0
base_crypto_atoms = crypto_atoms
commission_amount_sats = 0
# Calculate exchange rate based on base amounts # Calculate exchange rate based on base amounts
exchange_rate = base_crypto_atoms / fiat_amount if fiat_amount > 0 else 0 # sats per fiat unit exchange_rate = calculate_exchange_rate(base_crypto_atoms, fiat_amount)
logger.info(f"Transaction - Total crypto: {crypto_atoms} sats") logger.info(f"Transaction - Total crypto: {crypto_atoms} sats")
logger.info(f"Commission: {commission_percentage*100:.1f}% - {discount:.1f}% discount = {effective_commission*100:.1f}% effective ({commission_amount_sats} sats)") logger.info(f"Commission: {commission_percentage*100:.1f}% - {discount:.1f}% discount = {effective_commission*100:.1f}% effective ({commission_amount_sats} sats)")
@ -758,67 +751,29 @@ class LamassuTransactionProcessor:
if total_confirmed_deposits == 0: if total_confirmed_deposits == 0:
logger.info("No clients with remaining DCA balance - skipping distribution") logger.info("No clients with remaining DCA balance - skipping distribution")
return {} return {}
# Calculate proportional distribution with remainder allocation # Calculate sat allocations using the extracted pure function
sat_allocations = calculate_distribution(base_crypto_atoms, client_balances)
if not sat_allocations:
logger.info("No allocations calculated - skipping distribution")
return {}
# Build final distributions dict with additional tracking fields
distributions = {} distributions = {}
distributed_sats = 0 for client_id, client_sats_amount in sat_allocations.items():
client_calculations = [] # Calculate proportion for logging
proportion = client_balances[client_id] / total_confirmed_deposits
# First pass: calculate base amounts and track remainders
for client_id, client_balance in client_balances.items():
# Calculate this client's proportion of the total DCA pool
proportion = client_balance / total_confirmed_deposits
# Calculate exact share (with decimals)
exact_share = base_crypto_atoms * proportion
# Use banker's rounding for base allocation
client_sats_amount = round(exact_share)
client_calculations.append({
'client_id': client_id,
'proportion': proportion,
'exact_share': exact_share,
'allocated_sats': client_sats_amount,
'client_balance': client_balance
})
distributed_sats += client_sats_amount
# Handle any remainder due to rounding (should be small)
remainder = base_crypto_atoms - distributed_sats
if remainder != 0:
logger.info(f"Distributing remainder: {remainder} sats among {len(client_calculations)} clients")
# Sort clients by largest fractional remainder to distribute fairly
client_calculations.sort(
key=lambda x: x['exact_share'] - x['allocated_sats'],
reverse=True
)
# Distribute remainder one sat at a time to clients with largest fractional parts
for i in range(abs(remainder)):
if remainder > 0:
client_calculations[i % len(client_calculations)]['allocated_sats'] += 1
else:
client_calculations[i % len(client_calculations)]['allocated_sats'] -= 1
# Second pass: create distributions with final amounts
for calc in client_calculations:
client_id = calc['client_id']
client_sats_amount = calc['allocated_sats']
proportion = calc['proportion']
# Calculate equivalent fiat value in GTQ for tracking purposes # Calculate equivalent fiat value in GTQ for tracking purposes
client_fiat_amount = round(client_sats_amount / exchange_rate, 2) if exchange_rate > 0 else 0.0 client_fiat_amount = round(client_sats_amount / exchange_rate, 2) if exchange_rate > 0 else 0.0
distributions[client_id] = { distributions[client_id] = {
"fiat_amount": client_fiat_amount, "fiat_amount": client_fiat_amount,
"sats_amount": client_sats_amount, "sats_amount": client_sats_amount,
"exchange_rate": exchange_rate "exchange_rate": exchange_rate
} }
logger.info(f"Client {client_id[:8]}... gets {client_sats_amount} sats (≈{client_fiat_amount:.2f} GTQ, {proportion:.2%} share)") logger.info(f"Client {client_id[:8]}... gets {client_sats_amount} sats (≈{client_fiat_amount:.2f} GTQ, {proportion:.2%} share)")
# Verification: ensure total distribution equals base amount # Verification: ensure total distribution equals base amount
@ -1062,18 +1017,13 @@ class LamassuTransactionProcessor:
elif transaction_time.tzinfo != timezone.utc: elif transaction_time.tzinfo != timezone.utc:
transaction_time = transaction_time.astimezone(timezone.utc) transaction_time = transaction_time.astimezone(timezone.utc)
# Calculate commission metrics # Calculate commission metrics using the extracted pure function
if commission_percentage > 0: base_crypto_atoms, commission_amount_sats, effective_commission = calculate_commission(
effective_commission = commission_percentage * (100 - discount) / 100 crypto_atoms, commission_percentage, discount
base_crypto_atoms = round(crypto_atoms / (1 + effective_commission)) )
commission_amount_sats = crypto_atoms - base_crypto_atoms
else:
effective_commission = 0.0
base_crypto_atoms = crypto_atoms
commission_amount_sats = 0
# Calculate exchange rate # Calculate exchange rate
exchange_rate = base_crypto_atoms / fiat_amount if fiat_amount > 0 else 0 exchange_rate = calculate_exchange_rate(base_crypto_atoms, fiat_amount)
# Create transaction data with GTQ amounts # Create transaction data with GTQ amounts
transaction_data = CreateLamassuTransactionData( transaction_data = CreateLamassuTransactionData(
@ -1201,12 +1151,10 @@ class LamassuTransactionProcessor:
commission_percentage = transaction.get("commission_percentage", 0.0) commission_percentage = transaction.get("commission_percentage", 0.0)
discount = transaction.get("discount", 0.0) discount = transaction.get("discount", 0.0)
if commission_percentage and commission_percentage > 0: # Calculate commission amount using the extracted pure function
effective_commission = commission_percentage * (100 - discount) / 100 _, commission_amount_sats, _ = calculate_commission(
base_crypto_atoms = round(crypto_atoms / (1 + effective_commission)) crypto_atoms, commission_percentage, discount
commission_amount_sats = crypto_atoms - base_crypto_atoms )
else:
commission_amount_sats = 0
# Distribute to clients # Distribute to clients
await self.distribute_to_clients(transaction, distributions) await self.distribute_to_clients(transaction, distributions)