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

1# This file is part of scrilla: https://github.com/chinchalinchin/scrilla. 

2 

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. 

6 

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. 

11 

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>. 

15 

16import datetime 

17 

18from scrilla import services, settings 

19from scrilla.static import constants 

20from scrilla.util import dater, errors, outputter 

21import scrilla.analysis.estimators as estimators 

22 

23logger = outputter.Logger( 

24 'scrilla.analysis.objects.cashflow', settings.LOG_LEVEL) 

25 

26# Technically these are periods 

27FREQ_DAY = 1/365 

28FREQ_MONTH = 1/12 

29FREQ_QUARTER = 1/4 

30FREQ_ANNUAL = 1 

31 

32# Frequency = 1 / period 

33 

34 

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.  

38 

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, 

40 

41 $$ E(X_2 \mid X_1) = X_1 $$ 

42 

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. 

44 

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`.  

46 

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. 

59 

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 

68 

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`. 

73 

74 .. todos:: 

75 * Implement prediction interval function to get error bars for graphs and general usage. 

76 

77 """ 

78 

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 

83 

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 

94 

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() 

99 

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 

104 

105 logger.debug( 

106 f'Using discount_rate = {self.discount_rate}', 'Cashflow.__init__') 

107 

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() 

111 

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() 

114 

115 def infer_period(self): 

116 logger.debug( 

117 'Attempting to infer period/frequency of cashflows.', 'Cashflow.infer_period') 

118 

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 

124 

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 

130 

131 else: 

132 for date in dates: 

133 if first_pass: 

134 tomorrows_date = dater.parse(date) 

135 first_pass = False 

136 

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 

143 

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') 

150 

151 # TODO: trading days or actual days? 

152 def generate_time_series_for_sample(self): 

153 self.time_series = [] 

154 

155 dates, no_of_dates = self.sample.keys(), len(self.sample.keys()) 

156 

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]) 

163 

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) 

169 

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) 

175 

176 def regress_growth_function(self): 

177 to_list = [self.sample[date] for date in self.sample] 

178 

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) 

183 

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.') 

194 

195 else: 

196 logger.debug( 

197 f'Linear regression model : y = {self.beta} * x + {self.alpha}', 'Cashflow.regress_growth_function') 

198 

199 def generate_model_series(self): 

200 return [self.alpha + self.beta*time for time in self.time_series] 

201 

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() 

207 

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())] 

212 

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.  

216 

217 Parameters 

218 ---------- 

219 1. **x**: ``float`` 

220 Time in years. 

221 

222 Returns 

223 ------- 

224 ``float`` : Value of the cash flow's growth function at time `x`. 

225 

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) 

232 

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. 

237 

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. 

242 

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}') 

250 

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.") 

254 

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() 

261 

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() 

264 

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() 

267 

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 

270 

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) 

276 

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) 

284 

285 if net_present_value - previous_value < constants.constants['NPV_DELTA_TOLERANCE']: 

286 calculating = False 

287 i += 1 

288 

289 return net_present_value