Tulitula commited on
Commit
653d088
·
verified ·
1 Parent(s): c83ef19

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +152 -138
app.py CHANGED
@@ -391,12 +391,20 @@ def set_horizon(years: float):
391
 
392
  def search_tickers_cb(q: str):
393
  opts = yahoo_search(q)
394
- note = "Select a symbol and click 'Add selected to portfolio'." if opts else "No matches."
395
- return note, gr.update(choices=opts, value=None)
 
 
 
 
 
 
396
 
397
  def add_symbol(selection: str, table: Optional[pd.DataFrame]):
398
  if not selection:
399
  return table if isinstance(table, pd.DataFrame) else pd.DataFrame(columns=["ticker","amount_usd"]), "Pick a row in Matches first."
 
 
400
  symbol = selection.split("|")[0].strip().upper()
401
 
402
  current = []
@@ -420,6 +428,11 @@ def add_symbol(selection: str, table: Optional[pd.DataFrame]):
420
  return new_table, f"Reached max of {MAX_TICKERS}."
421
  return new_table, f"Added {symbol}."
422
 
 
 
 
 
 
423
  def lock_ticker_column(tb: Optional[pd.DataFrame]):
424
  if not isinstance(tb, pd.DataFrame) or tb.empty:
425
  return pd.DataFrame(columns=["ticker", "amount_usd"])
@@ -447,9 +460,11 @@ def compute(
447
  years_lookback: int,
448
  table: Optional[pd.DataFrame],
449
  pick_band_to_show: str, # "Low" | "Medium" | "High"
450
- progress=gr.Progress(track_tqdm=True),
451
  ):
452
- progress(0.03, desc="Validating tickers...")
 
 
453
 
454
  # sanitize table
455
  if isinstance(table, pd.DataFrame):
