jlquant commited on
Commit
e62abda
·
verified ·
1 Parent(s): 4427f54

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +260 -33
src/streamlit_app.py CHANGED
@@ -1,40 +1,267 @@
1
- import altair as alt
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import numpy as np
3
  import pandas as pd
 
 
 
 
4
  import streamlit as st
5
 
6
- """
7
- # Welcome to Streamlit!
8
 
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
12
 
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
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
+ )