Tulitula commited on
Commit
cbb9529
·
verified ·
1 Parent(s): 04c51ad

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +154 -126
app.py CHANGED
@@ -202,7 +202,7 @@ def efficient_same_return(mu_target: float, rf_ann: float, erp_ann: float, sigma
202
  a = (mu_target - rf_ann) / erp_ann
203
  return a, 1.0 - a, abs(a) * sigma_mkt
204
 
205
- # -------------- plotting (CAPM on CML; x=hist σ, y=CAPM E[r]) --------------
206
  def _pct(x):
207
  return np.asarray(x, dtype=float) * 100.0
208
 
@@ -222,16 +222,13 @@ 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 point (y clamped to 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)
@@ -256,9 +253,6 @@ def build_synthetic_dataset(universe_user: List[str],
256
  erp_ann: float,
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)
264
  if len(assets) == 0:
@@ -343,9 +337,10 @@ def rerank_and_pick_one(df_band: pd.DataFrame,
343
  f"portfolio with tickers {r['tickers']} having beta {float(r['beta']):.2f}, "
344
  f"expected return {float(r['mu_capm']):.3f}, sigma {float(r['sigma_hist']):.3f}"
345
  )
 
346
  C = model.encode(cand_texts)
347
  qv = q.reshape(-1)
348
- coss = (C @ qv) / (np.linalg.norm(C, axis=1) * (np.linalg.norm(qv) + 1e-12))
349
  coss = np.nan_to_num(coss, nan=0.0)
350
  else:
351
  coss = np.zeros(len(df_band))
@@ -393,14 +388,19 @@ def search_tickers_cb(q: str):
393
  opts = yahoo_search(q)
394
  if not opts:
395
  opts = ["No matches found"]
396
- # First match auto-selected, helper info inside dropdown
397
- first = opts[0] if opts and ("No matches" not in opts[0]) else None
398
- info = "Select a symbol and click 'Add selected to portfolio'." if first else "No matches."
399
- return gr.update(choices=opts, value=first, info=info)
 
 
400
 
401
  def add_symbol(selection: str, table: Optional[pd.DataFrame]):
402
  if (not selection) or ("No matches" in selection) or ("Select a symbol" in selection) or ("type above" in selection):
403
- return table if isinstance(table, pd.DataFrame) else pd.DataFrame(columns=["ticker","amount_usd"]), "Pick a valid match first."
 
 
 
404
  symbol = selection.split("|")[0].strip().upper()
405
 
406
  current = []
@@ -438,7 +438,20 @@ def lock_ticker_column(tb: Optional[pd.DataFrame]):
438
  amounts = amounts[:len(tickers)] + [0.0] * max(0, len(tickers) - len(amounts))
439
  return pd.DataFrame({"ticker": tickers, "amount_usd": amounts})
440
 
441
- # -------------- main compute --------------
 
 
 
 
 
 
 
 
 
 
 
 
 
442
  UNIVERSE: List[str] = [MARKET_TICKER, "QQQ", "VTI", "SOXX", "IBIT"]
443
 
444
  def _holdings_table_from_row(row: pd.Series, budget: float) -> pd.DataFrame:
@@ -451,14 +464,23 @@ def _holdings_table_from_row(row: pd.Series, budget: float) -> pd.DataFrame:
451
  columns=["ticker", "weight_%", "amount_$"]
452
  )
453
 
454
- def compute(
455
  years_lookback: int,
456
  table: Optional[pd.DataFrame],
457
  pick_band_to_show: str, # "Low" | "Medium" | "High"
458
  progress=gr.Progress(track_tqdm=True),
459
  ):
460
- progress(0.05, desc="Validating tickers...")
 
 
 
 
 
 
 
 
461
 
 
462
  # sanitize table
463
  if isinstance(table, pd.DataFrame):
464
  df = table.copy()
