Skip to content

Commit d04ad6c

Browse files
Code refeactoring
1 parent 3e2a84a commit d04ad6c

23 files changed

+805
-695
lines changed

curo/calculator.py

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -97,14 +97,21 @@ def add(self, series: Series) -> None:
9797
series.amount = gauss_round(series.amount, self.precision)
9898
self._series.append(series)
9999

100-
def solve_value(self, convention: Convention, interest_rate: float) -> float:
100+
def solve_value(
101+
self,
102+
convention: Convention,
103+
interest_rate: float,
104+
start_date: Optional[pd.Timestamp] = None) -> float:
101105
"""
102106
Solves for one or more unknown payment or advance cash flow amounts to achieve
103107
a net future value (NFV) of zero.
104108
105109
Args:
106110
convention (Convention): Day count convention (e.g., US30360, USAppendixJ).
107111
interest_rate (float): Annualized interest rate (e.g., 0.12 for 12%).
112+
start_date (pd.Timestamp, optional): The start date for constructing the
113+
cash flow profile for `undated` series. Defaults to the current system
114+
date if None.
108115
109116
Returns:
110117
float: The raw cash flow amount (before weightings), unrounded.
@@ -130,7 +137,6 @@ def solve_value(self, convention: Convention, interest_rate: float) -> float:
130137
else:
131138
if not self._series:
132139
raise ValidationError("No cash flow series provided")
133-
start_date = min(s.post_date_from for s in self._series)
134140
cash_flows = self._build_profile(start_date)
135141

136142
sort_by = SortColumn.POST_DATE if convention.use_post_dates else SortColumn.VALUE_DATE
@@ -141,11 +147,6 @@ def solve_value(self, convention: Convention, interest_rate: float) -> float:
141147
mode=ValidationMode.SOLVE_VALUE
142148
)
143149

144-
# Check for at least one unknown amount
145-
# unknown_mask = ~cash_flows[Column.IS_KNOWN.value] | cash_flows[Column.AMOUNT.value].isna()
146-
# if not unknown_mask.any():
147-
# raise ValidationError("At least one unknown amount required")
148-
149150
# Assign day count factors
150151
cash_flows = self._assign_factors(cash_flows, convention)
151152

@@ -180,19 +181,26 @@ def nfv_function(value: float) -> float:
180181
self.profile = self._update_unknowns(
181182
cash_flows, value, precision=self.precision, is_rounded=True
182183
)
183-
184+
184185
# Amortise interest if not using XIRR method
185186
if not convention.use_xirr_method:
186187
self.profile = self._amortise_interest(self.profile, original_interest_rate)
187-
188-
return round(value, self.precision)
189188

190-
def solve_rate(self, convention: Convention, upper_bound: float = 10.0) -> float:
189+
return gauss_round(value, self.precision)
190+
191+
def solve_rate(
192+
self,
193+
convention: Convention,
194+
start_date: Optional[pd.Timestamp] = None,
195+
upper_bound: float = 10.0) -> float:
191196
"""
192197
Computes the effective interest rate that results in a net future value (NFV) of zero.
193198
194199
Args:
195200
convention (Convention): Day count convention (e.g., US30360, USAppendixJ).
201+
start_date (pd.Timestamp, optional): The start date for constructing the
202+
cash flow profile for `undated` series. Defaults to the current system
203+
date if None.
196204
upper_bound (float): Upper bound for the interest rate search (default: 10.0, or 1000%).
197205
198206
Returns:
@@ -219,7 +227,6 @@ def solve_rate(self, convention: Convention, upper_bound: float = 10.0) -> float
219227
else:
220228
if not self._series:
221229
raise ValidationError("No cash flow series provided")
222-
start_date = min(s.post_date_from for s in self._series) #FIXME
223230
cash_flows = self._build_profile(start_date)
224231

