Portfolio Optimizer#
This application performs portfolio optimization given a set of stock time series. Data flows through a chain of pn.rx expressions, and the expensive random sampling and efficient frontier are only recomputed when Run Analysis is clicked.
from io import BytesIO
import holoviews as hv
import hvplot.pandas # noqa: F401
import numpy as np
import pandas as pd
import panel as pn
from scipy.optimize import minimize
pn.extension('tabulator', loading_indicator=True)
SEED = 42
Load data#
@pn.cache
def get_stocks(data):
if data is None:
stock_file = 'https://datasets.holoviz.org/stocks/v1/stocks.csv'
else:
stock_file = BytesIO(data)
return pd.read_csv(stock_file, index_col='Date', parse_dates=True)
def select_columns(df, cols):
return df[cols] if cols else df
Business logic#
These are plain functions of DataFrames, kept separate from the UI so the reactive pipeline below only wires them together.
def compute_random_allocations(log_return, num_ports=15000, seed=SEED):
_, ncols = log_return.shape
# Compute log and mean return
mean_return = np.nanmean(log_return, axis=0)
# Allocate normalized weights
weights = np.random.default_rng(seed).random((num_ports, ncols))
normed_weights = (weights.T / np.sum(weights, axis=1)).T
data = dict(zip(log_return.columns, normed_weights.T))
# Compute expected return and volatility of random portfolios
data['Return'] = expected_return = np.sum((mean_return * normed_weights) * 252, axis=1)
return_covariance = np.cov(log_return[1:], rowvar=False) * 252
if not return_covariance.shape:
return_covariance = np.array([[252.]])
data['Volatility'] = volatility = np.sqrt(
(normed_weights * np.tensordot(return_covariance, normed_weights.T, axes=1).T).sum(axis=1)
)
data['Sharpe'] = expected_return/volatility
df = pd.DataFrame(data)
df.attrs['mean_return'] = mean_return
df.attrs['log_return'] = log_return
return df
def check_sum(weights):
return np.sum(weights) - 1
def get_return(mean_ret, weights):
return np.sum(mean_ret * weights) * 252
def get_volatility(log_ret, weights):
return np.sqrt(np.dot(weights.T, np.dot(np.cov(log_ret[1:], rowvar=False) * 252, weights)))
def compute_frontier(df, n=30):
frontier_ret = np.linspace(df.Return.min(), df.Return.max(), n)
frontier_volatility = []
cols = len(df.columns) - 3
bounds = tuple((0, 1) for i in range(cols))
init_guess = [1./cols for i in range(cols)]
for possible_return in frontier_ret:
cons = (
{'type':'eq', 'fun': check_sum},
{'type':'eq', 'fun': lambda w, r=possible_return: get_return(df.attrs['mean_return'], w) - r}
)
result = minimize(lambda w: get_volatility(df.attrs['log_return'], w), init_guess, bounds=bounds, constraints=cons)
frontier_volatility.append(result['fun'])
return pd.DataFrame({'Volatility': frontier_volatility, 'Return': frontier_ret})
def minimize_difference(weights, des_vol, des_ret, log_ret, mean_ret):
ret = get_return(mean_ret, weights)
vol = get_volatility(log_ret, weights)
return abs(des_ret-ret) + abs(des_vol-vol)
@pn.cache
def find_best_allocation(log_return, vol, ret):
cols = log_return.shape[1]
vol = vol or 0
ret = ret or 0
mean_return = np.nanmean(log_return, axis=0)
bounds = tuple((0, 1) for i in range(cols))
init_guess = [1./cols for i in range(cols)]
cons = (
{'type':'eq','fun': check_sum},
{'type':'eq','fun': lambda w: get_return(mean_return, w) - ret},
{'type':'eq','fun': lambda w: get_volatility(log_return, w) - vol}
)
opt = minimize(
minimize_difference, init_guess, args=(vol, ret, log_return, mean_return),
bounds=bounds, constraints=cons
)
ret = get_return(mean_return, opt.x)
vol = get_volatility(log_return, opt.x)
return pd.Series(list(opt.x)+[ret, vol], index=list(log_return.columns)+['Return', 'Volatility'], name='Weight')
def compute_log_return(stocks):
return np.log(stocks/stocks.shift(1))
def max_sharpe_row(df):
return df[df.Sharpe == df.Sharpe.max()]
def log_return_of(allocations):
return allocations.attrs['log_return']
Declare UI components#
The stock selector options and the date range follow the loaded data through bound references.
file_input = pn.ui.FileInput(accept='.csv', sizing_mode='stretch_width')
stocks = pn.rx(get_stocks)(file_input.param.value)
selector = pn.ui.MultiSelect(
label='Select stocks', sizing_mode='stretch_width',
options=stocks.rx.pipe(lambda df: df.columns.to_list())
)
n_samples = pn.ui.IntSlider(
label='Random samples', value=10_000, start=1000, end=20_000, step=1000, sizing_mode='stretch_width'
)
button = pn.ui.Button(label='Run Analysis', sizing_mode='stretch_width')
investment = pn.ui.IntInput(label='Investment Value in $', value=5000, step=1000, start=1000, end=100000)
year = pn.ui.DateRangeSlider(
label='Year',
start=stocks.rx.pipe(lambda df: df.index.min()),
end=stocks.rx.pipe(lambda df: df.index.max()),
value=stocks.rx.pipe(lambda df: (df.index.min(), df.index.max())),
)
posxy = hv.streams.Tap(x=None, y=None)
text = """
# Portfolio optimization
This application performs portfolio optimization given a set of stock time series.
To optimize your portfolio:
1. Upload a CSV of the daily stock time series for the stocks you are considering
2. Select the stocks to be included.
3. Run the Analysis
4. Click on the Return/Volatility plot to select the desired risk/reward profile
Upload a CSV containing stock data:
"""
explanation = """
The code for this app was taken from [this excellent introduction to Python for Finance](https://github.com/PrateekKumarSingh/Python/tree/master/Python%20for%20Finance/Python-for-Finance-Repo-master).
To learn some of the background and theory about portfolio optimization see [this notebook](https://github.com/PrateekKumarSingh/Python/blob/master/Python%20for%20Finance/Python-for-Finance-Repo-master/09-Python-Finance-Fundamentals/02-Portfolio-Optimization.ipynb).
"""
sidebar = pn.ui.Column(
pn.ui.Markdown(text, margin=(0, 10)),
file_input,
selector,
n_samples,
button,
pn.ui.Markdown(explanation),
sizing_mode='stretch_width'
)
sidebar
Data pipeline#
rx.when(button) holds the random allocations, and everything derived from them, until the button is clicked. The closest allocation additionally follows the Tap stream on the scatter plot.
selected_stocks = pn.rx(select_columns)(stocks, selector.param.value)
log_return = selected_stocks.rx.pipe(compute_log_return)
random_allocations = pn.rx(compute_random_allocations)(log_return, n_samples.param.value).rx.when(button)
efficient_frontier = random_allocations.rx.pipe(compute_frontier)
max_sharpe = random_allocations.rx.pipe(max_sharpe_row)
closest_allocation = pn.rx(find_best_allocation)(
random_allocations.rx.pipe(log_return_of), posxy.param.x, posxy.param.y
)
Plot#
Portfolio optimization plot#
The scatter plot is the source of the Tap stream, so it is a DynamicMap that stays the same object while its data updates.
OPTS = {'x': 'Volatility', 'y': 'Return', 'responsive': True}
def allocations_plot(df):
return df.hvplot.scatter(alpha=0.1, color='Sharpe', cmap='plasma', **OPTS).opts(tools=[])
def frontier_plot(df):
return df.hvplot(line_dash='dashed', color='green', **OPTS)
def max_sharpe_plot(df):
return df.hvplot.scatter(line_color='black', size=50, **OPTS)
def closest_plot(allocation):
return allocation.to_frame().T.hvplot.scatter(color='green', line_color='black', size=50, **OPTS)
def describe(p):
return (
f"The selected portfolio has a volatility of {p.Volatility:.2f}, a return of {p.Return:.2f} "
f"and Sharpe ratio of {p.Return/p.Volatility:.2f}."
)
def weights_table(allocation):
return allocation.to_frame().iloc[:-2]
allocations_scatter = hv.DynamicMap(pn.bind(allocations_plot, random_allocations))
posxy.source = allocations_scatter
plot = pn.ui.HoloViews(
(
allocations_scatter *
hv.DynamicMap(pn.bind(frontier_plot, efficient_frontier)) *
hv.DynamicMap(pn.bind(max_sharpe_plot, max_sharpe)) *
hv.DynamicMap(pn.bind(closest_plot, closest_allocation))
).opts(min_height=400, show_grid=True),
sizing_mode='stretch_both',
)
summary = pn.ui.Markdown(pn.bind(describe, closest_allocation), width=250)
table = pn.ui.Tabulator(closest_allocation.rx.pipe(weights_table))
analysis = pn.ui.Row(plot, pn.ui.Column(summary, table), sizing_mode='stretch_both')
analysis
Portfolio Performance plot#
def performance_plot(stocks, allocation, investment, date_range):
start, end = date_range
weights = allocation.iloc[:-2] * investment
value = (stocks[start:end] * weights / stocks[start:].iloc[0]).sum(axis=1).rename()
return value.hvplot.line(
ylabel='Total Value ($)', title='Portfolio performance', responsive=True, min_height=400
)
performance = pn.ui.Column(
pn.ui.Row(year, investment),
pn.ui.HoloViews(
pn.bind(performance_plot, selected_stocks, closest_allocation, investment, year),
sizing_mode='stretch_both',
),
sizing_mode='stretch_both'
)
performance
Plot stock prices#
def timeseries_plot(stocks):
return stocks.hvplot.line(
'Date', group_label='Stock', value_label='Stock Price ($)', title='Daily Stock Price',
min_height=300, responsive=True, grid=True, legend='top_left'
)
timeseries = pn.ui.HoloViews(pn.bind(timeseries_plot, selected_stocks), sizing_mode='stretch_both')
timeseries
Log return plots#
def log_return_hists(log_return):
return log_return.hvplot.hist(
min_height=300, min_width=400, responsive=True, bins=100, subplots=True, group_label='Stock'
).cols(2).opts(sizing_mode='stretch_both')
log_ret_hists = pn.ui.HoloViews(pn.bind(log_return_hists, log_return), sizing_mode='stretch_both')
log_ret_hists
Overall layout#
main = pn.ui.Tabs(
('Analysis', pn.ui.Column(analysis, performance, sizing_mode='stretch_both')),
('Timeseries', timeseries),
('Log Return', pn.ui.Column(
pn.ui.Markdown('## Daily normalized log returns'),
pn.ui.Markdown('Width of distribution indicates volatility and center of distribution the mean daily return.'),
log_ret_hists,
sizing_mode='stretch_both'
)),
sizing_mode='stretch_both', min_height=1000
)
page = pn.ui.Page(main=[main], sidebar=[sidebar], title='Portfolio Optimizer')
page
Served App#
if pn.state.served:
page.servable()