340 lines
12 KiB
Python
340 lines
12 KiB
Python
"""
|
|
Markowitz Mean-Variance Portfolio Optimizer
|
|
============================================
|
|
Run:
|
|
pip install -r requirements.txt
|
|
streamlit run app.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pandas as pd
|
|
import streamlit as st
|
|
|
|
from portfolio_optimizer.data import load_returns, fetch_euribor_3m
|
|
from portfolio_optimizer.optimizer import (
|
|
min_variance,
|
|
max_sharpe,
|
|
efficient_frontier,
|
|
monte_carlo,
|
|
portfolio_stats,
|
|
)
|
|
|
|
from portfolio_optimizer.settings import cfg
|
|
|
|
from portfolio_optimizer.var import parametric_var, historical_var, montecarlo_var
|
|
from portfolio_optimizer.charts import (
|
|
plot_frontier,
|
|
plot_allocations,
|
|
plot_correlation,
|
|
plot_var_histogram,
|
|
plot_mc_var_histogram,
|
|
)
|
|
|
|
# ── Page config ───────────────────────────────────────────────────────────────
|
|
|
|
st.set_page_config(
|
|
page_title=cfg["ui"]["page_title"],
|
|
page_icon=cfg["ui"]["page_icon"],
|
|
layout="wide",
|
|
initial_sidebar_state="expanded",
|
|
)
|
|
|
|
st.markdown(
|
|
"""
|
|
<style>
|
|
/* Sidebar */
|
|
[data-testid="stSidebar"] {
|
|
background: #f2f2f2;
|
|
border-right: 1px solid #e0e0e0;
|
|
}
|
|
[data-testid="stSidebar"] * { color: #1a1a1a !important; }
|
|
[data-testid="stSidebar"] input {
|
|
background: #ffffff !important;
|
|
color: #1a1a1a !important;
|
|
border: 1px solid #cccccc !important;
|
|
}
|
|
|
|
[data-testid="stSidebar"] .stButton > button {
|
|
background: #ffffff;
|
|
color: #1a1a1a;
|
|
font-weight: 600;
|
|
border: 1px solid #cccccc;
|
|
border-radius: 3px;
|
|
width: 100%;
|
|
}
|
|
[data-testid="stSidebar"] .stButton > button:hover {
|
|
background: #e8e8e8;
|
|
color: #1a1a1a;
|
|
border-color: #999999;
|
|
}
|
|
|
|
/* Layout */
|
|
.block-container { padding-top: 1.5rem; }
|
|
.main { background: #ffffff; }
|
|
|
|
/* Typography */
|
|
h1 { font-size: 1.7rem !important; color: #1a1a1a !important; font-weight: 700 !important; }
|
|
h2, h3 { color: #2d2d2d !important; }
|
|
|
|
/* Tabs */
|
|
.stTabs [data-baseweb="tab-list"] { border-bottom: 2px solid #e0e0e0; }
|
|
.stTabs [data-baseweb="tab"] {
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
color: #888888;
|
|
padding: 0.5rem 1.2rem;
|
|
}
|
|
.stTabs [aria-selected="true"] {
|
|
color: #1a1a1a !important;
|
|
border-bottom: 3px solid #1a1a1a !important;
|
|
}
|
|
|
|
/* Metrics */
|
|
[data-testid="metric-container"] {
|
|
background: #f7f7f7;
|
|
border: 1px solid #e8e8e8;
|
|
border-top: 3px solid #2d2d2d;
|
|
border-radius: 3px;
|
|
padding: 0.8rem 1rem;
|
|
}
|
|
[data-testid="metric-container"] label {
|
|
color: #888888 !important;
|
|
font-size: 0.75rem !important;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
[data-testid="metric-container"] [data-testid="stMetricValue"] {
|
|
color: #1a1a1a !important;
|
|
font-weight: 700;
|
|
}
|
|
|
|
/* Divider */
|
|
hr { border-color: #e8e8e8; }
|
|
</style>
|
|
""",
|
|
unsafe_allow_html=True,
|
|
)
|
|
|
|
# ── Sidebar ───────────────────────────────────────────────────────────────────
|
|
|
|
with st.sidebar:
|
|
st.title("⚙️ Parameters")
|
|
|
|
raw_tickers = st.text_input(
|
|
"Tickers (comma-separated)", value=cfg["data"]["default_tickers"]
|
|
)
|
|
tickers = [t.strip().upper() for t in raw_tickers.split(",") if t.strip()]
|
|
|
|
col1, col2 = st.columns(2)
|
|
with col1:
|
|
start_date = st.date_input("Start", value=pd.Timestamp("2020-01-01"))
|
|
with col2:
|
|
end_date = st.date_input("End", value=pd.Timestamp("2024-12-31"))
|
|
|
|
euribor = fetch_euribor_3m()
|
|
rf_default = (
|
|
round(euribor * 100, 2) if euribor else cfg["portfolio"]["default_rf_pct"]
|
|
)
|
|
rf_label = (
|
|
f"Risk-free rate (%/yr) — Euribor 3M live: {euribor:.2%}"
|
|
if euribor
|
|
else "Risk-free rate (%/yr) — Euribor 3M unavailable"
|
|
)
|
|
rf_rate = st.slider(rf_label, 0.0, 10.0, rf_default, 0.1) / 100
|
|
|
|
n_mc = st.select_slider(
|
|
"Monte Carlo portfolios",
|
|
options=[1_000, 2_000, 5_000, 10_000],
|
|
value=cfg["portfolio"]["mc_portfolios"],
|
|
)
|
|
allow_leverage = st.toggle(
|
|
"Allow leverage & short selling",
|
|
value=False,
|
|
help="Removes the wᵢ ∈ [0,1] constraint. Weights can be negative (short) or >1 (leveraged).",
|
|
)
|
|
run = st.button("🚀 Optimise", type="primary")
|
|
|
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
|
|
st.title("📈 Markowitz Portfolio Optimizer")
|
|
st.caption(
|
|
"Mean-variance optimisation · Efficient Frontier · Max Sharpe · Min Variance"
|
|
)
|
|
|
|
if not run:
|
|
st.info("Configure parameters in the sidebar and click **Optimise**.")
|
|
st.stop()
|
|
|
|
# ── Load & validate ───────────────────────────────────────────────────────────
|
|
|
|
with st.spinner("Downloading price data…"):
|
|
returns = load_returns(tickers, str(start_date), str(end_date))
|
|
|
|
valid_tickers = [t for t in tickers if t in returns.columns]
|
|
missing = set(tickers) - set(valid_tickers)
|
|
|
|
if missing:
|
|
st.warning(f"Tickers not found / no data: {', '.join(missing)}")
|
|
if len(valid_tickers) < 2:
|
|
st.error("Need at least 2 valid tickers.")
|
|
st.stop()
|
|
|
|
returns = returns[valid_tickers]
|
|
mean_ret = returns.mean().values
|
|
cov = returns.cov().values
|
|
|
|
# ── Optimise ──────────────────────────────────────────────────────────────────
|
|
|
|
with st.spinner("Running optimisation…"):
|
|
mv_w = min_variance(mean_ret, cov, leverage=allow_leverage)
|
|
ms_w = max_sharpe(mean_ret, cov, rf=rf_rate / 252, leverage=allow_leverage)
|
|
ef_vols, ef_rets = efficient_frontier(mean_ret, cov, leverage=allow_leverage)
|
|
mc_rets, mc_vols, mc_sharpes = monte_carlo(mean_ret, cov, n_portfolios=n_mc)
|
|
|
|
mv_ret, mv_vol, mv_sharpe = portfolio_stats(mv_w, mean_ret, cov)
|
|
ms_ret, ms_vol, ms_sharpe = portfolio_stats(ms_w, mean_ret, cov)
|
|
|
|
# ── KPI row ───────────────────────────────────────────────────────────────────
|
|
|
|
kpi = st.columns(6)
|
|
kpi[0].metric("Assets", len(valid_tickers))
|
|
kpi[1].metric("Min-Var Return", f"{mv_ret:.2%}")
|
|
kpi[2].metric("Min-Var Vol", f"{mv_vol:.2%}")
|
|
kpi[3].metric("Max-Sharpe Return", f"{ms_ret:.2%}")
|
|
kpi[4].metric("Max-Sharpe Vol", f"{ms_vol:.2%}")
|
|
kpi[5].metric("Max Sharpe Ratio", f"{ms_sharpe:.2f}")
|
|
|
|
st.divider()
|
|
|
|
# ── Tabs ──────────────────────────────────────────────────────────────────────
|
|
|
|
tab1, tab2, tab3, tab4 = st.tabs(
|
|
["📊 Efficient Frontier", "🥧 Allocations", "🔗 Correlation", "⚠️ VaR"]
|
|
)
|
|
|
|
# Tab 1 — Efficient Frontier ──────────────────────────────────────────────────
|
|
|
|
with tab1:
|
|
st.plotly_chart(
|
|
plot_frontier(
|
|
ef_vols,
|
|
ef_rets,
|
|
mc_vols,
|
|
mc_rets,
|
|
mc_sharpes,
|
|
mv_vol,
|
|
mv_ret,
|
|
mv_sharpe,
|
|
ms_vol,
|
|
ms_ret,
|
|
ms_sharpe,
|
|
rf_rate,
|
|
),
|
|
width="stretch",
|
|
)
|
|
|
|
# Tab 2 — Allocations ─────────────────────────────────────────────────────────
|
|
|
|
with tab2:
|
|
fig_mv, fig_ms, df_mv, df_ms = plot_allocations(valid_tickers, mv_w, ms_w)
|
|
col_mv, col_ms = st.columns(2)
|
|
with col_mv:
|
|
st.plotly_chart(fig_mv, width="stretch")
|
|
st.dataframe(
|
|
df_mv[["Ticker", "Weight (%)"]].style.bar(
|
|
subset="Weight (%)", color="#4C9BE888"
|
|
),
|
|
width="stretch",
|
|
hide_index=True,
|
|
)
|
|
with col_ms:
|
|
st.plotly_chart(fig_ms, width="stretch")
|
|
st.dataframe(
|
|
df_ms[["Ticker", "Weight (%)"]].style.bar(
|
|
subset="Weight (%)", color="#F0803088"
|
|
),
|
|
width="stretch",
|
|
hide_index=True,
|
|
)
|
|
|
|
# Tab 3 — Correlation ─────────────────────────────────────────────────────────
|
|
|
|
with tab3:
|
|
fig_corr, summary = plot_correlation(returns, valid_tickers, rf_rate)
|
|
st.plotly_chart(fig_corr, width="stretch")
|
|
st.subheader("📋 Summary Statistics")
|
|
st.dataframe(
|
|
summary.style.format(
|
|
{"Ann. Return": "{:.2%}", "Ann. Vol": "{:.2%}", "Sharpe": "{:.2f}"}
|
|
).background_gradient(subset="Sharpe", cmap="RdYlGn"),
|
|
width="stretch",
|
|
hide_index=True,
|
|
)
|
|
|
|
# Tab 4 — VaR ─────────────────────────────────────────────────────────────────
|
|
|
|
with tab4:
|
|
st.subheader("⚠️ Value at Risk — Max Sharpe Portfolio")
|
|
st.caption("Three methods: parametric (normal), historical, Monte Carlo GBM")
|
|
|
|
var_col1, var_col2 = st.columns([1, 2])
|
|
with var_col1:
|
|
confidence = st.selectbox(
|
|
"Confidence level",
|
|
[0.95, 0.99, 0.999],
|
|
index=1,
|
|
format_func=lambda x: f"{x:.1%}",
|
|
)
|
|
horizon = st.selectbox("Horizon (days)", [1, 5, 10, 21], index=0)
|
|
n_var_sim = st.select_slider(
|
|
"MC simulations",
|
|
options=[10_000, 50_000, 100_000],
|
|
value=cfg["var"]["mc_simulations"],
|
|
)
|
|
notional = st.number_input(
|
|
"Portfolio value (€)",
|
|
value=cfg["var"]["default_notional"],
|
|
step=100_000,
|
|
)
|
|
|
|
pvar = parametric_var(returns, ms_w, confidence, horizon)
|
|
hvar = historical_var(returns, ms_w, confidence, horizon)
|
|
mvar = montecarlo_var(returns, ms_w, confidence, horizon, n_sim=n_var_sim)
|
|
|
|
with var_col2:
|
|
df_var = pd.DataFrame(
|
|
{
|
|
"Method": ["Parametric (Normal)", "Historical", "Monte Carlo (GBM)"],
|
|
f"VaR {horizon}d (%)": [pvar["var"], hvar["var"], mvar["var"]],
|
|
f"VaR {horizon}d (€)": [
|
|
v * notional for v in [pvar["var"], hvar["var"], mvar["var"]]
|
|
],
|
|
f"CVaR 1d (%)": [pvar["cvar"], hvar["cvar"], float("nan")],
|
|
}
|
|
)
|
|
st.dataframe(
|
|
df_var.style.format(
|
|
{
|
|
f"VaR {horizon}d (%)": "{:.3%}",
|
|
f"VaR {horizon}d (€)": "{:,.0f} €",
|
|
f"CVaR 1d (%)": "{:.3%}",
|
|
}
|
|
).background_gradient(subset=[f"VaR {horizon}d (%)"], cmap="Reds"),
|
|
width="stretch",
|
|
hide_index=True,
|
|
)
|
|
|
|
port_daily_rets = returns.values @ ms_w
|
|
var_param_1d = pvar["var"] / (horizon**0.5)
|
|
var_hist_1d = hvar["var"] / (horizon**0.5)
|
|
|
|
st.plotly_chart(
|
|
plot_var_histogram(port_daily_rets, var_param_1d, var_hist_1d, confidence),
|
|
width="stretch",
|
|
)
|
|
st.plotly_chart(
|
|
plot_mc_var_histogram(mvar["sim"], mvar["var"], confidence, horizon, n_var_sim),
|
|
width="stretch",
|
|
)
|