Skip to content

Commit 0eb7e60

Browse files
committed
Update ytm formula and added tests
1 parent 29c0ddd commit 0eb7e60

4 files changed

Lines changed: 471 additions & 1 deletion

File tree

poms/common/formula_accruals.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -680,9 +680,14 @@ def f_xirr(data, x0=0.0, tol=0.000001, maxiter=100):
680680
# Update the rate using Newton-Raphson
681681
new_rate = rate - npv / npv_derivative
682682

683+
if isinstance(new_rate, complex):
684+
new_rate = new_rate.real
685+
if new_rate <= -1:
686+
return 0.0
687+
683688
# Check for convergence
684689
if abs(new_rate - rate) < tol:
685-
return new_rate
690+
return float(new_rate)
686691

687692
rate = new_rate
688693

poms/common/tests/test_f_xirr.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import datetime
2+
3+
from django.test import SimpleTestCase
4+
5+
from poms.common.formula_accruals import f_xirr, f_xnpv
6+
7+
8+
class TestFXnpv(SimpleTestCase):
9+
def test_zero_rate(self):
10+
data = [
11+
(datetime.date(2024, 1, 1), -1000),
12+
(datetime.date(2025, 1, 1), 1100),
13+
]
14+
result = f_xnpv(data, 0.0)
15+
self.assertAlmostEqual(result, 100.0, places=1)
16+
17+
def test_rate_minus_one_returns_inf(self):
18+
data = [
19+
(datetime.date(2024, 1, 1), -1000),
20+
(datetime.date(2025, 1, 1), 1100),
21+
]
22+
result = f_xnpv(data, -1)
23+
self.assertEqual(result, float("inf"))
24+
25+
def test_positive_rate_discounts(self):
26+
data = [
27+
(datetime.date(2024, 1, 1), -1000),
28+
(datetime.date(2025, 1, 1), 1050),
29+
]
30+
result_at_0 = f_xnpv(data, 0.0)
31+
result_at_10 = f_xnpv(data, 0.10)
32+
self.assertGreater(result_at_0, result_at_10)
33+
34+
def test_result_is_always_real(self):
35+
data = [
36+
(datetime.date(2024, 1, 1), -1000),
37+
(datetime.date(2025, 6, 15), 1100),
38+
]
39+
for rate in [0.0, 0.5, -0.5, -0.99]:
40+
result = f_xnpv(data, rate)
41+
self.assertIsInstance(result, float, f"f_xnpv returned non-float for rate={rate}")
42+
43+
44+
class TestFXirr(SimpleTestCase):
45+
def test_empty_data_returns_zero(self):
46+
self.assertEqual(f_xirr([]), 0.0)
47+
48+
def test_simple_positive_return(self):
49+
data = [
50+
(datetime.date(2024, 1, 1), -1000),
51+
(datetime.date(2025, 1, 1), 1100),
52+
]
53+
result = f_xirr(data)
54+
self.assertAlmostEqual(result, 0.1, places=3)
55+
56+
def test_simple_negative_return(self):
57+
"""Bought above par, small coupon — YTM should be negative."""
58+
data = [
59+
(datetime.date(2024, 1, 1), -1050),
60+
(datetime.date(2025, 1, 1), 1010),
61+
]
62+
result = f_xirr(data)
63+
self.assertLess(result, 0)
64+
self.assertGreater(result, -1)
65+
66+
def test_result_is_always_float(self):
67+
data = [
68+
(datetime.date(2024, 1, 1), -1000),
69+
(datetime.date(2025, 1, 1), 1050),
70+
]
71+
result = f_xirr(data)
72+
self.assertIsInstance(result, float)
73+
74+
def test_never_returns_complex(self):
75+
"""f_xirr must never return a complex number, regardless of input."""
76+
test_cases = [
77+
[
78+
(datetime.date(2024, 1, 1), -1000),
79+
(datetime.date(2025, 1, 1), 1),
80+
],
81+
[
82+
(datetime.date(2024, 1, 1), -1000),
83+
(datetime.date(2025, 1, 1), 0.01),
84+
],
85+
[
86+
(datetime.date(2024, 1, 1), -10000),
87+
(datetime.date(2024, 3, 15), 5),
88+
(datetime.date(2025, 1, 1), 10),
89+
],
90+
]
91+
for data in test_cases:
92+
result = f_xirr(data)
93+
self.assertNotIsInstance(result, complex, f"f_xirr returned complex for data={data}")
94+
self.assertIsInstance(result, float)
95+
96+
def test_maturity_in_past_defaulted_bond(self):
97+
"""
98+
Reproduces the production error with USP7807HAM71:
99+
bond in default, maturity_date (2022) is before trade_date (2026).
100+
Cash flows have dates going backwards, which caused complex numbers.
101+
YTM should be 0.0 for such instruments.
102+
"""
103+
data = [
104+
(datetime.date(2026, 3, 20), -98.5), # purchase (trade_date)
105+
(datetime.date(2022, 6, 15), 100.0), # maturity in the past (defaulted)
106+
]
107+
result = f_xirr(data)
108+
self.assertIsInstance(result, float)
109+
self.assertNotIsInstance(result, complex)
110+
111+
def test_maturity_in_past_with_coupons(self):
112+
"""
113+
Defaulted bond with past maturity and past coupon dates.
114+
All future cash flows are in the past relative to purchase.
115+
"""
116+
data = [
117+
(datetime.date(2026, 3, 20), -98.5), # purchase
118+
(datetime.date(2021, 12, 15), 3.5), # coupon (past)
119+
(datetime.date(2022, 6, 15), 3.5), # coupon (past)
120+
(datetime.date(2022, 6, 15), 100.0), # maturity (past)
121+
]
122+
result = f_xirr(data)
123+
self.assertIsInstance(result, float)
124+
self.assertNotIsInstance(result, complex)
125+
126+
def test_extreme_loss_does_not_produce_complex(self):
127+
"""
128+
Extreme loss scenario: paid a lot, getting almost nothing back.
129+
Newton-Raphson may try rates below -100%.
130+
"""
131+
data = [
132+
(datetime.date(2024, 1, 1), -10000),
133+
(datetime.date(2025, 1, 1), 1),
134+
]
135+
result = f_xirr(data)
136+
self.assertIsInstance(result, float)
137+
self.assertEqual(result, 0.0)
138+
139+
def test_rate_never_exceeds_minus_one(self):
140+
"""Result should never be <= -1 (below -100%)."""
141+
data = [
142+
(datetime.date(2024, 1, 1), -1000),
143+
(datetime.date(2025, 1, 1), 50),
144+
]
145+
result = f_xirr(data)
146+
self.assertIsInstance(result, float)
147+
self.assertGreaterEqual(result, -1)
148+
149+
def test_with_multiple_cash_flows(self):
150+
"""Standard bond with semiannual coupons."""
151+
data = [
152+
(datetime.date(2024, 1, 1), -1000),
153+
(datetime.date(2024, 7, 1), 25),
154+
(datetime.date(2025, 1, 1), 25),
155+
(datetime.date(2025, 7, 1), 25),
156+
(datetime.date(2026, 1, 1), 1025),
157+
]
158+
result = f_xirr(data)
159+
self.assertIsInstance(result, float)
160+
self.assertAlmostEqual(result, 0.05, places=2)
161+
162+
def test_bad_initial_guess(self):
163+
"""Large x0 far from the real answer should still converge or return 0."""
164+
data = [
165+
(datetime.date(2024, 1, 1), -1000),
166+
(datetime.date(2025, 1, 1), 1050),
167+
]
168+
result = f_xirr(data, x0=10.0)
169+
self.assertIsInstance(result, float)
170+
self.assertNotIsInstance(result, complex)
171+
172+
def test_non_convergence_returns_zero(self):
173+
"""If maxiter=1, method likely won't converge — should return 0.0."""
174+
data = [
175+
(datetime.date(2024, 1, 1), -1000),
176+
(datetime.date(2025, 1, 1), 1050),
177+
]
178+
result = f_xirr(data, maxiter=1)
179+
self.assertIsInstance(result, float)
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from django.test import SimpleTestCase
2+
3+
from poms.transactions.utils import _sanitize_ytm
4+
5+
6+
class TestSanitizeYtm(SimpleTestCase):
7+
def test_none_returns_zero(self):
8+
self.assertEqual(_sanitize_ytm(None), 0.0)
9+
10+
def test_complex_returns_zero(self):
11+
self.assertEqual(_sanitize_ytm(complex(-0.98, 0.58)), 0.0)
12+
13+
def test_complex_with_tiny_imaginary_returns_zero(self):
14+
"""Even complex with negligible imaginary part should return 0."""
15+
self.assertEqual(_sanitize_ytm(complex(-0.617, 8.3e-17)), 0.0)
16+
17+
def test_complex_from_production_error(self):
18+
"""Exact complex values from production error logs."""
19+
self.assertEqual(
20+
_sanitize_ytm(complex(-0.9802270234352741, 0.586687270683598)),
21+
0.0,
22+
)
23+
self.assertEqual(
24+
_sanitize_ytm(complex(-1.6290682385111677, 0.24653067562824657)),
25+
0.0,
26+
)
27+
self.assertEqual(
28+
_sanitize_ytm(complex(-0.6171933097394031, 8.336640977585254e-17)),
29+
0.0,
30+
)
31+
32+
def test_nan_returns_zero(self):
33+
self.assertEqual(_sanitize_ytm(float("nan")), 0.0)
34+
35+
def test_inf_returns_zero(self):
36+
self.assertEqual(_sanitize_ytm(float("inf")), 0.0)
37+
self.assertEqual(_sanitize_ytm(float("-inf")), 0.0)
38+
39+
def test_normal_float_passes_through(self):
40+
result = _sanitize_ytm(0.05)
41+
self.assertIsInstance(result, float)
42+
self.assertAlmostEqual(result, 0.05, places=4)
43+
44+
def test_negative_float_passes_through(self):
45+
result = _sanitize_ytm(-0.03)
46+
self.assertIsInstance(result, float)
47+
self.assertAlmostEqual(result, -0.03, places=4)
48+
49+
def test_zero_returns_zero(self):
50+
self.assertEqual(_sanitize_ytm(0), 0.0)
51+
self.assertEqual(_sanitize_ytm(0.0), 0.0)
52+
53+
def test_integer_works(self):
54+
result = _sanitize_ytm(1)
55+
self.assertIsInstance(result, (int, float))
56+
57+
def test_string_returns_zero(self):
58+
self.assertEqual(_sanitize_ytm("0.05"), 0.0)
59+
60+
def test_result_is_never_complex(self):
61+
test_values = [
62+
None,
63+
0,
64+
0.0,
65+
1,
66+
-1,
67+
0.05,
68+
-0.05,
69+
float("nan"),
70+
float("inf"),
71+
float("-inf"),
72+
complex(1, 0),
73+
complex(0, 1),
74+
complex(-0.98, 0.58),
75+
]
76+
for val in test_values:
77+
result = _sanitize_ytm(val)
78+
self.assertNotIsInstance(result, complex, f"_sanitize_ytm({val!r}) returned complex")
79+
self.assertIsInstance(result, (int, float), f"_sanitize_ytm({val!r}) didn't return a number")

0 commit comments

Comments
 (0)