@@ -472,19 +494,34 @@ def compute(
472
 
473
  symbols = [t for t in df["ticker"].tolist() if t]
474
  if len(symbols) == 0:
475
- return (
476
- None, "Add at least one ticker.", empty_positions_df(), empty_suggestion_df(), None,
 
 
 
 
 
477
  "", "", "",
478
- None, None, None, None, None, None, None, None, None
 
 
479
  )
 
480
 
481
  symbols = validate_tickers(symbols, years_lookback)
482
  if len(symbols) == 0:
483
- return (
484
- None, "Could not validate any tickers.", empty_positions_df(), empty_suggestion_df(), None,
 
 
 
 
485
  "", "", "",
486
- None, None, None, None, None, None, None, None, None
 
 
487
  )
 
488
 
489
  global UNIVERSE
490
  UNIVERSE = list(sorted(set(symbols)))[:MAX_TICKERS]
@@ -493,32 +530,34 @@ def compute(
493
  amounts = {r["ticker"]: float(r["amount_usd"]) for _, r in df.iterrows()}
494
  rf_ann = RF_ANN
495
 
496
- progress(0.25, desc="Downloading prices & computing moments...")
497
-
498
- # Moments
499
  moms = estimate_all_moments_aligned(symbols, years_lookback, rf_ann)
500
  betas, covA, erp_ann, sigma_mkt = moms["betas"], moms["cov_ann"], moms["erp_ann"], moms["sigma_m_ann"]
501
 
502
- # Weights
503
  gross = sum(abs(v) for v in amounts.values())
504
  if gross <= 1e-12:
505
- return (
506
- None, "All amounts are zero.", empty_positions_df(), empty_suggestion_df(), None,
 
 
 
 
507
  "", "", "",
508
- rf_ann, erp_ann, sigma_mkt, None, None, None, None, None, None
 
 
509
  )
 
 
510
  weights = {k: v / gross for k, v in amounts.items()}
511
 
512
- # Portfolio CAPM stats
513
  beta_p, mu_capm, sigma_hist = portfolio_stats(weights, covA, betas, rf_ann, erp_ann)
514
 
515
- progress(0.55, desc="Building synthetic dataset...")
516
-
517
- # Efficient alternatives on CML
518
  a_sigma, b_sigma, mu_eff_same_sigma = efficient_same_sigma(sigma_hist, rf_ann, erp_ann, sigma_mkt)
519
  a_mu, b_mu, sigma_eff_same_mu = efficient_same_return(mu_capm, rf_ann, erp_ann, sigma_mkt)
520
 
521
- # Synthetic dataset & suggestions — exactly the user's tickers
522
  user_universe = list(symbols)
523
  synth = build_synthetic_dataset(user_universe, covA, betas, rf_ann, erp_ann, sigma_mkt, n_rows=SYNTH_ROWS)
524
  csv_path = os.path.join(DATA_DIR, f"investor_profiles_{int(time.time())}.csv")
@@ -527,8 +566,7 @@ def compute(
527
  except Exception:
528
  csv_path = None
529
 
530
- progress(0.8, desc="Selecting suggestions...")
531
-
532
  picks = suggest_one_per_band(synth, sigma_mkt, user_universe)
533
 
534
  def _fmt(row: pd.Series) -> str:
@@ -549,7 +587,6 @@ def compute(
549
  else:
550
  chosen_sigma = float(chosen["sigma_hist"])
551
  chosen_mu = float(chosen["mu_capm"])
552
- # holdings table from chosen suggestion
553
  sugg_table = _holdings_table_from_row(chosen, budget=gross)
554
 
555
  pos_table = pd.DataFrame(
@@ -585,71 +622,78 @@ def compute(
585
  f"- **Same σ as your portfolio** → Market weight **{a_sigma:.2f}**, Bills weight **{b_sigma:.2f}** → E[r] **{mu_eff_same_sigma:.2%}**",
586
  f"- **Same E[r] as your portfolio** → Market weight **{a_mu:.2f}**, Bills weight **{b_mu:.2f}** → σ **{sigma_eff_same_mu:.2%}**",
587
  "",
588
- "_How to replicate:_ use a broad market ETF (e.g., VOO) for the **Market** leg and a T-bill/money-market fund for **Bills**. ",
589
- "Weights can be >1 or negative (e.g., Market > 1 and Bills < 0 implies leverage/borrowing). ",
590
- "If leverage isn’t allowed, scale both weights proportionally toward 1.0 to fit your constraints.",
591
  ])
592
 
593
- progress(1.0, desc="Done")
594
-
595
- return (
596
  img, info, pos_table, sugg_table, csv_path,
597
  txt_low, txt_med, txt_high,
598
- rf_ann, erp_ann, sigma_mkt, sigma_hist, mu_capm, mu_eff_same_sigma, sigma_eff_same_mu,
599
- chosen_sigma, chosen_mu
 
600
  )
601
 
602
  # -------------- UI --------------
603
- APP_CSS = """
 
604
  :root {
605
- --accent: hsl(340 70% 50%);
606
- --radius: 14px;
 
 
607
  }
608
- * { font-family: ui-sans-serif, system-ui, -apple-system, "Inter", Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; }
609
- .gr-button { border-radius: var(--radius); }
610
- .gr-textbox, .gr-dropdown, .gr-slider, .gr-number, .gr-dataframe { border-radius: var(--radius); }
 
611
  """
612
 
613
- with gr.Blocks(title="Efficient Portfolio Advisor", css=APP_CSS) as demo:
614
  gr.Markdown("## Efficient Portfolio Advisor")
615
 
616
- # States to absorb extra returns
617
- s1 = gr.State(); s2 = gr.State(); s3 = gr.State(); s4 = gr.State(); s5 = gr.State()
618
- s6 = gr.State(); s7 = gr.State(); s8 = gr.State(); s9 = gr.State()
619
-
620
  with gr.Row():
 
621
  with gr.Column(scale=1) as left_col:
622
- # --- Input column (full-width before compute) ---
623
- q = gr.Textbox(label="Search symbol")
624
- search_btn = gr.Button("Search")
625
- matches = gr.Dropdown(choices=[], label="Matches", value=None, info="")
626
- add_btn = gr.Button("Add selected to portfolio")
627
-
628
- gr.Markdown("### Portfolio positions")
629
- table = gr.Dataframe(
630
- headers=["ticker", "amount_usd"],
631
- datatype=["str", "number"],
632
- row_count=0,
633
- col_count=(2, "fixed")
634
- )
635
-
636
- horizon = gr.Number(label="Horizon in years (1–100)", value=HORIZON_YEARS, precision=0)
637
- lookback = gr.Slider(1, 15, value=DEFAULT_LOOKBACK_YEARS, step=1, label="Lookback years for betas & covariances")
 
 
 
 
 
 
 
 
 
 
 
638
 
639
- # Compute button (shows progress bar while compute runs)
640
- run_btn = gr.Button("Compute (build dataset & suggest)")
641
-
642
- # Suggestions (hidden until first compute)
643
  sugg_hdr = gr.Markdown("### Suggestions", visible=False)
644
- with gr.Row(visible=False) as sugg_btn_row:
645
  btn_low = gr.Button("Show Low")
646
  btn_med = gr.Button("Show Medium")
647
  btn_high = gr.Button("Show High")
648
- low_txt = gr.Markdown(visible=False)
649
- med_txt = gr.Markdown(visible=False)
650
- high_txt = gr.Markdown(visible=False)
651
 
652
- # --- Output column (hidden until first compute) ---
653
  with gr.Column(scale=1, visible=False) as right_col:
654
  plot = gr.Image(label="Capital Market Line (CAPM)", type="pil")
655
  summary = gr.Markdown(label="Inputs & Results")
@@ -671,64 +715,47 @@ with gr.Blocks(title="Efficient Portfolio Advisor", css=APP_CSS) as demo:
671
  )
672
  dl = gr.File(label="Generated dataset CSV", value=None, visible=True)
673
 
674
- # --- Wiring ---
 
675
  search_btn.click(fn=search_tickers_cb, inputs=q, outputs=matches)
676
  add_btn.click(fn=add_symbol_table_only, inputs=[matches, table], outputs=table)
 
 
677
  table.change(fn=lock_ticker_column, inputs=table, outputs=table)
678
- horizon.change(fn=set_horizon, inputs=horizon, outputs=[])
679
 
680
- # Compute (default band = Medium) -> populate outputs
681
- run_compute = run_btn.click(
682
- fn=compute,
683
- inputs=[lookback, table, gr.State("Medium")],
684
- outputs=[
685
- plot, summary, positions, sugg_table, dl,
686
- low_txt, med_txt, high_txt,
687
- s1, s2, s3, s4, s5, s6, s7, s8, s9
688
- ],
689
- show_progress=True,
690
- scroll_to_output=True,
691
- )
692
 
693
- # After compute: reveal right column + suggestions
694
- def _reveal():
695
- return (
696
- gr.update(visible=True), # right_col
697
- gr.update(visible=True), # sugg_hdr
698
- gr.update(visible=True), # sugg_btn_row
699
- gr.update(visible=True), # low_txt
700
- gr.update(visible=True), # med_txt
701
- gr.update(visible=True), # high_txt
702
- )
703
 
704
- run_compute.then(
705
- fn=_reveal,
706
- inputs=[],
707
- outputs=[right_col, sugg_hdr, sugg_btn_row, low_txt, med_txt, high_txt]
 
 
 
 
 
708
  )
709
 
710
- # Band buttons -> recompute quickly, keeping layout visible
711
- def _band_low(): return "Low"
712
- def _band_med(): return "Medium"
713
- def _band_high(): return "High"
714
-
715
  btn_low.click(
716
- fn=compute,
717
- inputs=[lookback, table, gr.State(_band_low())],
718
- outputs=[plot, summary, positions, sugg_table, dl, low_txt, med_txt, high_txt, s1, s2, s3, s4, s5, s6, s7, s8, s9],
719
- show_progress=True
720
  )
721
  btn_med.click(
722
- fn=compute,
723
- inputs=[lookback, table, gr.State(_band_med())],
724
- outputs=[plot, summary, positions, sugg_table, dl, low_txt, med_txt, high_txt, s1, s2, s3, s4, s5, s6, s7, s8, s9],
725
- show_progress=True
726
  )
727
  btn_high.click(
728
- fn=compute,
729
- inputs=[lookback, table, gr.State(_band_high())],
730
- outputs=[plot, summary, positions, sugg_table, dl, low_txt, med_txt, high_txt, s1, s2, s3, s4, s5, s6, s7, s8, s9],
731
- show_progress=True
732
  )
733
 
734
  # initialize risk-free at launch
@@ -737,3 +764,4 @@ RF_ANN = fetch_fred_yield_annual(RF_CODE)
737
 
738
  if __name__ == "__main__":
739
  demo.launch(server_name="0.0.0.0", server_port=7860, show_api=False)
 
 
202
  a = (mu_target - rf_ann) / erp_ann
203
  return a, 1.0 - a, abs(a) * sigma_mkt
204
 
205
+ # -------------- plotting --------------
206
  def _pct(x):
207
  return np.asarray(x, dtype=float) * 100.0
208
 
 
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
  y_cml_at_sigma_p = rf_ann + slope * max(0.0, float(sigma_hist_p))
226
  y_you = min(float(mu_capm_p), y_cml_at_sigma_p)
227
  plt.scatter([_pct(sigma_hist_p)], [_pct(y_you)], label="Your CAPM point")
228
 
 
229
  plt.scatter([_pct(sigma_hist_p)], [_pct(same_sigma_mu)], marker="^", label="Efficient (same σ)")
230
  plt.scatter([_pct(same_mu_sigma)], [_pct(mu_capm_p)], marker="^", label="Efficient (same E[r])")
231
 
 
232
  if sugg_sigma_hist is not None and sugg_mu_capm is not None:
233
  y_cml_at_sugg = rf_ann + slope * max(0.0, float(sugg_sigma_hist))
234
  y_sugg = min(float(sugg_mu_capm), y_cml_at_sugg)
 
253
  erp_ann: float,
254
  sigma_mkt: float,
255
  n_rows: int = SYNTH_ROWS) -> pd.DataFrame:
 
 
 
256
  rng = np.random.default_rng(12345)
257
  assets = list(universe_user)
258
  if len(assets) == 0:
 
337
  f"portfolio with tickers {r['tickers']} having beta {float(r['beta']):.2f}, "
338
  f"expected return {float(r['mu_capm']):.3f}, sigma {float(r['sigma_hist']):.3f}"
339
  )
340
+ from numpy.linalg import norm
341
  C = model.encode(cand_texts)
342
  qv = q.reshape(-1)
343
+ coss = (C @ qv) / (norm(C, axis=1) * (norm(qv) + 1e-12))
344
  coss = np.nan_to_num(coss, nan=0.0)
345
  else:
346
  coss = np.zeros(len(df_band))
 
388
  opts = yahoo_search(q)
389
  if not opts:
390
  opts = ["No matches found"]
391
+ # Pre-select the first result and put helper text into the box
392
+ return gr.update(
393
+ choices=opts,
394
+ value=opts[0],
395
+ info="Select a symbol and click 'Add selected to portfolio'."
396
+ )
397
 
398
  def add_symbol(selection: str, table: Optional[pd.DataFrame]):
399
  if (not selection) or ("No matches" in selection) or ("Select a symbol" in selection) or ("type above" in selection):
400
+ return (
401
+ table if isinstance(table, pd.DataFrame) else pd.DataFrame(columns=["ticker","amount_usd"]),
402
+ "Pick a valid match first."
403
+ )
404
  symbol = selection.split("|")[0].strip().upper()
405
 
406
  current = []
 
438
  amounts = amounts[:len(tickers)] + [0.0] * max(0, len(tickers) - len(amounts))
439
  return pd.DataFrame({"ticker": tickers, "amount_usd": amounts})
440
 
441
+ def current_ticker_choices(tb: Optional[pd.DataFrame]):
442
+ if not isinstance(tb, pd.DataFrame) or tb.empty:
443
+ return gr.update(choices=[], value=None)
444
+ tickers = [str(x).upper() for x in tb["ticker"].tolist() if str(x) != "nan"]
445
+ return gr.update(choices=tickers, value=None)
446
+
447
+ def remove_selected_ticker(symbol: Optional[str], table: Optional[pd.DataFrame]):
448
+ if not isinstance(table, pd.DataFrame) or table.empty or not symbol:
449
+ # nothing to do
450
+ return table if isinstance(table, pd.DataFrame) else pd.DataFrame(columns=["ticker", "amount_usd"]), gr.update()
451
+ out = table[table["ticker"].str.upper() != symbol.upper()].copy()
452
+ return out, current_ticker_choices(out)
453
+
454
+ # -------------- main compute (STREAMING to show progress) --------------
455
  UNIVERSE: List[str] = [MARKET_TICKER, "QQQ", "VTI", "SOXX", "IBIT"]
456
 
457
  def _holdings_table_from_row(row: pd.Series, budget: float) -> pd.DataFrame:
 
464
  columns=["ticker", "weight_%", "amount_$"]
465
  )
