Tulitula commited on
Commit
2e79685
·
verified ·
1 Parent(s): 797be6b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +60 -21
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])) # unique, keep order
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
- # Plot CML with CAPM point
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  # ==============================
237
  def plot_cml(rf_ann: float, erp_ann: float, sigma_mkt: float,
238
  user_beta: float,
239
- suggestion: Optional[Dict] = None) -> Image.Image:
 
 
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) # also clears dropdown
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) -> str:
 
 
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 # plotted, ensures point on CML
 
 
 
 
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(rf_ann, erp_ann, sigma_mkt, beta_p, suggestion=pick)
 
 
 
 
 
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(state["rf"], state["erp"], state["sigma_mkt"], state["user_beta"], suggestion=pick)
 
 
 
 
 
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 are from Yahoo Finance (monthly). Risk-free is from FRED. "
611
- "Plot shows **CAPM point on the CML** (no historical returns plotted)."
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],