Update app.py
Browse files
app.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
| 1 |
-
|
| 2 |
import os, io, math, json, warnings
|
| 3 |
warnings.filterwarnings("ignore")
|
| 4 |
|
|
@@ -106,7 +105,6 @@ def _extract_close(df: pd.DataFrame) -> pd.DataFrame:
|
|
| 106 |
return c
|
| 107 |
except Exception:
|
| 108 |
pass
|
| 109 |
-
# fallback: take first level
|
| 110 |
lvl0 = list(dict.fromkeys(df.columns.get_level_values(0)))
|
| 111 |
return df.xs(lvl0[0], axis=1, level=0)
|
| 112 |
else:
|
|
@@ -119,7 +117,7 @@ def _extract_close(df: pd.DataFrame) -> pd.DataFrame:
|
|
| 119 |
return df
|
| 120 |
|
| 121 |
def fetch_prices_monthly(tickers: List[str], years: int) -> pd.DataFrame:
|
| 122 |
-
tickers = list(dict.fromkeys([t for t in tickers if t]))
|
| 123 |
if not tickers:
|
| 124 |
return pd.DataFrame()
|
| 125 |
start = (pd.Timestamp.today(tz="UTC") - pd.DateOffset(years=years, days=7)).date()
|
|
@@ -136,11 +134,9 @@ def fetch_prices_monthly(tickers: List[str], years: int) -> pd.DataFrame:
|
|
| 136 |
if isinstance(df, pd.DataFrame):
|
| 137 |
df = _extract_close(df)
|
| 138 |
df = df.dropna(how="all").fillna(method="ffill")
|
| 139 |
-
# When single ticker, columns might be 1 col named by ticker or "Close"
|
| 140 |
if df.shape[1] == 1:
|
| 141 |
col = df.columns[0]
|
| 142 |
if col in ("Close", "Adj Close"):
|
| 143 |
-
# rename to ticker if only one requested
|
| 144 |
if len(tickers) == 1:
|
| 145 |
df.columns = [tickers[0]]
|
| 146 |
return df
|
|
@@ -156,7 +152,6 @@ def get_aligned_monthly_returns(symbols: List[str], years: int) -> Tuple[pd.Data
|
|
| 156 |
want = list(dict.fromkeys(uniq + MARKET_CANDIDATES))
|
| 157 |
px = fetch_prices_monthly(want, years)
|
| 158 |
rets = monthly_returns(px)
|
| 159 |
-
# pick first available market
|
| 160 |
market = None
|
| 161 |
for m in MARKET_CANDIDATES:
|
| 162 |
if m in rets.columns:
|
|
@@ -232,11 +227,28 @@ def portfolio_stats(weights: Dict[str, float],
|
|
| 232 |
return beta_p, er_capm, sigma_p
|
| 233 |
|
| 234 |
# ==============================
|
| 235 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
# ==============================
|
| 237 |
def plot_cml(rf_ann: float, erp_ann: float, sigma_mkt: float,
|
| 238 |
user_beta: float,
|
| 239 |
-
suggestion: Optional[Dict] = None
|
|
|
|
|
|
|
| 240 |
fig = plt.figure(figsize=(6.4, 4.2), dpi=120)
|
| 241 |
slope = erp_ann / max(sigma_mkt, 1e-12)
|
| 242 |
xmax = max(0.3, 2.0 * sigma_mkt)
|
|
@@ -253,6 +265,12 @@ def plot_cml(rf_ann: float, erp_ann: float, sigma_mkt: float,
|
|
| 253 |
mu_user = capm_er(user_beta, rf_ann, erp_ann)
|
| 254 |
plt.scatter([_pct(sig_user)], [_pct(mu_user)], label="Your CAPM point", s=35)
|
| 255 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
# Optional suggestion point
|
| 257 |
if suggestion is not None:
|
| 258 |
plt.scatter([_pct(float(suggestion["sigma"]))],
|
|
@@ -366,7 +384,6 @@ def parse_weights(row: pd.Series) -> Dict[str, float]:
|
|
| 366 |
ts = [t.strip() for t in str(row["tickers"]).split(",")]
|
| 367 |
ws = [float(x) for x in str(row["weights"]).split(",")]
|
| 368 |
wmap = {ts[i]: ws[i] for i in range(min(len(ts), len(ws)))}
|
| 369 |
-
# normalize just in case
|
| 370 |
s = sum(abs(v) for v in wmap.values()) or 1.0
|
| 371 |
return {k: v / s for k, v in wmap.items()}
|
| 372 |
|
|
@@ -374,7 +391,6 @@ def pick_top3_for_bucket(df: pd.DataFrame, bucket: str) -> List[Dict]:
|
|
| 374 |
cand = select_bucket_candidates(df, bucket)
|
| 375 |
if cand.empty:
|
| 376 |
return []
|
| 377 |
-
# Rank by embedding similarity to a short query
|
| 378 |
query_map = {
|
| 379 |
"Low": "low risk, stable portfolio, conservative volatility",
|
| 380 |
"Medium": "balanced risk portfolio, moderate volatility",
|
|
@@ -422,7 +438,7 @@ def add_symbol(selection: str, table: pd.DataFrame):
|
|
| 422 |
if len(new_table) > MAX_TICKERS:
|
| 423 |
new_table = new_table.iloc[:MAX_TICKERS]
|
| 424 |
msg = f"Reached max of {MAX_TICKERS}"
|
| 425 |
-
return new_table, msg, gr.update(value=None)
|
| 426 |
|
| 427 |
def lock_ticker_column(tb: pd.DataFrame):
|
| 428 |
if tb is None or len(tb) == 0:
|
|
@@ -446,7 +462,9 @@ def set_horizon(years: float):
|
|
| 446 |
|
| 447 |
def build_summary_md(lookback, rf_code, rf, erp, sigma_mkt,
|
| 448 |
beta_p, er_capm, sigma_cml_user,
|
| 449 |
-
market_sym
|
|
|
|
|
|
|
| 450 |
lines = []
|
| 451 |
lines.append("### Inputs")
|
| 452 |
lines.append(f"- Lookback years {lookback}")
|
|
@@ -459,6 +477,11 @@ def build_summary_md(lookback, rf_code, rf, erp, sigma_mkt,
|
|
| 459 |
lines.append(f"- Beta {beta_p:.2f}")
|
| 460 |
lines.append(f"- Expected return (CAPM / SML) {fmt_pct(er_capm)}")
|
| 461 |
lines.append(f"- σ on CML for your beta (|β|×σ_mkt) {fmt_pct(sigma_cml_user)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
return "\n".join(lines)
|
| 463 |
|
| 464 |
def pack_suggestion_table(pick: Dict, gross_usd: float) -> pd.DataFrame:
|
|
@@ -511,7 +534,11 @@ def compute(years_lookback: int,
|
|
| 511 |
|
| 512 |
# ---------- user stats (CAPM) ----------
|
| 513 |
beta_p, er_capm, _sigma_hist = portfolio_stats(weights_user, covA, betas, rf_ann, erp_ann)
|
| 514 |
-
sigma_user_on_cml = abs(beta_p) * sigma_mkt #
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
|
| 516 |
# ---------- positions table ----------
|
| 517 |
rows = []
|
|
@@ -552,7 +579,9 @@ def compute(years_lookback: int,
|
|
| 552 |
"rf": float(rf_ann),
|
| 553 |
"erp": float(erp_ann),
|
| 554 |
"sigma_mkt": float(sigma_mkt),
|
| 555 |
-
"user_beta": float(beta_p)
|
|
|
|
|
|
|
| 556 |
}
|
| 557 |
|
| 558 |
# ---------- decide which suggestion to show initially ----------
|
|
@@ -562,12 +591,19 @@ def compute(years_lookback: int,
|
|
| 562 |
pick = picks_list[pick_idx] if pick_idx < len(picks_list) else (picks_list[0] if picks_list else None)
|
| 563 |
|
| 564 |
# ---------- plot ----------
|
| 565 |
-
img = plot_cml(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
|
| 567 |
# ---------- summary ----------
|
| 568 |
info = build_summary_md(
|
| 569 |
years_lookback, RF_CODE, rf_ann, erp_ann, sigma_mkt,
|
| 570 |
-
beta_p, er_capm, sigma_user_on_cml, market_sym
|
|
|
|
|
|
|
| 571 |
)
|
| 572 |
|
| 573 |
# ---------- suggestion UI ----------
|
|
@@ -591,7 +627,12 @@ def update_suggestion(risk: str, pick_name: str, state: dict):
|
|
| 591 |
idx = ["Pick #1", "Pick #2", "Pick #3"].index(pick_name) if pick_name in ("Pick #1", "Pick #2", "Pick #3") else 0
|
| 592 |
idx = min(idx, len(picks_list) - 1)
|
| 593 |
pick = picks_list[idx]
|
| 594 |
-
img = plot_cml(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 595 |
sug_md = suggestion_metrics_md(pick)
|
| 596 |
sug_table = pack_suggestion_table(pick, state.get("gross", 0.0))
|
| 597 |
return img, sug_md, sug_table
|
|
@@ -607,8 +648,8 @@ with gr.Blocks(title="Efficient Portfolio Advisor") as demo:
|
|
| 607 |
gr.Markdown(
|
| 608 |
"## Efficient Portfolio Advisor\n"
|
| 609 |
"Search symbols, enter **dollar amounts**, set horizon. "
|
| 610 |
-
"Returns
|
| 611 |
-
"Plot shows **CAPM point on the CML**
|
| 612 |
)
|
| 613 |
|
| 614 |
with gr.Row():
|
|
@@ -663,7 +704,6 @@ with gr.Blocks(title="Efficient Portfolio Advisor") as demo:
|
|
| 663 |
# --- wiring ---
|
| 664 |
def do_search(query):
|
| 665 |
note, options = search_tickers_cb(query)
|
| 666 |
-
# Clear previous selection to avoid “not in choices”
|
| 667 |
return note, gr.update(choices=options, value=None)
|
| 668 |
|
| 669 |
search_btn.click(fn=do_search, inputs=q, outputs=[search_note, matches])
|
|
@@ -677,7 +717,6 @@ with gr.Blocks(title="Efficient Portfolio Advisor") as demo:
|
|
| 677 |
outputs=[plot, summary, universe_msg, positions, state, risk_selector, pick_selector, sugg_metrics, suggestions]
|
| 678 |
)
|
| 679 |
|
| 680 |
-
# Update suggestion view without recomputing moments
|
| 681 |
risk_selector.change(
|
| 682 |
fn=update_suggestion,
|
| 683 |
inputs=[risk_selector, pick_selector, state],
|
|
|
|
|
|
|
| 1 |
import os, io, math, json, warnings
|
| 2 |
warnings.filterwarnings("ignore")
|
| 3 |
|
|
|
|
| 105 |
return c
|
| 106 |
except Exception:
|
| 107 |
pass
|
|
|
|
| 108 |
lvl0 = list(dict.fromkeys(df.columns.get_level_values(0)))
|
| 109 |
return df.xs(lvl0[0], axis=1, level=0)
|
| 110 |
else:
|
|
|
|
| 117 |
return df
|
| 118 |
|
| 119 |
def fetch_prices_monthly(tickers: List[str], years: int) -> pd.DataFrame:
|
| 120 |
+
tickers = list(dict.fromkeys([t for t in tickers if t]))
|
| 121 |
if not tickers:
|
| 122 |
return pd.DataFrame()
|
| 123 |
start = (pd.Timestamp.today(tz="UTC") - pd.DateOffset(years=years, days=7)).date()
|
|
|
|
| 134 |
if isinstance(df, pd.DataFrame):
|
| 135 |
df = _extract_close(df)
|
| 136 |
df = df.dropna(how="all").fillna(method="ffill")
|
|
|
|
| 137 |
if df.shape[1] == 1:
|
| 138 |
col = df.columns[0]
|
| 139 |
if col in ("Close", "Adj Close"):
|
|
|
|
| 140 |
if len(tickers) == 1:
|
| 141 |
df.columns = [tickers[0]]
|
| 142 |
return df
|
|
|
|
| 152 |
want = list(dict.fromkeys(uniq + MARKET_CANDIDATES))
|
| 153 |
px = fetch_prices_monthly(want, years)
|
| 154 |
rets = monthly_returns(px)
|
|
|
|
| 155 |
market = None
|
| 156 |
for m in MARKET_CANDIDATES:
|
| 157 |
if m in rets.columns:
|
|
|
|
| 227 |
return beta_p, er_capm, sigma_p
|
| 228 |
|
| 229 |
# ==============================
|
| 230 |
+
# Efficient points on the CML (back again)
|
| 231 |
+
# ==============================
|
| 232 |
+
def efficient_same_sigma(sigma_target: float, rf_ann: float, erp_ann: float, sigma_mkt: float):
|
| 233 |
+
if sigma_mkt <= 1e-12:
|
| 234 |
+
return 0.0, 1.0, rf_ann
|
| 235 |
+
a = sigma_target / sigma_mkt # market weight
|
| 236 |
+
return a, 1.0 - a, rf_ann + a * erp_ann
|
| 237 |
+
|
| 238 |
+
def efficient_same_return(mu_target: float, rf_ann: float, erp_ann: float, sigma_mkt: float):
|
| 239 |
+
if abs(erp_ann) <= 1e-12:
|
| 240 |
+
return 0.0, 1.0, rf_ann
|
| 241 |
+
a = (mu_target - rf_ann) / erp_ann # market weight
|
| 242 |
+
return a, 1.0 - a, abs(a) * sigma_mkt
|
| 243 |
+
|
| 244 |
+
# ==============================
|
| 245 |
+
# Plot CML with CAPM point (+ efficient points)
|
| 246 |
# ==============================
|
| 247 |
def plot_cml(rf_ann: float, erp_ann: float, sigma_mkt: float,
|
| 248 |
user_beta: float,
|
| 249 |
+
suggestion: Optional[Dict] = None,
|
| 250 |
+
same_sigma_pt: Optional[Tuple[float, float]] = None,
|
| 251 |
+
same_return_pt: Optional[Tuple[float, float]] = None) -> Image.Image:
|
| 252 |
fig = plt.figure(figsize=(6.4, 4.2), dpi=120)
|
| 253 |
slope = erp_ann / max(sigma_mkt, 1e-12)
|
| 254 |
xmax = max(0.3, 2.0 * sigma_mkt)
|
|
|
|
| 265 |
mu_user = capm_er(user_beta, rf_ann, erp_ann)
|
| 266 |
plt.scatter([_pct(sig_user)], [_pct(mu_user)], label="Your CAPM point", s=35)
|
| 267 |
|
| 268 |
+
# Efficient points
|
| 269 |
+
if same_sigma_pt is not None:
|
| 270 |
+
plt.scatter([_pct(same_sigma_pt[0])], [_pct(same_sigma_pt[1])], marker="^", s=40, label="Efficient (same σ)")
|
| 271 |
+
if same_return_pt is not None:
|
| 272 |
+
plt.scatter([_pct(same_return_pt[0])], [_pct(same_return_pt[1])], marker="s", s=40, label="Efficient (same return)")
|
| 273 |
+
|
| 274 |
# Optional suggestion point
|
| 275 |
if suggestion is not None:
|
| 276 |
plt.scatter([_pct(float(suggestion["sigma"]))],
|
|
|
|
| 384 |
ts = [t.strip() for t in str(row["tickers"]).split(",")]
|
| 385 |
ws = [float(x) for x in str(row["weights"]).split(",")]
|
| 386 |
wmap = {ts[i]: ws[i] for i in range(min(len(ts), len(ws)))}
|
|
|
|
| 387 |
s = sum(abs(v) for v in wmap.values()) or 1.0
|
| 388 |
return {k: v / s for k, v in wmap.items()}
|
| 389 |
|
|
|
|
| 391 |
cand = select_bucket_candidates(df, bucket)
|
| 392 |
if cand.empty:
|
| 393 |
return []
|
|
|
|
| 394 |
query_map = {
|
| 395 |
"Low": "low risk, stable portfolio, conservative volatility",
|
| 396 |
"Medium": "balanced risk portfolio, moderate volatility",
|
|
|
|
| 438 |
if len(new_table) > MAX_TICKERS:
|
| 439 |
new_table = new_table.iloc[:MAX_TICKERS]
|
| 440 |
msg = f"Reached max of {MAX_TICKERS}"
|
| 441 |
+
return new_table, msg, gr.update(value=None)
|
| 442 |
|
| 443 |
def lock_ticker_column(tb: pd.DataFrame):
|
| 444 |
if tb is None or len(tb) == 0:
|
|
|
|
| 462 |
|
| 463 |
def build_summary_md(lookback, rf_code, rf, erp, sigma_mkt,
|
| 464 |
beta_p, er_capm, sigma_cml_user,
|
| 465 |
+
market_sym,
|
| 466 |
+
a_sigma=None, b_sigma=None, mu_eff_sigma=None,
|
| 467 |
+
a_mu=None, b_mu=None, sigma_eff_mu=None) -> str:
|
| 468 |
lines = []
|
| 469 |
lines.append("### Inputs")
|
| 470 |
lines.append(f"- Lookback years {lookback}")
|
|
|
|
| 477 |
lines.append(f"- Beta {beta_p:.2f}")
|
| 478 |
lines.append(f"- Expected return (CAPM / SML) {fmt_pct(er_capm)}")
|
| 479 |
lines.append(f"- σ on CML for your beta (|β|×σ_mkt) {fmt_pct(sigma_cml_user)}")
|
| 480 |
+
if (a_sigma is not None) and (a_mu is not None):
|
| 481 |
+
lines.append("")
|
| 482 |
+
lines.append("### Efficient alternatives on the CML")
|
| 483 |
+
lines.append(f"- Same σ as your CAPM point → Market {a_sigma:.2f}, Bills {b_sigma:.2f}, return {fmt_pct(mu_eff_sigma)}")
|
| 484 |
+
lines.append(f"- Same expected return (your CAPM μ) → Market {a_mu:.2f}, Bills {b_mu:.2f}, σ {fmt_pct(sigma_eff_mu)}")
|
| 485 |
return "\n".join(lines)
|
| 486 |
|
| 487 |
def pack_suggestion_table(pick: Dict, gross_usd: float) -> pd.DataFrame:
|
|
|
|
| 534 |
|
| 535 |
# ---------- user stats (CAPM) ----------
|
| 536 |
beta_p, er_capm, _sigma_hist = portfolio_stats(weights_user, covA, betas, rf_ann, erp_ann)
|
| 537 |
+
sigma_user_on_cml = abs(beta_p) * sigma_mkt # on CML
|
| 538 |
+
|
| 539 |
+
# ---------- efficient CML points (back again) ----------
|
| 540 |
+
a_sigma, b_sigma, mu_eff_sigma = efficient_same_sigma(sigma_user_on_cml, rf_ann, erp_ann, sigma_mkt)
|
| 541 |
+
a_mu, b_mu, sigma_eff_mu = efficient_same_return(er_capm, rf_ann, erp_ann, sigma_mkt)
|
| 542 |
|
| 543 |
# ---------- positions table ----------
|
| 544 |
rows = []
|
|
|
|
| 579 |
"rf": float(rf_ann),
|
| 580 |
"erp": float(erp_ann),
|
| 581 |
"sigma_mkt": float(sigma_mkt),
|
| 582 |
+
"user_beta": float(beta_p),
|
| 583 |
+
"same_sigma": (float(sigma_user_on_cml), float(mu_eff_sigma)),
|
| 584 |
+
"same_return": (float(sigma_eff_mu), float(er_capm)),
|
| 585 |
}
|
| 586 |
|
| 587 |
# ---------- decide which suggestion to show initially ----------
|
|
|
|
| 591 |
pick = picks_list[pick_idx] if pick_idx < len(picks_list) else (picks_list[0] if picks_list else None)
|
| 592 |
|
| 593 |
# ---------- plot ----------
|
| 594 |
+
img = plot_cml(
|
| 595 |
+
rf_ann, erp_ann, sigma_mkt, beta_p,
|
| 596 |
+
suggestion=pick,
|
| 597 |
+
same_sigma_pt=state["same_sigma"],
|
| 598 |
+
same_return_pt=state["same_return"]
|
| 599 |
+
)
|
| 600 |
|
| 601 |
# ---------- summary ----------
|
| 602 |
info = build_summary_md(
|
| 603 |
years_lookback, RF_CODE, rf_ann, erp_ann, sigma_mkt,
|
| 604 |
+
beta_p, er_capm, sigma_user_on_cml, market_sym,
|
| 605 |
+
a_sigma=a_sigma, b_sigma=b_sigma, mu_eff_sigma=mu_eff_sigma,
|
| 606 |
+
a_mu=a_mu, b_mu=b_mu, sigma_eff_mu=sigma_eff_mu
|
| 607 |
)
|
| 608 |
|
| 609 |
# ---------- suggestion UI ----------
|
|
|
|
| 627 |
idx = ["Pick #1", "Pick #2", "Pick #3"].index(pick_name) if pick_name in ("Pick #1", "Pick #2", "Pick #3") else 0
|
| 628 |
idx = min(idx, len(picks_list) - 1)
|
| 629 |
pick = picks_list[idx]
|
| 630 |
+
img = plot_cml(
|
| 631 |
+
state["rf"], state["erp"], state["sigma_mkt"], state["user_beta"],
|
| 632 |
+
suggestion=pick,
|
| 633 |
+
same_sigma_pt=state.get("same_sigma"),
|
| 634 |
+
same_return_pt=state.get("same_return")
|
| 635 |
+
)
|
| 636 |
sug_md = suggestion_metrics_md(pick)
|
| 637 |
sug_table = pack_suggestion_table(pick, state.get("gross", 0.0))
|
| 638 |
return img, sug_md, sug_table
|
|
|
|
| 648 |
gr.Markdown(
|
| 649 |
"## Efficient Portfolio Advisor\n"
|
| 650 |
"Search symbols, enter **dollar amounts**, set horizon. "
|
| 651 |
+
"Returns use Yahoo Finance monthly data; risk-free from FRED. "
|
| 652 |
+
"Plot shows **CAPM point on the CML** plus efficient CML points."
|
| 653 |
)
|
| 654 |
|
| 655 |
with gr.Row():
|
|
|
|
| 704 |
# --- wiring ---
|
| 705 |
def do_search(query):
|
| 706 |
note, options = search_tickers_cb(query)
|
|
|
|
| 707 |
return note, gr.update(choices=options, value=None)
|
| 708 |
|
| 709 |
search_btn.click(fn=do_search, inputs=q, outputs=[search_note, matches])
|
|
|
|
| 717 |
outputs=[plot, summary, universe_msg, positions, state, risk_selector, pick_selector, sugg_metrics, suggestions]
|
| 718 |
)
|
| 719 |
|
|
|
|
| 720 |
risk_selector.change(
|
| 721 |
fn=update_suggestion,
|
| 722 |
inputs=[risk_selector, pick_selector, state],
|