466
 
467
+ def compute_stream(
468
  years_lookback: int,
469
  table: Optional[pd.DataFrame],
470
  pick_band_to_show: str, # "Low" | "Medium" | "High"
471
  progress=gr.Progress(track_tqdm=True),
472
  ):
473
+ # Yield 0: show loading banner, keep right panel hidden
474
+ loading_banner = "**🔄 Computations running…** This can take a moment."
475
+ yield (
476
+ None, "", empty_positions_df(), empty_suggestion_df(), None,
477
+ "", "", "",
478
+ gr.update(visible=False), # right_col
479
+ gr.update(visible=False), # sugg_row
480
+ gr.update(value=loading_banner, visible=True) # status_md
481
+ )
482
 
483
+ progress(0.05, desc="Validating inputs…")
484
  # sanitize table
485
  if isinstance(table, pd.DataFrame):
486
  df = table.copy()
 
494
 
495
  symbols = [t for t in df["ticker"].tolist() if t]
496
  if len(symbols) == 0:
497
+ # final yield with message; keep right panel hidden
498
+ yield (
499
+ None,
500
+ "Add at least one ticker.",
501
+ empty_positions_df(),
502
+ empty_suggestion_df(),
503
+ None,
504
  "", "", "",
505
+ gr.update(visible=False),
506
+ gr.update(visible=False),
507
+ gr.update(value="", visible=False)
508
  )
