Coverage for src/scrilla/analysis/optimizer.py: 66%
103 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
2# This file is part of scrilla: https://github.com/chinchalinchin/scrilla.
4# scrilla is free software: you can redistribute it and/or modify
5# it under the terms of the GNU General Public License version 3
6# as published by the Free Software Foundation.
8# scrilla is distributed in the hope that it will be useful,
9# but WITHOUT ANY WARRANTY; without even the implied warranty of
10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11# GNU General Public License for more details.
13# You should have received a copy of the GNU General Public License
14# along with scrilla. If not, see <https://www.gnu.org/licenses/>
15# or <https://github.com/chinchalinchin/scrilla/blob/develop/main/LICENSE>.
17"""
18A module of functions that wrap around `scipy.optimize` in order to optimize statistical and financial functions of interest.
19"""
21from typing import List, Tuple
22from math import sqrt
24import scipy.optimize as optimize
26from scrilla import settings
27from scrilla.static import constants
28from scrilla.analysis import estimators
29from scrilla.analysis.objects.portfolio import Portfolio
30import scrilla.util.outputter as outputter
32logger = outputter.Logger('scrilla.analysis.optimizer', settings.LOG_LEVEL)
35def maximize_univariate_normal_likelihood(data: List[float]) -> List[float]:
36 r"""
37 Maximizes the normal (log-)likelihood of the sample with respect to the mean and volatility in order to estimate the mean and volatility of the population distribution described by the sample.
39 Parameters
40 ----------
41 1. **data**: ``List[float]``
42 Sample of data drawn from a univariate normal population.
44 Returns
45 -------
46 ``List[float]``
47 A list containing the maximum likelihood estimates of the univariates normal distribution's parameters. The first element of the list corresponds to the mean and the second element corresponds to the volatility.
49 .. notes::
50 * Some comments about the methodology. This module assumes an underlying asset price process that follows Geometric Brownian motion. This implies the return on the asset over intervals of \\(\delta t\\) is normally distributed with mean \\(\mu * \delta t\\) and volatility \\(\sigma \cdot \sqrt{\delta t}\\). If the sample is scaled by \\(\delta t\\), then the mean becomes \\(\mu\\) and the volatility \\(\frac{\sigma}{\sqrt t}\\). Moreover, increments are independent. Therefore, if the observations are made over equally spaced intervals, each observation is drawn from an independent, identially distributed normal random variable. The parameters \\(\mu\\) and :\\(\frac {\sigma}{\delta t}\\) can then be estimated by maximizing the probability of observing a given sample with respect to the parameters. To obtain the estimate for \\(\sigma\\), multiply the result of this function by \\(\delta t\\).
51 * Theoretically, the output of this function should equal the same value obtained from the method of moment matching. However, there is a small discrepancy. It could be due to floating point arthimetic. However, see Section 2.2 of the following for what I think may be, if not the source, at least related to the [issue](https://www.researchgate.net/publication/5071468_Maximum_Likelihood_Estimation_of_Generalized_Ito_Processes_With_Discretely-Sampled_Data)
52 The author, however, is not considering the transformed Ito process, the log of the asset price process. It seems like his conclusion may be an artifact of Ito's Lemma? Not sure. Will need to think.
53 """
55 def likelihood(x): return (-1) * \
56 estimators.univariate_normal_likelihood_function(params=x, data=data)
58 # make an educated guess
59 first_quartile = estimators.sample_percentile(data=data, percentile=0.25)
60 median = estimators.sample_percentile(data=data, percentile=0.5)
61 third_quartile = estimators.sample_percentile(data, percentile=0.75)
62 guess = [median, (third_quartile-first_quartile)/2]
64 params = optimize.minimize(fun=likelihood, x0=guess, options={'disp': False},
65 method=constants.constants['OPTIMIZATION_METHOD'])
66 return params.x
69def maximize_bivariate_normal_likelihood(data: List[Tuple[float, float]]) -> List[float]:
70 r"""
72 .. warning ::
73 This can take an extremely long time to solve...
75 Returns
76 -------
77 ``List[float]``
78 A list containing the maximum likelihood estimates of the bivariates normal distribution's parameters. The first element of the list corresponds to \\(\mu_x)\\), the second element the \\(\mu_y)\\), the third element \\(\sigma_x)\\), the fourth element \\(\sigma_y)\\) and the fifth element \\(\rho_{xy} \cdot \sigma_y \cdot \sigma_x)\\).
79 """
81 x_data = [datum[0] for datum in data]
82 y_data = [datum[1] for datum in data]
84 x_25_percentile = estimators.sample_percentile(x_data, 0.25)
85 y_25_percentile = estimators.sample_percentile(y_data, 0.25)
86 x_median = estimators.sample_percentile(x_data, 0.5)
87 y_median = estimators.sample_percentile(y_data, 0.5)
88 x_75_percentile = estimators.sample_percentile(x_data, 0.75)
89 y_75_percentile = estimators.sample_percentile(y_data, 0.75)
90 x_1_percentile = estimators.sample_percentile(x_data, 0.01)
91 y_1_percentile = estimators.sample_percentile(y_data, 0.01)
92 x_99_percentile = estimators.sample_percentile(x_data, 0.99)
93 y_99_percentile = estimators.sample_percentile(y_data, 0.99)
95 def likelihood(x): return (-1)*estimators.bivariate_normal_likelihood_function(params=x,
96 data=data)
98 var_x_guess = (x_75_percentile - x_25_percentile)/2
99 var_y_guess = (y_75_percentile - y_25_percentile)/2
100 guess = [x_median, y_median, var_x_guess, var_y_guess, 0]
101 var_x_bounds = x_99_percentile - x_1_percentile
102 var_y_bounds = y_99_percentile - y_1_percentile
103 cov_bounds = sqrt(var_x_bounds*var_y_bounds)
105 params = optimize.minimize(fun=likelihood,
106 x0=guess,
107 bounds=[
108 (None, None),
109 (None, None),
110 (0, var_x_bounds),
111 (0, var_y_bounds),
112 (-cov_bounds, cov_bounds)
113 ],
114 options={'disp': False},
115 method='Nelder-Mead')
116 return params.x
119def optimize_portfolio_variance(portfolio: Portfolio, target_return: float = None) -> List[float]:
120 """
121 Parameters
122 ----------
123 1. **portfolio**: `scrilla.analysis.objects.Portfolio`
124 An instance of the `Portfolio` class. Must be initialized with an array of ticker symbols. Optionally, it can be initialized with a start_date and end_date datetime. If start_date and end_date are specified, the portfolio will be optimized over the stated time period. Otherwise, date range will default to range defined by `scrilla.settings.DEFAULT_ANALYSIS_PERIOD`.
125 2. **target_return**: ``float``
126 *Optional*. Defaults to `None`. The target return, as a decimal, subject to which the portfolio's volatility will be minimized.
128 Returns
129 -------
130 `List[float]`
131 A list of floats that represents the proportion of the portfolio that should be allocated to the corresponding ticker symbols given as a parameter within the portfolio object. In other words, if portfolio.tickers = ['AAPL', 'MSFT'] and the output is [0.25, 0.75], this result means a portfolio with 25% allocation in AAPL and a 75% allocation in MSFT will result in an optimally constructed portfolio with respect to its volatility.
132 """
133 tickers = portfolio.tickers
134 portfolio.set_target_return(target_return)
136 init_guess = portfolio.get_init_guess()
137 equity_bounds = portfolio.get_default_bounds()
138 equity_constraint = {
139 'type': 'eq',
140 'fun': portfolio.get_constraint
141 }
143 if target_return is not None:
144 logger.debug(
145 f'Optimizing {tickers} Portfolio Volatility Subject To Return = {target_return}', 'optimize_portfolio_variance')
147 return_constraint = {
148 'type': 'eq',
149 'fun': portfolio.get_target_return_constraint
150 }
151 portfolio_constraints = [equity_constraint, return_constraint]
152 else:
153 logger.debug(
154 f'Minimizing {tickers} Portfolio Volatility', 'optimize_portfolio_variance')
155 portfolio_constraints = equity_constraint
157 allocation = optimize.minimize(fun=portfolio.volatility_function, x0=init_guess,
158 method=constants.constants['OPTIMIZATION_METHOD'], bounds=equity_bounds,
159 constraints=portfolio_constraints, options={'disp': False})
161 return allocation.x
164def optimize_conditional_value_at_risk(portfolio: Portfolio, prob: float, expiry: float, target_return: float = None) -> List[float]:
165 """
166 Parameters
167 ----------
168 1. **portfolio**: `scrilla.analysis.objects.Portfolio`
169 An instance of the `Portfolio` class. Must be initialized with an array of ticker symbols. Optionally, it can be initialized with a start_date and end_date datetime. If start_date and end_date are specified, the portfolio will be optimized over the stated time period. Otherwise, date range will default to range defined by `scrilla.settings.DEFAULT_ANALYSIS_PERIOD`.
170 2. **prob**: ``float``
171 Probability of loss.
172 3. **expiry**: ``float``
173 Time horizon for the value at risk expectation, i.e. the time in the future at which point the portfolio will be considered "closed" and the hypothetical loss will occur.
174 4. **target_return**: ``float``
175 *Optional*. Defaults to `None`. The target return constraint, as a decimal, subject to which the portfolio's conditional value at risk will be optimized.
177 Returns
178 -------
179 ``list``
180 A list of floats that represents the proportion of the portfolio that should be allocated to the corresponding ticker symbols given as a parameter within the `Portfolio` object. In other words, if `portfolio.tickers = ['AAPL', 'MSFT']` and the output to this function is `[0.25, 0.75]`, this result means a portfolio with 25% allocation in AAPL and a 75% allocation in MSFT will result in an optimally constructed portfolio with respect to its conditional value at risk.
182 """
183 tickers = portfolio.tickers
184 init_guess = portfolio.get_init_guess()
185 equity_bounds = portfolio.get_default_bounds()
187 equity_constraint = {
188 'type': 'eq',
189 'fun': portfolio.get_constraint
190 }
192 if target_return is not None:
193 logger.debug(
194 f'Optimizing {tickers} Portfolio Conditional Value at Risk Subject To Return = {target_return}', 'optimize_conditional_value_at_risk')
196 return_constraint = {
197 'type': 'eq',
198 'fun': portfolio.get_target_return_constraint
199 }
200 portfolio_constraints = [equity_constraint, return_constraint]
201 else:
202 logger.debug(
203 f'Minimizing {tickers} Portfolio Conditional Value at Risk', 'optimize_conditional_value_at_risk')
204 portfolio_constraints = equity_constraint
206 allocation = optimize.minimize(fun=lambda x: portfolio.conditional_value_at_risk_function(x, expiry, prob),
207 x0=init_guess,
208 method=constants.constants['OPTIMIZATION_METHOD'], bounds=equity_bounds,
209 constraints=portfolio_constraints, options={'disp': False})
211 return allocation.x
214def maximize_sharpe_ratio(portfolio: Portfolio, target_return: float = None) -> List[float]:
215 """
216 Parameters
217 ----------
218 1. **portfolio**: `scrilla.analysis.objects.Portfolio`
219 An instance of the `Portfolio` class. Must be initialized with an array of ticker symbols. Optionally, it can be initialized with a start_date and end_date datetime. If start_date and end_date are specified, the portfolio will be optimized over the stated time period. Otherwise, date range will default to range defined by `scrilla.settings.DEFAULT_ANALYSIS_PERIOD`.
220 2. **target_return**: ``float``
221 *Optional*. Defaults to `None`. The target return constraint, as a decimal, subject to which the portfolio's sharpe ratio will be maximized.
223 Returns
224 -------
225 ``list``
226 A list of floats that represents the proportion of the portfolio that should be allocated to the corresponding ticker symbols given as a parameter within the `Portfolio` object. In other words, if `portfolio.tickers = ['AAPL', 'MSFT']` and the output to this function is `[0.25, 0.75]`, this result means a portfolio with 25% allocation in AAPL and a 75% allocation in MSFT will result in an optimally constructed portfolio with respect to its sharpe ratio.
227 """
228 tickers = portfolio.tickers
229 portfolio.set_target_return(target_return)
231 init_guess = portfolio.get_init_guess()
232 equity_bounds = portfolio.get_default_bounds()
233 equity_constraint = {
234 'type': 'eq',
235 'fun': portfolio.get_constraint
236 }
238 if target_return is not None:
239 logger.debug(
240 f'Optimizing {tickers} Portfolio Sharpe Ratio Subject To Return = {target_return}', 'maximize_sharpe_ratio')
242 return_constraint = {
243 'type': 'eq',
244 'fun': portfolio.get_target_return_constraint
245 }
246 portfolio_constraints = [equity_constraint, return_constraint]
247 else:
248 logger.debug(f'Maximizing {tickers} Portfolio Sharpe Ratio',
249 'maximize_sharpe_ratio')
250 portfolio_constraints = equity_constraint
252 allocation = optimize.minimize(fun=lambda x: (-1)*portfolio.sharpe_ratio_function(x),
253 x0=init_guess,
254 method=constants.constants['OPTIMIZATION_METHOD'], bounds=equity_bounds,
255 constraints=portfolio_constraints, options={'disp': False})
257 return allocation.x
260def maximize_portfolio_return(portfolio: Portfolio) -> List[float]:
261 """
262 Parameters
263 ----------
264 1. **portfolio**: `scrilla.analysis.objects.Portfolio`
265 An instance of the Portfolio class. Must be initialized with an array of ticker symbols. Optionally, it can be initialized with a ``start_date`` and ``end_date`` ``datetime.date``. If ``start_date`` and ``end_date`` are specified, the portfolio will be optimized over the stated time period.
267 Output
268 ------
269 ``List[float]``
270 An array of floats that represents the proportion of the portfolio that should be allocated to the corresponding ticker symbols given as a parameter within the portfolio object to achieve the maximum return. Note, this function is often uninteresting because if the rate of return for equity A is 50% and the rate of return of equity B is 25%, the portfolio with a maximized return will always allocated 100% of its value to equity A. However, this function is useful for determining whether or not the optimization algorithm is actually working, so it has been left in the program for debugging purposes.
272 Notes
273 -----
274 This should always return a portfolio with 100% allocated to the asset with the highest rate of return. If assets had negative correlation and shorting were allowed into the application, this would not necessarily be true.
275 """
276 tickers = portfolio.tickers
277 init_guess = portfolio.get_init_guess()
278 equity_bounds = portfolio.get_default_bounds()
279 equity_constraint = {
280 'type': 'eq',
281 'fun': portfolio.get_constraint
282 }
284 logger.debug(f'Maximizing {tickers} Portfolio Return',
285 'maximize_portfolio_retrun')
286 allocation = optimize.minimize(fun=lambda x: (-1)*portfolio.return_function(x),
287 x0=init_guess, method=constants.constants['OPTIMIZATION_METHOD'],
288 bounds=equity_bounds, constraints=equity_constraint,
289 options={'disp': False})
291 return allocation.x
294def calculate_efficient_frontier(portfolio: Portfolio, steps=None) -> List[List[float]]:
295 """
296 Parameters
297 ----------
298 1. **portfolio**: `scrilla.analysis.objects.Portfolio`
299 An instance of the Portfolio class defined in analysis.objects.portfolio. Must be initialized with an array of ticker symbols. Optionally, it can be initialized with a start_date and end_date datetime. If start_date and end_date are specified, the portfolio will be optimized over the stated time period.\n \n
301 2. **steps**: ``int``
302 *Optional*. Defaults to `None`. The number of points calculated in the efficient frontier. If none is provided, it defaults to the environment variable **FRONTIER_STEPS**.
304 Returns
305 -------
306 ``List[List[float]]``
307 A nested list of floats. Each float list corresponds to a point on a portfolio's efficient frontier, i.e. each list represents the percentage of a portfolio that should be allocated to the equity with the corresponding ticker symbol supplied as an attribute to the ``scrilla.analysis.objects.Portfolio`` object parameter.
308 """
309 if steps is None: 309 ↛ 312line 309 didn't jump to line 312, because the condition on line 309 was never false
310 steps = settings.FRONTIER_STEPS
312 minimum_allocation = optimize_portfolio_variance(portfolio=portfolio)
313 maximum_allocation = maximize_portfolio_return(portfolio=portfolio)
315 minimum_return = portfolio.return_function(minimum_allocation)
316 maximum_return = portfolio.return_function(maximum_allocation)
317 return_width = (maximum_return - minimum_return)/steps
319 frontier = []
320 for i in range(steps):
321 target_return = minimum_return + return_width*i
322 allocation = optimize_portfolio_variance(
323 portfolio=portfolio, target_return=target_return)
324 frontier.append(allocation)
326 return frontier