-
Notifications
You must be signed in to change notification settings - Fork 219
/
portfolio_multi.py
451 lines (337 loc) · 15.1 KB
/
portfolio_multi.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
########################################################################
#
# Classes for optimizing and allocating portfolios of stocks.
#
# This version is for MULTI-OBJECTIVE optimization. It is very
# similar to portfolio.py except that it uses another optimizer
# and has some minor modifications. Parts of the source-code could
# have been reused from portfolio.py but it is probably easier to
# read, understand and modify the code when it is all in one file.
#
########################################################################
#
# This file is part of FinanceOps:
#
# https://github.com/Hvass-Labs/FinanceOps
#
# Published under the MIT License. See the file LICENSE for details.
#
# Copyright 2018 by Magnus Erik Hvass Pedersen
#
########################################################################
import numpy as np
import pygmo as pg # This has the NSGA-2 optimizer.
########################################################################
# Private helper-functions.
def _sigmoid(x):
"""
Sigmoid function that smoothly limits values between 0.0 and 1.0
:param x: Numpy array with float values that are to be limited.
:return: Numpy array with float values between 0.0 and 1.0
"""
return 1.0 / (1.0 + np.exp(-x))
########################################################################
# Public classes.
class Model:
"""
Base-class for a portfolio model providing functions for doing
the optimization and calculating the returns from using the model.
This version is for MULTI-OBJECTIVE optimization.
"""
def __init__(self, signals_train, daily_rets_train, min_weights, max_weights):
"""
Create object instance and run optimization of the portfolio model.
:param signals_train: 2-d numpy array with signals.
:param daily_rets_train: 2-d numpy array with daily returns.
:param min_weights: 1-d numpy array with min stock weights.
:param max_weights: 1-d numpy array with max stock weights.
"""
# Copy args.
self.signals_train = signals_train
self.daily_rets_train = daily_rets_train
self.min_weights = min_weights
self.max_weights = max_weights
# Number of stocks.
self.num_stocks = self.signals_train.shape[1]
assert signals_train.shape == daily_rets_train.shape
assert self.num_stocks == len(min_weights) == len(max_weights)
# Optimize the portfolio allocation model.
self._optimize()
def get_weights(self, signals):
"""
Map the signals to stock-weights.
:param signals: 2-d numpy array with signals for the stocks.
:return: (weights: 2-d numpy array, weights_cash: 1-d numpy array)
"""
raise NotImplementedError
@property
def bounds(self):
"""Parameter bounds for the model that is going to be optimized."""
raise NotImplementedError
def _set_parameters(self, params):
"""
Unpack and set the parameters for the portfolio allocation model.
:param params: 1-d numpy array with the model parameters.
:return: None.
"""
raise NotImplementedError
def value(self, daily_rets, signals=None):
"""
Calculate the portfolio value when rebalancing the portfolio daily.
The stock-weights are calculated from the given signals.
:param daily_rets: 2-d numpy array with daily returns for the stocks.
:param signals: 2-d numpy array with daily signals for the stocks.
:return: 1-d numpy array with the cumulative portfolio value.
"""
# Map the signals to stock-weights.
weights, weights_cash = self.get_weights(signals=signals)
# Calculate the weighted daily returns of the stocks.
weighted_daily_rets = np.sum(daily_rets * weights, axis=1) + weights_cash
# Accumulate the weighted daily returns to get the portfolio value.
value = np.cumprod(weighted_daily_rets)
# Normalize so it starts at 1.0
value /= value[0]
return value
def _optimize(self):
"""
Optimize the portfolio model's parameters.
This is the MULTI-OBJECTIVE version that uses the NSGA-2 optimizer.
:return: None.
"""
class Problem:
"""
Wrapper for the Model-class that connects it with
the optimizer. This is necessary because the optimizer
creates a deep-copy of the problem-object passed to it,
so it does not work when passing the Model-object directly.
"""
def __init__(self, model):
"""
:param model: Object-instance of the Model-class.
"""
self.model = model
def fitness(self, params):
"""Calculate and return the fitness for the given parameters."""
return self.model.fitness(params=params)
def get_bounds(self):
"""Get boundaries of the search-space."""
return self.model.bounds
def get_nobj(self):
"""Get number of fitness-objectives."""
return self.model.num_objectives
# Create a problem-instance.
problem = Problem(model=self)
# Create an NSGA-2 Multi-Objective optimizer.
optimizer = pg.algorithm(pg.nsga2(gen=500))
# Create a population of candidate solutions.
population = pg.population(prob=problem, size=200)
# Optimize the problem.
population = optimizer.evolve(population)
# Save the best-found parameters and fitnesses for later use.
self.best_parameters = population.get_x()
self.best_fitness = population.get_f()
# Sorted index for the fitnesses.
idx_sort = np.argsort(self.best_fitness[:, 0])
# Sort the best-found parameters and fitnesses.
self.best_parameters = self.best_parameters[idx_sort]
self.best_fitness = self.best_fitness[idx_sort]
def use_best_parameters_max_return(self):
"""
Use the best found model-parameters that maximize the mean return.
"""
# The parameters are already sorted according to max return,
# so get the first set of parameters in the list.
params = self.best_parameters[0]
# Use these parameters as the model's active parameters.
self._set_parameters(params=params)
def use_best_parameters_min_prob_loss(self):
"""
Use the best found model-parameters that minimize the probability of loss.
"""
# The parameters are already sorted so the lowest prob. of loss
# are at the end of the list.
params = self.best_parameters[-1]
# Use these parameters as the model's active parameters.
self._set_parameters(params=params)
def use_best_parameters(self, max_prob_loss=1.0):
"""
Use the best found model-parameters that maximize the mean return
while having a probability of loss that is less than that given.
:param max_prob_loss: Max allowed probability of loss.
"""
try:
# The parameters are already sorted, so we get the parameters with
# the highest mean and probability of loss below the given limit.
# This is a little cryptic to understand, but you can try and print
# the array self.best_fitness to understand how it works.
idx = np.min(np.argwhere(self.best_fitness[:, 1] <= max_prob_loss))
params = self.best_parameters[idx]
# Use these parameters as the model's active parameters.
self._set_parameters(params=params)
except ValueError:
# Print error-message if the probability of loss was too low.
msg = "Error: max_prob_loss is too low! Must be higher than {0:.3f}"
min_prob_loss = np.min(self.best_fitness[:, 1])
print(msg.format(min_prob_loss))
def _limit_weights(self, weights):
"""
Limit stock-weights between self.min_weights and self.max_weights.
Also ensure the stock-weights sum to 1.0 or less.
:param weights: 2-d numpy array with stock-weights between 0.0 and 1.0
:return: 2-d numpy array with limited stock-weights.
"""
# We could just clip the weights, but if they were created from
# e.g. a sigmoid-mapping then a hard-clip would destroy the softness.
# So we assume weights are between 0.0 and 1.0 so we can rescale them.
# We do not assert this because there could be tiny floating-point
# rounding errors that are unimportant and would then cause a crash.
# Scale weights to be between min and max.
weights = weights * (self.max_weights - self.min_weights) + self.min_weights
# Ensure sum(weights) <= 1
weights_sum = np.sum(weights, axis=1)
mask = (weights_sum > 1.0)
weights[mask, :] /= weights_sum[mask, np.newaxis]
# Recalculate the weight-sum for each day.
weights_sum = np.sum(weights, axis=1)
# If the sum of stock-weights for a day is less than 1.0
# then let the remainder be the cash-weight.
weights_cash = 1.0 - weights_sum
return weights, weights_cash
@property
def num_objectives(self):
"""Number of fitness objectives to optimize."""
return 2
def fitness(self, params):
"""
Calculate the MULTIPLE fitness-objectives that are to be minimized.
:param params: Parameters for the portfolio-model.
:return: (fitness1, fitness2) tuple with two floats.
"""
# Set the model parameters received from the optimizer.
self._set_parameters(params=params)
# Calculate the cumulative portfolio value using the training-data.
# This uses the portfolio-model with the parameters we have just set,
# so we can evaluate how well those parameters perform.
value = self.value(daily_rets=self.daily_rets_train,
signals=self.signals_train)
# Portfolio returns for all 1-year periods.
rets_1year = value[365:] / value[:-365]
# Mean return for all 1-year periods.
mean_return = np.mean(rets_1year) - 1.0
# Portfolio returns for all 3-month periods.
rets_3month = value[90:] / value[:-90]
# Probability of loss for all 3-month periods.
prob_loss = np.sum(rets_3month < 1.0) / len(rets_3month)
# Fitness objectives.
# Note the fitness-value is negated because we are doing minimization.
fitness1 = -mean_return
fitness2 = prob_loss
return [fitness1, fitness2]
class EqualWeights(Model):
"""
Portfolio model where the stock-weights are always equal.
"""
def __init__(self, num_stocks, use_cash=False):
"""
Create object instance.
This is a special case because the portfolio-model is so simple.
We also don't call Model.__init__() because the model should not
be optimized.
:param num_stocks:
Number of stocks in the portfolio.
:param use_cash:
Boolean whether to use cash as an equal part of the portfolio.
"""
# Copy args.
self.num_stocks = num_stocks
self.use_cash = use_cash
def get_weights(self, signals=None):
"""
Get the stock-weights for the portfolio-model.
:param signals: Ignored.
:return: (weights: 2-d numpy array, weights_cash: 1-d numpy array)
"""
if self.use_cash:
# Stocks and cash get equal weights.
weight = 1.0 / (self.num_stocks + 1)
weights_cash = weight
else:
# Only use stocks and no cash in the portfolio.
weight = 1.0 / self.num_stocks
weights_cash = 0.0
# Create a 2-dim array with the equal stock-weights,
# so it can easily be multiplied and broadcast with daily returns.
weights = np.full(shape=(1, self.num_stocks), fill_value=weight)
return weights, weights_cash
class FixedWeights(Model):
"""
Portfolio model where the stock-weights are always held fixed,
but the best stock-weights are found through optimization.
This version is for MULTI-OBJECTIVE optimization.
"""
def __init__(self, *args, **kwargs):
Model.__init__(self, *args, **kwargs)
@property
def bounds(self):
"""Parameter bounds for the portfolio-model."""
# We want to find the best fixed weights between 0.0 and 1.0
lo = np.zeros(self.num_stocks, dtype=np.float)
hi = np.ones(self.num_stocks, dtype=np.float)
return lo, hi
def _set_parameters(self, params):
"""
Unpack and set the parameters for the portfolio model.
:param params: 1-d numpy array with the model parameters.
:return: None.
"""
# The parameters are actually the raw stock-weights between 0.0 and 1.0
# which are then limited between min_weights and max_weights.
self._weights, self._weights_cash = self._limit_weights(weights=params[np.newaxis, :])
def get_weights(self, signals=None):
"""
Get the stock-weights for the portfolio-model.
:param signals: Ignored.
:return: (weights: 2-d numpy array, weights_cash: 1-d numpy array)
"""
return self._weights, self._weights_cash
class AdaptiveWeights(Model):
"""
Portfolio model where the stock-weights are mapped from predictive signals
using the basic function: weight = sigmoid(a * signal + b) so we want to
find the parameters a and b that result in the best performance according
to the fitness function in Model.fitness().
This version is for MULTI-OBJECTIVE optimization.
"""
def __init__(self, *args, **kwargs):
Model.__init__(self, *args, **kwargs)
@property
def bounds(self):
"""Parameter bounds for the portfolio-model."""
# We want to find the a and b parameters for each stock.
# We allow both a and b to be between e.g. -10.0 and 10.0
k = 10.0
lo = [-k] * self.num_stocks * 2
hi = [k] * self.num_stocks * 2
return lo, hi
def _set_parameters(self, params):
"""
Unpack and set the parameters for the portfolio model.
:param params: 1-d numpy array with the model parameters.
:return: None.
"""
self._a = params[0:self.num_stocks]
self._b = params[self.num_stocks:]
def get_weights(self, signals):
"""
Get the stock-weights for the portfolio-model.
:param signals: 2-d numpy array with signals for the stocks.
:return: (weights: 2-d numpy array, weights_cash: 1-d numpy array)
"""
# Linear mapping.
weights = signals * self._a + self._b
# Use sigmoid-function to softly limit between 0.0 and 1.0
weights = _sigmoid(weights)
# Limit the weights between min_weights and max_weights.
weights, weights_cash = self._limit_weights(weights=weights)
return weights, weights_cash
########################################################################