509
+ return
510
 
511
  symbols = validate_tickers(symbols, years_lookback)
512
  if len(symbols) == 0:
513
+ yield (
514
+ None,
515
+ "Could not validate any tickers.",
516
+ empty_positions_df(),
517
+ empty_suggestion_df(),
518
+ None,
519
  "", "", "",
520
+ gr.update(visible=False),
521
+ gr.update(visible=False),
522
+ gr.update(value="", visible=False)
523
  )
524
+ return
525
 
526
  global UNIVERSE
527
  UNIVERSE = list(sorted(set(symbols)))[:MAX_TICKERS]
 
530
  amounts = {r["ticker"]: float(r["amount_usd"]) for _, r in df.iterrows()}
531
  rf_ann = RF_ANN
532
 
533
+ progress(0.25, desc="Estimating betas & covariances…")
 
 
534
  moms = estimate_all_moments_aligned(symbols, years_lookback, rf_ann)
535
  betas, covA, erp_ann, sigma_mkt = moms["betas"], moms["cov_ann"], moms["erp_ann"], moms["sigma_m_ann"]
536
 
 
537
  gross = sum(abs(v) for v in amounts.values())
538
  if gross <= 1e-12:
539
+ yield (
540
+ None,
541
+ "All amounts are zero.",
542
+ empty_positions_df(),
543
+ empty_suggestion_df(),
544
+ None,
545
  "", "", "",
546
+ gr.update(visible=False),
547
+ gr.update(visible=False),
548
+ gr.update(value="", visible=False)
549
  )
