Tulitula commited on
Commit
eee101a
·
verified ·
1 Parent(s): f8757bd

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +389 -205
app.py CHANGED
@@ -1,12 +1,7 @@
1
  # app.py
2
- import os, io, math, time, warnings
3
  warnings.filterwarnings("ignore")
4
 
5
- # --- make matplotlib headless & writable ---
6
- import matplotlib
7
- matplotlib.use("Agg")
8
- os.environ.setdefault("MPLCONFIGDIR", "/home/user/.config/matplotlib")
9
-
10
  from typing import List, Tuple, Dict, Optional
11
 
12
  import numpy as np
@@ -25,12 +20,15 @@ MAX_TICKERS = 30
25
  DEFAULT_LOOKBACK_YEARS = 10
26
  MARKET_TICKER = "VOO"
27
 
28
- SYNTH_ROWS = 1000 # size of generated dataset for suggestions
 
 
 
29
 
30
- # Globals that update with horizon changes
31
  HORIZON_YEARS = 10
32
  RF_CODE = "DGS10"
33
- RF_ANN = 0.0375 # updated at launch
34
 
35
  # ---------------- helpers ----------------
36
  def fred_series_for_horizon(years: float) -> str:
@@ -55,8 +53,8 @@ def fetch_fred_yield_annual(code: str) -> float:
55
  return 0.03
56
 
57
  def fetch_prices_monthly(tickers: List[str], years: int) -> pd.DataFrame:
58
- tickers = list(dict.fromkeys([t.upper().strip() for t in tickers]))
59
- start = (pd.Timestamp.today(tz="UTC") - pd.DateOffset(years=years, days=7)).date()
60
  end = pd.Timestamp.today(tz="UTC").date()
61
 
62
  df = yf.download(
@@ -71,7 +69,7 @@ def fetch_prices_monthly(tickers: List[str], years: int) -> pd.DataFrame:
71
  threads=False,
72
  )
73
 
74
- # Normalize to wide frame of prices (one column per ticker)
75
  if isinstance(df, pd.Series):
76
  df = df.to_frame()
77
  if isinstance(df.columns, pd.MultiIndex):
@@ -118,7 +116,7 @@ def validate_tickers(symbols: List[str], years: int) -> List[str]:
118
  px = fetch_prices_monthly(base + [MARKET_TICKER], years)
119
  ok = [s for s in base if s in px.columns]
120
  if MARKET_TICKER not in px.columns:
121
- return []
122
  return ok
123
 
124
  # -------------- aligned moments --------------
@@ -154,7 +152,6 @@ def estimate_all_moments_aligned(symbols: List[str], years: int, rf_ann: float):
154
  ex_s = R[s] - rf_m
155
  cov_sm = float(np.cov(ex_s.values, ex_m.values, ddof=1)[0, 1])
156
  betas[s] = cov_sm / var_m
157
-
158
  betas[MARKET_TICKER] = 1.0
159
 
160
  asset_cols = [c for c in R.columns if c != MARKET_TICKER]