@@ -464,18 +479,35 @@ def compute(
464
 
465
  symbols = [t for t in df["ticker"].tolist() if t]
466
  if len(symbols) == 0:
 
467
  return (
468
- None, "Add at least one ticker.", empty_positions_df(), empty_suggestion_df(), None,
469
- "", "", "",
470
- None, None, None, None, None, None, None, None, None
 
 
 
 
 
 
 
471
  )
472
 
 
473
  symbols = validate_tickers(symbols, years_lookback)
474
  if len(symbols) == 0:
 
475
  return (
476
- None, "Could not validate any tickers.", empty_positions_df(), empty_suggestion_df(), None,
477
- "", "", "",
478
- None, None, None, None, None, None, None, None, None
 
 
 
 
 
 
 
479
  )
480
 
481
  global UNIVERSE
@@ -485,23 +517,37 @@ def compute(
485
  amounts = {r["ticker"]: float(r["amount_usd"]) for _, r in df.iterrows()}
486
  rf_ann = RF_ANN
487
 
488
- progress(0.18, desc="Downloading prices & computing moments...")
489
  moms = estimate_all_moments_aligned(symbols, years_lookback, rf_ann)
490
  betas, covA, erp_ann, sigma_mkt = moms["betas"], moms["cov_ann"], moms["erp_ann"], moms["sigma_m_ann"]
491
 
492
  gross = sum(abs(v) for v in amounts.values())
493
  if gross <= 1e-12:
 
494
  return (
495
- None, "All amounts are zero.", empty_positions_df(), empty_suggestion_df(), None,
496
- "", "", "",
497
- rf_ann, erp_ann, sigma_mkt, None, None, None, None, None, None
 
 
 
 
 
 
 
498
  )
499
  weights = {k: v / gross for k, v in amounts.items()}
500
 
501
- progress(0.35, desc="Computing portfolio CAPM stats...")
 
502
  beta_p, mu_capm, sigma_hist = portfolio_stats(weights, covA, betas, rf_ann, erp_ann)
503
 
504
- progress(0.55, desc="Building synthetic dataset...")
 
 
 
 
 
505
  user_universe = list(symbols)
506
  synth = build_synthetic_dataset(user_universe, covA, betas, rf_ann, erp_ann, sigma_mkt, n_rows=SYNTH_ROWS)
507
  csv_path = os.path.join(DATA_DIR, f"investor_profiles_{int(time.time())}.csv")
@@ -510,9 +556,6 @@ def compute(
510
  except Exception:
511
  csv_path = None
512
 
513
- progress(0.75, desc="Deriving efficient mixes & suggestions...")
514
- a_sigma, b_sigma, mu_eff_same_sigma = efficient_same_sigma(sigma_hist, rf_ann, erp_ann, sigma_mkt)
515
- a_mu, b_mu, sigma_eff_same_mu = efficient_same_return(mu_capm, rf_ann, erp_ann, sigma_mkt)
516
  picks = suggest_one_per_band(synth, sigma_mkt, user_universe)
517
 
518
  def _fmt(row: pd.Series) -> str:
@@ -529,11 +572,11 @@ def compute(
529
  if chosen is None or chosen.empty:
530
  chosen_sigma = None
531
  chosen_mu = None
532
- sugg_table = empty_suggestion_df()
533
  else:
534
  chosen_sigma = float(chosen["sigma_hist"])
535
  chosen_mu = float(chosen["mu_capm"])
536
- sugg_table = _holdings_table_from_row(chosen, budget=gross)
537
 
538
  pos_table = pd.DataFrame(
539
  [{
@@ -545,7 +588,6 @@ def compute(
545
  columns=["ticker", "amount_usd", "weight_exposure", "beta"]
546
  )
547
 
548
- progress(0.9, desc="Rendering chart & summary...")
549
  img = plot_cml(
550
  rf_ann, erp_ann, sigma_mkt,
551
  sigma_hist, mu_capm,
@@ -553,6 +595,7 @@ def compute(
553
  sugg_sigma_hist=chosen_sigma, sugg_mu_capm=chosen_mu
554
  )
555
 
 
556
  info = "\n".join([
557
  "### Inputs",
558
  f"- Lookback years {years_lookback}",
@@ -574,141 +617,116 @@ def compute(
574
  "If leverage isn’t allowed, scale both weights proportionally toward 1.0 to fit your constraints.",
575
  ])
576
 
577
- progress(1.0, desc="Done.")
 
578
 
579
  return (
580
- img, info, pos_table, sugg_table, csv_path,
581
- txt_low, txt_med, txt_high,
582
- rf_ann, erp_ann, sigma_mkt, sigma_hist, mu_capm, mu_eff_same_sigma, sigma_eff_same_mu,
583
- chosen_sigma, chosen_mu
 
 
 
 
 
 
584
  )
585
 
586
  # -------------- UI --------------
587
- with gr.Blocks(
588
- title="Efficient Portfolio Advisor",
589
- ) as demo:
590
  gr.Markdown(
591
  "## Efficient Portfolio Advisor\n"
592
- "Search symbols, enter **dollar amounts**, set horizon. Returns use Yahoo Finance monthly data; risk-free from FRED."
 
593
  )
594
 
595
- with gr.Row():
596
- with gr.Column(scale=5) as left_col:
597
- # --- Vertical flow: Search -> Button -> Matches -> Add ---
598
- q = gr.Textbox(label="Search symbol")
599
- search_btn = gr.Button("Search")
600
- search_note = gr.Markdown()
601
- matches = gr.Dropdown(choices=[], label="Matches")
602
- add_btn = gr.Button("Add selected to portfolio")
603
-
604
- gr.Markdown("### Portfolio positions")
605
- table = gr.Dataframe(
606
- headers=["ticker", "amount_usd"],
607
- datatype=["str", "number"],
608
- row_count=0,
609
- col_count=(2, "fixed")
610
- )
611
-
612
- horizon = gr.Number(label="Horizon in years (1–100)", value=HORIZON_YEARS, precision=0)
613
- lookback = gr.Slider(1, 15, value=DEFAULT_LOOKBACK_YEARS, step=1, label="Lookback years for betas & covariances")
614
 
615
- # Loading banner (appears instantly on click, hidden after results show)
616
- loading_md = gr.Markdown(" Working... fetching data and computing suggestions...", visible=False)
617
 
618
- # Compute button directly under lookback slider
619
- run_btn = gr.Button("Compute (build dataset & suggest)")
620
 
621
- # Suggestions UI (hidden until Compute)
622
- sugg_hdr = gr.Markdown("### Suggestions", visible=False)
 
623
  with gr.Row():
624
- btn_low = gr.Button("Show Low", visible=False)
625
- btn_med = gr.Button("Show Medium", visible=False)
626
- btn_high = gr.Button("Show High", visible=False)
627
- low_txt = gr.Markdown(visible=False)
628
- med_txt = gr.Markdown(visible=False)
629
- high_txt = gr.Markdown(visible=False)
630
-
631
- with gr.Column(scale=7, visible=False) as right_col:
632
- # Results (hidden until Compute)
633
- plot = gr.Image(label="Capital Market Line (CAPM)", type="pil", visible=False)
634
- summary = gr.Markdown(label="Inputs & Results", visible=False)
635
- positions = gr.Dataframe(
636
- label="Computed positions",
637
- headers=["ticker", "amount_usd", "weight_exposure", "beta"],
638
- datatype=["str", "number", "number", "number"],
639
- col_count=(4, "fixed"),
640
- value=empty_positions_df(),
641
- interactive=False,
642
- visible=False
643
- )
644
- sugg_table = gr.Dataframe(
645
- label="Selected suggestion holdings (% / $)",
646
- headers=["ticker", "weight_%", "amount_$"],
647
- datatype=["str", "number", "number"],
648
- col_count=(3, "fixed"),
649
- value=empty_suggestion_df(),
650
- interactive=False,
651
- visible=False
652
- )
653
- dl = gr.File(label="Generated dataset CSV", value=None, visible=False)
654
-
655
- # --- wiring ---
656
- search_btn.click(fn=search_tickers_cb, inputs=q, outputs=[search_note, matches])
657
- add_btn.click(fn=add_symbol, inputs=[matches, table], outputs=[table, search_note])
 
658
  table.change(fn=lock_ticker_column, inputs=table, outputs=table)
659
- horizon.change(fn=set_horizon, inputs=horizon, outputs=[])
660
-
661
- # Small helpers to toggle the loading banner
662
- def _show_loading():
663
- return gr.update(visible=True)
664
- def _hide_loading():
665
- return gr.update(visible=False)
666
 
667
- # 1) Show loading immediately
668
- # 2) Run compute (with full-page progress bar)
669
- # 3) Reveal results and suggestion controls
670
- # 4) Hide loading banner
671
- show_evt = run_btn.click(_show_loading, inputs=[], outputs=[loading_md])
672
 
673
- compute_evt = show_evt.then(
 
674
  fn=compute,
675
  inputs=[lookback, table, gr.State("Medium")],
676
  outputs=[
677
  plot, summary, positions, sugg_table, dl,
678
  low_txt, med_txt, high_txt,
679
- gr.State(), gr.State(), gr.State(), gr.State(), gr.State(), gr.State(), gr.State(),
680
- gr.State(), gr.State()
681
- ],
682
- show_progress="full", # <-- full-page progress overlay
683
- )
684
-
685
- def _reveal():
686
- up = gr.update(visible=True)
687
- return (up, up, up, up, up, up, up, up, up, up, up, up, up)
688
-
689
- after_compute = compute_evt.then(
690
- _reveal,
691
- inputs=[],
692
- outputs=[
693
- right_col,
694
- plot, summary, positions, sugg_table, dl,
695
- sugg_hdr, btn_low, btn_med, btn_high, low_txt, med_txt, high_txt
696
  ]
697
  )
698
 
699
- after_compute.then(_hide_loading, inputs=[], outputs=[loading_md])
700
-
701
- # Band buttons (after reveal). Also request full progress overlay.
702
  btn_low.click(
703
  fn=compute,
704
  inputs=[lookback, table, gr.State("Low")],
705
  outputs=[
706
  plot, summary, positions, sugg_table, dl,
707
  low_txt, med_txt, high_txt,
708
- gr.State(), gr.State(), gr.State(), gr.State(), gr.State(), gr.State(), gr.State(),
709
- gr.State(), gr.State()
710
- ],
711
- show_progress="full",
712
  )
713
  btn_med.click(
714
  fn=compute,
@@ -716,10 +734,8 @@ with gr.Blocks(
716
  outputs=[
717
  plot, summary, positions, sugg_table, dl,
718
  low_txt, med_txt, high_txt,
719
- gr.State(), gr.State(), gr.State(), gr.State(), gr.State(), gr.State(), gr.State(),
720
- gr.State(), gr.State()
721
- ],
722
- show_progress="full",
723
  )
724
  btn_high.click(
725
  fn=compute,
@@ -727,10 +743,8 @@ with gr.Blocks(
727
  outputs=[
728
  plot, summary, positions, sugg_table, dl,
729
  low_txt, med_txt, high_txt,
730
- gr.State(), gr.State(), gr.State(), gr.State(), gr.State(), gr.State(), gr.State(),
731
- gr.State(), gr.State()
732
- ],
733
- show_progress="full",
734
  )
735
 
736
  # initialize risk-free at launch
@@ -738,6 +752,6 @@ RF_CODE = fred_series_for_horizon(HORIZON_YEARS)
738
  RF_ANN = fetch_fred_yield_annual(RF_CODE)
739
 
740
  if __name__ == "__main__":
741
- # enable event queue so progress bar + async UI updates work properly
742
- demo.queue()
743
- demo.launch(server_name="0.0.0.0", server_port=7860, show_api=False)
 
391
 
392
  def search_tickers_cb(q: str):
393
  opts = yahoo_search(q)
394
+ if not opts:
395
+ opts = ["No matches found"]
396
+ # Put helper text into Matches box
397
+ return gr.update(
398
+ choices=opts,
399
+ value=None,
400
+ info="Select a symbol and click 'Add selected to portfolio'."
401
+ )
402
 
403
  def add_symbol(selection: str, table: Optional[pd.DataFrame]):
404
  if not selection:
405
  return table if isinstance(table, pd.DataFrame) else pd.DataFrame(columns=["ticker","amount_usd"]), "Pick a row in Matches first."
406
+ if "No matches" in str(selection):
407
+ return table if isinstance(table, pd.DataFrame) else pd.DataFrame(columns=["ticker","amount_usd"]), "No symbol to add."
408
  symbol = selection.split("|")[0].strip().upper()
409
 
410
  current = []
 
428
  return new_table, f"Reached max of {MAX_TICKERS}."
429
  return new_table, f"Added {symbol}."
430
 
431
+ def add_symbol_table_only(selection: str, table: Optional[pd.DataFrame]):
432
+ """Wrapper that only returns the updated table (no status string)."""
433
+ new_table, _ = add_symbol(selection, table)
434
+ return new_table
435
+
436
  def lock_ticker_column(tb: Optional[pd.DataFrame]):
437
  if not isinstance(tb, pd.DataFrame) or tb.empty:
438
  return pd.DataFrame(columns=["ticker", "amount_usd"])
 
460
  years_lookback: int,
461
  table: Optional[pd.DataFrame],
462
  pick_band_to_show: str, # "Low" | "Medium" | "High"
463
+ progress=gr.Progress(track_tqdm=True)
464
  ):
465
+ # progress indicator
466
+ progress(0, desc="Validating inputs...")
467
+ time.sleep(0.05)
468
 
469
  # sanitize table
470
  if isinstance(table, pd.DataFrame):
 
479
 
480
  symbols = [t for t in df["ticker"].tolist() if t]
481
  if len(symbols) == 0:
482
+ msg = "Add at least one ticker (and ensure VOO is present in data history)."
483
  return (
484
+ gr.update(value=None, visible=True), # plot placeholder stays blank
485
+ gr.update(value="### Error\n" + msg, visible=True),
486
+ gr.update(value=empty_positions_df(), visible=True),
487
+ gr.update(value=empty_suggestion_df(), visible=True),
488
+ gr.update(value=None, visible=False),
489
+ gr.update(value="", visible=True),
490
+ gr.update(value="", visible=True),
491
+ gr.update(value="", visible=True),
492
+ gr.update(visible=True), # show outputs group so message is visible
493
+ gr.update(visible=True) # show suggestions container (buttons/text)
494
  )
495
 
496
+ progress(0.15, desc="Validating tickers...")
497
  symbols = validate_tickers(symbols, years_lookback)
498
  if len(symbols) == 0:
499
+ msg = "Could not validate any tickers for the chosen lookback (VOO data required)."
500
  return (
501
+ gr.update(value=None, visible=True),
502
+ gr.update(value="### Error\n" + msg, visible=True),
503
+ gr.update(value=empty_positions_df(), visible=True),
504
+ gr.update(value=empty_suggestion_df(), visible=True),
505
+ gr.update(value=None, visible=False),
506
+ gr.update(value="", visible=True),
507
+ gr.update(value="", visible=True),
508
+ gr.update(value="", visible=True),
509
+ gr.update(visible=True),
510
+ gr.update(visible=True)
511
  )
512
 
513
  global UNIVERSE
 
517
  amounts = {r["ticker"]: float(r["amount_usd"]) for _, r in df.iterrows()}
518
  rf_ann = RF_ANN
519
 
520
+ progress(0.35, desc="Downloading prices & estimating moments...")
521
  moms = estimate_all_moments_aligned(symbols, years_lookback, rf_ann)
522
  betas, covA, erp_ann, sigma_mkt = moms["betas"], moms["cov_ann"], moms["erp_ann"], moms["sigma_m_ann"]
523
 
524
  gross = sum(abs(v) for v in amounts.values())
525
  if gross <= 1e-12:
526
+ msg = "All amounts are zero."
527
  return (
528
+ gr.update(value=None, visible=True),
529
+ gr.update(value="### Error\n" + msg, visible=True),
530
+ gr.update(value=empty_positions_df(), visible=True),
531
+ gr.update(value=empty_suggestion_df(), visible=True),
532
+ gr.update(value=None, visible=False),
533
+ gr.update(value="", visible=True),
534
+ gr.update(value="", visible=True),
535
+ gr.update(value="", visible=True),
536
+ gr.update(visible=True),
537
+ gr.update(visible=True)
538
  )
539
  weights = {k: v / gross for k, v in amounts.items()}
540
 
541
+ # Portfolio CAPM stats
542
+ progress(0.55, desc="Computing CAPM stats...")
543
  beta_p, mu_capm, sigma_hist = portfolio_stats(weights, covA, betas, rf_ann, erp_ann)
544
 
545
+ # Efficient alternatives on CML
546
+ a_sigma, b_sigma, mu_eff_same_sigma = efficient_same_sigma(sigma_hist, rf_ann, erp_ann, sigma_mkt)
547
+ a_mu, b_mu, sigma_eff_same_mu = efficient_same_return(mu_capm, rf_ann, erp_ann, sigma_mkt)
548
+
549
+ # Synthetic dataset & suggestions — exactly the user's tickers
550
+ progress(0.75, desc="Building synthetic dataset & suggestions...")
551
  user_universe = list(symbols)
552
  synth = build_synthetic_dataset(user_universe, covA, betas, rf_ann, erp_ann, sigma_mkt, n_rows=SYNTH_ROWS)
553
  csv_path = os.path.join(DATA_DIR, f"investor_profiles_{int(time.time())}.csv")
 
556
  except Exception:
557
  csv_path = None
558
 
 
 
 
559
  picks = suggest_one_per_band(synth, sigma_mkt, user_universe)
560
 
561
  def _fmt(row: pd.Series) -> str:
 
572
  if chosen is None or chosen.empty:
573
  chosen_sigma = None
574
  chosen_mu = None
575
+ sugg_table_df = empty_suggestion_df()
576
  else:
577
  chosen_sigma = float(chosen["sigma_hist"])
578
  chosen_mu = float(chosen["mu_capm"])
579
+ sugg_table_df = _holdings_table_from_row(chosen, budget=gross)
580
 
581
  pos_table = pd.DataFrame(
582
  [{
 
588
  columns=["ticker", "amount_usd", "weight_exposure", "beta"]
589
  )
590
 
 
591
  img = plot_cml(
592
  rf_ann, erp_ann, sigma_mkt,
593
  sigma_hist, mu_capm,
 
595
  sugg_sigma_hist=chosen_sigma, sugg_mu_capm=chosen_mu
596
  )
597
 
598
+ # Summary text (clean)
599
  info = "\n".join([
600
  "### Inputs",
601
  f"- Lookback years {years_lookback}",
 
617
  "If leverage isn’t allowed, scale both weights proportionally toward 1.0 to fit your constraints.",
618
  ])
619
 
620
+ progress(0.98, desc="Rendering...")
621
+ time.sleep(0.05)
622
 
623
  return (
624
+ gr.update(value=img, visible=True),
625
+ gr.update(value=info, visible=True),
626
+ gr.update(value=pos_table, visible=True),
627
+ gr.update(value=sugg_table_df, visible=True),
628
+ gr.update(value=csv_path, visible=bool(csv_path)),
629
+ gr.update(value=txt_low, visible=True),
630
+ gr.update(value=txt_med, visible=True),
631
+ gr.update(value=txt_high, visible=True),
632
+ gr.update(visible=True), # show outputs group
633
+ gr.update(visible=True) # show suggestions group
634
  )
635
 
636
  # -------------- UI --------------
637
+ with gr.Blocks(title="Efficient Portfolio Advisor") as demo:
 
 
638
  gr.Markdown(
639
  "## Efficient Portfolio Advisor\n"
640
+ "Search symbols, enter **dollar amounts**, set horizon. Returns use Yahoo Finance monthly data; risk-free from FRED. "
641
+ "Plot shows **your CAPM point on the CML** plus efficient market/bills points."
642
  )
643
 
644
+ # LEFT: controls (fill page initially)
645
+ with gr.Group(visible=True) as controls_group:
646
+ # Search flow: Search -> Button -> Matches -> Add
647
+ q = gr.Textbox(label="Search symbol")
648
+ search_btn = gr.Button("Search")
649
+ matches = gr.Dropdown(choices=[], label="Matches")
650
+ add_btn = gr.Button("Add selected to portfolio")
651
+
652
+ gr.Markdown("### Portfolio positions")
653
+ table = gr.Dataframe(
654
+ headers=["ticker", "amount_usd"],
655
+ datatype=["str", "number"],
656
+ row_count=0,
657
+ col_count=(2, "fixed")
658
+ )
 
 
 
 
659
 
660
+ horizon = gr.Number(label="Horizon in years (1–100)", value=HORIZON_YEARS, precision=0)
661
+ lookback = gr.Slider(1, 15, value=DEFAULT_LOOKBACK_YEARS, step=1, label="Lookback years for betas & covariances")
662
 
663
+ # Compute button directly under lookback slider
664
+ run_btn = gr.Button("Compute (build dataset & suggest)")
665
 
666
+ # Suggestions container (hidden until compute)
667
+ with gr.Group(visible=False) as sugg_group:
668
+ gr.Markdown("### Suggestions")
669
  with gr.Row():
670
+ btn_low = gr.Button("Show Low")
671
+ btn_med = gr.Button("Show Medium")
672
+ btn_high = gr.Button("Show High")
673
+ low_txt = gr.Markdown()
674
+ med_txt = gr.Markdown()
675
+ high_txt = gr.Markdown()
676
+
677
+ # RIGHT: outputs (hidden until compute)
678
+ with gr.Group(visible=False) as out_group:
679
+ with gr.Row():
680
+ with gr.Column(scale=1):
681
+ plot = gr.Image(label="Capital Market Line (CAPM)", type="pil")
682
+ summary = gr.Markdown(label="Inputs & Results")
683
+ with gr.Column(scale=1):
684
+ positions = gr.Dataframe(
685
+ label="Computed positions",
686
+ headers=["ticker", "amount_usd", "weight_exposure", "beta"],
687
+ datatype=["str", "number", "number", "number"],
688
+ col_count=(4, "fixed"),
689
+ value=empty_positions_df(),
690
+ interactive=False
691
+ )
692
+ sugg_table = gr.Dataframe(
693
+ label="Selected suggestion holdings (% / $)",
694
+ headers=["ticker", "weight_%", "amount_$"],
695
+ datatype=["str", "number", "number"],
696
+ col_count=(3, "fixed"),
697
+ value=empty_suggestion_df(),
698
+ interactive=False
699
+ )
700
+ dl = gr.File(label="Generated dataset CSV", value=None, visible=True)
701
+
702
+ # wire search / add / locking
703
+ search_btn.click(fn=search_tickers_cb, inputs=q, outputs=matches)
704
+ add_btn.click(fn=add_symbol_table_only, inputs=[matches, table], outputs=table)
705
  table.change(fn=lock_ticker_column, inputs=table, outputs=table)
 
 
 
 
 
 
 
706
 
707
+ # horizon updates globals silently (no UI output)
708
+ horizon.change(fn=set_horizon, inputs=horizon, outputs=[])
 
 
 
709
 
710
+ # compute + render (default to Medium band)
711
+ run_btn.click(
712
  fn=compute,
713
  inputs=[lookback, table, gr.State("Medium")],
714
  outputs=[
715
  plot, summary, positions, sugg_table, dl,
716
  low_txt, med_txt, high_txt,
717
+ out_group, sugg_group
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
718
  ]
719
  )
720
 
721
+ # band buttons recompute picks quickly (keep groups visible)
 
 
722
  btn_low.click(
723
  fn=compute,
724
  inputs=[lookback, table, gr.State("Low")],
725
  outputs=[
726
  plot, summary, positions, sugg_table, dl,
727
  low_txt, med_txt, high_txt,
728
+ out_group, sugg_group
729
+ ]
 
 
730
  )
731
  btn_med.click(
732
  fn=compute,
 
734
  outputs=[
735
  plot, summary, positions, sugg_table, dl,
736
  low_txt, med_txt, high_txt,
737
+ out_group, sugg_group
738
+ ]
 
 
739
  )
740
  btn_high.click(
741
  fn=compute,
 
743
  outputs=[
744
  plot, summary, positions, sugg_table, dl,
745
  low_txt, med_txt, high_txt,
746
+ out_group, sugg_group
747
+ ]
 
 
748
  )
749
 
750
  # initialize risk-free at launch
 
752
  RF_ANN = fetch_fred_yield_annual(RF_CODE)
753
 
754
  if __name__ == "__main__":
755
+ # enable queue for visible progress bar
756
+ demo.queue().launch(server_name="0.0.0.0", server_port=7860, show_api=False)
757
+