550
+ return
551
+
552
  weights = {k: v / gross for k, v in amounts.items()}
553
 
554
+ progress(0.45, desc="Computing portfolio statistics…")
555
  beta_p, mu_capm, sigma_hist = portfolio_stats(weights, covA, betas, rf_ann, erp_ann)
556
 
 
 
 
557
  a_sigma, b_sigma, mu_eff_same_sigma = efficient_same_sigma(sigma_hist, rf_ann, erp_ann, sigma_mkt)
558
  a_mu, b_mu, sigma_eff_same_mu = efficient_same_return(mu_capm, rf_ann, erp_ann, sigma_mkt)
559
 
560
+ progress(0.7, desc="Generating candidate portfolios…")
561
  user_universe = list(symbols)
562
  synth = build_synthetic_dataset(user_universe, covA, betas, rf_ann, erp_ann, sigma_mkt, n_rows=SYNTH_ROWS)
563
  csv_path = os.path.join(DATA_DIR, f"investor_profiles_{int(time.time())}.csv")
 
566
  except Exception:
567
  csv_path = None
568
 
569
+ progress(0.85, desc="Selecting suggestions")
 
570
  picks = suggest_one_per_band(synth, sigma_mkt, user_universe)
571
 
572
  def _fmt(row: pd.Series) -> str:
 
