|
| 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 |
0 commit comments