225232
sort_by = SortColumn.POST_DATE if convention.use_post_dates else SortColumn.VALUE_DATE
@@ -723,16 +730,16 @@ def _calculate_nfv(self, cash_flows: pd.DataFrame, day_count: Convention, rate:
723730
amount = row[Column.AMOUNT.value] or 0.0
724731

725732
if isinstance(day_count, USAppendixJ):
726-
principal_factor = (1 + rate) ** factor.year_fraction
727-
fractional_adjustment = (
728-
1.0 + (factor.fractional_period * rate)
729-
if (factor.fractional_period is not None and
730-
factor.fractional_period / 12.0 > 0.0)
733+
primary_period_factor = (1 + rate) ** factor.primary_period_fraction
734+
partial_period_factor = (
735+
1.0 + (factor.partial_period_fraction * rate)
736+
if (factor.partial_period_fraction is not None and
737+
factor.partial_period_fraction / 12.0 > 0.0)
731738
else 1.0
732739
)
733-
capital_balance += amount / (principal_factor * fractional_adjustment)
740+
capital_balance += amount / (primary_period_factor * partial_period_factor)
734741
else:
735-
capital_balance += amount * (1 + rate) ** (-factor.year_fraction)
742+
capital_balance += amount * (1 + rate) ** (-factor.primary_period_fraction)
736743
else: # NEIGHBOUR
737744
accrued_interest = 0.0
738745
for _, row in cash_flows.iterrows():
@@ -742,7 +749,7 @@ def _calculate_nfv(self, cash_flows: pd.DataFrame, day_count: Convention, rate:
742749
factor = row['factor']
743750
amount = row[Column.AMOUNT.value] or 0.0
744751
is_payment = row[Column.IS_INTEREST_CAPITALISED.value] is not None
745-
period_interest = capital_balance * rate * factor.year_fraction
752+
period_interest = capital_balance * rate * factor.primary_period_fraction
746753

747754
if is_payment:
748755
if row[Column.IS_INTEREST_CAPITALISED.value]:
@@ -797,7 +804,7 @@ def _update_unknowns(
797804

798805
# Apply rounding if requested
799806
if is_rounded:
800-
adjusted_values = adjusted_values.round(precision)
807+
adjusted_values = adjusted_values.apply(lambda x: gauss_round(x, precision))
801808

802809
# Update amounts for unknown cash flows
803810
cash_flows.loc[mask, Column.AMOUNT.value] = adjusted_values
@@ -838,13 +845,13 @@ def _amortise_interest(
838845
if row.get(Column.IS_CHARGE.value, False):
839846
continue
840847

841-
factor = row['factor'].year_fraction
848+
factor = row['factor'].primary_period_fraction
842849
amount = row[Column.AMOUNT.value] or 0.0
843-
period_interest = round(capital_balance * interest_rate * factor, precision)
850+
period_interest = gauss_round(capital_balance * interest_rate * factor, precision)
844851

845852
if row.get(Column.IS_INTEREST_CAPITALISED.value) is not None: # Payment
846853
if row[Column.IS_INTEREST_CAPITALISED.value]:
847-
interest = round(accrued_interest + period_interest, precision)
854+
interest = gauss_round(accrued_interest + period_interest, precision)
848855
capital_balance += interest + amount
849856
accrued_interest = 0.0
850857
else:
@@ -864,7 +871,7 @@ def _amortise_interest(
864871
if not payment_indices.empty:
865872
last_payment_idx = payment_indices[-1]
866873
current_interest = cash_flows.at[last_payment_idx, 'interest']
867-
cash_flows.at[last_payment_idx, 'interest'] = round(
874+
cash_flows.at[last_payment_idx, 'interest'] = gauss_round(
868875
current_interest - capital_balance, precision
869876
)
870877

curo/daycount/actual_360.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ class Actual360(Convention):
2929
... pd.Timestamp('2020-02-28', tz='UTC')
3030
... )
3131
>>> print(factor)
32-
31/360 = 0.08611111
32+
f = 31/360 = 0.08611111
3333
"""
3434
def __init__(
3535
self,
@@ -63,16 +63,16 @@ def compute_factor(self, start: pd.Timestamp, end: pd.Timestamp) -> DayCountFact
6363
... pd.Timestamp('2020-01-28', tz='UTC'),
6464
... pd.Timestamp('2020-02-28', tz='UTC')
6565
... )
66-
>>> factor.year_fraction
66+
>>> factor.primary_period_fraction
6767
0.08611111111111111
68-
>>> factor.year_fraction_operands
68+
>>> factor.discount_factor_log
6969
['31/360']
7070
"""
7171
if end < start:
7272
raise ValueError("end must be after start")
7373
days = (end - start).days if end > start else 0 # Exclude end date, 0 for same day
7474
factor = days / 360
7575
return DayCountFactor(
76-
year_fraction=factor,
77-
year_fraction_operands=[f"{days}/360"]
76+
primary_period_fraction=factor,
77+
discount_factor_log=[f"{days}/360"]
7878
)

curo/daycount/actual_365.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ class Actual365(Convention):
2929
... pd.Timestamp('2020-02-28', tz='UTC')
3030
... )
3131
>>> print(factor)
32-
31/365 = 0.08493151
32+
f = 31/365 = 0.08493151
3333
"""
3434
def __init__(
3535
self,
@@ -63,16 +63,16 @@ def compute_factor(self, start: pd.Timestamp, end: pd.Timestamp) -> DayCountFact
6363
... pd.Timestamp('2020-01-28', tz='UTC'),
6464
... pd.Timestamp('2020-02-28', tz='UTC')
6565
... )
66-
>>> factor.year_fraction
66+
>>> factor.primary_period_fraction
6767
0.08493150684931507
68-
>>> factor.year_fraction_operands
68+
>>> factor.discount_factor_log
6969
['31/365']
7070
"""
7171
if end < start:
7272
raise ValueError("end must be after start")
7373
days = (end - start).days if end > start else 0 # Exclude end date, 0 for same day
7474
factor = days / 365
7575
return DayCountFactor(
76-
year_fraction=factor,
77-
year_fraction_operands=[f"{days}/365"]
76+
primary_period_fraction=factor,
77+
discount_factor_log=[f"{days}/365"]
7878
)

curo/daycount/actual_isda.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class ActualISDA(Convention):
3131
... pd.Timestamp('2020-02-28', tz='UTC')
3232
... )
3333
>>> print(factor)
34-
31/366 = 0.08469945
34+
f = 31/366 = 0.08469945
3535
"""
3636
def __init__(
3737
self,
@@ -65,15 +65,15 @@ def compute_factor(self, start: pd.Timestamp, end: pd.Timestamp) -> DayCountFact
6565
... pd.Timestamp('2020-01-28', tz='UTC'),
6666
... pd.Timestamp('2020-02-28', tz='UTC')
6767
... )
68-
>>> factor.year_fraction
68+
>>> factor.primary_period_fraction
6969
0.08469945355191257
70-
>>> factor.year_fraction_operands
70+
>>> factor.discount_factor_log
7171
['31/366']
7272
"""
7373
if end < start:
7474
raise ValueError("end must be after start")
7575
if end == start:
76-
return DayCountFactor(year_fraction=0.0, year_fraction_operands=["0/365"])
76+
return DayCountFactor(primary_period_fraction=0.0, discount_factor_log=["0/365"])
7777

7878
start_year = start.year
7979
end_year = end.year
@@ -85,12 +85,12 @@ def compute_factor(self, start: pd.Timestamp, end: pd.Timestamp) -> DayCountFact
8585
denominator = 366 if calendar.isleap(start_year) else 365
8686
factor = days / denominator
8787
return DayCountFactor(
88-
year_fraction=factor,
89-
year_fraction_operands=[f"{days}/{denominator}"]
88+
primary_period_fraction=factor,
89+
discount_factor_log=[f"{days}/{denominator}"]
9090
)
9191

9292
# Multi-year: split by year
93-
operand_log = []
93+
discount_factor_log = []
9494
current_date = start
9595
current_year = start_year
9696

@@ -99,7 +99,7 @@ def compute_factor(self, start: pd.Timestamp, end: pd.Timestamp) -> DayCountFact
9999
days = (year_end - current_date).days + 1 if year_end >= current_date else 0
100100
denominator = 366 if calendar.isleap(current_year) else 365
101101
factor += days / denominator
102-
operand_log.append(f"{days}/{denominator}")
102+
discount_factor_log.append(f"{days}/{denominator}")
103103
current_date = year_end + pd.Timedelta(days=1) # Move to Jan 1 next year
104104
current_year += 1
105105

@@ -108,9 +108,9 @@ def compute_factor(self, start: pd.Timestamp, end: pd.Timestamp) -> DayCountFact
108108
denominator = 366 if calendar.isleap(end_year) else 365
109109
if days > 0:
110110
factor += days / denominator
111-
operand_log.append(f"{days}/{denominator}")
111+
discount_factor_log.append(f"{days}/{denominator}")
112112

113113
return DayCountFactor(
114-
year_fraction=factor,
115-
year_fraction_operands=operand_log
114+
primary_period_fraction=factor,
115+
discount_factor_log=discount_factor_log
116116
)

0 commit comments

Comments
 (0)