From f097402c43a97470e09cdfa70b1bada253462321 Mon Sep 17 00:00:00 2001 From: Jeroen Bouma Date: Mon, 2 Jan 2023 17:24:33 +0100 Subject: [PATCH 1/6] Update the package to reflect changes to yfinance --- thepassiveinvestor/__init__.py | 6 +- thepassiveinvestor/collect_data.py | 66 ++++----- thepassiveinvestor/config.py | 26 ++-- thepassiveinvestor/create_report.py | 204 ++++++++++++++++++++-------- thepassiveinvestor/utils.py | 38 ++++-- 5 files changed, 232 insertions(+), 108 deletions(-) diff --git a/thepassiveinvestor/__init__.py b/thepassiveinvestor/__init__.py index 32d1813..b4f4156 100644 --- a/thepassiveinvestor/__init__.py +++ b/thepassiveinvestor/__init__.py @@ -2,4 +2,8 @@ from .create_report import create_ETF_report from .collect_data import collect_data from .utils import data_placer, graph_placer, image_placer -from .config import DEFAULT_KEY_STATISTICS_CHOICES, DEFAULT_SUMMARY_DETAIL_CHOICES, EMPTY_RISK_STATISTICS \ No newline at end of file +from .config import ( + DEFAULT_KEY_STATISTICS_CHOICES, + DEFAULT_SUMMARY_DETAIL_CHOICES, + EMPTY_RISK_STATISTICS, +) diff --git a/thepassiveinvestor/collect_data.py b/thepassiveinvestor/collect_data.py index 6f861c2..bcfd95f 100644 --- a/thepassiveinvestor/collect_data.py +++ b/thepassiveinvestor/collect_data.py @@ -1,6 +1,6 @@ from datetime import datetime -from yfinance.utils import get_json +import yfinance as yf from .config import DEFAULT_KEY_STATISTICS_CHOICES, DEFAULT_SUMMARY_DETAIL_CHOICES @@ -23,58 +23,62 @@ def collect_data(ticker): ticker_data (dictionary) Returns a dictionary with the most important data about the ticker. """ - data = get_json(f"https://finance.yahoo.com/quote/{ticker}") + data = yf.Ticker(ticker).stats() ticker_data = {} - fund_performance = data['fundPerformance'] - top_holdings = data['topHoldings'] - default_key_statistics = data['defaultKeyStatistics'] - summary_detail = data['summaryDetail'] + fund_performance = data["fundPerformance"] + top_holdings = data["topHoldings"] + default_key_statistics = data["defaultKeyStatistics"] + summary_detail = data["summaryDetail"] - ticker_data['long_name'] = data['quoteType']['longName'] - ticker_data['summary'] = data['assetProfile']['longBusinessSummary'] - ticker_data['image_URL'] = data['fundProfile']['styleBoxUrl'] + ticker_data["long_name"] = data["quoteType"]["longName"] + ticker_data["summary"] = data["assetProfile"]["longBusinessSummary"] + ticker_data["image_URL"] = data["fundProfile"]["styleBoxUrl"] - sector_data = top_holdings['sectorWeightings'] - ticker_data['sector_holdings'] = {} + sector_data = top_holdings["sectorWeightings"] + ticker_data["sector_holdings"] = {} for sector in sector_data: for key, value in sector.items(): - ticker_data['sector_holdings'][key] = f"{str(round(value * 100, 2))}%" + ticker_data["sector_holdings"][key] = f"{str(round(value * 100, 2))}%" - company_data = top_holdings['holdings'] - ticker_data['company_holdings'] = {} + company_data = top_holdings["holdings"] + ticker_data["company_holdings"] = {} for company in company_data: - ticker_data['company_holdings'][company['holdingName']] = f"{str(round(company['holdingPercent'] * 100, 2))}%" + ticker_data["company_holdings"][ + company["holdingName"] + ] = f"{str(round(company['holdingPercent'] * 100, 2))}%" - annual_returns_data = fund_performance['annualTotalReturns']['returns'][:6] - ticker_data['annual_returns'] = {} + annual_returns_data = fund_performance["annualTotalReturns"]["returns"][:6] + ticker_data["annual_returns"] = {} for returns in annual_returns_data: - if returns['annualValue'] is None: - ticker_data['annual_returns'][returns['year']] = "N/A" + if returns["annualValue"] is None: + ticker_data["annual_returns"][returns["year"]] = "N/A" else: - ticker_data['annual_returns'][returns['year']] = f"{str(round(returns['annualValue'] * 100, 2))}%" + ticker_data["annual_returns"][ + returns["year"] + ] = f"{str(round(returns['annualValue'] * 100, 2))}%" - risk_statistics = fund_performance['riskOverviewStatistics']['riskStatistics'] - ticker_data['risk_data'] = {} + risk_statistics = fund_performance["riskOverviewStatistics"]["riskStatistics"] + ticker_data["risk_data"] = {} for risk in risk_statistics: - ticker_data['risk_data'][risk['year']] = risk + ticker_data["risk_data"][risk["year"]] = risk - ticker_data['key_characteristics'] = {} + ticker_data["key_characteristics"] = {} for option in DEFAULT_KEY_STATISTICS_CHOICES: - if option == 'fundInceptionDate': - ticker_data['key_characteristics'][option] = default_key_statistics[option] - ticker_data['key_characteristics'][option] = datetime.fromtimestamp( - ticker_data['key_characteristics'][option]).strftime( - '%Y-%m-%d') + if option == "fundInceptionDate": + ticker_data["key_characteristics"][option] = default_key_statistics[option] + ticker_data["key_characteristics"][option] = datetime.fromtimestamp( + ticker_data["key_characteristics"][option] + ).strftime("%Y-%m-%d") else: - ticker_data['key_characteristics'][option] = default_key_statistics[option] + ticker_data["key_characteristics"][option] = default_key_statistics[option] for option in DEFAULT_SUMMARY_DETAIL_CHOICES: - ticker_data['key_characteristics'][option] = summary_detail[option] + ticker_data["key_characteristics"][option] = summary_detail[option] return ticker_data diff --git a/thepassiveinvestor/config.py b/thepassiveinvestor/config.py index a9eae8b..8f10e67 100644 --- a/thepassiveinvestor/config.py +++ b/thepassiveinvestor/config.py @@ -1,20 +1,18 @@ # Default collected key statistics, could differ if desired -DEFAULT_KEY_STATISTICS_CHOICES = ['fundInceptionDate', - 'category', - 'totalAssets'] +DEFAULT_KEY_STATISTICS_CHOICES = ["fundInceptionDate", "category", "totalAssets"] # Default collected summary details, could differ if desired -DEFAULT_SUMMARY_DETAIL_CHOICES = ['currency', - 'navPrice', - 'previousClose'] +DEFAULT_SUMMARY_DETAIL_CHOICES = ["currency", "navPrice", "previousClose"] # In case no risk statistics are available (because the ETF doesn't exist that long), this # default is used to fill in the gaps -EMPTY_RISK_STATISTICS = {"year": 0, - "alpha": 0, - "beta": 0, - "meanAnnualReturn": 0, - "rSquared": 0, - "stdDev": 0, - "sharpeRatio": 0, - "treynorRatio": 0} +EMPTY_RISK_STATISTICS = { + "year": 0, + "alpha": 0, + "beta": 0, + "meanAnnualReturn": 0, + "rSquared": 0, + "stdDev": 0, + "sharpeRatio": 0, + "treynorRatio": 0, +} diff --git a/thepassiveinvestor/create_report.py b/thepassiveinvestor/create_report.py index 3eb2f2c..41dcb78 100644 --- a/thepassiveinvestor/create_report.py +++ b/thepassiveinvestor/create_report.py @@ -32,7 +32,7 @@ def create_ETF_report(tickers, filename, folder=None): Returns an Excel file with the given filename with data on each ticker. """ workbook = Workbook() - stock_data = yf.download(tickers, period='10y', progress=False)['Adj Close'] + stock_data = yf.download(tickers, period="10y", progress=False)["Adj Close"] if isinstance(tickers, str): tickers = [tickers] @@ -42,17 +42,17 @@ def create_ETF_report(tickers, filename, folder=None): elif isinstance(stock_data, pd.Series): stock_data = pd.DataFrame(stock_data) - if filename[-4:] not in ['xlsx', 'xlsm', 'xlsb']: + if filename[-4:] not in ["xlsx", "xlsm", "xlsb"]: filename = f"{filename}.xlsx" if folder is not None: filename = os.path.join(folder, filename) - workbook.create_sheet(title='Stock Data') - stock_sheet = workbook['Stock Data'] + workbook.create_sheet(title="Stock Data") + stock_sheet = workbook["Stock Data"] for row in dataframe_to_rows(stock_data, index=True, header=True): stock_sheet.append(row) - stock_sheet.column_dimensions['A'].width = len(str(stock_data.index[0])) + stock_sheet.column_dimensions["A"].width = len(str(stock_data.index[0])) stock_sheet.sheet_view.showGridLines = False min_col, min_row, max_col = 1, 3, 1 @@ -65,75 +65,173 @@ def create_ETF_report(tickers, filename, folder=None): try: ticker_data = collect_data(ticker) except (KeyError, TypeError): - sheet['B2'].value = "No data available" - sheet['B2'].font = Font(italic=True) + sheet["B2"].value = "No data available" + sheet["B2"].font = Font(italic=True) continue - sheet['B2'].value = ticker_data['long_name'] - sheet['B2'].font = Font(bold=True, size=15) - sheet['B2'].alignment = Alignment(horizontal='left') - sheet.merge_cells('B2:M2') + sheet["B2"].value = ticker_data["long_name"] + sheet["B2"].font = Font(bold=True, size=15) + sheet["B2"].alignment = Alignment(horizontal="left") + sheet.merge_cells("B2:M2") - sheet['B3'].value = ticker_data['summary'] - sheet['B3'].alignment = Alignment(wrap_text=True, vertical='center', horizontal='left') - sheet.merge_cells('B3:M3') + sheet["B3"].value = ticker_data["summary"] + sheet["B3"].alignment = Alignment( + wrap_text=True, vertical="center", horizontal="left" + ) + sheet.merge_cells("B3:M3") sheet.row_dimensions[3].height = 100 - sheet['B4'].value = "Sector Holdings" - sheet['B4'].font = Font(bold=True) - - sheet['B17'].value = "Top Company Holdings" - sheet['B17'].font = Font(bold=True) - - sheet['E19'].value = "Risk Statistics" - sheet['E19'].font = Font(bold=True) - sheet['E19'].alignment = Alignment(horizontal='center') - sheet.merge_cells('E19:J19') - - sheet['L21'].value = "Last Five Annual Returns" - sheet['L21'].font = Font(bold=True) - - sheet['L4'].value = 'Key Characteristics' - sheet['L4'].font = Font(bold=True) - - data_placer(ticker_data['sector_holdings'], sheet, 5, 2, 'B', 'C', - value_formatting_style='percentage') - data_placer(ticker_data['company_holdings'], sheet, 18, 2, 'B', 'C', - change_key_dimensions=False, value_formatting_style='percentage') - data_placer(ticker_data['annual_returns'], sheet, 22, 12, 'L', 'M', - change_key_dimensions=False, change_value_dimensions=False, - value_formatting_style='percentage') - data_placer(ticker_data['key_characteristics'], sheet, 5, 12, 'L', 'M', - horizontal_alignment_value='left') + sheet["B4"].value = "Sector Holdings" + sheet["B4"].font = Font(bold=True) + + sheet["B17"].value = "Top Company Holdings" + sheet["B17"].font = Font(bold=True) + + sheet["E19"].value = "Risk Statistics" + sheet["E19"].font = Font(bold=True) + sheet["E19"].alignment = Alignment(horizontal="center") + sheet.merge_cells("E19:J19") + + sheet["L21"].value = "Last Five Annual Returns" + sheet["L21"].font = Font(bold=True) + + sheet["L4"].value = "Key Characteristics" + sheet["L4"].font = Font(bold=True) + + data_placer( + ticker_data["sector_holdings"], + sheet, + 5, + 2, + "B", + "C", + value_formatting_style="percentage", + ) + data_placer( + ticker_data["company_holdings"], + sheet, + 18, + 2, + "B", + "C", + change_key_dimensions=False, + value_formatting_style="percentage", + ) + data_placer( + ticker_data["annual_returns"], + sheet, + 22, + 12, + "L", + "M", + change_key_dimensions=False, + change_value_dimensions=False, + value_formatting_style="percentage", + ) + data_placer( + ticker_data["key_characteristics"], + sheet, + 5, + 12, + "L", + "M", + horizontal_alignment_value="left", + ) try: - data_placer(ticker_data['risk_data']['3y'], sheet, 20, 5, 'E', 'F', False, 'right', True, False) + data_placer( + ticker_data["risk_data"]["3y"], + sheet, + 20, + 5, + "E", + "F", + False, + "right", + True, + False, + ) except KeyError: risk_data = EMPTY_RISK_STATISTICS - risk_data['year'] = '3y' - data_placer(ticker_data['risk_data'], sheet, 20, 5, 'E', 'F', False, 'right', True, False) + risk_data["year"] = "3y" + data_placer( + ticker_data["risk_data"], + sheet, + 20, + 5, + "E", + "F", + False, + "right", + True, + False, + ) try: - data_placer(ticker_data['risk_data']['5y'], sheet, 20, 7, 'G', 'H', False, 'right', True, False) + data_placer( + ticker_data["risk_data"]["5y"], + sheet, + 20, + 7, + "G", + "H", + False, + "right", + True, + False, + ) except KeyError: risk_data = EMPTY_RISK_STATISTICS - risk_data['year'] = '5y' - data_placer(ticker_data['risk_data'], sheet, 20, 7, 'G', 'H', False, 'right', True, False) + risk_data["year"] = "5y" + data_placer( + ticker_data["risk_data"], + sheet, + 20, + 7, + "G", + "H", + False, + "right", + True, + False, + ) try: - data_placer(ticker_data['risk_data']['10y'], sheet, 20, 9, 'I', 'J', False, 'right', True, False) + data_placer( + ticker_data["risk_data"]["10y"], + sheet, + 20, + 9, + "I", + "J", + False, + "right", + True, + False, + ) except KeyError: risk_data = EMPTY_RISK_STATISTICS - risk_data['year'] = '10y' - data_placer(ticker_data['risk_data'], sheet, 20, 9, 'I', 'J', False, 'right', True, False) - - image_placer(ticker_data['image_URL'], sheet, 'L12') + risk_data["year"] = "10y" + data_placer( + ticker_data["risk_data"], + sheet, + 20, + 9, + "I", + "J", + False, + "right", + True, + False, + ) + + image_placer(ticker_data["image_URL"], sheet, "L12") graph_placer(stock_sheet, stock_data, sheet, min_col, min_row, max_col, "E4") min_col += 1 max_col += 1 try: - workbook.remove(workbook['Sheet']) + workbook.remove(workbook["Sheet"]) except KeyError: pass - stock_sheet.sheet_state = 'hidden' + stock_sheet.sheet_state = "hidden" workbook.save(filename) diff --git a/thepassiveinvestor/utils.py b/thepassiveinvestor/utils.py index 42279d5..3c296a1 100644 --- a/thepassiveinvestor/utils.py +++ b/thepassiveinvestor/utils.py @@ -8,9 +8,19 @@ from openpyxl.styles.numbers import FORMAT_PERCENTAGE_00 -def data_placer(data, sheet, starting_row, column, column_key, column_value, horizontal_alignment_key=False, - horizontal_alignment_value=False, change_key_dimensions=True, change_value_dimensions=True, - value_formatting_style=None): +def data_placer( + data, + sheet, + starting_row, + column, + column_key, + column_value, + horizontal_alignment_key=False, + horizontal_alignment_value=False, + change_key_dimensions=True, + change_value_dimensions=True, + value_formatting_style=None, +): """ Description ---- @@ -49,10 +59,12 @@ def data_placer(data, sheet, starting_row, column, column_key, column_value, hor max_length_value = 0 for key, value in data.items(): - if value_formatting_style == 'percentage': + if value_formatting_style == "percentage": try: value = float(value[:-1]) / 100 - sheet[f"{column_value}{starting_row}"].number_format = FORMAT_PERCENTAGE_00 + sheet[ + f"{column_value}{starting_row}" + ].number_format = FORMAT_PERCENTAGE_00 except ValueError: pass @@ -104,7 +116,7 @@ def image_placer(image_url, sheet, location): """ try: http = urllib3.PoolManager() - image_location = http.request('GET', image_url) + image_location = http.request("GET", image_url) image_file = io.BytesIO(image_location.data) image = ExcelImage(image_file) sheet.add_image(image, location) @@ -138,8 +150,16 @@ def graph_placer(stock_sheet, stock_data, sheet, min_col, min_row, max_col, loca ---- Fills in the sheet with the selected graph at the specified location. """ - data = Reference(stock_sheet, min_col=min_col + 1, min_row=min_row, max_col=max_col + 1, max_row=len(stock_data)) - cats = Reference(stock_sheet, min_col=1, min_row=3, max_col=1, max_row=len(stock_data)) + data = Reference( + stock_sheet, + min_col=min_col + 1, + min_row=min_row, + max_col=max_col + 1, + max_row=len(stock_data), + ) + cats = Reference( + stock_sheet, min_col=1, min_row=3, max_col=1, max_row=len(stock_data) + ) chart = LineChart() chart.title = None @@ -147,7 +167,7 @@ def graph_placer(stock_sheet, stock_data, sheet, min_col, min_row, max_col, loca chart.y_axis.title = "Stock Price" chart.y_axis.crossAx = 500 chart.x_axis = DateAxis() - chart.x_axis.number_format = 'yyyy' + chart.x_axis.number_format = "yyyy" chart.x_axis.title = "Date" chart.add_data(data) From 5308685cf4589d75a1fe6a6d9723cfb45bff6a2a Mon Sep 17 00:00:00 2001 From: Jeroen Bouma Date: Mon, 2 Jan 2023 17:24:47 +0100 Subject: [PATCH 2/6] Remove the program, maintenance is no longer possible --- program/program.py | 96 -------------------------- program/report.py | 166 --------------------------------------------- program/utils.py | 123 --------------------------------- 3 files changed, 385 deletions(-) delete mode 100644 program/program.py delete mode 100644 program/report.py delete mode 100644 program/utils.py diff --git a/program/program.py b/program/program.py deleted file mode 100644 index 30f1eb1..0000000 --- a/program/program.py +++ /dev/null @@ -1,96 +0,0 @@ -import threading -from tkinter import * -from tkinter import messagebox - -from PIL import Image -from PIL import ImageTk - -from report import excelReport -from utils import * - - -class Window(Frame): - def __init__(self, master=None): - Frame.__init__(self, master) - self.master = master - self.initWindow() - - def initWindow(self): - def onClick(event): - if event.widget.get() == self.filenameEntryExample: - event.widget.delete(0, END) - event.widget.insert(0, "") - event.widget.config(fg='black') - elif event.widget.get() == self.screenerEntryExample: - event.widget.delete(0, END) - event.widget.insert(0, "") - event.widget.config(fg='black') - - self.master.title("The Passive Investor") - self.pack(fill=BOTH, expand=1) - - image = ImageTk.PhotoImage(Image.open(resourcePath("images\ThePassiveInvestorPNG.png"))) - panel = Label(self, image=image, bg=background) - panel.image = image - panel.grid(row=1, column=1, columnspan=2, sticky=W + E + N + S) - - filenameLabel = Label(self, text="Filename", bg=background) - filenameLabel.grid(row=2, column=1, sticky=E) - screenerLabel = Label(self, text='Tickers URL/File', bg=background) - screenerLabel.grid(row=3, column=1, sticky=E) - - self.filenameEntry = StringVar() - self.filenameEntryExample = "Example: C:\Documents\Investing\Output\S&P500_Output.xlsx" - filenameEntry = Entry(self, textvariable=self.filenameEntry, width=100, fg=entryTextColour) - filenameEntry.grid(row=2, column=2, sticky=W, pady=10, padx=10) - filenameEntry.insert(0, self.filenameEntryExample) - filenameEntry.bind('', onClick) - - self.screenerEntry = StringVar() - self.screenerEntryExample = "Example: https://finance.yahoo.com/etfs (or C:\Documents\Investing\Input\S&P500_Input.xlsx)" - screenerEntry = Entry(self, textvariable=self.screenerEntry, width=100, fg=entryTextColour) - screenerEntry.grid(row=3, column=2, sticky=W, pady=10, padx=10) - screenerEntry.insert(0, self.screenerEntryExample) - screenerEntry.bind('', onClick) - - excelReportButton = Button(self, text="Create Report", command=self.runProgram, bg=buttonColor, - fg=buttonTextColour) - excelReportButton.grid(row=4, column=1, columnspan=2, sticky=W + E + N + S, padx=10, pady=10) - - def generateReport(self): - try: - screenerURL = self.screenerEntry.get() - - if self.filenameEntry.get()[-5:] == '.xlsx': - filename = self.filenameEntry.get() - else: - filename = self.filenameEntry.get() + '.xlsx' - - progress = Label(self, text="Collecting tickers..", bg=buttonColor, fg=buttonTextColour) - progress.grid(row=4, column=1, columnspan=2, sticky=W + E + N + S, padx=10, pady=10) - tickers = symbolCollector(screenerURL) - - excelReport.excelWriter(self, tickers, filename) - - except Exception as error: - messagebox.showerror('Error', "The program has crashed with the following error: \n\n" - + str(error) + "\n\nIf the problem persists, please create an Issue with the error " - + "message on the project's GitHub page:" - + "https://github.com/JerBouma/ThePassiveInvestor/issues. \n\n" - + "You can copy this entire message with CTRL + C.") - - excelReportButton = Button(self, text="Create Report", command=self.runProgram, bg=buttonColor, - fg=buttonTextColour) - excelReportButton.grid(row=4, column=1, columnspan=2, sticky=W + E + N + S, padx=10, pady=10) - - def runProgram(self): - threading.Thread(target=self.generateReport).start() - - -root = Tk() -app = Window(root) -app.configure(background=background) -root.geometry('725x200') -root.iconbitmap("images/iconICO.ico") -root.resizable(False, False) -root.mainloop() diff --git a/program/report.py b/program/report.py deleted file mode 100644 index 49b590b..0000000 --- a/program/report.py +++ /dev/null @@ -1,166 +0,0 @@ -from datetime import datetime -from tkinter import * - -import yfinance as yf -from openpyxl import Workbook -from openpyxl.cell.cell import Cell -from openpyxl.formatting.rule import ColorScaleRule -from openpyxl.utils.dataframe import dataframe_to_rows -from scipy.stats.mstats import gmean -from yfinance.utils import get_json - -from utils import * - - -class excelReport: - - def excelWriter(self, tickers, filename): - wb = Workbook() - - progress = Label(self, text="Collecting historic data..", bg=buttonColor, fg=buttonTextColour) - progress.grid(row=4, column=1, columnspan=2, sticky=W + E + N + S, padx=10, pady=10) - - stockData = yf.download(tickers, period='10y')['Adj Close'] - stockData = stockData[tickers] - wb.create_sheet(title='Stock Data') - stockSheet = wb['Stock Data'] - - for row in dataframe_to_rows(stockData, index=True, header=True): - stockSheet.append(row) - stockSheet.column_dimensions['A'].width = len(str(stockData.index[0])) - stockSheet.sheet_view.showGridLines = False - - minCol, minRow, maxCol = 1, 3, 1 - - for ticker in tickers: - progress = Label(self, text="Creating report for " + ticker, bg=buttonColor, fg=buttonTextColour) - progress.grid(row=4, column=1, columnspan=2, sticky=W + E + N + S, padx=10, pady=10) - - wb.create_sheet(title=ticker) - sheet = wb[ticker] - sheet.sheet_view.showGridLines = False - - try: - excelReport.collectData(self, ticker) - except (KeyError, TypeError): - sheet['B2'].value = "No data available" - sheet['B2'].font = Font(italic=True) - continue - - sheet['B2'].value = self.tickerName - sheet['B2'].font = Font(bold=True, size=15) - sheet['B2'].alignment = Alignment(horizontal='left') - sheet.merge_cells('B2:M2') - - sheet['B3'].value = self.businessSummary - sheet['B3'].alignment = Alignment(wrap_text=True, vertical='center', horizontal='left') - sheet.merge_cells('B3:M3') - sheet.row_dimensions[3].height = 100 - - sheet['B4'].value = "Sector Holdings" - sheet['B4'].font = Font(bold=True) - - sheet['B17'].value = "Top Company Holdings" - sheet['B17'].font = Font(bold=True) - - sheet['E19'].value = "Risk Statistics" - sheet['E19'].font = Font(bold=True) - sheet['E19'].alignment = Alignment(horizontal='center') - sheet.merge_cells('E19:J19') - - sheet['L21'].value = "Last Five Annual Returns" - sheet['L21'].font = Font(bold=True) - - sheet['L4'].value = 'Key Characteristics' - sheet['L4'].font = Font(bold=True) - - dataPlacer(self.sectorHoldings, sheet, 5, 2, 'B', 'C') - dataPlacer(self.companyHoldings, sheet, 18, 2, 'B', 'C', - changeKeyDimensions=False) - dataPlacer(self.annualReturns, sheet, 22, 12, 'L', 'M', - changeKeyDimensions=False, changeValueDimensions=False) - dataPlacer(self.keyCharacteristics, sheet, 5, 12, 'L', 'M', - horizonalAlignmentValue='left') - - try: - dataPlacer(self.riskData['3y'], sheet, 20, 5, 'E', 'F', False, 'right', True, False) - except KeyError: - riskData = emptyRiskStatistics - riskData['year'] = '3y' - dataPlacer(riskData, sheet, 20, 5, 'E', 'F', False, 'right', True, False) - try: - dataPlacer(self.riskData['5y'], sheet, 20, 7, 'G', 'H', False, 'right', True, False) - except KeyError: - riskData = emptyRiskStatistics - riskData['year'] = '5y' - dataPlacer(riskData, sheet, 20, 7, 'G', 'H', False, 'right', True, False) - try: - dataPlacer(self.riskData['10y'], sheet, 20, 9, 'I', 'J', False, 'right', True, False) - except KeyError: - riskData = emptyRiskStatistics - riskData['year'] = '10y' - dataPlacer(riskData, sheet, 20, 9, 'I', 'J', False, 'right', True, False) - - imagePlacer(self.imageURL, sheet, 'L12') - graphPlacer(ticker, stockSheet, stockData, sheet, minCol, minRow, maxCol) - minCol += 1 - maxCol += 1 - - stockSheet.sheet_state = 'hidden' - wb.remove_sheet(wb['Sheet']) - wb.save(filename) - - def collectData(self, ticker): - url = "https://finance.yahoo.com/quote/" + ticker - data = get_json(url) - - fundPerformance = data['fundPerformance'] - topHoldings = data['topHoldings'] - defaultKeyStatistics = data['defaultKeyStatistics'] - summaryDetail = data['summaryDetail'] - - self.tickerName = data['quoteType']['longName'] - self.businessSummary = data['assetProfile']['longBusinessSummary'] - - sectorData = topHoldings['sectorWeightings'] - self.sectorHoldings = {} - - for sector in sectorData: - for key, value in sector.items(): - self.sectorHoldings[key] = str(round(value * 100, 2)) + '%' - - companyData = topHoldings['holdings'] - self.companyHoldings = {} - - for company in companyData: - self.companyHoldings[company['holdingName']] = str(round(company['holdingPercent'] * 100, 2)) + '%' - - annualReturnsData = fundPerformance['annualTotalReturns']['returns'][:6] - self.annualReturns = {} - - for returns in annualReturnsData: - if returns['annualValue'] == None: - self.annualReturns[returns['year']] = "N/A" - else: - self.annualReturns[returns['year']] = str(round(returns['annualValue'] * 100, 2)) + '%' - - riskStatistics = fundPerformance['riskOverviewStatistics']['riskStatistics'] - self.riskData = {} - - for risk in riskStatistics: - self.riskData[risk['year']] = risk - - self.imageURL = data['fundProfile']['styleBoxUrl'] - - self.keyCharacteristics = {} - - for option in defaultKeyStatisticsChoices: - if option == 'fundInceptionDate': - self.keyCharacteristics[option] = defaultKeyStatistics[option] - self.keyCharacteristics[option] = datetime.fromtimestamp(self.keyCharacteristics[option]).strftime( - '%Y-%m-%d') - else: - self.keyCharacteristics[option] = defaultKeyStatistics[option] - - for option in defaultsummaryDetailChoices: - self.keyCharacteristics[option] = summaryDetail[option] diff --git a/program/utils.py b/program/utils.py deleted file mode 100644 index f1d4803..0000000 --- a/program/utils.py +++ /dev/null @@ -1,123 +0,0 @@ -import io -import os -import sys - -import pandas as pd -import requests -import urllib3 -from lxml import html, etree -from openpyxl.chart import Reference, LineChart -from openpyxl.chart.axis import DateAxis -from openpyxl.drawing.image import Image as ExcelImage -from openpyxl.styles import Alignment, Font - - -def resourcePath(relativePath): - try: - basePath = sys._MEIPASS - except Exception: - basePath = os.path.abspath(".") - - return os.path.join(basePath, relativePath) - - -def symbolCollector(input): - if input[:4] == 'http': - page = requests.get(input) - tree = html.fromstring(page.content) - table = tree.xpath('//table') - symbol = pd.read_html(etree.tostring(table[0], method='html')) - symbol = symbol[0]['Symbol'].to_list() - else: - symbol = pd.read_excel(input, header=None)[0].to_list() - - return symbol - - -def dataPlacer(data, sheet, startingRow, column, columnKey, - columnValue, horizonalAlignmentKey=False, horizonalAlignmentValue=False, - changeKeyDimensions=True, changeValueDimensions=True): - maxLengthKey = 0 - maxLengthValue = 0 - - for key, value in data.items(): - keyPosition = sheet.cell(column=column, row=startingRow, value=key) - valuePosition = sheet.cell(column=column + 1, row=startingRow, value=value) - - startingRow += 1 - - if horizonalAlignmentKey: - keyPosition.alignment = Alignment(horizontal=horizonalAlignmentKey) - - if horizonalAlignmentValue: - valuePosition.alignment = Alignment(horizontal=horizonalAlignmentValue) - - lengthKey = len(str(keyPosition.value)) - lengthValue = len(str(valuePosition.value)) - - if lengthKey > maxLengthKey: - maxLengthKey = lengthKey - - if lengthValue > maxLengthValue: - maxLengthValue = lengthValue - - if changeKeyDimensions: - sheet.column_dimensions[columnKey].width = maxLengthKey * 1.2 - - if changeValueDimensions: - sheet.column_dimensions[columnValue].width = maxLengthValue * 1.2 - - -def imagePlacer(imageURL, sheet, location): - try: - http = urllib3.PoolManager() - imageLocation = http.request('GET', imageURL) - imageFile = io.BytesIO(imageLocation.data) - image = ExcelImage(imageFile) - sheet.add_image(image, location) - except Exception: - sheet[location] = "No image available" - sheet[location].font = Font(italic=True) - - -def graphPlacer(ticker, stockSheet, stockData, - sheet, minCol, minRow, maxCol): - data = Reference(stockSheet, min_col=minCol + 1, min_row=minRow, max_col=maxCol + 1, max_row=len(stockData)) - cats = Reference(stockSheet, min_col=1, min_row=3, max_col=1, max_row=len(stockData)) - - chart = LineChart() - chart.title = None - chart.legend = None - chart.y_axis.title = "Stock Price" - chart.y_axis.crossAx = 500 - chart.x_axis = DateAxis() - chart.x_axis.number_format = 'yyyy' - chart.x_axis.title = "Date" - - chart.add_data(data) - chart.set_categories(cats) - - sheet.add_chart(chart, 'E4') - - -background = '#e8e8e8' -buttonColor = '#4a00a0' -buttonTextColour = 'white' -entryTextColour = 'gray' - -defaultKeyStatisticsChoices = ['fundInceptionDate', - 'category', - 'totalAssets'] - -defaultsummaryDetailChoices = ['currency', - 'navPrice', - 'previousClose'] - -emptyRiskStatistics = {"year": 0, - "alpha": 0, - "beta": 0, - "meanAnnualReturn": 0, - "rSquared": 0, - "stdDev": 0, - "sharpeRatio": 0, - "treynorRatio": 0} From 4f80596e84935655623738ee926ed5ffc458a3c7 Mon Sep 17 00:00:00 2001 From: Jeroen Bouma Date: Mon, 2 Jan 2023 17:25:04 +0100 Subject: [PATCH 3/6] Release a new version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a932c20..280e8d1 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setuptools.setup( name="thepassiveinvestor", packages=["thepassiveinvestor"], - version="1.0.10", + version="1.0.11", license="MIT", description="Passive Investing for the Average Joe.", author="JerBouma", From f804078dc60875f9e01d818302cd5dbaff172cbc Mon Sep 17 00:00:00 2001 From: Jeroen Bouma Date: Mon, 2 Jan 2023 17:25:17 +0100 Subject: [PATCH 4/6] Update requirements --- requirements.txt | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/requirements.txt b/requirements.txt index b7181d9..4b3daaf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,5 @@ -yfinance>=0.1.63 -urllib3>=1.26.7 -openpyxl>=3.0.5 -pandas>=1.1.2 -scipy>=1.5.2 -requests>=2.25.1 -lxml>=4.5.2 -beautifulsoup4>=4.10.0 -Pillow>=8.4.0 +openpyxl>=3.0.9 +pandas>=1.5.2 +setuptools>=61.2.0 +urllib3>=1.26.9 +yfinance>=0.2.3 From 7b334c193bf72d5df946e48d1efaba3303581177 Mon Sep 17 00:00:00 2001 From: Jeroen Bouma Date: Mon, 2 Jan 2023 17:25:28 +0100 Subject: [PATCH 5/6] Update readme files --- CODE_OF_CONDUCT.md | 30 ++++----- README.md | 148 +++++++++------------------------------------ 2 files changed, 43 insertions(+), 135 deletions(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 6672a43..979e96b 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -14,22 +14,22 @@ appearance, race, religion, or sexual identity and orientation. Examples of behavior that contributes to creating a positive environment include: -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members Examples of unacceptable behavior by participants include: -* The use of sexualized language or imagery and unwelcome sexual attention or - advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic - address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting +- The use of sexualized language or imagery and unwelcome sexual attention or + advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic + address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting ## Our Responsibilities @@ -70,7 +70,7 @@ members of the project's leadership. This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html -[homepage]: https://www.contributor-covenant.org - For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq + +[homepage]: https://www.contributor-covenant.org diff --git a/README.md b/README.md index e71ab0e..ccf80c1 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,40 @@ # The Passive Investor + [![BuyMeACoffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-Donate-brightgreen?logo=buymeacoffee)](https://www.buymeacoffee.com/jerbouma) [![Issues](https://img.shields.io/github/issues/jerbouma/ThePassiveInvestor)](https://github.com/JerBouma/ThePassiveInvestor/issues) [![Pull Requests](https://img.shields.io/github/issues-pr/JerBouma/ThePassiveInvestor?color=yellow)](https://github.com/JerBouma/ThePassiveInvestor/pulls) [![PYPI Version](https://img.shields.io/pypi/v/ThePassiveInvestor)](https://pypi.org/project/ThePassiveInvestor/) [![PYPI Downloads](https://img.shields.io/pypi/dm/ThePassiveInvestor)](https://pypi.org/project/ThePassiveInvestor/) -Theories and research about the stock market have stated that the semi-strong form of market efficiency seems to hold. -This means that all public information is accurately reflected in the price of a financial instrument. -This makes the job of a portfolio manager primarily managing the desired risk appetite of the client and not explicitly -trying to outperform the market. This fact in combination with Finance professionals all around the world looking for -that 'edge' to make their investment decisions as profitable as possible, makes it so the average joe can not compete. +Theories and research about the stock market have stated that the semi-strong form of market efficiency seems to hold. This means that all public information is accurately reflected in the price of a financial instrument. This makes the job of a portfolio manager primarily managing the desired risk appetite of the client and not explicitly trying to outperform the market. This fact in combination with Finance professionals all around the world looking for that 'edge' to make their investment decisions as profitable as possible, makes it so the average joe can not compete. -Therefore, the term 'Passive Investing' is often coined around. This refers to buying funds -(either ETFs or Mutual Funds) that follow the index (i.e. S&P 500, Dow Jones Index) or a broad market -(Developed Markets, MSCI World) for diversification benefits. This means that a sudden decrease in performance -of one stock within the index does not (on average) lead to a significant decline in the index as a whole. -This allows the holder to spend limited time monitoring his holdings, therefore the term 'Passive'. +Therefore, the term 'Passive Investing' is often coined around. This refers to buying funds (either ETFs or Mutual Funds) that follow the index (i.e. S&P 500, Dow Jones Index) or a broad market (Developed Markets, MSCI World) for diversification benefits. This means that a sudden decrease in performance of one stock within the index does not (on average) lead to a significant decline in the index as a whole. This allows the holder to spend limited time monitoring his holdings, therefore the term 'Passive'. -With a large increase in ETFs available (over 5,000 in 2020), it can become difficult to make the best choice in -what you wish to invest. There are many different providers (iShares, Vanguard, Invesco) as well as with changes -to the underlying stocks (i.e. High Yield, Super Dividends, Equal Weighted). This is quickly reflected when looking -for a S&P 500 ETF as there are over 20 different ETFs available. With the package and program, I wish to make -investment decisions easier to make and manage. +With a large increase in ETFs available (over 5,000 in 2020), it can become difficult to make the best choice in what you wish to invest. There are many different providers (iShares, Vanguard, Invesco) as well as with changes to the underlying stocks (i.e. High Yield, Super Dividends, Equal Weighted). This is quickly reflected when looking for a S&P 500 ETF as there are over 20 different ETFs available. With this package, I wish to make investment decisions easier to make and manage. -An example of the output can be found in the GIF below. This depicts several ETFs collected -from [the Top ETFs according to Yahoo Finance](https://finance.yahoo.com/etfs). +An example of the output can be found in the GIF below. This depicts several ETFs collected from [the Top ETFs according to Yahoo Finance](https://finance.yahoo.com/etfs). ![ThePassiveInvestor](https://raw.githubusercontent.com/JerBouma/ThePassiveInvestor/master/Images/outputExample.gif) ## Installation -### Package The package can be installed via the following commands: 1. `pip install thepassiveinvestor` - * Alternatively, download this repository. -2. (within Python) `import thepassiveinvestor as pi` + - Alternatively, download this repository. +1. (within Python) `import thepassiveinvestor as pi` The functions within this package are: -- `collect_data(ticker)` - collects the most important data for ETFs as listed in the [Functionality](#Functionality) + +- `collect_data(ticker)` - collects the most important data for ETFs as listed in the [Functionality](#Functionality) section. -- `create_ETF_report(tickers, filename, folder=None)` - uses collect_data to create an Excel report with data, as -depicted in the GIF above, per sheet for each ticker. - -Therefore, if you wish to collect data on an ETF or create a report of a selection of ETFs you can use the following +- `create_ETF_report(tickers, filename, folder=None)` - uses collect_data to create an Excel report with data, as + depicted in the GIF above, per sheet for each ticker. + +Therefore, if you wish to collect data on an ETF or create a report of a selection of ETFs you can use the following example: -```` + +``` import thepassiveinvestor as pi # Collect data from a specific ETF @@ -54,101 +43,20 @@ vanguard_sp500 = pi.collect_data('VOO') # Create a report from a list of ETFs etf_list = ['VOO', 'QQQ', 'ARKG', 'VUG', 'SCHA', 'VWO'] pi.create_ETF_report(etf_list, 'Popular ETFs.xlsx') -```` - - -### Program -Installing the program and running an analysis: - -1. Download the most recent release [here](https://github.com/JerBouma/ThePassiveInvestor/releases). - * If you have Python you can also download the repository and run program.py. -2. Unpack the ZIP file to your prefered location and run the file "ThePassiveInvestor.exe" -3. Go to the [FinanceDatabase](https://github.com/JerBouma/FinanceDatabase) and search the database for your preferred tickers. -Then, place the tickers in an Excel sheet with the tickers listed vertically. See the example on the page of the FinanceDatabase. - * You can also use the Yahoo Finance Screener ([ETFs](https://finance.yahoo.com/screener/etf/new) - or [Mutual Funds](https://finance.yahoo.com/screener/mutualfund/new)), select your preferences and click - "Find ETFs". Then you can copy the URL. - * You can also use 'Quote Lookup' ([example](https://finance.yahoo.com/lookup/etf?s=developed%20markets)) - * You can also use your own Excel file that has the tickers listed vertically. -4. Open the program, enter your save location (i.e. C:/Documents/DevelopedMarketsETF.xlsx) and -input the URL or Excelfile you decided to use in Step 2. Note that you do not have to create an Excel file, -the program does this for you. However, it does not create folders. -5. Run the program, this takes less than a minute to complete. -6. Analyse the Excelfile created - -The input should either be an Excel File (with solely tickers in it) or via Yahoo Finance's ETF or Mutual Fund -Screener (see [here](https://finance.yahoo.com/screener/etf/new) -and [here](https://finance.yahoo.com/screener/mutualfund/new)). Note that the program can not -handle stocks, bonds or anything else that is not a fund. This is because the data used is only available -for funds and equity investing is not considered Passive Investing. - -![ThePassiveInvestor](https://raw.githubusercontent.com/JerBouma/ThePassiveInvestor/master/Images/programExample.png) +``` ## Functionality -The program and package are able to output an overview of each fund on a separate sheet. In this overview the -following data is shown: -* The title of the fund -* A summary about the fund's purpose/goal -* Sector Holdings (% in each sector) -* Company Holdings (top 10 companies with highest %) -* Risk Statistics (several measures of risk) - * Displayed in 3, 5 and 10 years - * Alpha - * Beta - * Mean Annual Return - * R Squared - * Standard Deviation - * Sharpe Ratio - * Treynor Ratio -* Characteristics of the instrument - * Inception date (start of fund) - * Category - * Total assets - * Currency - * Net Asset Value - * Latest close price -* Morningstar Style Box (style of the fund) -* Last five annual returns -* Graph depicting the adjusted close prices over the last 10 years -* Last 10 years of adjusted close prices for all Tickers (hidden sheet) + +The package outputs an overview of each fund on a separate sheet. In this overview the following data is shown: + +| Topic | Contains | +| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| General | The title of the fund and a summary about the fund's purpose and goal. | +| Characteristics | Inception data (the start of the fund), the category, assets under management (AUM), the denominated currency, net asset value (NAV), the latest close price and the Morningstar Style Box (if available). | +| Holdings | Sector holdings (% in each sector) and company holdings (top 10 companies with highest %). | +| Risk Metrics | All metrics are displayed in an interval of 3, 5 and 10 years. This includes Jensen's Alpha, Beta, Mean Annual Return, R-squared, Standard Deviation, Sharpe Ratio and Treynor Ratio. | +| Performance | The last five annual returns of the fund as wel as a graph depicting the adjusted close prices over the last 10 years. The actual data for this graph is available on a hidden sheet. | ## Contribution -Projects are bound to have (small) errors and can always be improved. Therefore, -I highly encourage you to submit issues and create pull requests to improve the program: - -If you wish to test the packaging, you can do so by: - -1. Clone/Download this repository. -2. Open CMD/PowerShell/Terminal in folder. -3. install dependencies: ```pip install -r requirements.txt``` - -Buy Me A Coffee - -### Run/Develop -Run the following command: -- ```python program.py``` - -### Build - Installation: -- Windows: - - ```pyinstaller --add-data="images;images" --icon=images\iconICO.ico --name=ThePassiveInvestor program.py``` -- MacOS/Linux: - - ```pyinstaller --add-data="images:images" --icon=images/iconICO.ico --name=ThePassiveInvestor --windowed program.py``` - - Open the 'dist' folder and the 'ThePassiveInvestor' folder, run exe/app. Or: -- Windows: - - CMD: - - ```start dist\ThePassiveInvestor\ThePassiveInvestor.exe``` - - PowerShell: - - ```dist\ThePassiveInvestor\ThePassiveInvestor.exe``` -- MacOS - - ```open dist/ThePassiveInvestor.app``` - -## Troubleshooting -The following issue is known: -- Error pyi_rth_certifi: include the files found in the folder "SSL" to the main directory of the program. -Alternatively, download the latest release which fixes this issue. - -## Disclaimer -While the program allows you to make financial decisions more easily, it explicitly does not make the -decisions for you. Therefore, these decisions remain your own and I am not responsible for any losses (or gains) made. + +Projects are bound to have (small) errors and can always be improved. Therefore, I highly encourage you to submit issues and create pull requests to improve the package. From 214aec940015ca647909fc40f78316259f50f19e Mon Sep 17 00:00:00 2001 From: Jeroen Bouma Date: Mon, 2 Jan 2023 17:25:41 +0100 Subject: [PATCH 6/6] Update readme files --- .github/ISSUE_TEMPLATE/feature_request.md | 7 ++++--- .github/ISSUE_TEMPLATE/program_report.md | 12 +++++++----- .github/ISSUE_TEMPLATE/spreadsheet_report.md | 12 +++++++----- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index bbcbbe7..de0e194 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,14 +1,15 @@ ---- +______________________________________________________________________ + name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' ---- +______________________________________________________________________ **Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] +A clear and concise description of what the problem is. Ex. I'm always frustrated when \[...\] **Describe the solution you'd like** A clear and concise description of what you want to happen. diff --git a/.github/ISSUE_TEMPLATE/program_report.md b/.github/ISSUE_TEMPLATE/program_report.md index 51d5eeb..6ac95c9 100644 --- a/.github/ISSUE_TEMPLATE/program_report.md +++ b/.github/ISSUE_TEMPLATE/program_report.md @@ -1,11 +1,12 @@ ---- +______________________________________________________________________ + name: Program Report about: Create a report about problems with the program title: '' labels: '' assignees: '' ---- +______________________________________________________________________ **Describe the error** A clear and concise description of what the bug is. @@ -17,9 +18,10 @@ Include the input (filepath and ticker url/file) If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] + +- OS: \[e.g. iOS\] +- Browser \[e.g. chrome, safari\] +- Version \[e.g. 22\] **Additional context** Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/spreadsheet_report.md b/.github/ISSUE_TEMPLATE/spreadsheet_report.md index cd48370..c5392a5 100644 --- a/.github/ISSUE_TEMPLATE/spreadsheet_report.md +++ b/.github/ISSUE_TEMPLATE/spreadsheet_report.md @@ -1,11 +1,12 @@ ---- +______________________________________________________________________ + name: Spreadsheet Report about: Create a report about problems with the spreadsheet title: '' labels: '' assignees: '' ---- +______________________________________________________________________ **Describe the error** A clear and concise description of what the bug is. @@ -17,9 +18,10 @@ Include the used formula/data If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] + +- OS: \[e.g. iOS\] +- Browser \[e.g. chrome, safari\] +- Version \[e.g. 22\] **Additional context** Add any other context about the problem here.