Spaces:
Runtime error
Runtime error
Update src/streamlit_app.py
Browse files- src/streamlit_app.py +260 -33
src/streamlit_app.py
CHANGED
|
@@ -1,40 +1,267 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import numpy as np
|
| 3 |
import pandas as pd
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
import streamlit as st
|
| 5 |
|
| 6 |
-
|
| 7 |
-
# Welcome to Streamlit!
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
"
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app.py
|
| 2 |
+
# Streamlit front-end for the Liquidity Decision Map
|
| 3 |
+
# ---------------------------------------------------
|
| 4 |
+
# pip install streamlit numpy pandas seaborn matplotlib
|
| 5 |
+
|
| 6 |
+
# --- bootstrap: install missing deps automatically ---------------------------
|
| 7 |
+
def _ensure_packages(pkgs):
|
| 8 |
+
"""
|
| 9 |
+
pkgs: sequence of dicts like
|
| 10 |
+
{"pip": "streamlit", "import": "streamlit", "spec": ">=1.30"}
|
| 11 |
+
- "pip": name used with pip install
|
| 12 |
+
- "import":module name used in 'import ...' (defaults to pip name)
|
| 13 |
+
- "spec": optional version spec (e.g., '==1.26.4' or '>=1.26')
|
| 14 |
+
"""
|
| 15 |
+
import importlib, subprocess, sys
|
| 16 |
+
|
| 17 |
+
for meta in pkgs:
|
| 18 |
+
pip_name = meta["pip"]
|
| 19 |
+
import_name = meta.get("import", pip_name)
|
| 20 |
+
spec = meta.get("spec", "")
|
| 21 |
+
try:
|
| 22 |
+
importlib.import_module(import_name)
|
| 23 |
+
except ImportError:
|
| 24 |
+
pkg_spec = pip_name + (spec or "")
|
| 25 |
+
print(f"[bootstrap] Installing {pkg_spec} …")
|
| 26 |
+
try:
|
| 27 |
+
subprocess.check_call([sys.executable, "-m", "pip", "install", pkg_spec])
|
| 28 |
+
except subprocess.CalledProcessError:
|
| 29 |
+
# Fallback: try --user (useful on locked-down machines)
|
| 30 |
+
subprocess.check_call([sys.executable, "-m", "pip", "install", "--user", pkg_spec])
|
| 31 |
+
# try import again (module just installed)
|
| 32 |
+
importlib.invalidate_caches()
|
| 33 |
+
importlib.import_module(import_name)
|
| 34 |
+
|
| 35 |
+
# call it for your app's deps
|
| 36 |
+
_ensure_packages([
|
| 37 |
+
{"pip": "streamlit"},
|
| 38 |
+
{"pip": "numpy"},
|
| 39 |
+
{"pip": "pandas"},
|
| 40 |
+
{"pip": "seaborn"},
|
| 41 |
+
{"pip": "matplotlib"},
|
| 42 |
+
{"pip":'io'},
|
| 43 |
+
])
|
| 44 |
+
# -----------------------------------------------------------------------------
|
| 45 |
+
|
| 46 |
+
import io
|
| 47 |
import numpy as np
|
| 48 |
import pandas as pd
|
| 49 |
+
import seaborn as sns
|
| 50 |
+
import matplotlib.pyplot as plt
|
| 51 |
+
from matplotlib.colors import ListedColormap
|
| 52 |
+
from matplotlib.patches import FancyArrowPatch
|
| 53 |
import streamlit as st
|
| 54 |
|
| 55 |
+
# ---------- Core math ----------
|
|
|
|
| 56 |
|
| 57 |
+
def annuity_factor(r, T):
|
| 58 |
+
"""(1 - (1+r)^(-T)) / r, with r->0 limit = T"""
|
| 59 |
+
return T if np.isclose(r, 0.0) else (1.0 - (1.0 + r) ** (-T)) / r
|
| 60 |
|
| 61 |
+
def breakeven_portfolio_return_traced(cost_basis_pct, tax_rate, mm_yield, horizon_years,
|
| 62 |
+
mortgage_rate, shield_sell, shield_use):
|
| 63 |
+
"""
|
| 64 |
+
r* = ( ((1 - g*tax) * (1 + i*Δshield*AF))^(1/T) * (1 + r_mm) ) - 1
|
| 65 |
+
g = 1 - cost_basis_pct ; Δshield = shield_sell - shield_use ; AF = annuity_factor(r_mm, T)
|
| 66 |
+
"""
|
| 67 |
+
cb = np.asarray(cost_basis_pct, dtype=float)
|
| 68 |
+
tr = np.asarray(tax_rate, dtype=float)
|
| 69 |
+
g = np.clip(1.0 - cb, 0.0, 1.0)
|
| 70 |
+
one_minus_wedge = 1.0 - g * tr
|
| 71 |
+
dshield = np.asarray(shield_sell, dtype=float) - np.asarray(shield_use, dtype=float)
|
| 72 |
+
adj = 1.0 + mortgage_rate * dshield * annuity_factor(mm_yield, horizon_years)
|
| 73 |
+
with np.errstate(invalid="ignore"):
|
| 74 |
+
rp_star = np.where(
|
| 75 |
+
(one_minus_wedge > 0.0) & (horizon_years > 0),
|
| 76 |
+
((one_minus_wedge * adj) ** (1.0 / horizon_years)) * (1.0 + mm_yield) - 1.0,
|
| 77 |
+
np.nan
|
| 78 |
+
)
|
| 79 |
+
return rp_star
|
| 80 |
+
|
| 81 |
+
# ---------- Plotting ----------
|
| 82 |
+
|
| 83 |
+
def make_decision_heatmap(cb_vals, tax_vals, r_mm, T, r_mort, r_exp, shield_sell, shield_use,
|
| 84 |
+
title):
|
| 85 |
+
"""
|
| 86 |
+
Returns (fig, df_rstar, df_decision)
|
| 87 |
+
- two-color squares: light green = Use MM, light blue = Sell
|
| 88 |
+
- per-cell label with r*
|
| 89 |
+
- decision boundary with upward arrow
|
| 90 |
+
"""
|
| 91 |
+
CB, TR = np.meshgrid(cb_vals, tax_vals) # rows=tax, cols=cb
|
| 92 |
+
Rstar = breakeven_portfolio_return_traced(CB, TR, r_mm, T, r_mort, shield_sell, shield_use)
|
| 93 |
+
Delta = Rstar - r_exp
|
| 94 |
+
decision_idx = (Delta >= 0).astype(int) # 0=MM, 1=SELL
|
| 95 |
+
|
| 96 |
+
color_mm, color_sell = "#CDECCF", "#ADD8E6"
|
| 97 |
+
cmap = ListedColormap([color_mm, color_sell])
|
| 98 |
+
|
| 99 |
+
fig, ax = plt.subplots(figsize=(12, 7))
|
| 100 |
+
sns.heatmap(
|
| 101 |
+
decision_idx,
|
| 102 |
+
ax=ax, cmap=cmap, vmin=-0.5, vmax=1.5, cbar=False,
|
| 103 |
+
linewidths=0.8, linecolor="white", square=True,
|
| 104 |
+
xticklabels=[f"{x:.0%}" for x in cb_vals],
|
| 105 |
+
yticklabels=[f"{y:.0%}" for y in tax_vals]
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
# Per-cell r* labels
|
| 109 |
+
M, N = decision_idx.shape
|
| 110 |
+
for i in range(M):
|
| 111 |
+
for j in range(N):
|
| 112 |
+
rs = Rstar[i, j]
|
| 113 |
+
if np.isfinite(rs):
|
| 114 |
+
ax.text(j + 0.5, i + 0.5, f"{rs*100:.1f}%", ha="center", va="center",
|
| 115 |
+
fontsize=9, color="#0f172a")
|
| 116 |
+
|
| 117 |
+
# Decision boundary + upward arrow
|
| 118 |
+
finite = np.isfinite(Delta)
|
| 119 |
+
if finite.any() and (np.nanmin(Delta) <= 0.0 <= np.nanmax(Delta)):
|
| 120 |
+
Xc = np.arange(N); Yc = np.arange(M)
|
| 121 |
+
XX, YY = np.meshgrid(Xc, Yc)
|
| 122 |
+
CS = ax.contour(XX + 0.5, YY + 0.5, Delta, levels=[0.0], colors="black", linewidths=2)
|
| 123 |
+
|
| 124 |
+
try:
|
| 125 |
+
path = max(CS.collections[0].get_paths(), key=lambda p: p.vertices.shape[0])
|
| 126 |
+
verts = path.vertices
|
| 127 |
+
mid = len(verts) // 2
|
| 128 |
+
p0, p1 = verts[mid-1], verts[mid+1]
|
| 129 |
+
if p1[1] < p0[1]: # ensure arrow points upward (toward SELL region)
|
| 130 |
+
p0, p1 = p1, p0
|
| 131 |
+
arrow = FancyArrowPatch((p0[0], p0[1]), (p1[0], p1[1]),
|
| 132 |
+
arrowstyle='->', mutation_scale=16, lw=2, color='black')
|
| 133 |
+
ax.add_patch(arrow)
|
| 134 |
+
#ax.text(p1[0] + 0.2, min(p1[1] + 0.3, M+0.3), "Sell portfolio",
|
| 135 |
+
# fontsize=11, weight="bold")
|
| 136 |
+
#ax.text(max(p0[0] - 1.0, -0.1), max(p0[1] - 0.5, -0.3), "Use money market",
|
| 137 |
+
# fontsize=11, weight="bold")
|
| 138 |
+
except Exception:
|
| 139 |
+
pass
|
| 140 |
+
else:
|
| 141 |
+
ax.text(0.5, 1.02, "No decision boundary within shown range",
|
| 142 |
+
transform=ax.transAxes, ha="center", va="bottom", fontsize=10, color="dimgray")
|
| 143 |
+
|
| 144 |
+
# Legend chips
|
| 145 |
+
mm_patch = plt.Line2D([0],[0], marker='s', color='w', label='Use money market',
|
| 146 |
+
markerfacecolor=color_mm, markersize=14)
|
| 147 |
+
sell_patch = plt.Line2D([0],[0], marker='s', color='w', label='Sell portfolio',
|
| 148 |
+
markerfacecolor=color_sell, markersize=14)
|
| 149 |
+
ax.legend(handles=[mm_patch, sell_patch], loc="upper left")
|
| 150 |
+
|
| 151 |
+
ax.set_xlabel("Cost basis (% of market value)")
|
| 152 |
+
ax.set_ylabel("Capital gains tax rate")
|
| 153 |
+
subtitle = (f"Horizon {T:.0f}y | MM {r_mm:.1%} | Mortgage {r_mort:.2%} | "
|
| 154 |
+
f"Expected rₚ {r_exp:.1%} | Δshield {(shield_sell - shield_use):.1%}")
|
| 155 |
+
ax.set_title(f"{title}\n{subtitle}", fontsize=12)
|
| 156 |
+
fig.text(0.5, -0.02,
|
| 157 |
+
"Decision boundary (black line): below the line → expected portfolio return rₚ is ABOVE breakeven r* → Use money-market proceeds; "
|
| 158 |
+
"above the line → rₚ is BELOW r* → Sell portfolio.",
|
| 159 |
+
ha='center', va='top', fontsize=10, color='dimgray')
|
| 160 |
+
|
| 161 |
+
fig.tight_layout()
|
| 162 |
+
return fig, pd.DataFrame(Rstar, index=[f"{y:.0%}" for y in tax_vals],
|
| 163 |
+
columns=[f"{x:.0%}" for x in cb_vals]), \
|
| 164 |
+
pd.DataFrame(np.where(decision_idx==1, "SELL", "MM"),
|
| 165 |
+
index=[f"{y:.0%}" for y in tax_vals],
|
| 166 |
+
columns=[f"{x:.0%}" for x in cb_vals])
|
| 167 |
+
|
| 168 |
+
# ---------- Streamlit UI ----------
|
| 169 |
|
| 170 |
+
st.set_page_config(page_title="Liquidity Decision Map", layout="wide")
|
| 171 |
+
|
| 172 |
+
st.title("Liquidity Decision Map (Python)")
|
| 173 |
+
st.caption("Square-cell decision heatmap comparing **Sell portfolio** vs **Use money-market cash** with IRS tracing-aware deductibility.")
|
| 174 |
+
|
| 175 |
+
with st.sidebar:
|
| 176 |
+
st.header("Assumptions")
|
| 177 |
+
T = st.number_input("Horizon (years)", value=10, min_value=1, max_value=60, step=1)
|
| 178 |
+
r_mm = st.number_input("Money market yield (decimal)", value=0.042, step=0.001, format="%.3f")
|
| 179 |
+
r_mort = st.number_input("Mortgage rate (decimal)", value=0.06, step=0.001, format="%.3f")
|
| 180 |
+
r_exp = st.number_input("Expected portfolio return (decimal)", value=0.05, step=0.001, format="%.3f")
|
| 181 |
+
|
| 182 |
+
st.header("Scenario / Shields")
|
| 183 |
+
scenario = st.radio(
|
| 184 |
+
"Preset",
|
| 185 |
+
["Personal use (Use-MM loses deduction)",
|
| 186 |
+
"Investment use (both retain deduction)",
|
| 187 |
+
"Personal + NII cap (partial in SELL)"],
|
| 188 |
+
index=0
|
| 189 |
+
)
|
| 190 |
+
if scenario == "Personal use (Use-MM loses deduction)":
|
| 191 |
+
shield_sell, shield_use = 0.37, 0.00
|
| 192 |
+
elif scenario == "Investment use (both retain deduction)":
|
| 193 |
+
shield_sell, shield_use = 0.37, 0.37
|
| 194 |
+
else:
|
| 195 |
+
shield_sell, shield_use = 0.15, 0.00
|
| 196 |
+
|
| 197 |
+
st.caption("Override shields (effective tax value of interest deductibility):")
|
| 198 |
+
shield_sell = st.number_input("SELL path shield (decimal)", value=float(shield_sell), step=0.01, min_value=0.0, max_value=0.5)
|
| 199 |
+
shield_use = st.number_input("USE-MM path shield (decimal)", value=float(shield_use), step=0.01, min_value=0.0, max_value=0.5)
|
| 200 |
+
|
| 201 |
+
st.header("Grid")
|
| 202 |
+
cb_min = st.number_input("Cost basis min (decimal)", value=0.30, step=0.05, min_value=0.0, max_value=1.0)
|
| 203 |
+
cb_max = st.number_input("Cost basis max (decimal)", value=0.90, step=0.05, min_value=0.0, max_value=1.0)
|
| 204 |
+
cb_steps= st.number_input("# cost basis steps", value=13, step=1, min_value=3, max_value=51)
|
| 205 |
+
tax_min = st.number_input("CGT min (decimal)", value=0.10, step=0.01, min_value=0.0, max_value=0.6)
|
| 206 |
+
tax_max = st.number_input("CGT max (decimal)", value=0.35, step=0.01, min_value=0.0, max_value=0.6)
|
| 207 |
+
tax_steps=st.number_input("# CGT steps", value=11, step=1, min_value=3, max_value=51)
|
| 208 |
+
|
| 209 |
+
# ---------- Step-by-step breakeven explainer ----------
|
| 210 |
+
def explain_breakeven(cb_pct, tax_rate, r_mm, T, r_mort, shield_sell, shield_use):
|
| 211 |
+
"""
|
| 212 |
+
Returns a dict with all intermediate pieces for the traced breakeven formula:
|
| 213 |
+
r* = ( ((1 - g*τ) * (1 + i*Δs*AF))^(1/T) * (1 + r_mm) ) - 1
|
| 214 |
+
where g = 1 - cb, Δs = shield_sell - shield_use, AF = (1 - (1+r_mm)^(-T))/r_mm
|
| 215 |
+
"""
|
| 216 |
+
g = 1.0 - cb_pct # embedded gain ratio
|
| 217 |
+
one_minus_wedge = 1.0 - g * tax_rate # net $ after CGT per $ sold
|
| 218 |
+
dshield = shield_sell - shield_use # difference in tax shields
|
| 219 |
+
AF = annuity_factor(r_mm, T) # annuity factor
|
| 220 |
+
adj = 1.0 + r_mort * dshield * AF # deductibility adjustment
|
| 221 |
+
r_star = ((one_minus_wedge * adj) ** (1.0 / T)) * (1.0 + r_mm) - 1.0
|
| 222 |
+
|
| 223 |
+
return {
|
| 224 |
+
"cb": cb_pct, "tax": tax_rate, "g": g,
|
| 225 |
+
"one_minus_wedge": one_minus_wedge,
|
| 226 |
+
"dshield": dshield, "AF": AF, "adj": adj,
|
| 227 |
+
"r_mm": r_mm, "T": T, "r_mort": r_mort, "r_star": r_star
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
def linspace(a, b, n):
|
| 231 |
+
if n <= 1: return np.array([a])
|
| 232 |
+
return np.linspace(a, b, int(n))
|
| 233 |
+
|
| 234 |
+
cb_vals = linspace(cb_min, cb_max, cb_steps)
|
| 235 |
+
tax_vals = linspace(tax_min, tax_max, tax_steps)
|
| 236 |
+
|
| 237 |
+
# Plot
|
| 238 |
+
title = ("Decision map — PERSONAL use of proceeds (tracing breaks if you use MM)"
|
| 239 |
+
if not np.isclose(shield_sell, shield_use) else
|
| 240 |
+
"Decision map — INVESTMENT use of proceeds (both retain deductibility)")
|
| 241 |
+
fig, df_rstar, df_decision = make_decision_heatmap(cb_vals, tax_vals, r_mm, T, r_mort, r_exp,
|
| 242 |
+
shield_sell, shield_use, title)
|
| 243 |
+
st.pyplot(fig, clear_figure=True)
|
| 244 |
+
|
| 245 |
+
# Explanation
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
# Download PNG
|
| 249 |
+
buf = io.BytesIO()
|
| 250 |
+
fig.savefig(buf, format="png", dpi=150, bbox_inches="tight")
|
| 251 |
+
st.download_button("Download PNG", data=buf.getvalue(), file_name="liquidity_decision_map.png", mime="image/png")
|
| 252 |
+
|
| 253 |
+
with st.expander("Show data tables"):
|
| 254 |
+
st.subheader("Breakeven r* (annualized)")
|
| 255 |
+
st.dataframe(df_rstar.style.format("{:.2%}"))
|
| 256 |
+
st.subheader("Decision")
|
| 257 |
+
st.dataframe(df_decision)
|
| 258 |
+
|
| 259 |
+
st.markdown(
|
| 260 |
+
"""
|
| 261 |
+
**How to read:**
|
| 262 |
+
- Each square shows the **breakeven return** \(r^*\).
|
| 263 |
+
- **Light green = Use money market** (your expected return \(r_p\) is **above** \(r^*\)).
|
| 264 |
+
- **Light blue = Sell portfolio** (your \(r_p\) is **below** \(r^*\)).
|
| 265 |
+
- The **black curve** is the decision boundary; the arrow points toward the **Sell** region.
|
| 266 |
+
"""
|
| 267 |
+
)
|