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)