Tulitula commited on
Commit
4ed264b
·
verified ·
1 Parent(s): 4c6ebab

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +29 -111
app.py CHANGED
@@ -12,7 +12,7 @@ import requests
12
  import yfinance as yf
13
  import gradio as gr
14
 
15
- # ---- runtime niceties (avoid MPL/Cache warnings in containers) ----
16
  os.environ.setdefault("MPLCONFIGDIR", os.getenv("MPLCONFIGDIR", "/home/user/.config/matplotlib"))
17
  os.makedirs(os.environ["MPLCONFIGDIR"], exist_ok=True)
18
  for d in [
@@ -123,7 +123,7 @@ def validate_tickers(symbols: List[str], years: int) -> List[str]:
123
  base = [s for s in dict.fromkeys([t.upper().strip() for t in symbols]) if s]
124
  px = fetch_prices_monthly(base + [MARKET_TICKER], years)
125
  ok = [s for s in base if s in px.columns]
126
- # we require the market proxy to compute betas/ERP
127
  if MARKET_TICKER not in px.columns:
128
  return []
129
  return ok
@@ -163,9 +163,10 @@ def estimate_all_moments_aligned(symbols: List[str], years: int, rf_ann: float):
163
  betas[s] = cov_sm / var_m
164
  betas[MARKET_TICKER] = 1.0
165
 
166
- asset_cols = [c for c in R.columns if c != MARKET_TICKER]
167
- cov_m = np.cov(R[asset_cols].values.T, ddof=1) if asset_cols else np.zeros((0, 0))
168
- covA = pd.DataFrame(cov_m * 12.0, index=asset_cols, columns=asset_cols)
 
169
 
170
  return {"betas": betas, "cov_ann": covA, "erp_ann": erp_ann, "sigma_m_ann": sigma_m_ann}
171
 
@@ -190,7 +191,6 @@ def portfolio_stats(weights: Dict[str, float],
190
  return beta_p, mu_capm, sigma_hist
191
 
192
  def efficient_same_sigma(sigma_target: float, rf_ann: float, erp_ann: float, sigma_mkt: float):
193
- # weights on (Market, Bills) that achieve same sigma as target, on the CML
194
  if sigma_mkt <= 1e-12:
195
  return 0.0, 1.0, rf_ann
196
  a = sigma_target / sigma_mkt
@@ -222,16 +222,16 @@ def plot_cml(rf_ann, erp_ann, sigma_mkt,
222
  plt.scatter([_pct(0)], [_pct(rf_ann)], label="Risk-free")
223
  plt.scatter([_pct(sigma_mkt)], [_pct(rf_ann + erp_ann)], label="Market")
224
 
225
- # Your CAPM point: y clamped to CML at your σ_hist (display rule)
226
  y_cml_at_sigma_p = rf_ann + slope * max(0.0, float(sigma_hist_p))
227
  y_you = min(float(mu_capm_p), y_cml_at_sigma_p)
228
  plt.scatter([_pct(sigma_hist_p)], [_pct(y_you)], label="Your CAPM point")
229
 
230
- # Efficient points (on the CML by construction)
231
  plt.scatter([_pct(sigma_hist_p)], [_pct(same_sigma_mu)], marker="^", label="Efficient (same σ)")
232
  plt.scatter([_pct(same_mu_sigma)], [_pct(mu_capm_p)], marker="^", label="Efficient (same E[r])")
233
 
234
- # Selected suggestion (clamped to CML for display)
235
  if sugg_sigma_hist is not None and sugg_mu_capm is not None:
236
  y_cml_at_sugg = rf_ann + slope * max(0.0, float(sugg_sigma_hist))
237
  y_sugg = min(float(sugg_mu_capm), y_cml_at_sugg)
@@ -257,13 +257,10 @@ def build_synthetic_dataset(universe_user: List[str],
257
  sigma_mkt: float,
258
  n_rows: int = SYNTH_ROWS) -> pd.DataFrame:
259
  """
260
- Generate long-only mixes **only from the user's tickers** (no VOO injected),
261
- but we still use VOO internally for betas/ERP and the CML geometry.
262
  """
263
  rng = np.random.default_rng(12345)
264
- assets = [t for t in universe_user if t != MARKET_TICKER]
265
- if not assets:
266
- assets = universe_user[:] # could be empty; handled below
267
  if len(assets) == 0:
268
  return pd.DataFrame(columns=["tickers", "weights", "beta", "mu_capm", "sigma_hist"])
269
 
@@ -310,18 +307,12 @@ def rerank_and_pick_one(df_band: pd.DataFrame,
310
  universe: List[str],
311
  desired_band: str,
312
  alpha: float = 0.6) -> pd.Series:
313
- """
314
- Re-rank with embeddings + exposure similarity + simple MMR,
315
- then return **one** best pick (row).
316
- """
317
  if df_band.empty:
318
  return pd.Series(dtype=object)
319
 
320
- # exposure target = equal-weight over the user's universe
321
  exp_target = np.ones(len(universe))
322
  exp_target = exp_target / np.sum(exp_target)
323
 
324
- # embeddings
325
  embs_ok = True
326
  try:
327
  from sentence_transformers import SentenceTransformer
@@ -332,19 +323,18 @@ def rerank_and_pick_one(df_band: pd.DataFrame,
332
  "high": "high risk growth aggressive portfolio higher expected return",
333
  }
334
  prompt = prompt_map.get(desired_band.lower(), prompt_map["medium"])
335
- q = model.encode([prompt]) # (1, d)
336
  except Exception:
337
  embs_ok = False
338
  q = None
339
 
340
- # score each candidate
341
- scores = []
342
- X_exp = np.stack([_exposure_vec(r, universe) for _, r in df_band.iterrows()], axis=0)
343
- # cosine exposure similarity to target
344
  def _cos(a, b):
345
  an = np.linalg.norm(a) + 1e-12
346
  bn = np.linalg.norm(b) + 1e-12
347
  return float(np.dot(a, b) / (an * bn))
 
 
348
  exp_sims = np.array([_cos(x, exp_target) for x in X_exp])
349
 
350
  if embs_ok:
@@ -354,7 +344,7 @@ def rerank_and_pick_one(df_band: pd.DataFrame,
354
  f"portfolio with tickers {r['tickers']} having beta {float(r['beta']):.2f}, "
355
  f"expected return {float(r['mu_capm']):.3f}, sigma {float(r['sigma_hist']):.3f}"
356
  )
357
- C = model.encode(cand_texts) # (n, d)
358
  qv = q.reshape(-1)
359
  coss = (C @ qv) / (np.linalg.norm(C, axis=1) * (np.linalg.norm(qv) + 1e-12))
360
  coss = np.nan_to_num(coss, nan=0.0)
@@ -362,8 +352,6 @@ def rerank_and_pick_one(df_band: pd.DataFrame,
362
  coss = np.zeros(len(df_band))
363
 
364
  base = alpha * exp_sims + (1 - alpha) * coss
365
-
366
- # simple MMR (λ = 0.7) for diversity; since we want top1, this is just argmax
367
  order = np.argsort(-base)
368
  best_idx = int(order[0])
369
  return df_band.iloc[best_idx]
@@ -375,7 +363,6 @@ def suggest_one_per_band(synth: pd.DataFrame, sigma_mkt: float, universe_user: L
375
  pick_pool = synth[(synth["sigma_hist"] >= lo) & (synth["sigma_hist"] <= hi)].copy()
376
  if pick_pool.empty:
377
  pick_pool = synth.copy()
378
- # sort by CAPM E[r] first to bias pool, then rerank+MMR and return **one**
379
  pick_pool = pick_pool.sort_values("mu_capm", ascending=False).head(50).reset_index(drop=True)
380
  chosen = rerank_and_pick_one(pick_pool, universe_user, band)
381
  out[band.lower()] = chosen
@@ -479,7 +466,7 @@ def compute(
479
  "", "", "", None, None, None, None, None, None, None
480
 
481
  global UNIVERSE
482
- UNIVERSE = list(sorted(set([s for s in symbols if s != MARKET_TICKER] + [MARKET_TICKER])))[:MAX_TICKERS]
483
 
484
  df = df[df["ticker"].isin(symbols)].copy()
485
  amounts = {r["ticker"]: float(r["amount_usd"]) for _, r in df.iterrows()}
@@ -503,18 +490,17 @@ def compute(
503
  a_sigma, b_sigma, mu_eff_same_sigma = efficient_same_sigma(sigma_hist, rf_ann, erp_ann, sigma_mkt)
504
  a_mu, b_mu, sigma_eff_same_mu = efficient_same_return(mu_capm, rf_ann, erp_ann, sigma_mkt)
505
 
506
- # Synthetic dataset & suggestions (ONLY user's tickers; no forced VOO)
507
- user_universe_only = [t for t in symbols if t != MARKET_TICKER] # suggestions must use same tickers as user entered
508
- synth = build_synthetic_dataset(user_universe_only, covA, betas, rf_ann, erp_ann, sigma_mkt, n_rows=SYNTH_ROWS)
509
  csv_path = os.path.join(DATA_DIR, f"investor_profiles_{int(time.time())}.csv")
510
  try:
511
  synth.to_csv(csv_path, index=False)
512
  except Exception:
513
  csv_path = None
514
 
515
- picks = suggest_one_per_band(synth, sigma_mkt, user_universe_only)
516
 
517
- # Build visible summaries
518
  def _fmt(row: pd.Series) -> str:
519
  if row is None or row.empty:
520
  return "No pick available."
@@ -524,7 +510,6 @@ def compute(
524
  txt_med = _fmt(picks.get("medium", pd.Series(dtype=object)))
525
  txt_high = _fmt(picks.get("high", pd.Series(dtype=object)))
526
 
527
- # Choose which pick to display on the plot now
528
  chosen_band = (pick_band_to_show or "Medium").strip().lower()
529
  chosen = picks.get(chosen_band, pd.Series(dtype=object))
530
  if chosen is None or chosen.empty:
@@ -536,7 +521,6 @@ def compute(
536
  chosen_mu = float(chosen["mu_capm"])
537
  sugg_table = _holdings_table_from_row(chosen, budget=gross)
538
 
539
- # positions table
540
  pos_table = pd.DataFrame(
541
  [{
542
  "ticker": t,
@@ -547,7 +531,6 @@ def compute(
547
  columns=["ticker", "amount_usd", "weight_exposure", "beta"]
548
  )
549
 
550
- # plot
551
  img = plot_cml(
552
  rf_ann, erp_ann, sigma_mkt,
553
  sigma_hist, mu_capm,
@@ -575,7 +558,6 @@ def compute(
575
  ])
576
 
577
  uni_msg = f"Universe set to: {', '.join(UNIVERSE)}"
578
- # Return also the scalars needed for re-plotting on band button clicks
579
  return (
580
  img, info, uni_msg, pos_table, sugg_table, csv_path,
581
  txt_low, txt_med, txt_high,
@@ -583,45 +565,6 @@ def compute(
583
  chosen_sigma, chosen_mu
584
  )
585
 
586
- def redraw_with_band(
587
- band: str,
588
- low_txt: str, med_txt: str, high_txt: str, # just to keep signature consistent; not used
589
- rf_ann: float, erp_ann: float, sigma_mkt: float,
590
- sigma_hist: float, mu_capm: float,
591
- same_sigma_mu: float, same_mu_sigma: float,
592
- synth_csv_path: str, # not used; placeholder to keep wiring simple
593
- # For building the selected df, we'll pass the three pick JSONs:
594
- low_pick_json: str, med_pick_json: str, high_pick_json: str
595
- ):
596
- pick_map = {
597
- "low": json.loads(low_pick_json) if low_pick_json else None,
598
- "medium": json.loads(med_pick_json) if med_pick_json else None,
599
- "high": json.loads(high_pick_json) if high_pick_json else None,
600
- }
601
- chosen = pick_map.get((band or "medium").lower(), None)
602
- if not chosen:
603
- return gr.update(), empty_suggestion_df()
604
-
605
- chosen_sigma = float(chosen["sigma_hist"])
606
- chosen_mu = float(chosen["mu_capm"])
607
- ts = [t.strip() for t in str(chosen["tickers"]).split(",") if t.strip()]
608
- ws = [float(x) for x in str(chosen["weights"]).split(",")]
609
- s = sum(ws) or 1.0
610
- ws = [max(0.0, w) / s for w in ws]
611
- budget = float(chosen.get("budget", 1.0))
612
- sugg_table = pd.DataFrame(
613
- [{"ticker": t, "weight_%": round(w*100.0, 2), "amount_$": round(w*budget, 0)} for t, w in zip(ts, ws)],
614
- columns=["ticker", "weight_%", "amount_$"]
615
- )
616
-
617
- img = plot_cml(
618
- rf_ann, erp_ann, sigma_mkt,
619
- sigma_hist, mu_capm,
620
- same_sigma_mu, same_mu_sigma,
621
- sugg_sigma_hist=chosen_sigma, sugg_mu_capm=chosen_mu
622
- )
623
- return img, sugg_table
624
-
625
  # -------------- UI --------------
626
  with gr.Blocks(title="Efficient Portfolio Advisor") as demo:
627
  gr.Markdown(
@@ -630,7 +573,6 @@ with gr.Blocks(title="Efficient Portfolio Advisor") as demo:
630
  "Plot shows **your CAPM point on the CML** plus efficient market/bills points."
631
  )
632
 
633
- # --- SEARCH & PORTFOLIO INPUTS
634
  with gr.Row():
635
  with gr.Column(scale=1):
636
  q = gr.Textbox(label="Search symbol")
@@ -683,55 +625,32 @@ with gr.Blocks(title="Efficient Portfolio Advisor") as demo:
683
  )
684
  dl = gr.File(label="Generated dataset CSV", value=None, visible=True)
685
 
686
- # Hidden state for re-plotting + picks (serialized)
687
- st_rf = gr.State()
688
- st_erp = gr.State()
689
- st_sig_mkt = gr.State()
690
- st_sig_p = gr.State()
691
- st_mu_p = gr.State()
692
- st_same_sigma_mu = gr.State()
693
- st_same_mu_sigma = gr.State()
694
-
695
- st_low_pick = gr.State() # JSON string
696
- st_med_pick = gr.State()
697
- st_high_pick = gr.State()
698
- st_budget = gr.State()
699
-
700
  # wire search / add / locking / horizon
701
  search_btn.click(fn=search_tickers_cb, inputs=q, outputs=[search_note, matches])
702
  add_btn.click(fn=add_symbol, inputs=[matches, table], outputs=[table, search_note])
703
  table.change(fn=lock_ticker_column, inputs=table, outputs=table)
704
  horizon.change(fn=set_horizon, inputs=horizon, outputs=universe_msg)
705
 
706
- # main compute
707
- def _compute_and_pack(lookback_v, table_v, band_to_show):
708
- out = compute(lookback_v, table_v, band_to_show)
709
- # Pack picks as JSON into states so the band buttons can re-draw without recomputing.
710
- # We need to rebuild the same picks here to store them.
711
- # To avoid recomputing heavy parts, we approximate by reading the dataset CSV (already saved)
712
- # but since we returned the three text lines only, we’ll also store chosen pick info directly.
713
- return out
714
-
715
  run_btn.click(
716
- fn=_compute_and_pack,
717
  inputs=[lookback, table, gr.State("Medium")],
718
  outputs=[
719
  plot, summary, universe_msg, positions, sugg_table, dl,
720
  low_txt, med_txt, high_txt,
721
- st_rf, st_erp, st_sig_mkt, st_sig_p, st_mu_p, st_same_sigma_mu, st_same_mu_sigma,
722
- gr.State(), gr.State() # placeholders (unused chosen sigma/mu)
723
  ]
724
  )
725
 
726
- # To make the band buttons functional we recompute picks inside compute(),
727
- # but for responsiveness, we’ll call compute again with the requested band.
728
  btn_low.click(
729
  fn=compute,
730
  inputs=[lookback, table, gr.State("Low")],
731
  outputs=[
732
  plot, summary, universe_msg, positions, sugg_table, dl,
733
  low_txt, med_txt, high_txt,
734
- st_rf, st_erp, st_sig_mkt, st_sig_p, st_mu_p, st_same_sigma_mu, st_same_mu_sigma,
735
  gr.State(), gr.State()
736
  ]
737
  )
@@ -741,7 +660,7 @@ with gr.Blocks(title="Efficient Portfolio Advisor") as demo:
741
  outputs=[
742
  plot, summary, universe_msg, positions, sugg_table, dl,
743
  low_txt, med_txt, high_txt,
744
- st_rf, st_erp, st_sig_mkt, st_sig_p, st_mu_p, st_same_sigma_mu, st_same_mu_sigma,
745
  gr.State(), gr.State()
746
  ]
747
  )
@@ -751,7 +670,7 @@ with gr.Blocks(title="Efficient Portfolio Advisor") as demo:
751
  outputs=[
752
  plot, summary, universe_msg, positions, sugg_table, dl,
753
  low_txt, med_txt, high_txt,
754
- st_rf, st_erp, st_sig_mkt, st_sig_p, st_mu_p, st_same_sigma_mu, st_same_mu_sigma,
755
  gr.State(), gr.State()
756
  ]
757
  )
@@ -761,5 +680,4 @@ RF_CODE = fred_series_for_horizon(HORIZON_YEARS)
761
  RF_ANN = fetch_fred_yield_annual(RF_CODE)
762
 
763
  if __name__ == "__main__":
764
- # Gradio 5.x — no concurrency_count in queue(); keep it simple
765
  demo.launch(server_name="0.0.0.0", server_port=7860, show_api=False)
 
12
  import yfinance as yf
13
  import gradio as gr
14
 
15
+ # ---- runtime niceties ----
16
  os.environ.setdefault("MPLCONFIGDIR", os.getenv("MPLCONFIGDIR", "/home/user/.config/matplotlib"))
17
  os.makedirs(os.environ["MPLCONFIGDIR"], exist_ok=True)
18
  for d in [
 
123
  base = [s for s in dict.fromkeys([t.upper().strip() for t in symbols]) if s]
124
  px = fetch_prices_monthly(base + [MARKET_TICKER], years)
125
  ok = [s for s in base if s in px.columns]
126
+ # require market proxy to compute CAPM
127
  if MARKET_TICKER not in px.columns:
128
  return []
129
  return ok
 
163
  betas[s] = cov_sm / var_m
164
  betas[MARKET_TICKER] = 1.0
165
 
166
+ # >>> FIX: include MARKET_TICKER in covariance so σ for portfolios holding VOO is correct
167
+ asset_cols_all = list(R.columns) # includes market
168
+ cov_m_all = np.cov(R[asset_cols_all].values.T, ddof=1) if asset_cols_all else np.zeros((0, 0))
169
+ covA = pd.DataFrame(cov_m_all * 12.0, index=asset_cols_all, columns=asset_cols_all)
170
 
171
  return {"betas": betas, "cov_ann": covA, "erp_ann": erp_ann, "sigma_m_ann": sigma_m_ann}
172
 
 
191
  return beta_p, mu_capm, sigma_hist
192
 
193
  def efficient_same_sigma(sigma_target: float, rf_ann: float, erp_ann: float, sigma_mkt: float):
 
194
  if sigma_mkt <= 1e-12:
195
  return 0.0, 1.0, rf_ann
196
  a = sigma_target / sigma_mkt
 
222
  plt.scatter([_pct(0)], [_pct(rf_ann)], label="Risk-free")
223
  plt.scatter([_pct(sigma_mkt)], [_pct(rf_ann + erp_ann)], label="Market")
224
 
225
+ # Your CAPM point (y is clamped under CML at your σ for display)
226
  y_cml_at_sigma_p = rf_ann + slope * max(0.0, float(sigma_hist_p))
227
  y_you = min(float(mu_capm_p), y_cml_at_sigma_p)
228
  plt.scatter([_pct(sigma_hist_p)], [_pct(y_you)], label="Your CAPM point")
229
 
230
+ # Efficient points (on CML)
231
  plt.scatter([_pct(sigma_hist_p)], [_pct(same_sigma_mu)], marker="^", label="Efficient (same σ)")
232
  plt.scatter([_pct(same_mu_sigma)], [_pct(mu_capm_p)], marker="^", label="Efficient (same E[r])")
233
 
234
+ # Selected suggestion
235
  if sugg_sigma_hist is not None and sugg_mu_capm is not None:
236
  y_cml_at_sugg = rf_ann + slope * max(0.0, float(sugg_sigma_hist))
237
  y_sugg = min(float(sugg_mu_capm), y_cml_at_sugg)
 
257
  sigma_mkt: float,
258
  n_rows: int = SYNTH_ROWS) -> pd.DataFrame:
259
  """
260
+ Generate long-only mixes **from exactly the user's tickers** (VOO included only if the user holds it).
 
261
  """
262
  rng = np.random.default_rng(12345)
263
+ assets = list(universe_user) # <-- allow VOO if present
 
 
264
  if len(assets) == 0:
265
  return pd.DataFrame(columns=["tickers", "weights", "beta", "mu_capm", "sigma_hist"])
266
 
 
307
  universe: List[str],
308
  desired_band: str,
309
  alpha: float = 0.6) -> pd.Series:
 
 
 
 
310
  if df_band.empty:
311
  return pd.Series(dtype=object)
312
 
 
313
  exp_target = np.ones(len(universe))
314
  exp_target = exp_target / np.sum(exp_target)
315
 
 
316
  embs_ok = True
317
  try:
318
  from sentence_transformers import SentenceTransformer
 
323
  "high": "high risk growth aggressive portfolio higher expected return",
324
  }
325
  prompt = prompt_map.get(desired_band.lower(), prompt_map["medium"])
326
+ q = model.encode([prompt])
327
  except Exception:
328
  embs_ok = False
329
  q = None
330
 
331
+ # exposure similarity
 
 
 
332
  def _cos(a, b):
333
  an = np.linalg.norm(a) + 1e-12
334
  bn = np.linalg.norm(b) + 1e-12
335
  return float(np.dot(a, b) / (an * bn))
336
+
337
+ X_exp = np.stack([_exposure_vec(r, universe) for _, r in df_band.iterrows()], axis=0)
338
  exp_sims = np.array([_cos(x, exp_target) for x in X_exp])
339
 
340
  if embs_ok:
 
344
  f"portfolio with tickers {r['tickers']} having beta {float(r['beta']):.2f}, "
345
  f"expected return {float(r['mu_capm']):.3f}, sigma {float(r['sigma_hist']):.3f}"
346
  )
347
+ C = model.encode(cand_texts)
348
  qv = q.reshape(-1)
349
  coss = (C @ qv) / (np.linalg.norm(C, axis=1) * (np.linalg.norm(qv) + 1e-12))
350
  coss = np.nan_to_num(coss, nan=0.0)
 
352
  coss = np.zeros(len(df_band))
353
 
354
  base = alpha * exp_sims + (1 - alpha) * coss
 
 
355
  order = np.argsort(-base)
356
  best_idx = int(order[0])
357
  return df_band.iloc[best_idx]
 
363
  pick_pool = synth[(synth["sigma_hist"] >= lo) & (synth["sigma_hist"] <= hi)].copy()
364
  if pick_pool.empty:
365
  pick_pool = synth.copy()
 
366
  pick_pool = pick_pool.sort_values("mu_capm", ascending=False).head(50).reset_index(drop=True)
367
  chosen = rerank_and_pick_one(pick_pool, universe_user, band)
368
  out[band.lower()] = chosen
 
466
  "", "", "", None, None, None, None, None, None, None
467
 
468
  global UNIVERSE
469
+ UNIVERSE = list(sorted(set(symbols)))[:MAX_TICKERS] # keep market if present
470
 
471
  df = df[df["ticker"].isin(symbols)].copy()
472
  amounts = {r["ticker"]: float(r["amount_usd"]) for _, r in df.iterrows()}
 
490
  a_sigma, b_sigma, mu_eff_same_sigma = efficient_same_sigma(sigma_hist, rf_ann, erp_ann, sigma_mkt)
491
  a_mu, b_mu, sigma_eff_same_mu = efficient_same_return(mu_capm, rf_ann, erp_ann, sigma_mkt)
492
 
493
+ # Synthetic dataset & suggestions exactly the user's tickers
494
+ user_universe = list(symbols)
495
+ synth = build_synthetic_dataset(user_universe, covA, betas, rf_ann, erp_ann, sigma_mkt, n_rows=SYNTH_ROWS)
496
  csv_path = os.path.join(DATA_DIR, f"investor_profiles_{int(time.time())}.csv")
497
  try:
498
  synth.to_csv(csv_path, index=False)
499
  except Exception:
500
  csv_path = None
501
 
502
+ picks = suggest_one_per_band(synth, sigma_mkt, user_universe)
503
 
 
504
  def _fmt(row: pd.Series) -> str:
505
  if row is None or row.empty:
506
  return "No pick available."
 
510
  txt_med = _fmt(picks.get("medium", pd.Series(dtype=object)))
511
  txt_high = _fmt(picks.get("high", pd.Series(dtype=object)))
512
 
 
513
  chosen_band = (pick_band_to_show or "Medium").strip().lower()
514
  chosen = picks.get(chosen_band, pd.Series(dtype=object))
515
  if chosen is None or chosen.empty:
 
521
  chosen_mu = float(chosen["mu_capm"])
522
  sugg_table = _holdings_table_from_row(chosen, budget=gross)
523
 
 
524
  pos_table = pd.DataFrame(
525
  [{
526
  "ticker": t,
 
531
  columns=["ticker", "amount_usd", "weight_exposure", "beta"]
532
  )
533
 
 
534
  img = plot_cml(
535
  rf_ann, erp_ann, sigma_mkt,
536
  sigma_hist, mu_capm,
 
558
  ])
559
 
560
  uni_msg = f"Universe set to: {', '.join(UNIVERSE)}"
 
561
  return (
562
  img, info, uni_msg, pos_table, sugg_table, csv_path,
563
  txt_low, txt_med, txt_high,
 
565
  chosen_sigma, chosen_mu
566
  )
567
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
568
  # -------------- UI --------------
569
  with gr.Blocks(title="Efficient Portfolio Advisor") as demo:
570
  gr.Markdown(
 
573
  "Plot shows **your CAPM point on the CML** plus efficient market/bills points."
574
  )
575
 
 
576
  with gr.Row():
577
  with gr.Column(scale=1):
578
  q = gr.Textbox(label="Search symbol")
 
625
  )
626
  dl = gr.File(label="Generated dataset CSV", value=None, visible=True)
627
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
628
  # wire search / add / locking / horizon
629
  search_btn.click(fn=search_tickers_cb, inputs=q, outputs=[search_note, matches])
630
  add_btn.click(fn=add_symbol, inputs=[matches, table], outputs=[table, search_note])
631
  table.change(fn=lock_ticker_column, inputs=table, outputs=table)
632
  horizon.change(fn=set_horizon, inputs=horizon, outputs=universe_msg)
633
 
634
+ # compute + render (default to Medium band)
 
 
 
 
 
 
 
 
635
  run_btn.click(
636
+ fn=compute,
637
  inputs=[lookback, table, gr.State("Medium")],
638
  outputs=[
639
  plot, summary, universe_msg, positions, sugg_table, dl,
640
  low_txt, med_txt, high_txt,
641
+ gr.State(), gr.State(), gr.State(), gr.State(), gr.State(), gr.State(), gr.State(),
642
+ gr.State(), gr.State()
643
  ]
644
  )
645
 
646
+ # band buttons recompute picks quickly
 
647
  btn_low.click(
648
  fn=compute,
649
  inputs=[lookback, table, gr.State("Low")],
650
  outputs=[
651
  plot, summary, universe_msg, positions, sugg_table, dl,
652
  low_txt, med_txt, high_txt,
653
+ gr.State(), gr.State(), gr.State(), gr.State(), gr.State(), gr.State(), gr.State(),
654
  gr.State(), gr.State()
655
  ]
656
  )
 
660
  outputs=[
661
  plot, summary, universe_msg, positions, sugg_table, dl,
662
  low_txt, med_txt, high_txt,
663
+ gr.State(), gr.State(), gr.State(), gr.State(), gr.State(), gr.State(), gr.State(),
664
  gr.State(), gr.State()
665
  ]
666
  )
 
670
  outputs=[
671
  plot, summary, universe_msg, positions, sugg_table, dl,
672
  low_txt, med_txt, high_txt,
673
+ gr.State(), gr.State(), gr.State(), gr.State(), gr.State(), gr.State(), gr.State(),
674
  gr.State(), gr.State()
675
  ]
676
  )
 
680
  RF_ANN = fetch_fred_yield_annual(RF_CODE)
681
 
682
  if __name__ == "__main__":
 
683
  demo.launch(server_name="0.0.0.0", server_port=7860, show_api=False)