@@ -179,55 +176,76 @@ def portfolio_stats(weights: Dict[str, float],
179
  w_expo = w / gross
180
  beta_p = float(np.dot([betas.get(t, 0.0) for t in tickers], w_expo))
181
  mu_capm = capm_er(beta_p, rf_ann, erp_ann)
182
- cov = cov_ann.reindex(index=tickers, columns=tickers).fillna(0.0).to_numpy()
183
- sigma_hist = float(max(w_expo.T @ cov @ w_expo, 0.0)) ** 0.5
184
- return beta_p, mu_capm, sigma_hist # <-- X uses HIST sigma
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
 
186
  def efficient_same_sigma(sigma_target: float, rf_ann: float, erp_ann: float, sigma_mkt: float):
187
  if sigma_mkt <= 1e-12:
188
  return 0.0, 1.0, rf_ann
189
  a = sigma_target / sigma_mkt
190
- return a, 1.0 - a, rf_ann + a * erp_ann
191
 
192
  def efficient_same_return(mu_target: float, rf_ann: float, erp_ann: float, sigma_mkt: float):
193
  if abs(erp_ann) <= 1e-12:
194
  return 0.0, 1.0, rf_ann
195
  a = (mu_target - rf_ann) / erp_ann
196
- return a, 1.0 - a, abs(a) * sigma_mkt
197
 
198
- # -------------- plotting (CAPM on CML) --------------
199
  def _pct(x):
200
  return np.asarray(x, dtype=float) * 100.0
201
 
202
- def plot_cml(
203
  rf_ann, erp_ann, sigma_mkt,
204
- sigma_hist, mu_capm,
205
- mu_same_sigma, sigma_same_mu,
206
- sugg_mu=None, sugg_sigma=None
207
  ) -> Image.Image:
208
- fig = plt.figure(figsize=(6, 4), dpi=120)
209
-
210
- xmax = max(0.3, sigma_mkt * 2.2, (sigma_hist or 0.0) * 1.6, (sugg_sigma or 0.0) * 1.6)
211
- xs = np.linspace(0, xmax, 200)
212
- cml = rf_ann + (erp_ann / max(sigma_mkt, 1e-9)) * xs
213
-
214
- plt.plot(_pct(xs), _pct(cml), label="CML via Market", linewidth=1.8)
215
- plt.scatter([_pct(0)], [_pct(rf_ann)], label="Risk-free")
216
- plt.scatter([_pct(sigma_mkt)], [_pct(rf_ann + erp_ann)], label="Market")
217
-
218
- # YOUR point: X = historical sigma, Y = CAPM expected return
219
- plt.scatter([_pct(sigma_hist)], [_pct(mu_capm)], label="Your CAPM point", marker="o")
220
-
221
- # Efficient references on CML
222
- plt.scatter([_pct(sigma_hist)], [_pct(mu_same_sigma)], label="Efficient: same σ", marker="^")
223
- plt.scatter([_pct(sigma_same_mu)], [_pct(mu_capm)], label="Efficient: same E[r]", marker="v")
224
-
225
- if sugg_mu is not None and sugg_sigma is not None:
226
- plt.scatter([_pct(sugg_sigma)], [_pct(sugg_mu)], label="Selected Suggestion", marker="X", s=60)
227
-
228
- plt.xlabel( (annualized, %)")
229
- plt.ylabel("Expected return (annual, %)")
230
- plt.legend(loc="best")
 
 
 
 
 
 
231
  plt.tight_layout()
232
 
233
  buf = io.BytesIO()
@@ -253,24 +271,33 @@ def build_synthetic_dataset(universe: List[str],
253
  for _ in range(n_rows):
254
  k = int(rng.integers(low=2, high=min(8, len(universe)) + 1))
255
  picks = list(rng.choice(universe, size=k, replace=False))
 
 
256
  w = rng.dirichlet(np.ones(k))
 
 
257
  beta_p = float(np.dot([betas.get(t, 0.0) for t in picks], w))
258
  mu_capm = capm_er(beta_p, rf_ann, erp_ann)
259
- sub = covA.reindex(index=picks, columns=picks).fillna(0.0).to_numpy()
260
- sigma_hist = float(max(w.T @ sub @ w, 0.0)) ** 0.5
261
- sigma_capm = abs(beta_p) * sigma_mkt
 
 
 
 
 
 
262
 
263
  rows.append({
264
  "tickers": ",".join(picks),
265
  "weights": ",".join(f"{x:.6f}" for x in w),
266
  "beta": beta_p,
267
  "mu_capm": mu_capm,
268
- "sigma_hist": sigma_hist,
269
- "sigma_capm": sigma_capm
270
  })
271
  return pd.DataFrame(rows)
272
 
273
- def _band_bounds(sigma_mkt: float, band: str) -> Tuple[float, float]:
274
  band = (band or "Medium").strip().lower()
275
  if band.startswith("low"):
276
  return 0.0, 0.8 * sigma_mkt
@@ -278,46 +305,150 @@ def _band_bounds(sigma_mkt: float, band: str) -> Tuple[float, float]:
278
  return 1.2 * sigma_mkt, 3.0 * sigma_mkt
279
  return 0.8 * sigma_mkt, 1.2 * sigma_mkt
280
 
281
- def top3_by_return_in_band(df: pd.DataFrame, band: str, sigma_mkt: float) -> pd.DataFrame:
282
- lo, hi = _band_bounds(sigma_mkt, band)
283
- pick = df[(df["sigma_capm"] >= lo) & (df["sigma_capm"] <= hi)].copy()
284
- if pick.empty:
285
- pick = df.copy()
286
- pick = pick.sort_values("mu_capm", ascending=False).head(3).reset_index(drop=True)
287
- pick.insert(0, "pick", [1, 2, 3][: len(pick)])
288
- return pick
289
-
290
- # -------------- optional: embeddings rerank --------------
291
- def rerank_with_embeddings(top3: pd.DataFrame, band: str) -> pd.DataFrame:
 
 
 
 
 
 
 
 
 
292
  try:
293
  from sentence_transformers import SentenceTransformer
294
- model = SentenceTransformer("FinLang/finance-embeddings-investopedia")
295
- prompt = {
296
- "low": "low risk conservative portfolio stable diversified market exposure",
297
- "medium": "balanced medium risk diversified portfolio",
298
- "high": "high risk growth aggressive portfolio higher expected return"
299
- }[(band or "medium").lower() if (band or "medium").lower() in {"low","medium","high"} else "medium"]
300
-
301
- cand_texts = []
302
- for _, r in top3.iterrows():
303
- cand_texts.append(
304
- f"portfolio with tickers {r['tickers']} having beta {float(r['beta']):.2f}, "
305
- f"expected return {float(r['mu_capm']):.3f}, sigma {float(r['sigma_capm']):.3f}"
306
- )
307
-
308
- q = model.encode([prompt])
309
- c = model.encode(cand_texts)
310
- sims = (q @ c.T) / (np.linalg.norm(q) * np.linalg.norm(c, axis=1, keepdims=False))
311
- order = np.argsort(-sims.ravel())
312
- return top3.iloc[order].reset_index(drop=True)
313
  except Exception:
314
- return top3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
 
316
  # -------------- UI helpers --------------
317
  def empty_positions_df():
318
  return pd.DataFrame(columns=["ticker", "amount_usd", "weight_exposure", "beta"])
319
 
320
- def empty_suggestion_df():
321
  return pd.DataFrame(columns=["ticker", "weight_%", "amount_$"])
322
 
323
  def set_horizon(years: float):
@@ -371,18 +502,25 @@ def lock_ticker_column(tb: Optional[pd.DataFrame]):
371
  amounts = amounts[:len(tickers)] + [0.0] * max(0, len(tickers) - len(amounts))
372
  return pd.DataFrame({"ticker": tickers, "amount_usd": amounts})
373
 
374
- # -------------- main compute --------------
375
  UNIVERSE: List[str] = [MARKET_TICKER, "QQQ", "VTI", "SOXX", "IBIT"]
376
 
377
- def compute(
 
 
 
 
 
 
 
 
 
 
378
  years_lookback: int,
379
  table: Optional[pd.DataFrame],
380
- risk_band: str,
381
- use_embeddings: bool,
382
- pick_idx: int
383
  ):
384
- print("Compute: start")
385
- # sanitize table
386
  if isinstance(table, pd.DataFrame):
387
  df = table.copy()
388
  else:
@@ -395,12 +533,11 @@ def compute(
395
 
396
  symbols = [t for t in df["ticker"].tolist() if t]
397
  if len(symbols) == 0:
398
- return None, "Add at least one ticker.", "Universe empty.", empty_positions_df(), empty_suggestion_df(), None, gr.update()
399
 
400
  symbols = validate_tickers(symbols, years_lookback)
401
- print("Compute: validated", symbols)
402
  if len(symbols) == 0:
403
- return None, "Could not validate any tickers.", "Universe invalid.", empty_positions_df(), empty_suggestion_df(), None, gr.update()
404
 
405
  global UNIVERSE
406
  UNIVERSE = list(sorted(set([s for s in symbols if s != MARKET_TICKER] + [MARKET_TICKER])))[:MAX_TICKERS]
@@ -409,53 +546,56 @@ def compute(
409
  amounts = {r["ticker"]: float(r["amount_usd"]) for _, r in df.iterrows()}
410
  rf_ann = RF_ANN
411
 
412
- # Moments
413
  moms = estimate_all_moments_aligned(symbols, years_lookback, rf_ann)
414
  betas, covA, erp_ann, sigma_mkt = moms["betas"], moms["cov_ann"], moms["erp_ann"], moms["sigma_m_ann"]
415
- print("Compute: moments ok; sigma_mkt=", sigma_mkt, "erp=", erp_ann)
416
 
417
- # Weights
418
  gross = sum(abs(v) for v in amounts.values())
419
  if gross <= 1e-12:
420
- return None, "All amounts are zero.", "Universe ok.", empty_positions_df(), empty_suggestion_df(), None, gr.update()
 
421
  weights = {k: v / gross for k, v in amounts.items()}
422
 
423
- # Portfolio stats (X uses historical sigma; Y uses CAPM E[r])
424
  beta_p, mu_capm, sigma_hist = portfolio_stats(weights, covA, betas, rf_ann, erp_ann)
425
- sigma_capm = abs(beta_p) * sigma_mkt
426
 
427
- # Efficient alternatives (on CML)
428
  a_sigma, b_sigma, mu_eff_sigma = efficient_same_sigma(sigma_hist, rf_ann, erp_ann, sigma_mkt)
429
  a_mu, b_mu, sigma_eff_mu = efficient_same_return(mu_capm, rf_ann, erp_ann, sigma_mkt)
430
 
431
- # Synthetic dataset & suggestions
432
  synth = build_synthetic_dataset(UNIVERSE, covA, betas, rf_ann, erp_ann, sigma_mkt, n_rows=SYNTH_ROWS)
433
  csv_path = os.path.join(DATA_DIR, f"investor_profiles_{int(time.time())}.csv")
434
- synth.to_csv(csv_path, index=False)
435
-
436
- top3 = top3_by_return_in_band(synth, risk_band, sigma_mkt)
437
- if use_embeddings:
438
- top3 = rerank_with_embeddings(top3, risk_band)
439
- if top3.empty:
440
- top3 = synth.sort_values("mu_capm", ascending=False).head(3).reset_index(drop=True)
441
- top3.insert(0, "pick", [1, 2, 3][: len(top3)])
442
-
443
- idx = max(1, min(3, int(pick_idx))) - 1
444
- row = top3.iloc[idx]
 
 
 
 
 
 
 
 
 
445
 
446
- sugg_mu = float(row["mu_capm"])
447
- sugg_sigma = float(row["sigma_capm"])
 
448
 
449
- # suggestion holdings (% and $)
450
- ts = [t.strip() for t in str(row["tickers"]).split(",")]
451
- ws = [float(x) for x in str(row["weights"]).split(",")]
452
- s = sum(ws) if ws else 1.0
453
- ws = [max(0.0, w) / s for w in ws]
454
- budget = gross if gross > 0 else 1.0
455
- sugg_table = pd.DataFrame(
456
- [{"ticker": t, "weight_%": round(w*100.0, 2), "amount_$": round(w*budget, 0)} for t, w in zip(ts, ws)],
457
- columns=["ticker", "weight_%", "amount_$"]
458
- )
459
 
460
  # positions table
461
  pos_table = pd.DataFrame(
@@ -468,51 +608,96 @@ def compute(
468
  columns=["ticker", "amount_usd", "weight_exposure", "beta"]
469
  )
470
 
471
- # plot (CAPM on CML; your point uses sigma_hist on X)
472
- img = plot_cml(
473
- rf_ann, erp_ann, sigma_mkt,
474
- sigma_hist, mu_capm,
475
- mu_same_sigma=mu_eff_sigma, sigma_same_mu=sigma_eff_mu,
476
- sugg_mu=sugg_mu, sugg_sigma=sugg_sigma
477
- )
478
-
479
  info = "\n".join([
480
  "### Inputs",
481
  f"- Lookback years {years_lookback}",
482
  f"- Horizon years {int(round(HORIZON_YEARS))}",
483
  f"- Risk-free {rf_ann:.2%} from {RF_CODE}",
484
  f"- Market ERP {erp_ann:.2%}",
485
- f"- Market σ {sigma_mkt:.2%}",
486
  "",
487
- "### Your portfolio (CAPM on CML axes)",
488
  f"- Beta {beta_p:.2f}",
489
- f"- Expected return (CAPM / SML) {mu_capm:.2%}",
490
  f"- σ (historical) {sigma_hist:.2%}",
491
- f"- σ on CML for same β (|β|×σ_mkt) {sigma_capm:.2%}",
492
  "",
493
- "### Efficient alternatives on CML",
494
- f"- Same σ as your portfolio (historical): Market weight {a_sigma:.2f}, Bills weight {b_sigma:.2f}, return {mu_eff_sigma:.2%}",
495
- f"- Same return (CAPM): Market weight {a_mu:.2f}, Bills weight {b_mu:.2f}, σ {sigma_eff_mu:.2%}",
496
  "",
497
- "### Dataset-based suggestions (risk: " + risk_band + ")",
498
- f"- Showing Pick **#{idx+1}** → CAPM return {sugg_mu:.2%}, CAPM σ {sugg_sigma:.2%}",
499
- "",
500
- "_Plot shows CAPM E[r] vs σ; your point uses historical σ; efficient references are market/bills on the CML._"
501
  ])
502
 
503
  uni_msg = f"Universe set to: {', '.join(UNIVERSE)}"
504
- print("Compute: done")
505
- return img, info, uni_msg, pos_table, sugg_table, csv_path, gr.update(label=f"Pick #{idx+1} of 3")
506
 
507
- # -------------- UI --------------
508
- def inc_pick(i: int): return min(3, max(1, int(i or 1) + 1))
509
- def dec_pick(i: int): return max(1, min(3, int(i or 1) - 1))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
510
 
511
- with gr.Blocks(title="Efficient Portfolio Advisor", analytics_enabled=False) as demo:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
512
  gr.Markdown(
513
  "## Efficient Portfolio Advisor\n"
514
- "Search symbols, enter **dollar amounts**, set horizon. Returns use Yahoo Finance monthly data; risk-free from FRED. "
515
- "Plot shows **CAPM point (E[r]) vs historical σ** plus efficient CML points."
 
 
516
  )
517
 
518
  with gr.Row():
@@ -520,78 +705,76 @@ with gr.Blocks(title="Efficient Portfolio Advisor", analytics_enabled=False) as
520
  q = gr.Textbox(label="Search symbol")
521
  search_note = gr.Markdown()
522
  matches = gr.Dropdown(choices=[], label="Matches")
523
- search_btn = gr.Button("Search")
524
- add_btn = gr.Button("Add selected to portfolio")
 
525
 
526
- gr.Markdown("### Portfolio positions (enter $ amounts; negatives allowed for shorts)")
527
  table = gr.Dataframe(
528
- headers=["ticker", "amount_usd"],
529
- datatype=["str", "number"],
530
- type="pandas",
531
- row_count=0,
532
- col_count=(2, "fixed")
533
  )
534
 
535
  horizon = gr.Number(label="Horizon in years (1–100)", value=HORIZON_YEARS, precision=0)
536
- lookback = gr.Slider(1, 15, value=DEFAULT_LOOKBACK_YEARS, step=1, label="Lookback years for betas & covariances")
 
537
 
538
  gr.Markdown("### Suggestions")
539
- risk_band = gr.Radio(["Low", "Medium", "High"], value="Medium", label="Risk tolerance")
540
- use_emb = gr.Checkbox(value=True, label="Use finance embeddings to refine picks")
541
-
542
- with gr.Row():
543
- prev_btn = gr.Button("◀ Prev")
544
- pick_idx = gr.Number(value=1, precision=0, label="Carousel")
545
- next_btn = gr.Button("Next ")
 
 
 
546
 
547
  run_btn = gr.Button("Compute (build dataset & suggest)")
 
548
  with gr.Column(scale=1):
549
  plot = gr.Image(label="Capital Market Line (CAPM)", type="pil")
550
  summary = gr.Markdown(label="Inputs & Results")
551
  universe_msg = gr.Textbox(label="Universe status", interactive=False)
552
  positions = gr.Dataframe(
553
- label="Computed positions",
554
- headers=["ticker", "amount_usd", "weight_exposure", "beta"],
555
- datatype=["str", "number", "number", "number"],
556
- type="pandas",
557
- col_count=(4, "fixed"),
558
- value=empty_positions_df(),
559
- interactive=False
560
  )
561
- sugg_table = gr.Dataframe(
562
- label="Selected suggestion (carousel) — holdings shown in % and $",
563
- headers=["ticker", "weight_%", "amount_$"],
564
- datatype=["str", "number", "number"],
565
- type="pandas",
566
- col_count=(3, "fixed"),
567
- value=empty_suggestion_df(),
568
- interactive=False
569
  )
570
  dl = gr.File(label="Generated dataset CSV", value=None, visible=True)
571
 
572
- # wire search / add / locking / horizon
573
  search_btn.click(fn=search_tickers_cb, inputs=q, outputs=[search_note, matches])
574
  add_btn.click(fn=add_symbol, inputs=[matches, table], outputs=[table, search_note])
575
  table.change(fn=lock_ticker_column, inputs=table, outputs=table)
576
  horizon.change(fn=set_horizon, inputs=horizon, outputs=universe_msg)
577
 
578
- # carousel buttons update pick index and then recompute
579
- prev_btn.click(fn=dec_pick, inputs=pick_idx, outputs=pick_idx).then(
580
- fn=compute,
581
- inputs=[lookback, table, risk_band, use_emb, pick_idx],
582
- outputs=[plot, summary, universe_msg, positions, sugg_table, dl, pick_idx]
583
- )
584
- next_btn.click(fn=inc_pick, inputs=pick_idx, outputs=pick_idx).then(
585
- fn=compute,
586
- inputs=[lookback, table, risk_band, use_emb, pick_idx],
587
- outputs=[plot, summary, universe_msg, positions, sugg_table, dl, pick_idx]
588
  )
589
 
590
- # main compute
591
- run_btn.click(
592
- fn=compute,
593
- inputs=[lookback, table, risk_band, use_emb, pick_idx],
594
- outputs=[plot, summary, universe_msg, positions, sugg_table, dl, pick_idx]
 
 
 
 
 
 
 
 
 
 
595
  )
596
 
597
  # initialize risk-free at launch
@@ -599,10 +782,11 @@ RF_CODE = fred_series_for_horizon(HORIZON_YEARS)
599
  RF_ANN = fetch_fred_yield_annual(RF_CODE)
600
 
601
  if __name__ == "__main__":
602
- # IMPORTANT for Spaces/Docker: bind to 0.0.0.0 and the correct PORT
603
- demo.queue(concurrency_count=8).launch(
 
604
  server_name="0.0.0.0",
605
- server_port=int(os.environ.get("PORT", "7860")),
606
- show_error=True,
607
- share=False
608
  )
 
1
  # app.py
2
+ import os, io, math, time, warnings, json
3
  warnings.filterwarnings("ignore")
4
 
 
 
 
 
 
5
  from typing import List, Tuple, Dict, Optional
6
 
7
  import numpy as np
 
20
  DEFAULT_LOOKBACK_YEARS = 10
21
  MARKET_TICKER = "VOO"
22
 
23
+ SYNTH_ROWS = 1000 # dataset size for suggestions
24
+ EMBED_MODEL_NAME = "FinLang/finance-embeddings-investopedia"
25
+ EMBED_ALPHA = 0.6 # score = alpha*exposure_sim + (1-alpha)*embedding_sim
26
+ MMR_LAMBDA = 0.7 # diversity tradeoff for MMR (higher = prefer quality)
27
 
28
+ # Globals updated by horizon control
29
  HORIZON_YEARS = 10
30
  RF_CODE = "DGS10"
31
+ RF_ANN = 0.0375 # refreshed at launch
32
 
33
  # ---------------- helpers ----------------
34
  def fred_series_for_horizon(years: float) -> str:
 
53
  return 0.03
54
 
55
  def fetch_prices_monthly(tickers: List[str], years: int) -> pd.DataFrame:
56
+ tickers = list(dict.fromkeys([t.upper().strip() for t in tickers if t]))
57
+ start = (pd.Timestamp.today(tz="UTC") - pd.DateOffset(years=int(years), days=7)).date()
58
  end = pd.Timestamp.today(tz="UTC").date()
59
 
60
  df = yf.download(
 
69
  threads=False,
70
  )
71
 
72
+ # Normalize to wide (Close) frame
73
  if isinstance(df, pd.Series):
74
  df = df.to_frame()
75
  if isinstance(df.columns, pd.MultiIndex):
 
116
  px = fetch_prices_monthly(base + [MARKET_TICKER], years)
117
  ok = [s for s in base if s in px.columns]
118
  if MARKET_TICKER not in px.columns:
119
+ return [] # we need a market proxy to align CAPM
120
  return ok
121
 
122
  # -------------- aligned moments --------------
 
152
  ex_s = R[s] - rf_m
153
  cov_sm = float(np.cov(ex_s.values, ex_m.values, ddof=1)[0, 1])
154
  betas[s] = cov_sm / var_m
 
155
  betas[MARKET_TICKER] = 1.0
156
 
157
  asset_cols = [c for c in R.columns if c != MARKET_TICKER]
 
176
  w_expo = w / gross
177
  beta_p = float(np.dot([betas.get(t, 0.0) for t in tickers], w_expo))
178
  mu_capm = capm_er(beta_p, rf_ann, erp_ann)
179
+ cov = cov_ann.reindex(index=[t for t in tickers if t != MARKET_TICKER],
180
+ columns=[t for t in tickers if t != MARKET_TICKER]).fillna(0.0).to_numpy()
181
+ # treat market ticker (if any) as index asset with β=1; variance from cov_ann is on asset-only block
182
+ # when MARKET_TICKER is in weights, its variance contribution is ignored in cov (ok; σ_hist is approximate)
183
+ sigma_hist = 0.0
184
+ if cov.size and all(t != MARKET_TICKER for t in tickers):
185
+ sigma_hist = float(max(w_expo.T @ cov @ w_expo, 0.0)) ** 0.5
186
+ else:
187
+ # fallback: use weighted average variance/cov if market present; approximate via available submatrix
188
+ sub_t = [t for t in tickers if t != MARKET_TICKER]
189
+ if sub_t:
190
+ sub_w = np.array([weights[t] for t in sub_t], dtype=float)
191
+ sub_w = sub_w / max(np.sum(np.abs(sub_w)), 1e-12)
192
+ sub_cov = cov_ann.reindex(index=sub_t, columns=sub_t).fillna(0.0).to_numpy()
193
+ sigma_hist = float(max(sub_w.T @ sub_cov @ sub_w, 0.0)) ** 0.5
194
+ else:
195
+ sigma_hist = 0.0
196
+ return beta_p, mu_capm, sigma_hist
197
 
198
  def efficient_same_sigma(sigma_target: float, rf_ann: float, erp_ann: float, sigma_mkt: float):
199
  if sigma_mkt <= 1e-12:
200
  return 0.0, 1.0, rf_ann
201
  a = sigma_target / sigma_mkt
202
+ return a, 1.0 - a, rf_ann + a * erp_ann # weights (market, bills), return
203
 
204
  def efficient_same_return(mu_target: float, rf_ann: float, erp_ann: float, sigma_mkt: float):
205
  if abs(erp_ann) <= 1e-12:
206
  return 0.0, 1.0, rf_ann
207
  a = (mu_target - rf_ann) / erp_ann
208
+ return a, 1.0 - a, abs(a) * sigma_mkt # weights (market, bills), sigma
209
 
210
+ # -------------- plotting --------------
211
  def _pct(x):
212
  return np.asarray(x, dtype=float) * 100.0
213
 
214
+ def plot_cml_hybrid(
215
  rf_ann, erp_ann, sigma_mkt,
216
+ sigma_hist_port, mu_capm_port,
217
+ mu_eff_same_sigma, sigma_eff_same_return,
218
+ sugg_mu=None, sugg_sigma_hist=None
219
  ) -> Image.Image:
220
+ fig = plt.figure(figsize=(6.5, 4.2), dpi=120)
221
+
222
+ xmax = max(0.3,
223
+ sigma_mkt * 2.2,
224
+ (sigma_hist_port or 0.0) * 1.6,
225
+ (sigma_eff_same_return or 0.0) * 1.6,
226
+ (sugg_sigma_hist or 0.0) * 1.6)
227
+ xs = np.linspace(0.0, xmax, 240)
228
+ cml = rf_ann + (erp_ann / max(sigma_mkt, 1e-9)) * xs if sigma_mkt > 1e-12 else np.full_like(xs, rf_ann)
229
+
230
+ # CML and fixtures
231
+ plt.plot(_pct(xs), _pct(cml), label="CML (Market/Bills)", linewidth=1.8)
232
+ plt.scatter([_pct(0)], [_pct(rf_ann)], label="Risk-free", zorder=3)
233
+ plt.scatter([_pct(sigma_mkt)], [_pct(rf_ann + erp_ann)], label="Market", zorder=3)
234
+
235
+ # Your CAPM point (x = historical σ, y = CAPM E[r])
236
+ plt.scatter([_pct(sigma_hist_port)], [_pct(mu_capm_port)], label="Your CAPM point", marker="o", zorder=4)
237
+
238
+ # Efficient points
239
+ plt.scatter([_pct(sigma_hist_port)], [_pct(mu_eff_same_sigma)], label="Efficient (same σ)", marker="^", zorder=4)
240
+ plt.scatter([_pct(sigma_eff_same_return)], [_pct(mu_capm_port)], label="Efficient (same E[r])", marker="s", zorder=4)
241
+
242
+ # Selected suggestion
243
+ if (sugg_mu is not None) and (sugg_sigma_hist is not None):
244
+ plt.scatter([_pct(sugg_sigma_hist)], [_pct(sugg_mu)], label="Selected Suggestion", marker="X", s=70, zorder=5)
245
+
246
+ plt.xlabel("σ (historical, annualized, %)")
247
+ plt.ylabel("CAPM E[r] (annual, %)")
248
+ plt.legend(loc="best", fontsize=8)
249
  plt.tight_layout()
250
 
251
  buf = io.BytesIO()
 
271
  for _ in range(n_rows):
272
  k = int(rng.integers(low=2, high=min(8, len(universe)) + 1))
273
  picks = list(rng.choice(universe, size=k, replace=False))
274
+
275
+ # long-only for clarity in suggestions
276
  w = rng.dirichlet(np.ones(k))
277
+
278
+ # beta and CAPM E[r]
279
  beta_p = float(np.dot([betas.get(t, 0.0) for t in picks], w))
280
  mu_capm = capm_er(beta_p, rf_ann, erp_ann)
281
+
282
+ # historical sigma from covA (ignore MARKET_TICKER variance entry)
283
+ sub = [t for t in picks if t != MARKET_TICKER]
284
+ if sub:
285
+ sub_w = np.array([w[i] for i, t in enumerate(picks) if t != MARKET_TICKER], dtype=float)
286
+ sub_cov = covA.reindex(index=sub, columns=sub).fillna(0.0).to_numpy()
287
+ sigma_hist = float(max(sub_w.T @ sub_cov @ sub_w, 0.0)) ** 0.5
288
+ else:
289
+ sigma_hist = 0.0
290
 
291
  rows.append({
292
  "tickers": ",".join(picks),
293
  "weights": ",".join(f"{x:.6f}" for x in w),
294
  "beta": beta_p,
295
  "mu_capm": mu_capm,
296
+ "sigma_hist": sigma_hist
 
297
  })
298
  return pd.DataFrame(rows)
299
 
300
+ def _band_bounds_sigma_hist(sigma_mkt: float, band: str) -> Tuple[float, float]:
301
  band = (band or "Medium").strip().lower()
302
  if band.startswith("low"):
303
  return 0.0, 0.8 * sigma_mkt
 
305
  return 1.2 * sigma_mkt, 3.0 * sigma_mkt
306
  return 0.8 * sigma_mkt, 1.2 * sigma_mkt
307
 
308
+ def _summarize_three(df: pd.DataFrame) -> pd.DataFrame:
309
+ if df.empty:
310
+ return pd.DataFrame(columns=["pick", "CAPM E[r] %", "σ (hist) %", "tickers"])
311
+ out = df.copy()
312
+ out = out.assign(**{
313
+ "CAPM E[r] %": (out["mu_capm"] * 100.0).round(2),
314
+ (hist) %": (out["sigma_hist"] * 100.0).round(2),
315
+ "tickers": out["tickers"]
316
+ })[["CAPM E[r] %", "σ (hist) %", "tickers"]].reset_index(drop=True)
317
+ out.insert(0, "pick", [1, 2, 3][: len(out)])
318
+ return out
319
+
320
+ # -------------- embeddings & re-ranking --------------
321
+ _EMBED_MODEL = None
322
+ _TICKER_EMBED_CACHE: Dict[str, np.ndarray] = {}
323
+
324
+ def _load_embed_model():
325
+ global _EMBED_MODEL
326
+ if _EMBED_MODEL is not None:
327
+ return _EMBED_MODEL
328
  try:
329
  from sentence_transformers import SentenceTransformer
330
+ _EMBED_MODEL = SentenceTransformer(EMBED_MODEL_NAME)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  except Exception:
332
+ _EMBED_MODEL = None
333
+ return _EMBED_MODEL
334
+
335
+ def _embed_texts(texts: List[str]) -> np.ndarray:
336
+ model = _load_embed_model()
337
+ if model is None:
338
+ return np.zeros((len(texts), 384), dtype=float) # fallback dim
339
+ return np.array(model.encode(texts), dtype=float)
340
+
341
+ def _ticker_vec(t: str) -> np.ndarray:
342
+ t = t.upper().strip()
343
+ if t in _TICKER_EMBED_CACHE:
344
+ return _TICKER_EMBED_CACHE[t]
345
+ v = _embed_texts([f"ticker {t}"])[0]
346
+ _TICKER_EMBED_CACHE[t] = v
347
+ return v
348
+
349
+ def _portfolio_embedding(tickers: List[str], weights: List[float]) -> np.ndarray:
350
+ if not tickers:
351
+ return np.zeros(384, dtype=float)
352
+ w = np.array(weights, dtype=float)
353
+ s = float(np.sum(np.abs(w)))
354
+ if s <= 1e-12:
355
+ w = np.ones(len(tickers), dtype=float) / len(tickers)
356
+ else:
357
+ w = w / s
358
+ vs = np.stack([_ticker_vec(t) for t in tickers], axis=0)
359
+ v = (w[:, None] * vs).sum(axis=0)
360
+ n = float(np.linalg.norm(v))
361
+ return v / (n if n > 1e-12 else 1.0)
362
+
363
+ def _cos_sim(a: np.ndarray, b: np.ndarray) -> float:
364
+ na = float(np.linalg.norm(a)); nb = float(np.linalg.norm(b))
365
+ if na <= 1e-12 or nb <= 1e-12: return 0.0
366
+ return float(np.dot(a, b) / (na * nb))
367
+
368
+ def _exposure_similarity(user_map: Dict[str, float], cand_map: Dict[str, float]) -> float:
369
+ # overlap mass on common tickers (long-only style 0..1)
370
+ s_user = sum(abs(x) for x in user_map.values())
371
+ s_cand = sum(abs(x) for x in cand_map.values())
372
+ if s_user <= 1e-12 or s_cand <= 1e-12:
373
+ return 0.0
374
+ u = {k: abs(v) / s_user for k, v in user_map.items()}
375
+ c = {k: abs(v) / s_cand for k, v in cand_map.items()}
376
+ common = set(u.keys()) & set(c.keys())
377
+ return float(sum(min(u[t], c[t]) for t in common))
378
+
379
+ def rerank_band_with_embeddings(user_df: pd.DataFrame,
380
+ band_df: pd.DataFrame,
381
+ alpha: float = EMBED_ALPHA,
382
+ mmr_lambda: float = MMR_LAMBDA,
383
+ top_k: int = 3) -> pd.DataFrame:
384
+ try:
385
+ # user portfolio embedding
386
+ u_t = user_df["ticker"].astype(str).str.upper().tolist()
387
+ u_w = pd.to_numeric(user_df["amount_usd"], errors="coerce").fillna(0.0).tolist()
388
+ u_map = {t: float(w) for t, w in zip(u_t, u_w)}
389
+ u_embed = _portfolio_embedding(u_t, u_w)
390
+
391
+ # candidate scores
392
+ cand_rows = []
393
+ cand_embeds = []
394
+ for _, r in band_df.iterrows():
395
+ ts = [t.strip().upper() for t in str(r["tickers"]).split(",")]
396
+ ws = [float(x) for x in str(r["weights"]).split(",")]
397
+ # normalize candidate weights
398
+ s = sum(max(0.0, w) for w in ws) or 1.0
399
+ ws = [max(0.0, w) / s for w in ws]
400
+ c_map = {t: w for t, w in zip(ts, ws)}
401
+
402
+ c_embed = _portfolio_embedding(ts, ws)
403
+ cand_embeds.append(c_embed)
404
+
405
+ expo_sim = _exposure_similarity(u_map, c_map)
406
+ emb_sim = _cos_sim(u_embed, c_embed)
407
+ score = alpha * expo_sim + (1.0 - alpha) * emb_sim
408
+
409
+ cand_rows.append((score, r))
410
+
411
+ if not cand_rows:
412
+ return band_df.head(top_k).reset_index(drop=True)
413
+
414
+ # MMR selection
415
+ cand_embeds = np.stack(cand_embeds, axis=0)
416
+ order = np.argsort([-s for s, _ in cand_rows])
417
+ picked = []
418
+ picked_idx = []
419
+
420
+ for i in order:
421
+ if len(picked) >= top_k: break
422
+ s_i, row_i = cand_rows[i]
423
+ if not picked:
424
+ picked.append(row_i)
425
+ picked_idx.append(i)
426
+ continue
427
+ # diversity penalty
428
+ sim_to_picked = 0.0
429
+ for j in picked_idx:
430
+ sim_to_picked = max(sim_to_picked, _cos_sim(cand_embeds[i], cand_embeds[j]))
431
+ mmr = mmr_lambda * s_i - (1.0 - mmr_lambda) * sim_to_picked
432
+ # simple thresholding vs worst current; try greedy insert
433
+ picked.append(row_i)
434
+ picked_idx.append(i)
435
+
436
+ out = pd.DataFrame([r for r in picked]).drop_duplicates().head(top_k).reset_index(drop=True)
437
+ if out.empty:
438
+ out = band_df.head(top_k).reset_index(drop=True)
439
+ out.insert(0, "pick", [1, 2, 3][: len(out)])
440
+ return out
441
+ except Exception:
442
+ # graceful fallback
443
+ out = band_df.sort_values("mu_capm", ascending=False).head(top_k).reset_index(drop=True)
444
+ out.insert(0, "pick", [1, 2, 3][: len(out)])
445
+ return out
446
 
447
  # -------------- UI helpers --------------
448
  def empty_positions_df():
449
  return pd.DataFrame(columns=["ticker", "amount_usd", "weight_exposure", "beta"])
450
 
451
+ def empty_holdings_df():
452
  return pd.DataFrame(columns=["ticker", "weight_%", "amount_$"])
453
 
454
  def set_horizon(years: float):
 
502
  amounts = amounts[:len(tickers)] + [0.0] * max(0, len(tickers) - len(amounts))
503
  return pd.DataFrame({"ticker": tickers, "amount_usd": amounts})
504
 
505
+ # -------------- compute core --------------
506
  UNIVERSE: List[str] = [MARKET_TICKER, "QQQ", "VTI", "SOXX", "IBIT"]
507
 
508
+ def _pick_to_holdings(row: pd.Series, budget: float) -> pd.DataFrame:
509
+ ts = [t.strip().upper() for t in str(row["tickers"]).split(",")]
510
+ ws = [float(x) for x in str(row["weights"]).split(",")]
511
+ s = sum(max(0.0, w) for w in ws) or 1.0
512
+ ws = [max(0.0, w) / s for w in ws]
513
+ return pd.DataFrame(
514
+ [{"ticker": t, "weight_%": round(w * 100.0, 2), "amount_$": round(w * budget, 0)} for t, w in zip(ts, ws)],
515
+ columns=["ticker", "weight_%", "amount_$"]
516
+ )
517
+
518
+ def compute_all(
519
  years_lookback: int,
520
  table: Optional[pd.DataFrame],
521
+ use_embeddings: bool
 
 
522
  ):
523
+ # sanitize input table
 
524
  if isinstance(table, pd.DataFrame):
525
  df = table.copy()
526
  else:
 
533
 
534
  symbols = [t for t in df["ticker"].tolist() if t]
535
  if len(symbols) == 0:
536
+ raise gr.Error("Add at least one ticker.")
537
 
538
  symbols = validate_tickers(symbols, years_lookback)
 
539
  if len(symbols) == 0:
540
+ raise gr.Error("Could not validate any tickers.")
541
 
542
  global UNIVERSE
543
  UNIVERSE = list(sorted(set([s for s in symbols if s != MARKET_TICKER] + [MARKET_TICKER])))[:MAX_TICKERS]
 
546
  amounts = {r["ticker"]: float(r["amount_usd"]) for _, r in df.iterrows()}
547
  rf_ann = RF_ANN
548
 
549
+ # moments
550
  moms = estimate_all_moments_aligned(symbols, years_lookback, rf_ann)
551
  betas, covA, erp_ann, sigma_mkt = moms["betas"], moms["cov_ann"], moms["erp_ann"], moms["sigma_m_ann"]
 
552
 
553
+ # weights
554
  gross = sum(abs(v) for v in amounts.values())
555
  if gross <= 1e-12:
556
+ raise gr.Error("All amounts are zero.")
557
+
558
  weights = {k: v / gross for k, v in amounts.items()}
559
 
560
+ # portfolio CAPM and σ (historical)
561
  beta_p, mu_capm, sigma_hist = portfolio_stats(weights, covA, betas, rf_ann, erp_ann)
 
562
 
563
+ # efficient counterparts (market/bills)
564
  a_sigma, b_sigma, mu_eff_sigma = efficient_same_sigma(sigma_hist, rf_ann, erp_ann, sigma_mkt)
565
  a_mu, b_mu, sigma_eff_mu = efficient_same_return(mu_capm, rf_ann, erp_ann, sigma_mkt)
566
 
567
+ # synthetic dataset from current universe
568
  synth = build_synthetic_dataset(UNIVERSE, covA, betas, rf_ann, erp_ann, sigma_mkt, n_rows=SYNTH_ROWS)
569
  csv_path = os.path.join(DATA_DIR, f"investor_profiles_{int(time.time())}.csv")
570
+ try:
571
+ synth.to_csv(csv_path, index=False)
572
+ except Exception:
573
+ csv_path = None # not fatal
574
+
575
+ # band splits
576
+ def band_top3(band: str) -> pd.DataFrame:
577
+ lo, hi = _band_bounds_sigma_hist(sigma_mkt, band)
578
+ pick = synth[(synth["sigma_hist"] >= lo) & (synth["sigma_hist"] <= hi)].copy()
579
+ if pick.empty:
580
+ pick = synth.copy()
581
+ # pre-sort by quality then re-rank with embeddings/MMR for diversity
582
+ pick = pick.sort_values("mu_capm", ascending=False).head(50).reset_index(drop=True)
583
+ if use_embeddings:
584
+ user_df = pd.DataFrame({"ticker": list(weights.keys()), "amount_usd": [amounts[t] for t in weights.keys()]})
585
+ top3 = rerank_band_with_embeddings(user_df, pick, EMBED_ALPHA, MMR_LAMBDA, top_k=3)
586
+ else:
587
+ top3 = pick.head(3).reset_index(drop=True)
588
+ top3.insert(0, "pick", [1, 2, 3][: len(top3)])
589
+ return top3
590
 
591
+ top3_low = band_top3("Low")
592
+ top3_med = band_top3("Medium")
593
+ top3_high = band_top3("High")
594
 
595
+ # descriptive tables for each tab
596
+ low_sum = _summarize_three(top3_low)
597
+ med_sum = _summarize_three(top3_med)
598
+ high_sum = _summarize_three(top3_high)
 
 
 
 
 
 
599
 
600
  # positions table
601
  pos_table = pd.DataFrame(
 
608
  columns=["ticker", "amount_usd", "weight_exposure", "beta"]
609
  )
610
 
611
+ # summary text
 
 
 
 
 
 
 
612
  info = "\n".join([
613
  "### Inputs",
614
  f"- Lookback years {years_lookback}",
615
  f"- Horizon years {int(round(HORIZON_YEARS))}",
616
  f"- Risk-free {rf_ann:.2%} from {RF_CODE}",
617
  f"- Market ERP {erp_ann:.2%}",
618
+ f"- Market σ (hist) {sigma_mkt:.2%}",
619
  "",
620
+ "### Your portfolio (CAPM on CML; x=σ_hist, y=CAPM E[r])",
621
  f"- Beta {beta_p:.2f}",
622
+ f"- CAPM E[r] {mu_capm:.2%}",
623
  f"- σ (historical) {sigma_hist:.2%}",
 
624
  "",
625
+ "### Efficient market/bills mixes",
626
+ f"- Same σ as your portfolio: Market {a_sigma:.2f}, Bills {b_sigma:.2f} E[r] {mu_eff_sigma:.2%}",
627
+ f"- Same E[r] as your portfolio: Market {a_mu:.2f}, Bills {b_mu:.2f} σ {sigma_eff_mu:.2%}",
628
  "",
629
+ "_Plot shows CAPM expectations on the CML with x-axis as **historical σ**._"
 
 
 
630
  ])
631
 
632
  uni_msg = f"Universe set to: {', '.join(UNIVERSE)}"
 
 
633
 
634
+ base_outputs = dict(
635
+ rf_ann=rf_ann, erp_ann=erp_ann, sigma_mkt=sigma_mkt,
636
+ mu_capm=mu_capm, sigma_hist=sigma_hist,
637
+ mu_eff_same_sigma=mu_eff_sigma, sigma_eff_same_return=sigma_eff_mu,
638
+ pos_table=pos_table, info=info, uni_msg=uni_msg,
639
+ csv_path=csv_path, low_sum=low_sum, med_sum=med_sum, high_sum=high_sum,
640
+ top3_low=top3_low, top3_med=top3_med, top3_high=top3_high, budget=sum(abs(v) for v in amounts.values())
641
+ )
642
+ return base_outputs
643
+
644
+ def compute_and_render(
645
+ years_lookback: int,
646
+ table: Optional[pd.DataFrame],
647
+ use_embeddings: bool,
648
+ which_band: str,
649
+ pick_idx: int
650
+ ):
651
+ outs = compute_all(years_lookback, table, use_embeddings)
652
+
653
+ # choose band & pick
654
+ band = (which_band or "Medium").strip().title()
655
+ idx = max(1, min(3, int(pick_idx))) - 1
656
 
657
+ if band == "Low":
658
+ top3 = outs["top3_low"]
659
+ elif band == "High":
660
+ top3 = outs["top3_high"]
661
+ else:
662
+ top3 = outs["top3_med"]
663
+
664
+ if top3.empty:
665
+ sugg_mu = None; sugg_sigma_hist = None
666
+ holdings = empty_holdings_df()
667
+ else:
668
+ row = top3.iloc[min(idx, len(top3)-1)]
669
+ sugg_mu = float(row["mu_capm"])
670
+ sugg_sigma_hist = float(row["sigma_hist"])
671
+ holdings = _pick_to_holdings(row, outs["budget"])
672
+
673
+ # plot
674
+ img = plot_cml_hybrid(
675
+ outs["rf_ann"], outs["erp_ann"], outs["sigma_mkt"],
676
+ outs["sigma_hist"], outs["mu_capm"],
677
+ outs["mu_eff_same_sigma"], outs["sigma_eff_same_return"],
678
+ sugg_mu, sugg_sigma_hist
679
+ )
680
+
681
+ return (
682
+ img, # plot
683
+ outs["info"], # summary
684
+ outs["uni_msg"], # universe msg
685
+ outs["pos_table"], # positions
686
+ holdings, # selected holdings
687
+ outs["csv_path"], # dataset file
688
+ outs["low_sum"], # low tab summary (3 picks)
689
+ outs["med_sum"], # medium tab summary
690
+ outs["high_sum"] # high tab summary
691
+ )
692
+
693
+ # -------------- UI --------------
694
+ with gr.Blocks(title="Efficient Portfolio Advisor") as demo:
695
  gr.Markdown(
696
  "## Efficient Portfolio Advisor\n"
697
+ "Search symbols, enter **dollar amounts** (negatives allowed), set horizon. "
698
+ "The plot shows **your CAPM point** on the CML with **x = historical σ** and **y = CAPM E[r] = rf + β·ERP**. "
699
+ "We also show two efficient market/bills mixes: same σ and same E[r].\n\n"
700
+ "Suggestions are generated from 1,000 candidate mixes and bucketed by risk (σ)."
701
  )
702
 
703
  with gr.Row():
 
705
  q = gr.Textbox(label="Search symbol")
706
  search_note = gr.Markdown()
707
  matches = gr.Dropdown(choices=[], label="Matches")
708
+ with gr.Row():
709
+ search_btn = gr.Button("Search")
710
+ add_btn = gr.Button("Add selected to portfolio")
711
 
712
+ gr.Markdown("### Portfolio positions (enter $ amounts; negatives allowed)")
713
  table = gr.Dataframe(
714
+ value=pd.DataFrame(columns=["ticker", "amount_usd"]),
715
+ interactive=True
 
 
 
716
  )
717
 
718
  horizon = gr.Number(label="Horizon in years (1–100)", value=HORIZON_YEARS, precision=0)
719
+ lookback = gr.Slider(1, 15, value=DEFAULT_LOOKBACK_YEARS, step=1, label="Lookback years")
720
+ use_emb = gr.Checkbox(value=True, label="Use finance embeddings + MMR for diverse picks")
721
 
722
  gr.Markdown("### Suggestions")
723
+ with gr.Tabs():
724
+ with gr.Tab("Low"):
725
+ low_summary = gr.Dataframe(value=empty_holdings_df(), interactive=False, label="Top 3 (Low risk)")
726
+ pick_low = gr.Radio(choices=["1", "2", "3"], value="1", label="Select a pick in Low")
727
+ with gr.Tab("Medium"):
728
+ med_summary = gr.Dataframe(value=empty_holdings_df(), interactive=False, label="Top 3 (Medium risk)")
729
+ pick_med = gr.Radio(choices=["1", "2", "3"], value="1", label="Select a pick in Medium")
730
+ with gr.Tab("High"):
731
+ high_summary = gr.Dataframe(value=empty_holdings_df(), interactive=False, label="Top 3 (High risk)")
732
+ pick_high = gr.Radio(choices=["1", "2", "3"], value="1", label="Select a pick in High")
733
 
734
  run_btn = gr.Button("Compute (build dataset & suggest)")
735
+
736
  with gr.Column(scale=1):
737
  plot = gr.Image(label="Capital Market Line (CAPM)", type="pil")
738
  summary = gr.Markdown(label="Inputs & Results")
739
  universe_msg = gr.Textbox(label="Universe status", interactive=False)
740
  positions = gr.Dataframe(
741
+ value=empty_positions_df(), interactive=False, label="Computed positions"
 
 
 
 
 
 
742
  )
743
+ selected_table = gr.Dataframe(
744
+ value=empty_holdings_df(),
745
+ interactive=False,
746
+ label="Selected suggestion holdings (% / $)"
 
 
 
 
747
  )
748
  dl = gr.File(label="Generated dataset CSV", value=None, visible=True)
749
 
750
+ # wire: search / add / locking / horizon
751
  search_btn.click(fn=search_tickers_cb, inputs=q, outputs=[search_note, matches])
752
  add_btn.click(fn=add_symbol, inputs=[matches, table], outputs=[table, search_note])
753
  table.change(fn=lock_ticker_column, inputs=table, outputs=table)
754
  horizon.change(fn=set_horizon, inputs=horizon, outputs=universe_msg)
755
 
756
+ # main compute (defaults to Medium, pick 1)
757
+ run_btn.click(
758
+ fn=compute_and_render,
759
+ inputs=[lookback, table, use_emb, gr.State("Medium"), gr.State(1)],
760
+ outputs=[plot, summary, universe_msg, positions, selected_table, dl, low_summary, med_summary, high_summary]
 
 
 
 
 
761
  )
762
 
763
+ # band radios trigger recompute with their band + index
764
+ pick_low.change(
765
+ fn=compute_and_render,
766
+ inputs=[lookback, table, use_emb, gr.State("Low"), pick_low],
767
+ outputs=[plot, summary, universe_msg, positions, selected_table, dl, low_summary, med_summary, high_summary]
768
+ )
769
+ pick_med.change(
770
+ fn=compute_and_render,
771
+ inputs=[lookback, table, use_emb, gr.State("Medium"), pick_med],
772
+ outputs=[plot, summary, universe_msg, positions, selected_table, dl, low_summary, med_summary, high_summary]
773
+ )
774
+ pick_high.change(
775
+ fn=compute_and_render,
776
+ inputs=[lookback, table, use_emb, gr.State("High"), pick_high],
777
+ outputs=[plot, summary, universe_msg, positions, selected_table, dl, low_summary, med_summary, high_summary]
778
  )
779
 
780
  # initialize risk-free at launch
 
782
  RF_ANN = fetch_fred_yield_annual(RF_CODE)
783
 
784
  if __name__ == "__main__":
785
+ # Gradio 5.x: no concurrency_count on .queue()
786
+ demo.queue()
787
+ demo.launch(
788
  server_name="0.0.0.0",
789
+ server_port=int(os.environ.get("PORT", 7860)),
790
+ show_api=False,
791
+ share=False,
792
  )