Skip to content

Commit 85f899b

Browse files
Code refactoring
1 parent acc575f commit 85f899b

File tree

9 files changed

+661
-41
lines changed

9 files changed

+661
-41
lines changed

.pylintrc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[MESSAGES CONTROL]
2+
# R0902: Too many instance attributes
3+
# W0107: Unnecessary pass statement
4+
disable = R0902,W0107,

MANIFEST.in

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
include CONTRIBUTING.md
2-
include HISTORY.md
1+
include CHANGELOG.md
32
include LICENSE
43
include README.md
54

Makefile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@
22

33

44
# Rebuild the virtual environment if corrupted or outdated
5+
# [`uv pip install -e .` installs curo in editable mode]
56
rebuild-venv:
67
rm -rf .venv
78
rm uv.lock
89
uv venv
910
uv pip install .
11+
uv pip install -e .
1012
uv lock
13+
14+
15+
all-tests:
16+
uv run pytest tests/ -v -s

curo/__init__.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,24 @@
1-
"""Top-level package for Curo Python."""
1+
"""
2+
Curo - a feature-rich library for performing simple to advanced
3+
instalment credit financial calculations.
4+
"""
25

3-
__author__ = """Andrew Murphy"""
4-
__email__ = 'curocalculator@gmail.com'
6+
__author__ = "Andrew Murphy"
7+
__email__ = "curocalculator@gmail.com"
8+
9+
# Declare top-level shortcuts
10+
# from calculator import Calculator
11+
# from daycount.us_30_360 import US30360
12+
# from daycount.us_30u_360 import US30U360
13+
# from daycount.us_appendix_j import USAppendixJ
14+
# from series import SeriesAdvance, SeriesPayment, SeriesCharge
15+
16+
# __all__ = [
17+
# "Calculator",
18+
# "US30360",
19+
# "US30U360",
20+
# "USAppendixJ",
21+
# "SeriesAdvance",
22+
# "SeriesPayment",
23+
# "SeriesCharge",
24+
# ]