587
  else:
588
  chosen_sigma = float(chosen["sigma_hist"])
589
  chosen_mu = float(chosen["mu_capm"])
 
590
  sugg_table = _holdings_table_from_row(chosen, budget=gross)
591
 
592
  pos_table = pd.DataFrame(
 
622
  f"- **Same σ as your portfolio** → Market weight **{a_sigma:.2f}**, Bills weight **{b_sigma:.2f}** → E[r] **{mu_eff_same_sigma:.2%}**",
623
  f"- **Same E[r] as your portfolio** → Market weight **{a_mu:.2f}**, Bills weight **{b_mu:.2f}** → σ **{sigma_eff_same_mu:.2%}**",
624
  "",
625
+ "_How to replicate:_ use a broad market ETF (e.g., VOO) for **Market** and a T-bill/money-market fund for **Bills**. ",
626
+ "Weights can be >1 or negative. If leverage isn’t allowed, scale both weights proportionally toward 1.0.",
 
627
  ])
628
 
629
+ # Final yield: results + reveal right column and suggestion row; hide banner
630
+ yield (
 
631
  img, info, pos_table, sugg_table, csv_path,
632
  txt_low, txt_med, txt_high,
633
+ gr.update(visible=True),
634
+ gr.update(visible=True),
635
+ gr.update(value="", visible=False)
636
  )
