- All domain models now use pydantic.BaseModel
- Removed manual to_dict()/from_dict() — use model_dump(mode='json')/model_validate()
- Added field validators:
- Account.balance >= 0
- Transaction.amount != 0
- RecurringCashflow.amount != 0, frequency in {daily, weekly, monthly, yearly}
- Liability.interest, payment >= 0
- ExchangeRate.rate > 0
- ForecastScenario.multipliers >= 0
- pydantic v2 native UUID handling (auto str in JSON)
- Updated excel_sync.py to use model_validate()
- Updated tests/test_model.py and tests/test_currency.py
- Added pydantic>=2.0 to pyproject.toml
Tests: 63/63 pass.
71 lines
2.0 KiB
Python
71 lines
2.0 KiB
Python
from pydantic import BaseModel, Field, field_validator
|
|
|
|
CURRENCY_SYMBOLS = {
|
|
"RUB": "₽",
|
|
"USD": "$",
|
|
"EUR": "€",
|
|
"GBP": "£",
|
|
"CNY": "¥",
|
|
"JPY": "¥",
|
|
"KZT": "₸",
|
|
"UAH": "₴",
|
|
}
|
|
|
|
|
|
class ExchangeRate(BaseModel):
|
|
from_currency: str = "USD"
|
|
to_currency: str = "RUB"
|
|
rate: float = 80.0
|
|
|
|
@field_validator("rate")
|
|
@classmethod
|
|
def _rate_positive(cls, v: float) -> float:
|
|
if v <= 0:
|
|
raise ValueError("rate must be positive")
|
|
return v
|
|
|
|
|
|
DEFAULT_RATES: list[ExchangeRate] = [
|
|
ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0),
|
|
]
|
|
|
|
|
|
class CurrencyError(Exception):
|
|
pass
|
|
|
|
|
|
class CurrencyConverter:
|
|
def __init__(self, rates: list[ExchangeRate] | None = None):
|
|
self._rates: dict[tuple[str, str], float] = {}
|
|
if rates:
|
|
for r in rates:
|
|
self.set_rate(r.from_currency, r.to_currency, r.rate)
|
|
|
|
def set_rate(self, from_currency: str, to_currency: str, rate: float) -> None:
|
|
if rate <= 0:
|
|
raise CurrencyError(f"Rate must be positive: {rate}")
|
|
self._rates[(from_currency, to_currency)] = rate
|
|
inverse = 1.0 / rate
|
|
self._rates[(to_currency, from_currency)] = inverse
|
|
|
|
def get_rate(self, from_currency: str, to_currency: str) -> float:
|
|
if from_currency == to_currency:
|
|
return 1.0
|
|
try:
|
|
return self._rates[(from_currency, to_currency)]
|
|
except KeyError:
|
|
raise CurrencyError(f"No exchange rate: {from_currency} → {to_currency}")
|
|
|
|
def convert(self, amount: float, from_currency: str, to_currency: str) -> float:
|
|
if from_currency == to_currency:
|
|
return amount
|
|
rate = self.get_rate(from_currency, to_currency)
|
|
return round(amount * rate, 2)
|
|
|
|
def get_symbol(self, currency: str) -> str:
|
|
return CURRENCY_SYMBOLS.get(currency, currency)
|
|
|
|
@classmethod
|
|
def with_defaults(cls) -> "CurrencyConverter":
|
|
return cls(DEFAULT_RATES)
|