Coverage for src/scrilla/analysis/objects/cashflow.py: 72%
121 statements
« prev ^ index » next coverage.py v6.4.2, created at 2022-07-18 18:14 +0000
« prev ^ index » next coverage.py v6.4.2, created at 2022-07-18 18:14 +0000
1# This file is part of scrilla: https://github.com/chinchalinchin/scrilla.
3# scrilla is free software: you can redistribute it and/or modify
4# it under the terms of the GNU General Public License version 3
5# as published by the Free Software Foundation.
7# scrilla is distributed in the hope that it will be useful,
8# but WITHOUT ANY WARRANTY; without even the implied warranty of
9# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10# GNU General Public License for more details.
12# You should have received a copy of the GNU General Public License
13# along with scrilla. If not, see <https://www.gnu.org/licenses/>
14# or <https://github.com/chinchalinchin/scrilla/blob/develop/main/LICENSE>.
16import datetime
18from scrilla import services, settings
19from scrilla.static import constants
20from scrilla.util import dater, errors, outputter
21import scrilla.analysis.estimators as estimators
23logger = outputter.Logger(
24 'scrilla.analysis.objects.cashflow', settings.LOG_LEVEL)
26# Technically these are periods
27FREQ_DAY = 1/365
28FREQ_MONTH = 1/12
29FREQ_QUARTER = 1/4
30FREQ_ANNUAL = 1
32# Frequency = 1 / period
35class Cashflow:
36 r"""
37 A class that represents a set of future cashflows. The class is initialized with the `sample` variable, a `dict` of past cashflows and their dates. From the `sample`, a linear regression model is inferred. Alternatively, a `growth_function` can be provided that describes the cash flow as a function of time measured in years. If a `growth_function` is provided, the class skips the linear regression model. See warning below for more information on constructing an instance of this cashflow. In general, it needs to know how to project future cashflows, whether that is through inference or a model assumption.
39 If the sample of data is not large enough to infer a linear regression model, the estimation model will default to simple Martingale process described by the equation,
41 $$ E(X_2 \mid X_1) = X_1 $$
43 Or, in plain English, the next expected value given the current value is the current value. To rephrase it yet again: without more information the best guess for the future value of an asset is its current value.
45 The growth model, whether estimated or provided, is used to project the future value of cashflows and then these projections are discounted back to the present by the `discount_rate`.
47 Parameters
48 ----------
49 1. **sample**: ``list``
50 *Optional*. A list comprised of the cashflow\'s historical values. The list must be ordered from latest to earliest, i.e. in descending order. Must be of the format: `{ 'date_1' : 'value_1', 'date_2': 'value_2', ... }`
51 2. **period**: ``float``
52 *Optional*. The period between the cash flow payments. Measured as the length of time between two distinct cash flows, assuming all such payments are evenly spaced across time. The value should be measured in years. If a period is not specified, then a period will be inferred from the sample of data by averaging the time periods between successive payments in the sample. Common period are statically accessible through `FREQ_DAY`, `FREQ_MONTH`, `FREQ_QUARTER` and `FREQ_ANNUAL`. (*Yes, I know period = 1 / frequency; deal with it.*)
53 3. **growth_function**: ``function``
54 *Optional*. A function that describes the cash flow as a function of time in years. If provided, the class will skip linear regression for estimating the cash flow model. If a `growth_function` is provided without a sample, a period must be specified. If a `growth_function` is provided with a sample and no period, the period will be inferred from the dates in the sample. If a `growth_function` is provided with a period, then the sample will be ignored altogether.
55 4. **discount_rate**: ``float``
56 *Optional.* The rate of return used to discount future cash flows back to the present. If not provided, the `discount_rate` defaults to the risk free rate defined by the **RISK_FREE** environment variable.
57 5. **constant**: ``float``
58 If the cashflow is constant with respect to time, specify the value of it with this argument. Will override `growth_function` and sample. If constant is specified, you MUST also specify a period or else you will encounter errors when trying to calculate the net present value of future cashflows.
60 .. warning::
61 * In general, the Cashflow object must always be initialized in one of the following ways:
62 1. **__init__** args: (`sample`) -> period inferred from sample, linear regression used for growth
63 2. **__init__** args: (`sample`, `period`) -> period from constructor, linear regression used for growth
64 3. **__init__** args: (`sample`, `period`, `growth_function`) -> period from constructor, `growth_function` used for growth, sample ignored
65 4. **__init__** args: (`sample`, `growth_function`) -> period inferred from sample, `growth_function` used for growth
66 5. **__init__** args: (`period`, `growth_function`) -> period from constructor, `growth_function` used for growth
67 6. **__init__** args: (`period`, `constant`) -> period from constructor, constant used for growth
69 .. notes::
70 * A constant cashflow can be specified in three ways: 1. By passing in a constant amount through the constructor `constant` variable. 2. By passing in a constant function with respect to time through the constructor `growth_function` variable. 3. By passing in a dataset of length one through the constructor `sample` variable. In any of the cases, you MUST pass in a period or the `net_present_value` method of this class will return False.
71 * Both a growth_function and a sample of data can be passed in at once to this class. If doing so, the `growth_function` will take precedence and be used for calculations in the `net_present_value` method. The sample will be used to infer the length of a period between cashflows, unless a period is also specified. If a period is specified in addition to `sample_prices` and `growth_function`, the period will take precedence over the period inferred from the sample of data.
72 * The coefficients of the inferred linear regression are accessibly through `Cashflow().alpha` (intercept) and `Cashflow().beta` (slope) instance variables. The time series used to create the model is accessible through the `Cashflow().time_series` instance variable; Note: it is measured in years and the `start_date` is set equal to 0. In other words, the intercept of the model represents, approximately, the value of the cashflow on the `start_date`.
74 .. todos::
75 * Implement prediction interval function to get error bars for graphs and general usage.
77 """
79 def __init__(self, sample=None, period=None, growth_function=None, constant=None, discount_rate=None):
80 self.sample = sample
81 self.period = period
82 self.growth_function = growth_function
84 # if constant is specified, override sample and growth_function
85 if constant is not None: 85 ↛ 86line 85 didn't jump to line 86, because the condition on line 85 was never true
86 logger.debug(
87 f'constant = $ {constant}; period MUST NOT be null!', 'Cashflow.__init__')
88 logger.debug(f'period = {self.period}', 'Cashflow.__init__')
89 self.constant = constant
90 self.sample = None
91 self.growth_function = None
92 else:
93 self.constant = None
95 # If sample provided, use simple linear regression
96 if self.sample is not None and self.growth_function is None: 96 ↛ 100line 96 didn't jump to line 100, because the condition on line 96 was never false
97 self.generate_time_series_for_sample()
98 self.regress_growth_function()
100 if discount_rate is None: 100 ↛ 101line 100 didn't jump to line 101, because the condition on line 100 was never true
101 self.discount_rate = services.get_risk_free_rate()
102 else:
103 self.discount_rate = discount_rate
105 logger.debug(
106 f'Using discount_rate = {self.discount_rate}', 'Cashflow.__init__')
108 # If no frequency is specified, infer frequency from sample
109 if self.sample is not None and self.period is None: 109 ↛ 112line 109 didn't jump to line 112, because the condition on line 109 was never false
110 self.infer_period()
112 if self.sample is not None and len(self.sample) > 0: 112 ↛ exitline 112 didn't return from function '__init__', because the condition on line 112 was never false
113 self.time_to_today = self.calculate_time_to_today()
115 def infer_period(self):
116 logger.debug(
117 'Attempting to infer period/frequency of cashflows.', 'Cashflow.infer_period')
119 # no_of_dates = len - 1 because delta is being computed, i.e.
120 # lose one date.
121 dates, no_of_dates = self.sample.keys(), (len(self.sample.keys()) - 1)
122 first_pass = True
123 mean_delta = 0
125 if no_of_dates < 2: 125 ↛ 126line 125 didn't jump to line 126, because the condition on line 125 was never true
126 logger.debug(
127 'Cannot infer period from sample size less than or equal to 1', 'Cashflow.infer_period')
128 self.period = None
129 self.frequency = None
131 else:
132 for date in dates:
133 if first_pass:
134 tomorrows_date = dater.parse(date)
135 first_pass = False
137 else:
138 todays_date = dater.parse(date)
139 # TODO: 365 or 252?
140 delta = (tomorrows_date - todays_date).days / 365
141 mean_delta += delta / no_of_dates
142 tomorrows_date = todays_date
144 self.period = mean_delta
145 self.frequency = 1 / self.period
146 logger.debug(
147 f'Inferred period = {self.period} yrs', 'Cashflow.infer_period')
148 logger.debug(
149 f'Inferred frequency = {self.frequency}', 'Cashflow.infer_period')
151 # TODO: trading days or actual days?
152 def generate_time_series_for_sample(self):
153 self.time_series = []
155 dates, no_of_dates = self.sample.keys(), len(self.sample.keys())
157 if no_of_dates == 0: 157 ↛ 158line 157 didn't jump to line 158, because the condition on line 157 was never true
158 logger.debug(
159 'Cannot generate a time series for a sample size of 0.', 'Cashflow.generate_time_series_for_sample')
160 self.time_series = None
161 else:
162 first_date = dater.parse(list(dates)[-1])
164 for date in dates:
165 this_date = dater.parse(date)
166 delta = (this_date - first_date).days
167 time_in_years = delta / 365
168 self.time_series.append(time_in_years)
170 # TODO: trading days or actual days?
171 def calculate_time_to_today(self):
172 first_date = dater.parse(list(self.sample.keys())[-1])
173 today = datetime.date.today()
174 return ((today - first_date).days/365)
176 def regress_growth_function(self):
177 to_list = [self.sample[date] for date in self.sample]
179 self.beta = estimators.simple_regression_beta(
180 x=self.time_series, y=to_list)
181 self.alpha = estimators.simple_regression_alpha(
182 x=self.time_series, y=to_list)
184 if not self.beta or not self.alpha: 184 ↛ 185line 184 didn't jump to line 185, because the condition on line 184 was never true
185 if len(self.sample) > 0:
186 self.alpha = list(self.sample.items())[0][1]
187 logger.debug(
188 'Error calculating regression coefficients; Defaulting to Markovian process E(X2|X1) = X1.', 'Cashflow.regress_growth_function')
189 logger.debug(
190 f'Estimation model : y = {self.alpha}', 'regress_growth_function')
191 else:
192 raise errors.SampleSizeError(
193 'Not enough information to formulate estimation model.')
195 else:
196 logger.debug(
197 f'Linear regression model : y = {self.beta} * x + {self.alpha}', 'Cashflow.regress_growth_function')
199 def generate_model_series(self):
200 return [self.alpha + self.beta*time for time in self.time_series]
202 def generate_model_comparison(self):
203 """
204 Returns a list of dictionaries with the predicted value of the linear regression model and the actual value on a given datas. Format: [ {'date': `str`, 'model_price': `float`, 'actual_price': `float` }, ... ]
205 """
206 model_prices = self.generate_model_series()
208 return[{'date': date,
209 'model_price': model_prices[index],
210 'actual_price': self.sample[date]}
211 for index, date in enumerate(self.sample.keys())]
213 def get_growth_function(self, x):
214 """
215 Traverses the hierarchy of instance variables to determine which method to use to describe the growth of future cashflows. Returns the value of determined function for the given value of `x`. Think of this function as a black box that hides the implementation of the `growth_function` from the user accessing the function.
217 Parameters
218 ----------
219 1. **x**: ``float``
220 Time in years.
222 Returns
223 -------
224 ``float`` : Value of the cash flow's growth function at time `x`.
226 """
227 if self.growth_function is None: 227 ↛ 231line 227 didn't jump to line 231, because the condition on line 227 was never false
228 if self.constant is not None: 228 ↛ 229line 228 didn't jump to line 229, because the condition on line 228 was never true
229 return self.constant
230 return (self.alpha + self.beta*(x + self.time_to_today))
231 return self.growth_function(x)
233 # TODO: use trading days or actual days?
234 def calculate_net_present_value(self):
235 """
236 Returns the net present value of the cash flow by using the `get_growth_function` method to project future cash flows and then discounting those projections back to the present by the value of the `discount_rate`. Call this method after constructing/initializing a `Cashflow` object to retrieve its NPV.
238 Raises
239 ------
240 1. **scrilla.errors.InputValidationError**
241 If not enough information is present in the instance of the `Cashflow` object to project future cash flows, this error will be thrown.
243 Returns
244 -------
245 ``float`` : NPV of cash flow.
246 """
247 if self.discount_rate < 0:
248 raise errors.ModelError(
249 f'Model assumptions violated: Cannot a future value with a discount rate of {self.discount_rate}')
251 if self.period is None: 251 ↛ 252line 251 didn't jump to line 252, because the condition on line 251 was never true
252 raise errors.InputValidationError(
253 "No period detected for cashflows. Not enough information to calculate net present value.")
255 time_to_first_payment = 0
256 if self.period is None: 256 ↛ 257line 256 didn't jump to line 257, because the condition on line 256 was never true
257 raise errors.InputValidationError(
258 'Not enough information to calculate net present value of cash flow.')
259 if self.period == FREQ_ANNUAL: 259 ↛ 260line 259 didn't jump to line 260, because the condition on line 259 was never true
260 time_to_first_payment = dater.get_time_to_next_year()
262 elif self.period == FREQ_QUARTER: 262 ↛ 263line 262 didn't jump to line 263, because the condition on line 262 was never true
263 time_to_first_payment = dater.get_time_to_next_quarter()
265 elif self.period == FREQ_MONTH: 265 ↛ 266line 265 didn't jump to line 266, because the condition on line 265 was never true
266 time_to_first_payment = dater.get_time_to_next_month()
268 elif self.period == FREQ_DAY: 268 ↛ 269line 268 didn't jump to line 269, because the condition on line 268 was never true
269 time_to_first_payment = FREQ_DAY
271 else:
272 dates = self.sample.keys()
273 latest_date = dater.parse(list(dates)[0])
274 time_to_first_payment = dater.get_time_to_next_period(
275 starting_date=latest_date, period=self.period)
277 net_present_value, i, current_time = 0, 0, 0
278 calculating = True
279 while calculating:
280 previous_value = net_present_value
281 current_time = time_to_first_payment + i * self.period
282 net_present_value += self.get_growth_function(current_time) / (
283 (1 + self.discount_rate)**current_time)
285 if net_present_value - previous_value < constants.constants['NPV_DELTA_TOLERANCE']:
286 calculating = False
287 i += 1
289 return net_present_value