curo/calculator.py

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
"""
2+
Docstring for calculator
3+
"""
4+
5+
from dataclasses import dataclass
6+
from typing import List, Optional
7+
import pandas as pd
8+
from scipy.optimize import root_scalar
9+
from daycount.convention import Convention
10+
from daycount.us_appendix_j import USAppendixJ
11+
from series import Series, SeriesAdvance, SeriesPayment, SeriesCharge
12+
from enums import DayCountOrigin
13+
from utils import gauss_round
14+
15+
@dataclass
16+
class Calculator:
17+
"""_summary_
18+
19+
Raises:
20+
ValueError: _description_
21+
22+
Returns:
23+
_type_: _description_
24+
"""
25+
precision: int = 2
26+
profile: Optional[pd.DataFrame] = None
27+
series: List[Series] = None
28+
29+
30+
def __post_init__(self):
31+
if not 0 <= self.precision <= 4:
32+
raise ValueError("Precision must be between 0 and 4")
33+
self.series = self.series or []
34+
self._is_bespoke_profile = self.profile is not None
35+
if self._is_bespoke_profile:
36+
self._validate_profile()
37+
38+
39+
def _validate_profile(self):
40+
if self.profile is None:
41+
return
42+
if not {'post_date', 'value_date', 'amount',
43+
'is_known', 'weighting', 'label'
44+
}.issubset(self.profile.columns):
45+
raise ValueError("Profile DataFrame missing required columns")
46+
if self.profile['weighting'].le(0).any():
47+
raise ValueError("Weighting must be > 0")
48+
if (self.profile['value_date'] < self.profile['post_date']).any():
49+
raise ValueError("value_date must be on or after post_date")
50+
advances = self.profile[self.profile['amount'] < 0]
51+
if len(advances) == 0:
52+
raise ValueError("At least one advance required")
53+
payments = self.profile[self.profile['amount'] > 0]
54+
if len(payments) == 0:
55+
raise ValueError("At least one payment required")
56+
unknowns = self.profile[~self.profile['is_known']]
57+
if len(unknowns) > 1:
58+
raise ValueError("Only one unknown value or rate allowed")
59+
60+
61+
def add(self, series: Series):
62+
"""_summary_
63+
64+
Args:
65+
series (Series): _description_
66+
67+
Raises:
68+
ValueError: _description_
69+
"""
70+
if self._is_bespoke_profile:
71+
raise ValueError("Cannot add series with a bespoke profile")
72+
if series.value is not None:
73+
series.value = gauss_round(series.value, self.precision)
74+
self.series.append(series)
75+
76+
77+
def _build_profile(self, start_date: Optional[pd.Timestamp] = None) -> pd.DataFrame:
78+
start_date = start_date or pd.Timestamp.now(tz='UTC')
79+
cash_flows = pd.DataFrame()
80+
prev_end_date = None
81+
for s in self.series:
82+
cf = s.to_cash_flows(start_date, prev_end_date)
83+
if isinstance(s, SeriesAdvance):
84+
cf['amount'] = -abs(cf['amount']) # Advances are negative
85+
elif isinstance(s, SeriesPayment):
86+
cf['is_interest_capitalised'] = s.is_interest_capitalised
87+
elif isinstance(s, SeriesCharge):
88+
cf['is_charge'] = True
89+
cash_flows = pd.concat([cash_flows, cf], ignore_index=True)
90+
prev_end_date = cash_flows['post_date'].iloc[-1]
91+
self._validate_profile()
92+
return cash_flows
93+
94+
95+
def solve_value(
96+
self,
97+
day_count: Convention,
98+
interest_rate: float,
99+
start_date: Optional[pd.Timestamp] = None,
100+
root_guess: float = 0.1
101+
) -> float:
102+
"""_summary_
103+
104+
Args:
105+
day_count (Convention): _description_
106+
interest_rate (float): _description_
107+
start_date (Optional[pd.Timestamp], optional): _description_. Defaults to None.
108+
root_guess (float, optional): _description_. Defaults to 0.1.
109+
110+
Raises:
111+
ValueError: _description_
112+
113+
Returns:
114+
float: _description_
115+
"""
116+
if self.profile is None and not self._is_bespoke_profile:
117+
self.profile = self._build_profile(start_date)
118+
cash_flows = self._assign_factors(self.profile, day_count)
119+
if isinstance(day_count, USAppendixJ):
120+
interest_rate /= day_count.time_period.periods_in_year
121+
122+
def nfv_function(guess: float) -> float:
123+
updated_cash_flows = self._update_unknowns(cash_flows, guess)
124+
return self._calculate_nfv(updated_cash_flows, day_count, interest_rate)
125+
126+
result = root_scalar(nfv_function, bracket=[-1e6, 1e6], x0=root_guess)
127+
if not result.converged:
128+
raise ValueError("Failed to solve for value")
129+
130+
value = gauss_round(result.root, self.precision)
131+
self.profile = self._update_unknowns(cash_flows, value)
132+
133+
if not day_count.use_xirr_method:
134+
self.profile = self._amortise_interest(self.profile, interest_rate)
135+
136+
return value
137+
138+
139+
def solve_rate(
140+
self,
141+
day_count: Convention,
142+
start_date: Optional[pd.Timestamp] = None,
143+
root_guess: float = 0.1
144+
) -> float:
145+
"""_summary_
146+
147+
Args:
148+
day_count (Convention): _description_
149+
start_date (Optional[pd.Timestamp], optional): _description_. Defaults to None.
150+
root_guess (float, optional): _description_. Defaults to 0.1.
151+
152+
Raises:
153+
ValueError: _description_
154+
155+
Returns:
156+
float: _description_
157+
"""
158+
if self.profile is None and not self._is_bespoke_profile:
159+
self.profile = self._build_profile(start_date)
160+
161+
cash_flows = self._assign_factors(self.profile, day_count)
162+
163+
def nfv_function(rate: float) -> float:
164+
return self._calculate_nfv(cash_flows, day_count, rate)
165+
166+
result = root_scalar(nfv_function, bracket=[-0.99, 10.0], x0=root_guess)
167+
if not result.converged:
168+
raise ValueError("Failed to solve for rate")
169+
170+
rate = result.root
171+
if isinstance(day_count, USAppendixJ):
172+
rate *= day_count.time_period.periods_in_year
173+
174+
if not day_count.use_xirr_method:
175+
self.profile = self._amortise_interest(self.profile, rate)
176+
177+
return gauss_round(rate, self.precision)
178+
179+
180+
def _assign_factors(self, cash_flows: pd.DataFrame, day_count: Convention) -> pd.DataFrame:
181+
cash_flows = cash_flows.copy()
182+
if day_count.day_count_origin == DayCountOrigin.DRAWDOWN:
183+
start_date = cash_flows['post_date'].iloc[0]
184+
cash_flows['factor'] = cash_flows['post_date'].apply(
185+
lambda d: day_count.compute_factor(start_date, d)
186+
)
187+
else:
188+
cash_flows['prev_date'] = cash_flows['post_date'].shift(1)
189+
cash_flows.loc[0, 'prev_date'] = cash_flows.loc[0, 'post_date']
190+
cash_flows['factor'] = cash_flows.apply(
191+
lambda row: day_count.compute_factor(row['prev_date'], row['post_date']),
192+
axis=1
193+
)
194+
cash_flows = cash_flows.drop(columns=['prev_date'])
195+
return cash_flows
196+
197+
198+
def _calculate_nfv(self, cash_flows: pd.DataFrame, day_count: Convention, rate: float) -> float:
199+
final_date = cash_flows['post_date'].iloc[-1]
200+
capital_balance = 0.0
201+
accrued_interest = 0.0
202+
203+
for _, row in cash_flows.iterrows():
204+
if row.get('is_charge', False) and not day_count.include_non_financing_flows:
205+
continue
206+
factor = day_count.compute_factor(row['post_date'], final_date)
207+
amount = row['amount'] or 0.0
208+
if isinstance(day_count, USAppendixJ):
209+
fractional = factor.fractional_adjustment or 0.0
210+
fv = amount * (1 + fractional * rate) * (1 + rate) ** factor.principal_factor
211+
else:
212+
fv = amount * (1 + rate) ** factor.total_factor
213+
214+
if row.get('is_payment', False) and row.get('is_interest_capitalised', False):
215+
capital_balance += accrued_interest + fv
216+
accrued_interest = 0.0
217+
else:
218+
accrued_interest += fv * rate * factor.total_factor
219+
capital_balance += fv
220+
221+
return capital_balance
222+
223+
224+
def _update_unknowns(self, cash_flows: pd.DataFrame, value: float) -> pd.DataFrame:
225+
cash_flows = cash_flows.copy()
226+
mask = ~cash_flows['is_known']
227+
cash_flows.loc[mask, 'amount'] = value * cash_flows.loc[mask, 'weighting']
228+
cash_flows.loc[mask, 'is_known'] = True
229+
return cash_flows
230+
231+
232+
def _amortise_interest(self, cash_flows: pd.DataFrame, rate: float) -> pd.DataFrame:
233+
cash_flows = cash_flows.copy()
234+
capital_balance = 0.0
235+
cash_flows['interest'] = 0.0
236+
237+
for i, row in cash_flows.iterrows():
238+
if row.get('is_charge', False):
239+
continue
240+
factor = cash_flows['factor'].iloc[i]
241+
period_interest = capital_balance * rate * factor.total_factor
242+
if row.get('is_payment', False):
243+
if row.get('is_interest_capitalised', False):
244+
capital_balance += period_interest + row['amount']
245+
else:
246+
capital_balance += row['amount']
247+
cash_flows.loc[i, 'interest'] = period_interest
248+
else:
249+
capital_balance += period_interest + row['amount']
250+
return cash_flows

curo/cli.py

Lines changed: 0 additions & 16 deletions
This file was deleted.

0 commit comments

Comments
 (0)