diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6d2ba94 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +README.md +LICENSE +Dockerfile \ No newline at end of file diff --git a/.gitignore b/.gitignore index 68bc17f..d2ed47d 100644 --- a/.gitignore +++ b/.gitignore @@ -99,7 +99,7 @@ ipython_config.py # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock +poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. @@ -152,9 +152,6 @@ dmypy.json # Cython debug symbols cython_debug/ -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +*.xlsx +!demo.xlsx +!template.xlsx \ No newline at end of file diff --git a/.streamlit/config.toml b/.streamlit/config.toml new file mode 100644 index 0000000..61d782c --- /dev/null +++ b/.streamlit/config.toml @@ -0,0 +1,7 @@ +[theme] +base="dark" +primaryColor="#B22222" +font="sans serif" + +[browser] +gatherUsageStats = false \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5a5a657 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,26 @@ +{ + "editor.detectIndentation": true, + "editor.insertSpaces": true, + "editor.formatOnSave": true, + "indentRainbow.ignoreErrorLanguages": [ + "markdown", + ], + "autoDocstring.docstringFormat": "numpy", + "autoDocstring.quoteStyle": "\"\"\"", + "autoDocstring.startOnNewLine": true, + "python.defaultInterpreterPath": "${workspaceFolder}/.venv", + "python.linting.mypyEnabled": true, + "python.linting.mypyPath": "${workspaceFolder}/.venv/bin/mypy", + "python.linting.mypyArgs": [ + "--ignore-missing-imports", + "--follow-imports=silent", + "--show-column-numbers", + "--disallow-untyped-defs", + "--disallow-untyped-calls" + ], + "python.formatting.provider": "black", + "python.formatting.blackPath": "${workspaceFolder}/.venv/bin/black", + "python.linting.enabled": true, + "workbench.colorTheme": "Night Owl", + "window.zoomLevel": 1, +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3fecb01 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.10-slim + +WORKDIR /app + +COPY . . + +RUN pip3 install poetry==v1.5.1 + +RUN poetry install --no-root + +EXPOSE 8501 + +ENTRYPOINT ["poetry", "run", "streamlit", "run", "./src/0_🏠_Home.py", "--server.port=8501", "--server.address=0.0.0.0"] \ No newline at end of file diff --git a/README.md b/README.md index f59a7b0..f3533c3 100644 --- a/README.md +++ b/README.md @@ -1 +1,56 @@ # Personal Finance for Newbies (PFN) + +## What is that? +A web app that, from a file of (buy/sell) financial asset transactions, produces near real-time statistics (updated to the last closing) on your investment portfolio + +## How can I run it? + +### Streamlit +Install dependencies via poetry + +- `poetry install` + +Launch the app + +- `streamlit run 0_🏠_Home.py` + +### Docker +To build the app's docker image + +- `docker build -t personal-finance-for-newbies .` + +To run the docker image and expose it on a preferred port (for example 8080) + +- `docker run -p 8080:8501 personal-finance-for-newbies` + +To run the docker image using the host's network (which will make the app accessible on port 8501) + +- `docker run --network host personal-finance-for-newbies` + +## How can I use my own data? +To load and use your data, download and fill in the template with your accumulation plan's buy/sell transactions and upload it. +Make sure you fill it in correctly. The fields to be entered are: + +- **Exchange**: name of the market (according to Yahoo Finance) [list of exchange suffixes](https://help.yahoo.com/kb/SLN2310.html); +- **Ticker**: symbol to identify a publicly traded security; +- **Transaction Date**: date of transaction in DD/MM/YYYY format; +- **Shares**: number of purchased/sold shares; please, include a minus sign to indicate selling; +- **Price**: price of a single share; +- **Fees**: transaction fees, if any. + +## How can I help? + +### To-Do list +We always look for pull requests, if you know better! +Here's an hopefully up-to-date list of things to build: +- Correlation map between assets +- Improve Sharpe Ratio calculation, to take into account a time-varying: + - risk-free rate + - asset allocation +- Rolling Sharpe ratio chart +- Max Drawdown evaluation +- Sortino and Calmar ratios +- Time slicing (1yr, 3yrs, 5yrs, ..., All) as a global filter +- Docker compose, with these services: + - `jupyter notebook` for prototyping + - `streamlit` to launch the web-app diff --git a/data/in/demo.xlsx b/data/in/demo.xlsx new file mode 100644 index 0000000..70e6380 Binary files /dev/null and b/data/in/demo.xlsx differ diff --git a/data/in/template.xlsx b/data/in/template.xlsx new file mode 100644 index 0000000..220101f Binary files /dev/null and b/data/in/template.xlsx differ diff --git a/images/favicon.ico b/images/favicon.ico new file mode 100644 index 0000000..2463b15 Binary files /dev/null and b/images/favicon.ico differ diff --git a/jupyters/near_real_time_pf.ipynb b/jupyters/near_real_time_pf.ipynb new file mode 100644 index 0000000..385b241 --- /dev/null +++ b/jupyters/near_real_time_pf.ipynb @@ -0,0 +1,855 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "f7fa1942", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "import pandas as pd\n", + "import numpy as np\n", + "import plotly.express as px\n", + "import plotly.graph_objects as go\n", + "import matplotlib.pyplot as plt\n", + "\n", + "from utils import aggregate_by_ticker, get_last_closing_price, get_full_price_history" + ] + }, + { + "cell_type": "markdown", + "id": "7616bbd2", + "metadata": {}, + "source": [ + "## Import" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3bf2317f", + "metadata": {}, + "outputs": [], + "source": [ + "io_path = Path('..','data','in')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14b007ec", + "metadata": {}, + "outputs": [], + "source": [ + "df_storico = pd.read_excel(\n", + " io_path / Path('demo.xlsx'),\n", + " sheet_name='Storico',\n", + " dtype={\n", + " 'Borsa': str,\n", + " 'Ticker': str,\n", + " 'Quote': int,\n", + " 'Prezzo (€)': float,\n", + " 'Commissioni': float,\n", + " }\n", + ").rename(\n", + " columns={\n", + " 'Borsa': 'exchange',\n", + " 'Ticker': 'ticker',\n", + " 'Data Operazione': 'transaction_date',\n", + " 'Quote': 'shares',\n", + " 'Prezzo (€)': 'price',\n", + " 'Commissioni (€)': 'fees',\n", + " }\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7438c464", + "metadata": {}, + "outputs": [], + "source": [ + "df_anagrafica = pd.read_excel(\n", + " io_path / Path('demo.xlsx'),\n", + " sheet_name='Anagrafica Titoli',\n", + " dtype=str\n", + ").rename(\n", + " columns={\n", + " 'Ticker': 'ticker',\n", + " 'Nome ETF': 'name',\n", + " 'Tipologia': 'asset_class',\n", + " 'Macro Tipologia': 'macro_asset_class',\n", + " }\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "63de10c6", + "metadata": {}, + "outputs": [], + "source": [ + "df_pf = aggregate_by_ticker(df_storico, in_pf_only=True)" + ] + }, + { + "cell_type": "markdown", + "id": "18f808d2", + "metadata": {}, + "source": [ + "## Ultima chiusura" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3e88e6b5", + "metadata": {}, + "outputs": [], + "source": [ + "ticker_list = df_pf['ticker_yf'].to_list()\n", + "\n", + "df_last_closing = get_last_closing_price(ticker_list=ticker_list)" + ] + }, + { + "cell_type": "markdown", + "id": "64b2943a", + "metadata": {}, + "source": [ + "## PMC *vs* prezzo attuale" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "342f4b77", + "metadata": {}, + "outputs": [], + "source": [ + "df_j = df_pf[['ticker_yf','dca','shares']].merge(\n", + " df_last_closing[['ticker_yf','price']],\n", + " how='left',\n", + " on='ticker_yf'\n", + ")\n", + "\n", + "df_j['gain'] = np.where(\n", + " df_j['price'].gt(df_j['dca']),\n", + " True,\n", + " False,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "f09c2ac7", + "metadata": {}, + "source": [ + "## PnL" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c09a9433", + "metadata": {}, + "outputs": [], + "source": [ + "expense = (\n", + " df_j['shares'] * df_j['dca']\n", + ").sum()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "68c9975c", + "metadata": {}, + "outputs": [], + "source": [ + "fees = df_storico['fees'].sum().round(2)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "235730d7", + "metadata": {}, + "outputs": [], + "source": [ + "pf_actual_value = (\n", + " df_j['shares'] * df_j['price']\n", + ").sum()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5bc03ca7", + "metadata": {}, + "outputs": [], + "source": [ + "(pf_actual_value - expense).round(2), (pf_actual_value - expense - fees).round(2)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e17b4251", + "metadata": {}, + "outputs": [], + "source": [ + "np.round(\n", + " 100 * (pf_actual_value - expense) / expense,\n", + " 1\n", + "), np.round(\n", + " 100 * (pf_actual_value - expense - fees) / expense,\n", + " 1\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "6a59aa2e", + "metadata": {}, + "source": [ + "## Pivot per tipologia" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "473f00dc", + "metadata": {}, + "outputs": [], + "source": [ + "df_j['ticker'] = df_j['ticker_yf'].str.split('.').str[0]\n", + "df_j['position_value'] = df_j['shares'] * df_j['price']" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "59d03a23", + "metadata": {}, + "outputs": [], + "source": [ + "df_pivot = df_j.merge(\n", + " df_anagrafica,\n", + " how='left',\n", + " on='ticker'\n", + ").groupby(\n", + " [\n", + " 'macro_asset_class',\n", + " 'asset_class',\n", + " 'ticker_yf',\n", + " 'name',\n", + " ]\n", + ")['position_value'].sum().reset_index()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ec66b53a", + "metadata": {}, + "outputs": [], + "source": [ + "df_pivot['weight_pf'] = (\n", + " 100 * df_pivot['position_value'].div(pf_actual_value)\n", + ").astype(float).round(1)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8652d1f5", + "metadata": {}, + "outputs": [], + "source": [ + "pd.pivot_table(\n", + " df_pivot,\n", + " values=['weight_pf'],\n", + " index=['macro_asset_class', 'asset_class'],\n", + " aggfunc='sum',\n", + " margins=True,\n", + " margins_name='Total',\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3fa346b9", + "metadata": {}, + "outputs": [], + "source": [ + "df_cool = df_j.merge(\n", + " df_anagrafica,\n", + " how='left',\n", + " on='ticker'\n", + ")\n", + "\n", + "df_cool['pnl'] = (\n", + " (df_cool['price'] - df_cool['dca']) * df_cool['shares']\n", + ").astype(float).round(1)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1b7d7bac", + "metadata": {}, + "outputs": [], + "source": [ + "df_pnl = df_cool.groupby(\n", + " ['macro_asset_class','asset_class']\n", + ")['pnl'].sum().reset_index().sort_values(['macro_asset_class','asset_class'])" + ] + }, + { + "cell_type": "markdown", + "id": "a81b1833", + "metadata": {}, + "source": [ + "## Full History" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f7b25f87", + "metadata": { + "scrolled": false + }, + "outputs": [], + "source": [ + "df_full_history = get_full_price_history(ticker_list)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "34c5e7bc", + "metadata": { + "scrolled": false + }, + "outputs": [], + "source": [ + "df_full_history_concat = pd.concat(\n", + " [df_full_history[t_] for t_ in ticker_list],\n", + " axis=1,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a0cab767", + "metadata": {}, + "outputs": [], + "source": [ + "# First not-null row\n", + "first_idx = df_full_history_concat.apply(\n", + " pd.Series.first_valid_index\n", + ").max()\n", + "\n", + "df = df_full_history_concat.loc[first_idx:]\n", + "\n", + "print(f'Starting from {str(first_idx)[:10]} ({df.shape[0]} days, {round(df.shape[0]/252, 1)} yrs)')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0624c633", + "metadata": {}, + "outputs": [], + "source": [ + "df.tail(20)" + ] + }, + { + "cell_type": "markdown", + "id": "7394bd19", + "metadata": {}, + "source": [ + "## Grafichetti\n", + "\n", + "[tipo](https://plotly.com/python/horizontal-bar-charts/)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8470b41a", + "metadata": {}, + "outputs": [], + "source": [ + "df.tail(1)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "965c35b8", + "metadata": {}, + "outputs": [], + "source": [ + "df['LCWD.MI'].values[0]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "73955d80", + "metadata": {}, + "outputs": [], + "source": [ + "((1 + total_return)**(12/months))-1" + ] + }, + { + "cell_type": "markdown", + "id": "b3747a4f", + "metadata": {}, + "source": [ + "## Sharpe, Sortino, Drawdon\n", + "\n", + "[link](https://www.codearmo.com/blog/sharpe-sortino-and-calmar-ratios-python)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fefce8c6", + "metadata": {}, + "outputs": [], + "source": [ + "df_storico['transaction_date'].min()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d0fe6423", + "metadata": {}, + "outputs": [], + "source": [ + "weights = [\n", + " df_pivot[df_pivot['ticker_yf'].eq(x_)]['weight_pf'].values[0]\n", + " for x_ in df.columns\n", + "]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c8823df4", + "metadata": {}, + "outputs": [], + "source": [ + "df_weighted = df.copy()\n", + "df_weighted['weighted_average'] = np.average(df, weights=weights, axis=1)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e542c2b6", + "metadata": {}, + "outputs": [], + "source": [ + "df_weighted" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11c8735c", + "metadata": {}, + "outputs": [], + "source": [ + "import datetime\n", + "\n", + "begin_date = df_storico['transaction_date'].min()\n", + "today = datetime.datetime.now().date()\n", + "\n", + "date_range = pd.date_range(start=begin_date, end=today, freq='D')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2653241a", + "metadata": {}, + "outputs": [], + "source": [ + "df_asset_allocation = pd.DataFrame(\n", + " index=date_range,\n", + " columns=ticker_list,\n", + " data=0,\n", + " dtype=int,\n", + ")\n", + "\n", + "for (data, ticker), group in df_storico[\n", + " df_storico['ticker_yf'].ne('EGLN.L')\n", + "].groupby(['transaction_date', 'ticker_yf']):\n", + " total_shares = group['shares'].sum()\n", + " df_asset_allocation.loc[data, ticker] += total_shares\n", + " \n", + "df_asset_allocation = df_asset_allocation.cumsum()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f36ad8c1", + "metadata": {}, + "outputs": [], + "source": [ + "# Crescita patrimonio\n", + "df_wealth = df_asset_allocation.multiply(\n", + " df.loc[begin_date:]\n", + ").fillna(method='ffill').sum(axis=1).rename(\"ap_daily_value\")" + ] + }, + { + "cell_type": "markdown", + "id": "50fe018b", + "metadata": {}, + "source": [ + "## Correlation" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "47c977b3", + "metadata": {}, + "outputs": [], + "source": [ + "def color_df(val: float) -> str:\n", + " if val <= 0.3:\n", + " color = 'darkblue'\n", + " elif (val > 0.3 and val <= 0.7):\n", + " color = 'darkorange'\n", + " elif (val > 0.7 and val < 1.0):\n", + " color = 'darkred'\n", + " elif val == 1.0:\n", + " color = 'white'\n", + " return 'color: %s' % color" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "299d230c", + "metadata": { + "scrolled": false + }, + "outputs": [], + "source": [ + "df_corr = df.corr()\n", + "\n", + "df_corr.style.applymap(color_df)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c8e41903", + "metadata": { + "scrolled": false + }, + "outputs": [], + "source": [ + "mask = np.tril(\n", + " np.ones_like(df_corr, dtype=bool)\n", + ")\n", + "\n", + "fig = go.Figure(go.Heatmap(\n", + " z=df_corr.mask(mask),\n", + " x=df_corr.columns,\n", + " y=df_corr.columns,\n", + " colorscale=px.colors.diverging.RdBu,\n", + " reversescale=True,\n", + " zmin=-1,\n", + " zmax=1\n", + "))\n", + "\n", + "fig.update_layout(\n", + " paper_bgcolor='rgba(0,0,0,0)',\n", + " plot_bgcolor='rgba(0,0,0,0)',\n", + ")\n", + "\n", + "fig.show()" + ] + }, + { + "cell_type": "markdown", + "id": "63455e33", + "metadata": {}, + "source": [ + "## PyPortfolioOpt" + ] + }, + { + "cell_type": "markdown", + "id": "f16d4372", + "metadata": {}, + "source": [ + "[risk-free rate](https://www.ecb.europa.eu/stats/financial_markets_and_interest_rates/euro_short-term_rate/html/index.en.html) area Euro\n", + "\n", + "[Fred](https://fred.stlouisfed.org/series/ECBESTRVOLWGTTRMDMNRT)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2547a1a5", + "metadata": {}, + "outputs": [], + "source": [ + "from pypfopt.efficient_frontier import EfficientFrontier\n", + "from pypfopt import risk_models, expected_returns, plotting\n", + "\n", + "# Calculate expected returns and sample covariance\n", + "mu = expected_returns.mean_historical_return(df)\n", + "S = risk_models.sample_cov(df)\n", + "\n", + "# Risk-free rate\n", + "risk_free_rate = 0.0314\n", + "\n", + "# Optimize for maximal Sharpe ratio\n", + "ef = EfficientFrontier(mu, S)\n", + "max_sharpe_weights = ef.max_sharpe(risk_free_rate=risk_free_rate)\n", + "ef.portfolio_performance(verbose=True, risk_free_rate=risk_free_rate);" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1a95f1ac", + "metadata": {}, + "outputs": [], + "source": [ + "for it_ in max_sharpe_weights.items():\n", + " print(it_)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d39ef8f4", + "metadata": {}, + "outputs": [], + "source": [ + "ef_plt = EfficientFrontier(mu, S)\n", + "\n", + "fig, ax = plt.subplots()\n", + "plotting.plot_efficient_frontier(\n", + " ef_plt,\n", + " ax=ax,\n", + " show_assets=True,\n", + ")\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "301d51de", + "metadata": {}, + "outputs": [], + "source": [ + "ef_plt = EfficientFrontier(mu, S)\n", + "\n", + "fig, ax = plt.subplots()\n", + "ef_max_sharpe = ef_plt\n", + "plotting.plot_efficient_frontier(\n", + " ef_plt,\n", + " ax=ax,\n", + " show_assets=False,\n", + ")\n", + "\n", + "# Find the tangency portfolio\n", + "ef_max_sharpe.max_sharpe()\n", + "ret_tangent, std_tangent, _ = ef_max_sharpe.portfolio_performance()\n", + "ax.scatter(std_tangent, ret_tangent, marker=\"*\", s=100, c=\"r\", label=\"Max Sharpe\")\n", + "\n", + "# Generate random portfolios\n", + "n_samples = 10000\n", + "w = np.random.dirichlet(np.ones(ef_plt.n_assets), n_samples)\n", + "rets = w.dot(ef_plt.expected_returns)\n", + "stds = np.sqrt(np.diag(w @ ef_plt.cov_matrix @ w.T))\n", + "sharpes = rets / stds\n", + "ax.scatter(stds, rets, marker=\".\", c=sharpes, cmap=\"viridis_r\")\n", + "\n", + "# Output\n", + "ax.set_title(\"Efficient Frontier with random portfolios\")\n", + "ax.set_xlim((0.0, 1.0))\n", + "ax.set_ylim((0.0, 0.2))\n", + "ax.legend()\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "d419b950", + "metadata": {}, + "source": [ + "## Efficient Frontier" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a1801332", + "metadata": {}, + "outputs": [], + "source": [ + "df_returns = df.pct_change()[1:]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "84debab3", + "metadata": {}, + "outputs": [], + "source": [ + "# Annualized returns (cumulative appreciation)\n", + "r = ((1 + df_returns).prod())**(252 / df_returns.shape[0]) - 1" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "be51b063", + "metadata": {}, + "outputs": [], + "source": [ + "# Annualized volatility\n", + "vol = df_returns.std() * np.sqrt(252 / df_returns.shape[0])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cc421c55", + "metadata": {}, + "outputs": [], + "source": [ + "pd.concat([r.rename('r'), vol.rename('vol')], axis=1).assign(r_v=r/vol)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "59b2f8c5", + "metadata": {}, + "outputs": [], + "source": [ + "# Covariance matrix\n", + "cov = 252 * df_returns.cov()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "08156296", + "metadata": {}, + "outputs": [], + "source": [ + "e = np.ones(r.shape[0])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "99495729", + "metadata": {}, + "outputs": [], + "source": [ + "# Investable universe\n", + "icov = np.linalg.inv(cov)\n", + "\n", + "h = np.matmul(e, icov)\n", + "g = np.matmul(r, icov)\n", + "\n", + "a = np.sum(e * h)\n", + "b = np.sum(r * h)\n", + "c = np.sum(r * g)\n", + "d = a * c - b**2" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "45645825", + "metadata": {}, + "outputs": [], + "source": [ + "# MVP (minimum-variance portfolio)\n", + "mvp = h / a\n", + "mvp_return = b / a\n", + "mvp_risk = 1 / np.sqrt(a)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a6e51ebb", + "metadata": {}, + "outputs": [], + "source": [ + "# Tangency portfolio (with zero risk-free rate)\n", + "tangency = g / b\n", + "tangency_return = c / b\n", + "tangency_risk = np.sqrt(c) / b" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "194d4856", + "metadata": {}, + "outputs": [], + "source": [ + "mvp_return, mvp_risk" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.8" + }, + "vscode": { + "interpreter": { + "hash": "916dbcbb3f70747c44a77c7bcd40155683ae19c65e1c03b4aa3499c5328201f1" + } + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/jupyters/utils.py b/jupyters/utils.py new file mode 100644 index 0000000..e73605f --- /dev/null +++ b/jupyters/utils.py @@ -0,0 +1,80 @@ +from typing import Dict, List + +import yfinance as yf +import pandas as pd +import numpy as np + + +def aggregate_by_ticker(df: pd.DataFrame, in_pf_only: bool = False) -> pd.DataFrame: + df["ap_amount"] = df["shares"] * df["price"] + df["ticker_yf"] = df["ticker"] + "." + df["exchange"] + + df_portfolio = ( + df.groupby("ticker_yf") + .agg( + shares=("shares", "sum"), + ap_amount=("ap_amount", "sum"), + fees=("fees", "sum"), + ) + .reset_index() + .sort_values( + "shares", + ascending=False, + ) + ) + + df_portfolio["is_in_pf"] = df_portfolio["shares"].ne(0) + + df_portfolio["dca"] = np.where( + df_portfolio["is_in_pf"].eq(True), + df_portfolio["ap_amount"].div(df_portfolio["shares"]), + 0, + ) + + if in_pf_only: + df_portfolio = df_portfolio[df_portfolio["is_in_pf"].eq(True)] + + return df_portfolio.drop(columns="is_in_pf").reset_index(drop=True) + + +def get_last_closing_price(ticker_list: List[str]) -> pd.DataFrame: + df_last_closing = pd.DataFrame( + columns=["ticker_yf", "last_closing_date", "price"], + index=range(len(ticker_list)), + ) + + for i, ticker_ in zip(range(len(ticker_list)), ticker_list): + ticker_data = yf.Ticker(ticker_) + closing_date_ = ( + ticker_data.history( + period="1d", + interval="1d", + )["Close"] + .reset_index() + .values.tolist() + ) + + df_last_closing.iloc[i] = [ticker_] + closing_date_[0] + + df_last_closing["last_closing_date"] = ( + df_last_closing["last_closing_date"].astype(str).str.slice(0, 10) + ) + + return df_last_closing + + +def get_full_price_history(ticker_list: List[str]) -> Dict: + df_history = dict() + + for i, ticker_ in zip(range(len(ticker_list)), ticker_list): + ticker_data = yf.Ticker(ticker_) + df_history[ticker_] = ticker_data.history( + period="max", + interval="1d", + )[ + "Close" + ].rename(ticker_) + + df_history[ticker_].index = pd.to_datetime(df_history[ticker_].index.date) + + return df_history diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..18cc440 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[tool.poetry] +name = "personal-finance-for-newbies" +version = "0.1.0" +description = "" +authors = ["Vincenzo "] +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.10" +pandas = "^2.0.2" +yfinance = "^0.2.20" +plotly = "^5.15.0" +jupyter = "^1.0.0" +ipykernel = "^6.23.2" +numpy = "^1.25.0" +matplotlib = "^3.7.1" +openpyxl = "^3.1.2" +pyportfolioopt = ">=1.1.0" +streamlit = "^1.23.1" +black = "^23.3.0" + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/src/.streamlit/config.toml b/src/.streamlit/config.toml new file mode 100644 index 0000000..61d782c --- /dev/null +++ b/src/.streamlit/config.toml @@ -0,0 +1,7 @@ +[theme] +base="dark" +primaryColor="#B22222" +font="sans serif" + +[browser] +gatherUsageStats = false \ No newline at end of file diff --git "a/src/0_\360\237\217\240_Home.py" "b/src/0_\360\237\217\240_Home.py" new file mode 100644 index 0000000..482f806 --- /dev/null +++ "b/src/0_\360\237\217\240_Home.py" @@ -0,0 +1,69 @@ +from pathlib import Path + +import streamlit as st + +from utils import load_data, write_disclaimer +from var import ( + GLOBAL_STREAMLIT_STYLE, + DATA_PATH, + FAVICON, + APP_VERSION, +) + +st.set_page_config( + page_title="PFN", + page_icon=FAVICON, + layout="wide", + initial_sidebar_state="auto", +) + +st.markdown(GLOBAL_STREAMLIT_STYLE, unsafe_allow_html=True) + +st.title(f"Welcome to Personal Finance for Newbies!") +st.text(f"v{APP_VERSION}") + +st.markdown( + "PFN is a web app that – from buy/sell financial asset transactions – provides easy-to-use, \ + near-real-time statistics (*i.e.*, updated to the last closing) on your investment portfolio." +) +st.markdown("***") +st.markdown("## Let's get started") +st.markdown( + "To load your data, fill in the template with your accumulation plan's buy/sell transactions and upload it here:", + unsafe_allow_html=True, +) +col_l, col_r = st.columns([1, 0.8], gap="small") + +uploaded_file = col_l.file_uploader( + label="Upload your Data", + label_visibility="collapsed", +) +if uploaded_file is not None: + try: + df_storico, df_anagrafica = load_data(uploaded_file) + st.session_state["data"] = df_storico + st.session_state["dimensions"] = df_anagrafica + except: + st.error("Please check your file format and make sure it matches the template") + st.stop() + +with open(DATA_PATH / Path("template.xlsx"), "rb") as f: + col_l.download_button( + "Download Template", + data=f, + file_name="template.xlsx", + mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + +st.markdown("##") +st.markdown( + "If you wish to **explore** the app first, load some **demo data** instead:", + unsafe_allow_html=True, +) + +if st.button(label="Load Mock Data", key="load_mock_df"): + df_storico, df_anagrafica = load_data(DATA_PATH / Path("demo.xlsx")) + st.session_state["data"] = df_storico + st.session_state["dimensions"] = df_anagrafica + +write_disclaimer() diff --git "a/src/pages/1_\360\237\223\210_Basic_Stats.py" "b/src/pages/1_\360\237\223\210_Basic_Stats.py" new file mode 100644 index 0000000..fe9e18d --- /dev/null +++ "b/src/pages/1_\360\237\223\210_Basic_Stats.py" @@ -0,0 +1,150 @@ +import streamlit as st + +from var import ( + GLOBAL_STREAMLIT_STYLE, + PLT_CONFIG, + PLT_CONFIG_NO_LOGO, + FAVICON, +) +from utils import ( + aggregate_by_ticker, + get_last_closing_price, + get_max_common_history, + get_wealth_history, + get_portfolio_pivot, + get_pnl_by_asset_class, + write_disclaimer, +) +from plot import plot_sunburst, plot_wealth, plot_pnl_by_asset_class + +st.set_page_config( + page_title="PFN | Basic Stats", + page_icon=FAVICON, + layout="wide", + initial_sidebar_state="auto", +) + +st.markdown(GLOBAL_STREAMLIT_STYLE, unsafe_allow_html=True) + +if "data" in st.session_state: + df_storico = st.session_state["data"] + df_anagrafica = st.session_state["dimensions"] +else: + st.error("Oops... there's nothing to display. Go through 🏠 first to load the data") + st.stop() + +df_pf = aggregate_by_ticker(df_storico, in_pf_only=True) + +ticker_list = df_pf["ticker_yf"].to_list() +df_last_closing = get_last_closing_price(ticker_list=ticker_list) + +df_j = df_pf[["ticker_yf", "dca", "shares"]].merge( + df_last_closing[["ticker_yf", "price"]], how="left", on="ticker_yf" +) + +expense = (df_j["shares"] * df_j["dca"]).sum() +fees = df_storico["fees"].sum().round(2) + +st.markdown("## Profit and Loss") + +col_l, col_r = st.columns([1, 1], gap="small") + +consider_fees = st.checkbox("Take fees into account") + +pf_actual_value = (df_j["shares"] * df_j["price"]).sum() +if consider_fees: + pnl = pf_actual_value - expense - fees + pnl_perc = 100 * (pf_actual_value - expense - fees) / expense +else: + pnl = pf_actual_value - expense + pnl_perc = 100 * (pf_actual_value - expense) / expense +sign = "+" if pnl >= 0 else "" + +col_l.metric( + label="Actual Portfolio Value", + value=f"{pf_actual_value: .1f} €", + delta=f"{pnl: .1f} € ({sign}{pnl_perc: .1f}%)", +) + +df_j["position_value"] = df_j["shares"] * df_j["price"] +df_pivot = get_portfolio_pivot( + df=df_j, + df_dimensions=df_anagrafica, + pf_actual_value=pf_actual_value, + aggregation_level="ticker", +) + +st.markdown("***") + +st.markdown("## Current Portfolio Asset Allocation") +fig = plot_sunburst(df=df_pivot) +st.plotly_chart(fig, use_container_width=True, config=PLT_CONFIG_NO_LOGO) + +with st.expander("Show pivot table"): + group_by = st.radio( + label="Aggregate by:", + options=["Macro Asset Classes", "Asset Classes", "Ticker"], + horizontal=True, + ) + dict_group_by = { + "Macro Asset Classes": "macro_asset_class", + "Asset Classes": "asset_class", + "Ticker": "ticker", + } + df_pivot_ = get_portfolio_pivot( + df=df_j, + df_dimensions=df_anagrafica, + pf_actual_value=pf_actual_value, + aggregation_level=dict_group_by[group_by], + ) + df_pivot_.index += 1 + st.table( + df_pivot_.rename( + columns={ + "macro_asset_class": "Macro Asset Class", + "asset_class": "Asset Class", + "ticker_yf": "Ticker", + "name": "Name", + "position_value": "Position Value (€)", + "weight_pf": "Weight (%)", + } + ).style.format(precision=1) + ) + +st.markdown("***") + +st.markdown("## Profit and Loss by asset class") + +group_by = st.radio( + label="Evaluate PnL with respect to:", + options=["Macro Asset Classes", "Asset Classes"], + horizontal=True, +) + +dict_group_by = { + "Macro Asset Classes": "macro_asset_class", + "Asset Classes": "asset_class", +} + +df_pnl_by_asset_class = get_pnl_by_asset_class( + df=df_j, df_dimensions=df_anagrafica, group_by=dict_group_by[group_by] +) + +fig = plot_pnl_by_asset_class( + df_pnl=df_pnl_by_asset_class, group_by=dict_group_by[group_by] +) +st.plotly_chart(fig, use_container_width=True, config=PLT_CONFIG) + +st.markdown("***") + +st.markdown("## Daily value of the Accumulation Plan") + +df_wealth = get_wealth_history( + df_transactions=df_storico, + df_prices=get_max_common_history(ticker_list=ticker_list), +) + +fig = plot_wealth(df=df_wealth) +st.plotly_chart(fig, use_container_width=True, config=PLT_CONFIG) + +write_disclaimer() diff --git "a/src/pages/2_\360\237\221\251\342\200\215\360\237\224\254_Advanced_Stats.py" "b/src/pages/2_\360\237\221\251\342\200\215\360\237\224\254_Advanced_Stats.py" new file mode 100644 index 0000000..8962143 --- /dev/null +++ "b/src/pages/2_\360\237\221\251\342\200\215\360\237\224\254_Advanced_Stats.py" @@ -0,0 +1,48 @@ +import streamlit as st + +from var import ( + GLOBAL_STREAMLIT_STYLE, + # PLT_CONFIG, + # PLT_CONFIG_NO_LOGO, + FAVICON, +) + +# from utils import ( +# sharpe_ratio, +# get_risk_free_rate_last_value, +# get_risk_free_rate_history, +# ) + +st.set_page_config( + page_title="PFN | Advanced Stats", + page_icon=FAVICON, + layout="wide", + initial_sidebar_state="auto", +) + +st.markdown(GLOBAL_STREAMLIT_STYLE, unsafe_allow_html=True) +st.warning("Work in progress! Please, come back later", icon="🚧") + +# df_common_history = get_max_common_history(ticker_list=ticker_list) + +# weights = [ +# df_pivot[df_pivot["ticker_yf"].eq(x_)]["weight_pf"].values[0] +# for x_ in df_common_history.columns +# ] +# weighted_average = pd.Series(np.average(df_common_history, weights=weights, axis=1)) + +# returns = np.log(weighted_average.div(weighted_average.shift(1))).fillna(0) + +# first_ap_day = str(df_storico["transaction_date"].min())[:10] + +# sr = sharpe_ratio( +# returns=returns, +# trading_days=df_common_history.shape[0], +# risk_free_rate=get_risk_free_rate_history(decimal=True) +# .sort_index() +# .loc[first_ap_day:] +# .median() +# .values[0], +# ) + +# st.write(sr) diff --git a/src/plot.py b/src/plot.py new file mode 100644 index 0000000..9b88d0f --- /dev/null +++ b/src/plot.py @@ -0,0 +1,72 @@ +from typing import Literal + +import plotly.express as px +import plotly.graph_objects as go +import pandas as pd +import numpy as np + +from var import PLT_FONT_SIZE + + +def plot_sunburst(df: pd.DataFrame) -> go.Figure: + fig = px.sunburst( + data_frame=df.assign(hole=" "), + path=["hole", "macro_asset_class", "asset_class", "ticker_yf"], + values="weight_pf", + ) + fig.update_traces(hovertemplate="%{value:.1f}%") + fig.update_layout( + autosize=False, + height=600, + margin=dict(l=0, r=0, t=20, b=20), + hoverlabel_font_size=PLT_FONT_SIZE, + ) + return fig + + +def plot_pnl_by_asset_class( + df_pnl: pd.DataFrame, + group_by: Literal["asset_class", "macro_asset_class"], +) -> go.Figure: + df_pnl = df_pnl.sort_values("pnl", ascending=False) + df_pnl["color"] = np.where(df_pnl["pnl"].ge(0), "green", "firebrick") + fig = go.Figure() + fig.add_trace( + go.Bar( + x=df_pnl[group_by], + y=df_pnl["pnl"], + marker_color=df_pnl["color"], + text=df_pnl["pnl"], + hoverinfo="skip", + ) + ) + fig.update_traces(texttemplate="%{y:.1f}") + fig.update_layout( + autosize=False, + height=500, + margin=dict(l=0, r=0, t=20, b=20), + barmode="stack", + yaxis=dict(title="PnL", showgrid=False, tickformat=",.0f", ticksuffix=" €"), + ) + return fig + + +def plot_wealth(df: pd.DataFrame) -> go.Figure: + fig = px.area( + data_frame=df, + x=df.index, + y="ap_daily_value", + ) + fig.update_traces(hovertemplate="%{x}: %{y:,.0f}€") + fig.update_layout( + autosize=False, + height=550, + hoverlabel_font_size=PLT_FONT_SIZE, + margin=dict(l=0, r=0, t=20, b=20), + xaxis=dict(title=""), + yaxis=dict( + title="Daily value", showgrid=False, tickformat=",.0f", ticksuffix=" €" + ), + showlegend=False, + ) + return fig diff --git a/src/utils.py b/src/utils.py new file mode 100644 index 0000000..60c6180 --- /dev/null +++ b/src/utils.py @@ -0,0 +1,343 @@ +from pathlib import Path +from datetime import datetime, timedelta +from typing import Tuple, Dict, List, Literal + +import streamlit as st +import yfinance as yf +import pandas as pd +import numpy as np + +from var import CACHE_EXPIRE_SECONDS + + +@st.cache_data(show_spinner=False) +def load_data(full_path: Path) -> Tuple[pd.DataFrame, pd.DataFrame]: + df_storico = pd.read_excel( + full_path, + sheet_name="Transactions History", + dtype={ + "Exchange": str, + "Ticker": str, + "Shares": int, + "Price (€)": float, + "Fees (€)": float, + }, + ).rename( + columns={ + "Exchange": "exchange", + "Ticker": "ticker", + "Transaction Date": "transaction_date", + "Shares": "shares", + "Price (€)": "price", + "Fees (€)": "fees", + } + ) + df_storico["ap_amount"] = df_storico["shares"] * df_storico["price"] + df_storico["ticker_yf"] = df_storico["ticker"] + "." + df_storico["exchange"] + + df_anagrafica = pd.read_excel( + full_path, sheet_name="Securities Master Table", dtype=str + ).rename( + columns={ + "Exchange": "exchange", + "Ticker": "ticker", + "Security Name": "name", + "Asset Class": "asset_class", + "Macro Asset Class": "macro_asset_class", + } + ) + df_anagrafica["ticker_yf"] = ( + df_anagrafica["ticker"] + "." + df_anagrafica["exchange"] + ) + + write_load_message(df_data=df_storico, df_dimensions=df_anagrafica) + return df_storico, df_anagrafica + + +def write_load_message(df_data: pd.DataFrame, df_dimensions: pd.DataFrame) -> None: + n_transactions = df_data.shape[0] + n_tickers = df_data["ticker"].nunique() + min_date, max_date = ( + str(df_data["transaction_date"].min())[:10], + str(df_data["transaction_date"].max())[:10], + ) + set_data_tickers = sorted(df_data["ticker"].unique()) + set_dimensions_tickers = sorted(df_dimensions["ticker"].unique()) + n_data_na = df_data.isnull().sum().sum() + n_dimensions_na = df_dimensions.isnull().sum().sum() + + if n_data_na > 0 or n_dimensions_na > 0: + st.error( + f"There are null values: {n_data_na} among transactions, {n_dimensions_na} among tickers' descriptions" + ) + + if set_data_tickers != set_dimensions_tickers: + st.warning( + "There is some inconsistency between the tickers traded and the tickers' descriptions" + ) + + st.success( + f"Successfully loaded **{n_transactions} transactions** relating to **{n_tickers} tickers** and spanning from {min_date} to {max_date}" + ) + + +@st.cache_data(show_spinner=False) +def aggregate_by_ticker(df: pd.DataFrame, in_pf_only: bool = False) -> pd.DataFrame: + df_portfolio = ( + df.groupby("ticker_yf") + .agg( + shares=("shares", "sum"), + ap_amount=("ap_amount", "sum"), + fees=("fees", "sum"), + ) + .reset_index() + .sort_values( + "shares", + ascending=False, + ) + ) + + df_portfolio["is_in_pf"] = df_portfolio["shares"].ne(0) + + df_portfolio["dca"] = np.where( + df_portfolio["is_in_pf"].eq(True), + df_portfolio["ap_amount"].div(df_portfolio["shares"]), + 0, + ) + + if in_pf_only: + df_portfolio = df_portfolio[df_portfolio["is_in_pf"].eq(True)] + + return df_portfolio.drop(columns="is_in_pf").reset_index(drop=True) + + +@st.cache_data(ttl=CACHE_EXPIRE_SECONDS, show_spinner=False) +def get_last_closing_price(ticker_list: List[str]) -> pd.DataFrame: + df_last_closing = pd.DataFrame( + columns=["ticker_yf", "last_closing_date", "price"], + index=range(len(ticker_list)), + ) + for i, ticker_ in zip(range(len(ticker_list)), ticker_list): + ticker_data = yf.Ticker(ticker_) + try: + closing_date_ = ( + ticker_data.history( + period="1d", + interval="1d", + )["Close "] + .reset_index() + .values.tolist() + ) + df_last_closing.iloc[i] = [ticker_] + closing_date_[0] + except: + try: + closing_date_ = get_last_closing_price_from_api(ticker=ticker_) + df_last_closing.iloc[i] = [ticker_] + closing_date_[0] + except: + st.error( + f"{ticker_}: latest data not available. Please check your internet connection or try again later", + icon="😔", + ) + + df_last_closing["last_closing_date"] = ( + df_last_closing["last_closing_date"].astype(str).str.slice(0, 10) + ) + + return df_last_closing + + +@st.cache_data(ttl=CACHE_EXPIRE_SECONDS, show_spinner=False) +def get_last_closing_price_from_api(ticker: str, days_of_delay: int = 5) -> List: + today = datetime.utcnow() + delayed = today - timedelta(days=days_of_delay) + + period1 = int(delayed.timestamp()) + period2 = int(datetime.utcnow().timestamp()) + + link = f"https://query1.finance.yahoo.com/v7/finance/download/{ticker}?period1={period1}&period2={period2}&interval=1d&events=history&includeAdjustedClose=true" + + try: + closing_date = pd.read_csv(link, usecols=["Date", "Adj Close"]).rename( + {"Adj Close": "Close"} + ) + closing_date["Date"] = pd.to_datetime(closing_date["Date"]) + closing_date = closing_date.head(1).values.tolist() + except: + closing_date = None + + return closing_date + + +@st.cache_data(ttl=CACHE_EXPIRE_SECONDS, show_spinner=False) +def get_full_price_history(ticker_list: List[str]) -> Dict: + df_history = dict() + + for i, ticker_ in zip(range(len(ticker_list)), ticker_list): + ticker_data = yf.Ticker(ticker_) + df_history[ticker_] = ticker_data.history(period="max", interval="1d",)[ + "Close" + ].rename(ticker_) + + df_history[ticker_].index = pd.to_datetime(df_history[ticker_].index.date) + + return df_history + + +@st.cache_data(ttl=CACHE_EXPIRE_SECONDS, show_spinner=False) +def get_max_common_history(ticker_list: List[str]) -> pd.DataFrame: + full_history = get_full_price_history(ticker_list) + df_full_history = pd.concat( + [full_history[t_] for t_ in ticker_list], + axis=1, + ) + first_idx = df_full_history.apply(pd.Series.first_valid_index).max() + return df_full_history.loc[first_idx:] + + +@st.cache_data(ttl=10 * CACHE_EXPIRE_SECONDS, show_spinner=False) +def get_risk_free_rate_last_value(decimal: bool = False) -> float: + try: + df_ecb = pd.read_html( + io="http://www.ecb.europa.eu/stats/financial_markets_and_interest_rates/euro_short-term_rate/html/index.en.html" + )[0] + risk_free_rate = df_ecb.iloc[0, 1].astype(float) + except: + risk_free_rate = 3 + if decimal: + risk_free_rate = risk_free_rate / 100 + return risk_free_rate + + +@st.cache_data(ttl=10 * CACHE_EXPIRE_SECONDS, show_spinner=False) +def get_risk_free_rate_history(decimal: bool = False) -> pd.DataFrame: + euro_str_link = "https://sdw.ecb.europa.eu/quickviewexport.do?SERIES_KEY=438.EST.B.EU000A2X2A25.WT&type=csv" + try: + df_ecb = ( + pd.read_csv( + euro_str_link, + sep=",", + skiprows=5, + index_col=0, + ) + .drop(columns="obs. status") + .rename(columns={"Unnamed: 1": "euro_str"}) + ).sort_index() + except: + df_ecb = pd.DataFrame() + if decimal: + df_ecb["euro_str"] = df_ecb["euro_str"].div(100) + return df_ecb + + +@st.cache_data(ttl=10 * CACHE_EXPIRE_SECONDS, show_spinner=False) +def sharpe_ratio( + returns: pd.Series, trading_days: int, risk_free_rate: float = 3 +) -> float: + mean = returns.mean() * trading_days - risk_free_rate + std = returns.std() * np.sqrt(trading_days) + return mean / std + + +# def sortino_ratio(series, N, rf): +# mean = series.mean() * N -rf +# std_neg = series[series<0].std()*np.sqrt(N) +# return mean/std_neg + +# def max_drawdown(return_series): +# comp_ret = (return_series+1).cumprod() +# peak = comp_ret.expanding(min_periods=1).max() +# dd = (comp_ret/peak)-1 +# return dd.min() + + +@st.cache_data(ttl=10 * CACHE_EXPIRE_SECONDS, show_spinner=False) +def get_wealth_history( + df_transactions: pd.DataFrame, df_prices: pd.DataFrame +) -> pd.Series: + ticker_list = df_prices.columns.to_list() + df_transactions = df_transactions[df_transactions["ticker_yf"].isin(ticker_list)] + + begin_date = df_transactions["transaction_date"].min() + today = datetime.now().date() + date_range = pd.date_range(start=begin_date, end=today, freq="D") + + df_asset_allocation = pd.DataFrame( + index=date_range, + columns=ticker_list, + data=0, + ) + + for (data, ticker), group in df_transactions.groupby( + ["transaction_date", "ticker_yf"] + ): + total_shares = group["shares"].sum() + df_asset_allocation.loc[data, ticker] += total_shares + df_asset_allocation = df_asset_allocation.cumsum() + + df_wealth = ( + df_asset_allocation.multiply(df_prices.loc[begin_date:]) + .fillna(method="ffill") + .sum(axis=1) + .rename("ap_daily_value") + ) + return df_wealth + + +@st.cache_data(ttl=10 * CACHE_EXPIRE_SECONDS, show_spinner=False) +def get_portfolio_pivot( + df: pd.DataFrame, + df_dimensions: pd.DataFrame, + pf_actual_value: float, + aggregation_level: Literal["ticker", "asset_class", "macro_asset_class"], +) -> pd.DataFrame: + if aggregation_level == "ticker": + groupby_keys = [ + "macro_asset_class", + "asset_class", + "ticker_yf", + "name", + ] + elif aggregation_level == "asset_class": + groupby_keys = [ + "macro_asset_class", + "asset_class", + ] + elif aggregation_level == "macro_asset_class": + groupby_keys = "macro_asset_class" + df_pivot = ( + df.copy() + .merge(df_dimensions, how="left", on="ticker_yf") + .groupby(groupby_keys)["position_value"] + .sum() + .reset_index() + ) + df_pivot["weight_pf"] = ( + (100 * df_pivot["position_value"].div(pf_actual_value)).astype(float).round(1) + ) + df_pivot["position_value"] = df_pivot["position_value"].astype(float).round(1) + return df_pivot + + +@st.cache_data(ttl=10 * CACHE_EXPIRE_SECONDS, show_spinner=False) +def get_pnl_by_asset_class( + df: pd.DataFrame, + df_dimensions: pd.DataFrame, + group_by: Literal["asset_class", "macro_asset_class"], +) -> pd.DataFrame: + df_pnl = df.merge(df_dimensions, how="left", on="ticker_yf") + df_pnl["pnl"] = np.round( + ((df_pnl["price"] - df_pnl["dca"]) * df_pnl["shares"]).astype(float), 1 + ) + df_pnl = df_pnl.groupby(group_by)["pnl"].sum().reset_index().sort_values([group_by]) + return df_pnl + + +def write_disclaimer() -> None: + st.markdown("***") + st.markdown( + '
\ + This content is for educational purposes only and is under no circumstances intended\ + to be used or considered as financial or investment advice\ +
', + unsafe_allow_html=True, + ) diff --git a/src/var.py b/src/var.py new file mode 100644 index 0000000..b20e76e --- /dev/null +++ b/src/var.py @@ -0,0 +1,46 @@ +from pathlib import Path, PurePath +from PIL import Image +import os + +script_running_path = str(Path(__file__).parent.resolve()) +split_script_running_path = path_split = PurePath(script_running_path).parts +assets_path = str( + os.path.join(*split_script_running_path[0 : len(split_script_running_path) - 1]) +) + +APP_VERSION = "0.1.0" + +# Data/images + +DATA_PATH = Path(assets_path, "data", "in") +FAVICON = Image.open(Path(assets_path, "images", "favicon.ico")) + +# Streamlit/Plotly vars + +GLOBAL_STREAMLIT_STYLE = """ + + """ + +PLT_CONFIG = { + "displaylogo": False, + "modeBarButtonsToAdd": [ + "drawline", + "drawopenpath", + "drawcircle", + "drawrect", + "eraseshape", + ], + "scrollZoom": False, +} + +PLT_CONFIG_NO_LOGO = {"displaylogo": False} +CACHE_EXPIRE_SECONDS = 600 +PLT_FONT_SIZE = 14 + +# Others + +TRADING_DAYS_YEAR = 252