- 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>
147 lines
4.6 KiB
Python
147 lines
4.6 KiB
Python
"""
|
|
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
|