feat: Enhance currency handling and validation across scenarios
- Updated form template to prefill currency input with default value and added help text for clarity. - Modified integration tests to assert more descriptive error messages for invalid currency codes. - Introduced new tests for currency normalization and validation in various scenarios, including imports and exports. - Added comprehensive tests for pricing calculations, ensuring defaults are respected and overrides function correctly. - Implemented unit tests for pricing settings repository, ensuring CRUD operations and default settings are handled properly. - Enhanced scenario pricing evaluation tests to validate currency handling and metadata defaults. - Added simulation tests to ensure Monte Carlo runs are accurate and handle various distribution scenarios.
This commit is contained in:
162
tests/test_financial.py
Normal file
162
tests/test_financial.py
Normal file
@@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from services.financial import (
|
||||
CashFlow,
|
||||
PaybackNotReachedError,
|
||||
internal_rate_of_return,
|
||||
net_present_value,
|
||||
normalize_cash_flows,
|
||||
payback_period,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_cash_flows_with_dates() -> None:
|
||||
base = date(2025, 1, 1)
|
||||
period_length = 365.0 / 4
|
||||
flows = [
|
||||
CashFlow(amount=-1_000_000, date=base),
|
||||
CashFlow(amount=350_000, date=date(2025, 4, 1)),
|
||||
CashFlow(amount=420_000, date=date(2025, 7, 1)),
|
||||
]
|
||||
|
||||
normalised = normalize_cash_flows(flows, compounds_per_year=4)
|
||||
|
||||
assert normalised[0] == (-1_000_000.0, 0.0)
|
||||
expected_second = (date(2025, 4, 1) - base).days / period_length
|
||||
expected_third = (date(2025, 7, 1) - base).days / period_length
|
||||
|
||||
assert normalised[1][1] == pytest.approx(expected_second, rel=1e-6)
|
||||
assert normalised[2][1] == pytest.approx(expected_third, rel=1e-6)
|
||||
|
||||
|
||||
def test_net_present_value_with_period_indices() -> None:
|
||||
rate = 0.10
|
||||
flows = [
|
||||
CashFlow(amount=-1_000, period_index=0),
|
||||
CashFlow(amount=500, period_index=1),
|
||||
CashFlow(amount=500, period_index=2),
|
||||
CashFlow(amount=500, period_index=3),
|
||||
]
|
||||
|
||||
expected = -1_000 + sum(500 / (1 + rate) **
|
||||
period for period in range(1, 4))
|
||||
|
||||
result = net_present_value(rate, flows)
|
||||
|
||||
assert result == pytest.approx(expected, rel=1e-9)
|
||||
|
||||
|
||||
def test_net_present_value_with_residual_value() -> None:
|
||||
rate = 0.08
|
||||
flows = [
|
||||
CashFlow(amount=-100_000, period_index=0),
|
||||
CashFlow(amount=30_000, period_index=1),
|
||||
CashFlow(amount=35_000, period_index=2),
|
||||
]
|
||||
|
||||
expected = (
|
||||
-100_000
|
||||
+ 30_000 / (1 + rate)
|
||||
+ 35_000 / (1 + rate) ** 2
|
||||
+ 25_000 / (1 + rate) ** 3
|
||||
)
|
||||
|
||||
result = net_present_value(rate, flows, residual_value=25_000)
|
||||
|
||||
assert result == pytest.approx(expected, rel=1e-9)
|
||||
|
||||
|
||||
def test_internal_rate_of_return_simple_case() -> None:
|
||||
flows = [
|
||||
CashFlow(amount=-1_000, period_index=0),
|
||||
CashFlow(amount=1_210, period_index=1),
|
||||
]
|
||||
|
||||
irr = internal_rate_of_return(flows, guess=0.05)
|
||||
|
||||
assert irr == pytest.approx(0.21, rel=1e-9)
|
||||
|
||||
|
||||
def test_internal_rate_of_return_multiple_sign_changes() -> None:
|
||||
flows = [
|
||||
CashFlow(amount=-500_000, period_index=0),
|
||||
CashFlow(amount=250_000, period_index=1),
|
||||
CashFlow(amount=-100_000, period_index=2),
|
||||
CashFlow(amount=425_000, period_index=3),
|
||||
]
|
||||
|
||||
irr = internal_rate_of_return(flows, guess=0.2)
|
||||
|
||||
npv = net_present_value(irr, flows)
|
||||
|
||||
assert npv == pytest.approx(0.0, abs=1e-6)
|
||||
|
||||
|
||||
def test_internal_rate_of_return_requires_mixed_signs() -> None:
|
||||
flows = [
|
||||
CashFlow(amount=100_000, period_index=0),
|
||||
CashFlow(amount=150_000, period_index=1),
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
internal_rate_of_return(flows)
|
||||
|
||||
|
||||
def test_payback_period_exact_period() -> None:
|
||||
flows = [
|
||||
CashFlow(amount=-120_000, period_index=0),
|
||||
CashFlow(amount=40_000, period_index=1),
|
||||
CashFlow(amount=40_000, period_index=2),
|
||||
CashFlow(amount=40_000, period_index=3),
|
||||
]
|
||||
|
||||
period = payback_period(flows, allow_fractional=False)
|
||||
|
||||
assert period == pytest.approx(3.0)
|
||||
|
||||
|
||||
def test_payback_period_fractional_period() -> None:
|
||||
flows = [
|
||||
CashFlow(amount=-100_000, period_index=0),
|
||||
CashFlow(amount=80_000, period_index=1),
|
||||
CashFlow(amount=30_000, period_index=2),
|
||||
]
|
||||
|
||||
fractional = payback_period(flows)
|
||||
whole = payback_period(flows, allow_fractional=False)
|
||||
|
||||
assert fractional == pytest.approx(1 + 20_000 / 30_000, rel=1e-9)
|
||||
assert whole == pytest.approx(2.0)
|
||||
|
||||
|
||||
def test_payback_period_raises_when_never_recovered() -> None:
|
||||
flows = [
|
||||
CashFlow(amount=-250_000, period_index=0),
|
||||
CashFlow(amount=50_000, period_index=1),
|
||||
CashFlow(amount=60_000, period_index=2),
|
||||
CashFlow(amount=70_000, period_index=3),
|
||||
]
|
||||
|
||||
with pytest.raises(PaybackNotReachedError):
|
||||
payback_period(flows)
|
||||
|
||||
|
||||
def test_payback_period_with_quarterly_compounding() -> None:
|
||||
base = date(2025, 1, 1)
|
||||
flows = [
|
||||
CashFlow(amount=-120_000, date=base),
|
||||
CashFlow(amount=35_000, date=date(2025, 4, 1)),
|
||||
CashFlow(amount=35_000, date=date(2025, 7, 1)),
|
||||
CashFlow(amount=50_000, date=date(2025, 10, 1)),
|
||||
]
|
||||
|
||||
period = payback_period(flows, compounds_per_year=4)
|
||||
|
||||
period_length = 365.0 / 4
|
||||
expected_period = (date(2025, 10, 1) - base).days / period_length
|
||||
|
||||
assert period == pytest.approx(expected_period, abs=1e-6)
|
||||
Reference in New Issue
Block a user