637
 
638
  # -------------- UI --------------
639
+ custom_css = """
640
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
641
  :root {
642
+ --lensiq-accent: #8b5cf6;
643
+ --lensiq-bg: #0b1220;
644
+ --lensiq-card: #121a2b;
645
+ --lensiq-text: #e5e7eb;
646
  }
647
+ .gradio-container { font-family: Inter, ui-sans-serif, system-ui, -apple-system !important; }
648
+ .lensiq-card { background: var(--lensiq-card); border-radius: 14px; padding: 14px; }
649
+ button, .gr-button { border-radius: 10px !important; }
650
+ .lensiq-status { background: #1f2937; color: #e5e7eb; border-left: 4px solid var(--lensiq-accent); padding: 10px 12px; border-radius: 8px; }
651
  """
652
 
653
+ with gr.Blocks(title="Efficient Portfolio Advisor", css=custom_css) as demo:
654
  gr.Markdown("## Efficient Portfolio Advisor")
655
 
 
 
 
 
656
  with gr.Row():
657
+ # LEFT COLUMN (full width pre-compute)
658
  with gr.Column(scale=1) as left_col:
659
+ with gr.Group(elem_classes="lensiq-card"):
660
+ q = gr.Textbox(label="Search symbol")
661
+ search_btn = gr.Button("Search")
662
+ matches = gr.Dropdown(choices=[], label="Matches", info="Type a query and hit Search")
663
+ add_btn = gr.Button("Add selected to portfolio")
664
+
665
+ with gr.Group(elem_classes="lensiq-card"):
666
+ gr.Markdown("### Portfolio positions")
667
+ table = gr.Dataframe(
668
+ headers=["ticker", "amount_usd"],
669
+ datatype=["str", "number"],
670
+ row_count=0,
671
+ col_count=(2, "fixed")
672
+ )
673
+
674
+ # remove controls
675
+ with gr.Row():
676
+ rm_dropdown = gr.Dropdown(choices=[], label="Remove ticker", value=None)
677
+ rm_btn = gr.Button("Remove selected")
678
+
679
+ with gr.Group(elem_classes="lensiq-card"):
680
+ horizon = gr.Number(label="Horizon in years (1–100)", value=HORIZON_YEARS, precision=0)
681
+ lookback = gr.Slider(1, 15, value=DEFAULT_LOOKBACK_YEARS, step=1, label="Lookback years for betas & covariances")
682
+ run_btn = gr.Button("Compute (build dataset & suggest)")
683
+
684
+ # visible loading/status banner
685
+ status_md = gr.Markdown("", visible=False, elem_classes="lensiq-status")
686
 
 
 
 
 
