- 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.
163 lines
4.5 KiB
Python
163 lines
4.5 KiB
Python
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)
|