-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathportfolio-builder.py
executable file
·394 lines (315 loc) · 12.9 KB
/
portfolio-builder.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
#!/usr/bin/env python3
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ #
# ____ __ ____ ___ ____ _ __ __ #
# / __ \____ _____/ /_/ __/___ / (_)___ / __ )__ __(_) /___/ /__ _____ #
# / /_/ / __ \/ ___/ __/ /_/ __ \/ / / __ \ / __ / / / / / / __ / _ \/ ___/ #
# / ____/ /_/ / / / /_/ __/ /_/ / / / /_/ / / /_/ / /_/ / / / /_/ / __/ / #
# /_/ \____/_/ \__/_/ \____/_/_/\____/ /_____/\__,_/_/_/\__,_/\___/_/ #
# #
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ #
"""
Portfolio Builder
=================
A simple tool to build an ETF-based portfolio with a mix of bonds and equities depending
on your preferred risk level and available cash.
The ETF selection used here is based on the "Model ETF Portfolios" article from the
Canadian Portfolio Manager website:
https://www.canadianportfoliomanagerblog.com/model-etf-portfolios/
"""
import datetime
import enum
import glob
import os
import sys
import click
import tabulate
import pandas as pd
import numpy as np
import yfinance as yf
# Custom exceptions
class PortfolioException(Exception):
"""Exceptions related to the portfolio builder."""
pass
# Error messages
def echo_warning(msg):
click.echo(f"{click.style('warning:', fg='yellow', bold=True)} {msg}", err=True)
def echo_error(msg):
click.echo(f"{click.style('error:', fg='red', bold=True)} {msg}", err=True)
def echo_fatal(msg):
click.echo(f"{click.style('fatal:', fg='red', bold=True)} {msg}", err=True)
class Mode(enum.Enum):
build = 1 # Build a portfolio from scratch
rebalance = 2 # Rebalance an existing portfolio
class Portfolio:
"""An object to represent a portfolio.
Parameters
----------
risk_level : int
Risk level (0 to 10).
targets : str
Either the path to file containing ETF allocation targets, or the name of a
predefined portfolio. If it is the latter, the portfolio builder will search the
`targets/` directory for a file of the form `<targets>.csv`, where `<targets>`
is the name provided.
account_file : str
Path to account data file. Account data is only used when rebalancing a
portfolio. If none is provided, the portfolio builder searches the 'accounts/'
directory for .csv files containing account data.
cash : float
Cash to invest (CAD).
fractions : bool
Allow fractions when computing number of shares to buy/sell. Normally these
numbers are required to be whole numbers.
mode : :class:`.Mode`
Portfolio mode. Choose from `build` (build a portfolio from scratch) and
`rebalance` (rebalance an existing portfolio).
verbose : bool, int
Be verbose.
"""
def __init__(
self, risk_level, targets, account_file, cash, fractions, mode, verbose
):
self.risk_level = risk_level
self.targets = targets
self.account_file = account_file
self.cash = cash
self.fractions = fractions
self.mode = mode
self.verbose = verbose
if os.path.isfile(self.targets):
self.allocations = pd.read_csv(self.targets, index_col=0)
elif os.path.isfile(os.path.join("targets", f"{self.targets}.csv")):
self.allocations = pd.read_csv(
os.path.join("targets", f"{self.targets}.csv"), index_col=0
)
else:
raise PortfolioException(f"could not open targets file '{self.targets}'")
self.current_prices = None
self.shares = None
if self.mode == Mode.build:
self.account = None
elif self.mode == Mode.rebalance:
self.account = self._read_account_data(self.account_file)
else:
raise PortfolioException(f"unknown portfolio mode '{self.mode}'")
def _read_account_data(self, account_file=None):
"""Read current account data.
If `account_file` is None, this function searches the 'accounts/' directory for
.csv files. If more than one file is found, the user is prompted to select which
one to use.
Parameters
----------
account_file : str, optional
Path to account data file. See note above if `None` is passed.
Returns
-------
account : pandas.DataFrame
Current account data as a pandas DataFrame.
"""
click.echo("Reading current account data...")
if account_file is None:
account_files = glob.glob("accounts/*.csv")
if len(account_files) == 1:
account_file = account_files[0]
elif len(account_files) > 1:
click.echo("Found multiple account data files:")
for i, account_file in enumerate(account_files):
click.echo(f" ({i}) {account_file}")
while True:
index = click.prompt(
"Please enter which account file you would like to use",
type=int,
)
if index >= 0 and index < len(account_files):
break
else:
click.echo(f"Error: invalid account file {index}")
account_file = account_files[index]
else:
raise PortfolioException("no account data file")
if self.verbose:
click.echo(f" -> Reading account data from file '{account_file}'")
account = pd.read_csv(account_file)
# You can add the target risk in your account data file for reference,
# but we do not want the dataframe to keep this information
if "risk" in account.columns:
account.drop("risk", axis="columns", inplace=True)
return account.iloc[0] # For now only return first row
def build(self):
"""Build the current portfolio based on current prices and available cash."""
click.echo("Retrieving current ETF prices...")
# Retrieve data for past 5 days
# Ensures data is available if running on a day when markets are closed
start_time = datetime.datetime.now() - datetime.timedelta(days=5)
self.current_prices = yf.download(
" ".join(self.allocations.columns), start=start_time
)["Close"].iloc[-1]
click.echo("Done")
# Use same ticker order
self.current_prices = self.current_prices.reindex(self.allocations.columns)
if self.mode == Mode.build:
# Build from scratch
self.shares = (
self.cash
* (self.allocations.loc[self.risk_level] / 100)
/ self.current_prices
)
elif self.mode == Mode.rebalance:
# Check that target and account securities agree
if not self.allocations.columns.equals(self.account.index):
raise PortfolioException("target and account securities do not agree")
# Rebalance current portfolio
self.shares = (
(self.allocations.loc[self.risk_level] / 100)
* (self.cash + np.sum(self.account * self.current_prices))
- self.account * self.current_prices
) / self.current_prices
if not self.fractions:
self.shares = np.floor(self.shares).astype("int")
if np.all(self.shares == 0):
echo_warning("Insufficient funds to build portfolio to targets")
click.echo()
def print_portfolio(self):
"""Print the built portfolio."""
if self.shares is None:
echo_warning("cannot display portfolio: portfolio has not been built yet")
return
if self.mode == Mode.build:
data = {
"ETF": self.shares.index.to_list(),
"Price\n(CAD)": self.current_prices.to_list(),
"Qnty To\nBuy/Sell": self.shares.to_list(),
"Price To\nBuy/Sell": (self.shares * self.current_prices).to_list(),
"% of\nPortfolio": (
100
* (self.shares * self.current_prices)
/ np.sum(self.shares * self.current_prices)
).to_list(),
"Target % of\nPortfolio": self.allocations.loc[
self.risk_level
].to_list(),
}
if self.fractions:
fmt = ("", ".3f", ".2f", ".2f", ".2f", ".2f")
else:
fmt = ("", ".3f", "", ".2f", ".2f", ".2f")
elif self.mode == Mode.rebalance:
total_shares = self.shares + self.account
data = {
"ETF": self.shares.index.to_list(),
"Price\n(CAD)": self.current_prices.to_list(),
"Current\nQuantity": self.account.to_list(),
"Current % of\nPortfolio": (
100
* (self.current_prices * self.account)
/ np.sum(self.current_prices * self.account)
),
"Qnty To\nBuy/Sell": self.shares.to_list(),
"Price To\nBuy/Sell": (self.shares * self.current_prices).to_list(),
"Total\nQuantity": total_shares.to_list(),
"Value\n(CAD)": (total_shares * self.current_prices).to_list(),
"New % of\nPortfolio": (
100
* (total_shares * self.current_prices)
/ np.sum(total_shares * self.current_prices)
).to_list(),
"Target % of\nPortfolio": self.allocations.loc[
self.risk_level
].to_list(),
}
if self.fractions:
fmt = ("", ".3f", "", ".2f", ".4f", ".2f", ".2f", ".2f", ".2f", ".2f")
else:
fmt = ("", ".3f", "", ".2f", "", ".2f", ".2f", ".2f", ".2f", ".2f")
click.echo("Your portfolio:")
click.echo("~~~~~~~~~~~~~~~\n")
click.echo(tabulate.tabulate(data, headers="keys", floatfmt=fmt))
total_cost = np.sum(self.shares * self.current_prices)
leftover_cash = self.cash - total_cost
click.echo()
click.echo(f"Total cost: ${total_cost:.2f} CAD")
click.echo(f"Leftover cash: ${leftover_cash:.2f} CAD")
@click.command(context_settings=dict(help_option_names=["-h", "--help"]))
@click.option(
"-r",
"--risk-level",
type=click.IntRange(0, 10),
prompt="Enter your risk level (0 to 10)",
help="Risk level on a scale from 0 (all bonds) to 10 (all equities).",
)
@click.option(
"-t",
"--targets",
prompt=(
"Enter the path to file containing ETF allocation targets or the name of the"
" portfolio"
),
help=(
"Either the path to file containing ETF allocation targets, or the name of a"
" predefined portfolio. If it is the latter, the portfolio builder will search"
" the `targets/` directory for a file of the form `<targets>.csv`, where"
" `<targets>` is the name provided."
),
)
@click.option(
"-a",
"--account",
type=click.Path(exists=True),
help=(
"Path to account data file. Account data is only used when rebalancing a"
" portfolio. If none is provided, the portfolio builder searches the"
" 'accounts/' directory for .csv files containing account data."
),
)
@click.option(
"-c",
"--cash",
type=float,
prompt="Enter your cash available to invest (CAD)",
help="Cash available to invest (CAD).",
)
@click.option(
"-f",
"--fractions",
is_flag=True,
default=False,
help=(
"Allow fractions when computing number of shares to buy/sell. Normally these"
" numbers are required to be whole numbers."
),
)
@click.option(
"--rebalance",
is_flag=True,
default=False,
help=(
"Rebalance an existing portfolio. Place accounts in your portfolio in the"
" 'accounts/' directory."
),
)
@click.option(
"-v",
"--verbose",
count=True,
help="Be verbose. Multiple -v options increase the verbosity.",
)
def main(risk_level, targets, account, cash, fractions, rebalance, verbose):
"""A simple tool to build an ETF-based portfolio with a mix of bonds and equities
depending on your preferred risk level and available cash.
"""
try:
mode = Mode.rebalance if rebalance else Mode.build
portfolio = Portfolio(
risk_level, targets, account, cash, fractions, mode, verbose
)
portfolio.build()
portfolio.print_portfolio()
except KeyboardInterrupt:
return 1
except PortfolioException as err:
echo_error(err)
except Exception as err:
echo_fatal(f"an unknown exception occurred: {err}")
raise
if __name__ == "__main__":
sys.exit(main())