687
  sugg_hdr = gr.Markdown("### Suggestions", visible=False)
688
+ with gr.Row(visible=False) as sugg_row:
689
  btn_low = gr.Button("Show Low")
690
  btn_med = gr.Button("Show Medium")
691
  btn_high = gr.Button("Show High")
692
+ low_txt = gr.Markdown()
693
+ med_txt = gr.Markdown()
694
+ high_txt = gr.Markdown()
695
 
696
+ # RIGHT COLUMN (hidden pre-compute)
697
  with gr.Column(scale=1, visible=False) as right_col:
698
  plot = gr.Image(label="Capital Market Line (CAPM)", type="pil")
699
  summary = gr.Markdown(label="Inputs & Results")
 
715
  )
716
  dl = gr.File(label="Generated dataset CSV", value=None, visible=True)
717
 
718
+ # ---------- wiring ----------
719
+ # search / add
720
  search_btn.click(fn=search_tickers_cb, inputs=q, outputs=matches)
721
  add_btn.click(fn=add_symbol_table_only, inputs=[matches, table], outputs=table)
722
+
723
+ # keep tickers valid & refresh remove dropdown when table changes
724
  table.change(fn=lock_ticker_column, inputs=table, outputs=table)
725
+ table.change(fn=current_ticker_choices, inputs=table, outputs=rm_dropdown)
726
 
727
+ # remove a ticker
728
+ rm_btn.click(fn=remove_selected_ticker, inputs=[rm_dropdown, table], outputs=[table, rm_dropdown])
 
 
 
 
 
 
 
 
 
 
729
 
730
+ # horizon updates globals silently
731
+ horizon.change(fn=set_horizon, inputs=horizon, outputs=[])
 
 
 
 
 
 
 
 
732
 
733
+ # compute + reveal results (default Medium band); STREAMING for visible progress
734
+ run_btn.click(
735
+ fn=compute_stream,
736
+ inputs=[lookback, table, gr.State("Medium")],
737
+ outputs=[plot, summary, positions, sugg_table, dl, low_txt, med_txt, high_txt, right_col, sugg_row, status_md]
738
+ ).then( # after results are visible, show Suggestions header too
739
+ lambda: (gr.update(visible=True),),
740
+ None,
741
+ [sugg_hdr]
742
  )
743
 
744
+ # band buttons recompute picks quickly (also stream with banner)
 
 
 
 
745
  btn_low.click(
746
+ fn=compute_stream,
747
+ inputs=[lookback, table, gr.State("Low")],
748
+ outputs=[plot, summary, positions, sugg_table, dl, low_txt, med_txt, high_txt, right_col, sugg_row, status_md]
 
749
  )
750
  btn_med.click(
751
+ fn=compute_stream,
752
+ inputs=[lookback, table, gr.State("Medium")],
753
+ outputs=[plot, summary, positions, sugg_table, dl, low_txt, med_txt, high_txt, right_col, sugg_row, status_md]
 
754
  )
755
  btn_high.click(
756
+ fn=compute_stream,
757
+ inputs=[lookback, table, gr.State("High")],
758
+ outputs=[plot, summary, positions, sugg_table, dl, low_txt, med_txt, high_txt, right_col, sugg_row, status_md]
 
759
  )
760
 
761
  # initialize risk-free at launch
 
764
 
765
  if __name__ == "__main__":
766
  demo.launch(server_name="0.0.0.0", server_port=7860, show_api=False)
767
+