feat: add unit tests for DCA calculations with empirical Lamassu data
- 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:
parent
8d94dcc2b7
commit
397fd4b002
3 changed files with 560 additions and 84 deletions
147
calculations.py
Normal file
147
calculations.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue