Tulitula commited on
Commit
cd0b356
·
verified ·
1 Parent(s): c640d64

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +235 -282
app.py CHANGED
@@ -1,6 +1,12 @@
1
- import os, io, math, json, traceback, warnings
2
  warnings.filterwarnings("ignore")
3
 
 
 
 
 
 
 
4
  from typing import List, Tuple, Dict, Optional
5
 
6
  import numpy as np
@@ -12,6 +18,8 @@ import requests
12
  import yfinance as yf
13
 
14
  from sentence_transformers import SentenceTransformer, util as st_util
 
 
15
 
16
  # =========================
17
  # Config
@@ -30,12 +38,10 @@ POS_COLS = ["ticker", "amount_usd", "weight_exposure", "beta"]
30
  SUG_COLS = ["ticker", "weight_%", "amount_$"]
31
  EFF_COLS = ["asset", "weight_%", "amount_$"]
32
 
33
- N_SYNTH = 1000 # synthetic dataset size
34
- MMR_K = 40 # shortlist size before MMR
35
  MMR_LAMBDA = 0.65 # similarity vs diversity tradeoff
36
 
37
- DEBUG = True # if True, surface tracebacks in the UI summary when something fails
38
-
39
  # ---------------- FRED mapping (risk-free source) ----------------
40
  FRED_MAP = [
41
  (1, "DGS1"),
@@ -46,7 +52,7 @@ FRED_MAP = [
46
  (10, "DGS10"),
47
  (20, "DGS20"),
48
  (30, "DGS30"),
49
- (100,"DGS30"),
50
  ]
51
 
52
  def fred_series_for_horizon(years: float) -> str:
@@ -57,6 +63,7 @@ def fred_series_for_horizon(years: float) -> str:
57
  return "DGS30"
58
 
59
  def fetch_fred_yield_annual(code: str) -> float:
 
60
  url = f"https://fred.stlouisfed.org/graph/fredgraph.csv?id={code}"
61
  try:
62
  r = requests.get(url, timeout=10)
@@ -70,88 +77,61 @@ def fetch_fred_yield_annual(code: str) -> float:
70
  # =========================
71
  # Data helpers
72
  # =========================
73
- def _to_cols_close(df: pd.DataFrame, tickers: List[str]) -> pd.DataFrame:
74
- """
75
- Coerce yfinance download to single-level columns of closes/adj closes.
76
- Handles Series, single-level, and MultiIndex frames safely.
77
- """
78
  if df is None or df.empty:
79
  return pd.DataFrame()
80
-
81
- # If Series (one ticker)
82
  if isinstance(df, pd.Series):
83
  df = df.to_frame("Close")
84
-
85
- # MultiIndex columns: (ticker, field)
86
  if isinstance(df.columns, pd.MultiIndex):
 
87
  fields = df.columns.get_level_values(1).unique().tolist()
88
  field = "Adj Close" if "Adj Close" in fields else ("Close" if "Close" in fields else fields[0])
89
  out = {}
90
- for t in dict.fromkeys(tickers):
91
  col = (t, field)
92
  if col in df.columns:
93
- out[t] = pd.to_numeric(df[col], errors="coerce")
94
- return pd.DataFrame(out)
95
-
96
- # Single-level columns: try common names
97
- if "Adj Close" in df.columns:
98
- col = pd.to_numeric(df["Adj Close"], errors="coerce")
99
- col.name = tickers[0] if tickers else "SINGLE"
100
- return col.to_frame()
101
- if "Close" in df.columns:
102
- col = pd.to_numeric(df["Close"], errors="coerce")
103
- col.name = tickers[0] if tickers else "SINGLE"
104
- return col.to_frame()
105
-
106
- # Fallback to first numeric column
107
- num_cols = [c for c in df.columns if pd.api.types.is_numeric_dtype(df[c])]
108
- if num_cols:
109
- col = pd.to_numeric(df[num_cols[0]], errors="coerce")
110
- col.name = tickers[0] if tickers else "SINGLE"
111
- return col.to_frame()
112
-
113
- return pd.DataFrame()
114
-
115
- def fetch_prices_monthly(tickers: List[str], years: int) -> pd.DataFrame:
116
- tickers = [t for t in dict.fromkeys(tickers) if t]
117
- if not tickers:
118
  return pd.DataFrame()
119
 
 
120
  start = (pd.Timestamp.today(tz="UTC") - pd.DateOffset(years=int(years), days=7)).date()
121
  end = pd.Timestamp.today(tz="UTC").date()
122
-
123
  df_raw = yf.download(
124
- tickers, start=start, end=end,
 
125
  interval="1mo", auto_adjust=True, progress=False, group_by="ticker",
126
  threads=True,
127
  )
128
- df = _to_cols_close(df_raw, tickers)
129
  if df.empty:
130
  return df
131
- df = df.dropna(how="all").fillna(method="ffill")
132
- # Keep only requested columns if present
133
- keep = [t for t in tickers if t in df.columns]
134
- if not keep and df.shape[1] == 1:
135
- # Single column; rename if needed
136
  df.columns = [tickers[0]]
137
- keep = [tickers[0]]
138
- return df[keep] if keep else pd.DataFrame()
139
 
140
  def monthly_returns(prices: pd.DataFrame) -> pd.DataFrame:
141
- if prices is None or prices.empty:
142
- return pd.DataFrame()
143
- return prices.pct_change().dropna(how="all")
144
 
145
  def validate_tickers(symbols: List[str], years: int) -> List[str]:
146
- """Return subset of symbols that have monthly data."""
147
  symbols = [s.strip().upper() for s in symbols if s and isinstance(s, str)]
148
- if not symbols:
149
- return []
150
  base = [s for s in symbols if s != MARKET_TICKER]
151
  px = fetch_prices_monthly(base + [MARKET_TICKER], years)
152
- if px.empty:
153
- return [s for s in symbols if s == MARKET_TICKER] # maybe only market survives
154
- ok = [s for s in symbols if s in px.columns]
 
155
  return ok
156
 
157
  # =========================
@@ -166,15 +146,13 @@ def get_aligned_monthly_returns(symbols: List[str], years: int) -> pd.DataFrame:
166
  uniq.append(MARKET_TICKER)
167
  px = fetch_prices_monthly(uniq, years)
168
  rets = monthly_returns(px)
169
- if rets.empty:
170
- return pd.DataFrame()
171
  cols = [c for c in uniq if c in rets.columns]
172
  R = rets[cols].dropna(how="any")
173
  return R.loc[:, ~R.columns.duplicated()]
174
 
175
  def estimate_all_moments_aligned(symbols: List[str], years: int, rf_ann: float):
176
  R = get_aligned_monthly_returns(symbols + [MARKET_TICKER], years)
177
- if R.empty or MARKET_TICKER not in R.columns or R.shape[0] < 3:
178
  raise ValueError("Not enough aligned data to estimate moments.")
179
  rf_m = rf_ann / 12.0
180
 
@@ -195,7 +173,8 @@ def estimate_all_moments_aligned(symbols: List[str], years: int, rf_ann: float):
195
  ex_s = R[s] - rf_m
196
  cov_sm = float(np.cov(ex_s.values, ex_m.values, ddof=1)[0, 1])
197
  betas[s] = cov_sm / var_m
198
- betas[MARKET_TICKER] = 1.0
 
199
 
200
  asset_cols = [c for c in R.columns if c != MARKET_TICKER]
201
  cov_m = np.cov(R[asset_cols].values.T, ddof=1) if asset_cols else np.zeros((0, 0))
@@ -212,8 +191,6 @@ def portfolio_stats(weights: Dict[str, float],
212
  rf_ann: float,
213
  erp_ann: float) -> Tuple[float, float, float]:
214
  tickers = list(weights.keys())
215
- if not tickers:
216
- return 0.0, rf_ann, 0.0
217
  w = np.array([weights[t] for t in tickers], dtype=float)
218
  gross = float(np.sum(np.abs(w)))
219
  if gross <= 1e-12:
@@ -244,15 +221,25 @@ def efficient_same_return(mu_target: float, rf_ann: float, erp_ann: float, sigma
244
  # Plot
245
  # =========================
246
  def _pct_arr(x):
247
- return np.asarray(x, dtype=float) * 100.0
248
-
249
- def plot_cml(rf_ann, erp_ann, sigma_mkt,
250
- pt_sigma_hist, pt_mu_capm,
251
- same_sigma_sigma, same_sigma_mu,
252
- same_mu_sigma, same_mu_mu) -> Image.Image:
 
 
 
253
  fig = plt.figure(figsize=(6.6, 4.4), dpi=130)
254
 
255
- xmax = max(0.3, sigma_mkt * 2.0, pt_sigma_hist * 1.4, same_mu_sigma * 1.4, same_sigma_sigma * 1.4)
 
 
 
 
 
 
 
256
  xs = np.linspace(0, xmax, 160)
257
  slope = erp_ann / max(sigma_mkt, 1e-12)
258
  cml = rf_ann + slope * xs
@@ -260,11 +247,12 @@ def plot_cml(rf_ann, erp_ann, sigma_mkt,
260
  plt.plot(_pct_arr(xs), _pct_arr(cml), label="CML via VOO", linewidth=1.8)
261
  plt.scatter([0.0], [_pct_arr(rf_ann)], label="Risk-free", zorder=5)
262
  plt.scatter([_pct_arr(sigma_mkt)], [_pct_arr(rf_ann + erp_ann)], label="Market (VOO)", zorder=5)
 
263
  plt.scatter([_pct_arr(pt_sigma_hist)], [_pct_arr(pt_mu_capm)], label="Your portfolio (CAPM)", zorder=6)
 
264
  plt.scatter([_pct_arr(same_sigma_sigma)], [_pct_arr(same_sigma_mu)], label="Efficient: same σ", zorder=5)
265
- plt.scatter([_pct_arr(same_mu_sigma)], [_pct_arr(same_mu_mu)], label="Efficient: same μ", zorder=5)
266
 
267
- # Guides
268
  plt.plot([_pct_arr(pt_sigma_hist), _pct_arr(same_sigma_sigma)],
269
  [_pct_arr(pt_mu_capm), _pct_arr(same_sigma_mu)],
270
  ls="--", lw=1.1, alpha=0.7, color="gray")
@@ -301,12 +289,10 @@ def build_synth_dataset(universe: List[str],
301
  rng = np.random.default_rng(seed)
302
  U = [u for u in universe if u != MARKET_TICKER] + [MARKET_TICKER]
303
  rows = []
304
- if not U:
305
- return pd.DataFrame()
306
  for i in range(n_rows):
307
- k = int(rng.integers(low=max(1, min(2, len(U))), high=min(8, len(U)) + 1))
308
  picks = list(rng.choice(U, size=k, replace=False))
309
- w = dirichlet_signed(k, rng)
310
  gross = float(np.sum(np.abs(w)))
311
  if gross <= 1e-12:
312
  continue
@@ -321,7 +307,8 @@ def build_synth_dataset(universe: List[str],
321
  "er_capm": float(er_capm_i),
322
  "sigma": float(sigma_i),
323
  })
324
- return pd.DataFrame(rows)
 
325
 
326
  # =========================
327
  # Embeddings + MMR selection
@@ -345,7 +332,10 @@ def row_to_sentence(row: pd.Series) -> str:
345
  f"beta {row['beta']:.3f}, "
346
  f"exposures {pairs}")
347
 
348
- def mmr_select(query_emb, cand_embs, k: int = 3, lambda_param: float = MMR_LAMBDA) -> List[int]:
 
 
 
349
  if cand_embs.shape[0] <= k:
350
  return list(range(cand_embs.shape[0]))
351
  sim_to_query = st_util.cos_sim(query_emb, cand_embs).cpu().numpy().reshape(-1)
@@ -357,11 +347,9 @@ def mmr_select(query_emb, cand_embs, k: int = 3, lambda_param: float = MMR_LAMBD
357
  while len(chosen) < k and candidate_indices:
358
  max_score = -1e9
359
  max_idx = candidate_indices[0]
360
- # compute diversity term against already chosen
361
- chosen_stack = cand_embs[chosen]
362
  for idx in candidate_indices:
363
  sim_q = sim_to_query[idx]
364
- sim_d = float(st_util.cos_sim(cand_embs[idx], chosen_stack).max().cpu().numpy())
365
  mmr_score = lambda_param * sim_q - (1.0 - lambda_param) * sim_d
366
  if mmr_score > max_score:
367
  max_score = mmr_score
@@ -396,22 +384,16 @@ def yahoo_search(query: str):
396
  except Exception:
397
  return [f"{query.strip().upper()} | typed symbol | n/a"]
398
 
399
- _last_matches = []
400
 
401
  # =========================
402
  # Formatting helpers
403
  # =========================
404
  def fmt_pct(x: float) -> str:
405
- try:
406
- return f"{float(x)*100:.2f}%"
407
- except Exception:
408
- return "n/a"
409
 
410
  def fmt_money(x: float) -> str:
411
- try:
412
- return f"${float(x):,.0f}"
413
- except Exception:
414
- return "n/a"
415
 
416
  # =========================
417
  # Gradio callbacks
@@ -435,7 +417,7 @@ def add_symbol(selection: str, table: pd.DataFrame):
435
  return table, "Pick a row from Matches first."
436
 
437
  current = []
438
- if isinstance(table, pd.DataFrame) and len(table) > 0 and "ticker" in table.columns:
439
  current = [str(x).upper() for x in table["ticker"].tolist() if str(x) != "nan"]
440
 
441
  tickers = current if symbol in current else current + [symbol]
@@ -445,7 +427,7 @@ def add_symbol(selection: str, table: pd.DataFrame):
445
  tickers = [t for t in tickers if t in val]
446
 
447
  amt_map = {}
448
- if isinstance(table, pd.DataFrame) and len(table) > 0:
449
  for _, r in table.iterrows():
450
  t = str(r.get("ticker", "")).upper()
451
  if t in tickers:
@@ -476,7 +458,7 @@ def set_horizon(years: float):
476
  HORIZON_YEARS = y
477
  RF_CODE = code
478
  RF_ANN = rf
479
- return f"Risk-free series {code}. Latest annual rate {rf:.2%}. Computations will use this."
480
 
481
  def _table_from_weights(weights: Dict[str, float], gross_amt: float) -> pd.DataFrame:
482
  items = []
@@ -485,20 +467,13 @@ def _table_from_weights(weights: Dict[str, float], gross_amt: float) -> pd.DataF
485
  amt = float(w) * gross_amt
486
  items.append({"ticker": t, "weight_%": round(pct * 100.0, 2), "amount_$": round(amt, 2)})
487
  df = pd.DataFrame(items, columns=SUG_COLS)
488
- if df.empty:
489
- return pd.DataFrame(columns=SUG_COLS)
490
  df["absw"] = df["weight_%"].abs()
491
  df = df.sort_values("absw", ascending=False).drop(columns=["absw"])
492
  return df
493
 
494
  def _weights_dict_from_row(r: pd.Series) -> Dict[str, float]:
495
- ts = [t.strip().upper() for t in str(r.get("tickers","")).split(",") if t]
496
- ws = []
497
- for x in str(r.get("weights","")).split(","):
498
- try:
499
- ws.append(float(x))
500
- except Exception:
501
- ws.append(0.0)
502
  wmap = {}
503
  for i in range(min(len(ts), len(ws))):
504
  wmap[ts[i]] = ws[i]
@@ -512,182 +487,153 @@ def compute(lookback_years: int,
512
  risk_bucket: str,
513
  horizon_years: float):
514
 
515
- try:
516
- # --- sanitize input table
517
- if table is None or len(table) == 0:
518
- empty = pd.DataFrame(columns=POS_COLS)
519
- emptyS = pd.DataFrame(columns=SUG_COLS)
520
- emptyE = pd.DataFrame(columns=EFF_COLS)
521
- return (None, "Add at least one ticker", "", empty,
522
- emptyS, emptyS, emptyS, emptyE, emptyE, "[]", 1, "No suggestions yet.")
523
-
524
- df = table.copy().dropna(how="all")
525
- if df.empty or "ticker" not in df.columns or "amount_usd" not in df.columns:
526
- empty = pd.DataFrame(columns=POS_COLS)
527
- emptyS = pd.DataFrame(columns=SUG_COLS)
528
- emptyE = pd.DataFrame(columns=EFF_COLS)
529
- return (None, "Positions table is empty or malformed.", "", empty,
530
- emptyS, emptyS, emptyS, emptyE, emptyE, "[]", 1, "No suggestions yet.")
531
-
532
- df["ticker"] = df["ticker"].astype(str).str.upper().str.strip()
533
- df["amount_usd"] = pd.to_numeric(df["amount_usd"], errors="coerce").fillna(0.0)
534
-
535
- symbols = [t for t in df["ticker"].tolist() if t]
536
- symbols = validate_tickers(symbols, lookback_years)
537
- if len(symbols) == 0:
538
- empty = pd.DataFrame(columns=POS_COLS)
539
- emptyS = pd.DataFrame(columns=SUG_COLS)
540
- emptyE = pd.DataFrame(columns=EFF_COLS)
541
- return (None, "Could not validate any tickers", "Universe invalid",
542
- empty, emptyS, emptyS, emptyS, emptyE, emptyE, "[]", 1, "No suggestions.")
543
-
544
- # --- universe & amounts
545
- universe = sorted(set([s for s in symbols if s != MARKET_TICKER] + [MARKET_TICKER]))
546
- df = df[df["ticker"].isin(symbols)].copy()
547
- amounts = {r["ticker"]: float(r["amount_usd"]) for _, r in df.iterrows()}
548
- gross_amt = sum(abs(v) for v in amounts.values())
549
- if gross_amt <= 1e-9:
550
- empty = pd.DataFrame(columns=POS_COLS)
551
- emptyS = pd.DataFrame(columns=SUG_COLS)
552
- emptyE = pd.DataFrame(columns=EFF_COLS)
553
- return (None, "All amounts are zero", "Universe ok",
554
- empty, emptyS, emptyS, emptyS, emptyE, emptyE, "[]", 1, "No suggestions.")
555
-
556
- weights = {k: v / gross_amt for k, v in amounts.items()}
557
-
558
- # --- risk free & moments
559
- rf_code = fred_series_for_horizon(horizon_years)
560
- rf_ann = fetch_fred_yield_annual(rf_code)
561
- moms = estimate_all_moments_aligned(universe, lookback_years, rf_ann)
562
- betas, covA, erp_ann, sigma_mkt = moms["betas"], moms["cov_ann"], moms["erp_ann"], moms["sigma_m_ann"]
563
-
564
- # --- portfolio stats (CAPM return + historical sigma)
565
- beta_p, er_capm_p, sigma_p = portfolio_stats(weights, covA, betas, rf_ann, erp_ann)
566
-
567
- # --- efficient alternatives on CML
568
- a_sigma, b_sigma, mu_eff_sigma = efficient_same_sigma(sigma_p, rf_ann, erp_ann, sigma_mkt)
569
- a_mu, b_mu, sigma_eff_mu = efficient_same_return(er_capm_p, rf_ann, erp_ann, sigma_mkt)
570
-
571
- eff_same_sigma_tbl = _table_from_weights({MARKET_TICKER: a_sigma, BILLS_TICKER: b_sigma}, gross_amt)
572
- eff_same_mu_tbl = _table_from_weights({MARKET_TICKER: a_mu, BILLS_TICKER: b_mu}, gross_amt)
573
-
574
- # --- build synthetic dataset (based ONLY on this universe)
575
- synth = build_synth_dataset(universe, covA, betas, rf_ann, erp_ann, n_rows=N_SYNTH, seed=777)
576
- if synth.empty:
577
- # fall back to trivial 3 variants of (market/bills) if universe too thin
578
- fallback = []
579
- for a in [0.2, 0.5, 0.8]:
580
- w = {MARKET_TICKER: a, BILLS_TICKER: 1-a}
581
- beta_i, er_capm_i, sigma_i = portfolio_stats(w, pd.DataFrame(), {MARKET_TICKER:1.0}, rf_ann, erp_ann)
582
- fallback.append({"tickers": ",".join(w.keys()),
583
- "weights": ",".join(f"{v:.6f}" for v in w.values()),
584
- "beta": beta_i, "er_capm": er_capm_i, "sigma": sigma_i})
585
- synth = pd.DataFrame(fallback)
586
-
587
- # --- risk buckets by sigma (absolute +/- 5% around median)
588
- median_sigma = float(synth["sigma"].median())
589
- low_max = max(float(synth["sigma"].min()), median_sigma - 0.05)
590
- high_min = median_sigma + 0.05
591
-
592
- if risk_bucket == "Low":
593
- cand_df = synth[synth["sigma"] <= low_max].copy()
594
- elif risk_bucket == "High":
595
- cand_df = synth[synth["sigma"] >= high_min].copy()
596
- else:
597
- cand_df = synth[(synth["sigma"] > low_max) & (synth["sigma"] < high_min)].copy()
598
- if len(cand_df) == 0:
599
- cand_df = synth.copy()
600
-
601
- # --- embeddings + MMR for 3 diverse picks
602
- embed = get_embedder()
603
- cand_sentences = cand_df.apply(row_to_sentence, axis=1).tolist()
604
- cur_pairs = ", ".join([f"{k}:{v:+.2f}" for k, v in sorted(weights.items())])
605
- q_sentence = f"user portfolio ({risk_bucket} risk); capm_target {er_capm_p:.4f}; sigma_hist {sigma_p:.4f}; exposures {cur_pairs}"
606
-
607
- cand_embs = embed.encode(cand_sentences, convert_to_tensor=True, normalize_embeddings=True, batch_size=64, show_progress_bar=False)
608
- q_emb = embed.encode([q_sentence], convert_to_tensor=True, normalize_embeddings=True)[0]
609
-
610
- sims = st_util.cos_sim(q_emb, cand_embs)[0]
611
- top_idx = sims.topk(k=min(MMR_K, len(cand_df))).indices.cpu().numpy().tolist()
612
- shortlist_embs = cand_embs[top_idx]
613
- mmr_local = mmr_select(q_emb, shortlist_embs, k=3, lambda_param=MMR_LAMBDA)
614
- chosen = [top_idx[i] for i in mmr_local]
615
- recs = cand_df.iloc[chosen].reset_index(drop=True)
616
-
617
- # --- suggestion tables for 3 picks
618
- sugg_tables = []
619
- sugg_meta = []
620
- for _, r in recs.iterrows():
621
- wmap = _weights_dict_from_row(r)
622
- sugg_tables.append(_table_from_weights(wmap, gross_amt))
623
- sugg_meta.append({"er_capm": float(r["er_capm"]), "sigma": float(r["sigma"]), "beta": float(r["beta"])})
624
-
625
- # --- plot
626
- img = plot_cml(
627
- rf_ann, erp_ann, sigma_mkt,
628
- sigma_p, er_capm_p,
629
- same_sigma_sigma=sigma_p, same_sigma_mu=mu_eff_sigma,
630
- same_mu_sigma=sigma_eff_mu, same_mu_mu=er_capm_p
631
- )
632
 
633
- # --- positions table (computed)
634
- rows = []
635
- for t in universe:
636
- if t == MARKET_TICKER:
637
- continue
638
- rows.append({
639
- "ticker": t,
640
- "amount_usd": round(amounts.get(t, 0.0), 2),
641
- "weight_exposure": round(weights.get(t, 0.0), 6),
642
- "beta": round(betas.get(t, np.nan), 4) if t != MARKET_TICKER else 1.0
643
- })
644
- pos_table = pd.DataFrame(rows, columns=POS_COLS)
645
-
646
- # --- info summary
647
- info_lines = []
648
- info_lines.append("### Inputs")
649
- info_lines.append(f"- Lookback years **{int(lookback_years)}**")
650
- info_lines.append(f"- Horizon years **{int(round(horizon_years))}**")
651
- info_lines.append(f"- Risk-free **{fmt_pct(rf_ann)}** from **{rf_code}**")
652
- info_lines.append(f"- Market ERP **{fmt_pct(erp_ann)}**")
653
- info_lines.append(f"- Market σ **{fmt_pct(sigma_mkt)}**")
654
- info_lines.append("")
655
- info_lines.append("### Your portfolio (plotted as CAPM return, historical σ)")
656
- info_lines.append(f"- Beta **{beta_p:.2f}**")
657
- info_lines.append(f"- σ (historical) **{fmt_pct(sigma_p)}**")
658
- info_lines.append(f"- E[return] (CAPM / SML) **{fmt_pct(er_capm_p)}**")
659
- info_lines.append("")
660
- info_lines.append("### Efficient alternatives on CML")
661
- info_lines.append(f"- Same σ → Market **{a_sigma:.2f}**, Bills **{b_sigma:.2f}**, Return **{fmt_pct(mu_eff_sigma)}**")
662
- info_lines.append(f"- Same μ → Market **{a_mu:.2f}**, Bills **{b_mu:.2f}**, σ **{fmt_pct(sigma_eff_mu)}**")
663
- info_lines.append("")
664
- info_lines.append(f"### Dataset-based suggestions (risk: **{risk_bucket}**)")
665
- info_lines.append("Use the selector to flip between **Pick #1 / #2 / #3**. Table shows % exposure and $ amounts.")
666
-
667
- # pad to exactly 3 tables for outputs
668
- while len(sugg_tables) < 3:
669
- sugg_tables.append(pd.DataFrame(columns=SUG_COLS))
670
-
671
- pick_idx_default = 1
672
- pick_msg_default = (f"Pick #1 — E[μ] {fmt_pct(sugg_meta[0]['er_capm'])}, "
673
- f"σ {fmt_pct(sugg_meta[0]['sigma'])}, β {sugg_meta[0]['beta']:.2f}") if sugg_meta else "No suggestion."
674
-
675
- return (img,
676
- "\n".join(info_lines),
677
- f"Universe set to {', '.join(universe)}",
678
- pos_table,
679
- sugg_tables[0], sugg_tables[1], sugg_tables[2],
680
- eff_same_sigma_tbl, eff_same_mu_tbl,
681
- json.dumps(sugg_meta), pick_idx_default, pick_msg_default)
682
 
683
- except Exception as e:
684
- empty = pd.DataFrame(columns=POS_COLS)
685
- emptyS = pd.DataFrame(columns=SUG_COLS)
686
- emptyE = pd.DataFrame(columns=EFF_COLS)
687
- msg = f"⚠️ Compute failed: {e}"
688
- if DEBUG:
689
- msg += "\n\n```\n" + traceback.format_exc() + "\n```"
690
- return (None, msg, "Error", empty, emptyS, emptyS, emptyS, emptyE, emptyE, "[]", 1, "No suggestions.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
691
 
692
  def on_pick_change(idx: int, meta_json: str):
693
  try:
@@ -704,7 +650,9 @@ def on_pick_change(idx: int, meta_json: str):
704
  # =========================
705
  # UI
706
  # =========================
707
- with gr.Blocks(title="Efficient Portfolio Advisor", css="#small-note {font-size: 12px; color:#666;}") as demo:
 
 
708
 
709
  gr.Markdown("## Efficient Portfolio Advisor\n"
710
  "Search symbols, enter **$ amounts**, set your **horizon**. "
@@ -739,7 +687,7 @@ with gr.Blocks(title="Efficient Portfolio Advisor", css="#small-note {font-size:
739
  search_btn.click(fn=do_search, inputs=q, outputs=[search_note, matches])
740
  add_btn.click(fn=add_symbol, inputs=[matches, table], outputs=[table, search_note])
741
  table.change(fn=lock_ticker_column, inputs=table, outputs=table)
742
- horizon.change(fn=set_horizon, inputs=horizon, outputs=[rf_msg]) # FIX: single output
743
 
744
  with gr.Tab("Results"):
745
  with gr.Row():
@@ -799,4 +747,9 @@ with gr.Blocks(title="Efficient Portfolio Advisor", css="#small-note {font-size:
799
  )
800
 
801
  if __name__ == "__main__":
802
- demo.launch()
 
 
 
 
 
 
1
+ import os, io, math, json, warnings
2
  warnings.filterwarnings("ignore")
3
 
4
+ # --- make caches writable BEFORE importing matplotlib / transformers ---
5
+ os.environ.setdefault("MPLCONFIGDIR", "/home/user/.config/matplotlib")
6
+ os.environ.setdefault("HF_HOME", "/home/user/.cache/huggingface")
7
+ os.environ.setdefault("SENTENCE_TRANSFORMERS_HOME", "/home/user/.cache/sentencetransformers")
8
+ os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "false")
9
+
10
  from typing import List, Tuple, Dict, Optional
11
 
12
  import numpy as np
 
18
  import yfinance as yf
19
 
20
  from sentence_transformers import SentenceTransformer, util as st_util
21
+ from sklearn.preprocessing import StandardScaler
22
+ from sklearn.neighbors import KNeighborsRegressor
23
 
24
  # =========================
25
  # Config
 
38
  SUG_COLS = ["ticker", "weight_%", "amount_$"]
39
  EFF_COLS = ["asset", "weight_%", "amount_$"]
40
 
41
+ N_SYNTH = 1000 # size of synthetic dataset per run
42
+ MMR_K = 40 # shortlist size before MMR
43
  MMR_LAMBDA = 0.65 # similarity vs diversity tradeoff
44
 
 
 
45
  # ---------------- FRED mapping (risk-free source) ----------------
46
  FRED_MAP = [
47
  (1, "DGS1"),
 
52
  (10, "DGS10"),
53
  (20, "DGS20"),
54
  (30, "DGS30"),
55
+ (100, "DGS30"),
56
  ]
57
 
58
  def fred_series_for_horizon(years: float) -> str:
 
63
  return "DGS30"
64
 
65
  def fetch_fred_yield_annual(code: str) -> float:
66
+ # FRED CSV endpoint (no API key required). Fallback to 3% if it fails.
67
  url = f"https://fred.stlouisfed.org/graph/fredgraph.csv?id={code}"
68
  try:
69
  r = requests.get(url, timeout=10)
 
77
  # =========================
78
  # Data helpers
79
  # =========================
80
+ def _to_cols_close(df: pd.DataFrame) -> pd.DataFrame:
81
+ """Coerce yfinance download to a single-level columns DataFrame of adjusted closes."""
 
 
 
82
  if df is None or df.empty:
83
  return pd.DataFrame()
 
 
84
  if isinstance(df, pd.Series):
85
  df = df.to_frame("Close")
 
 
86
  if isinstance(df.columns, pd.MultiIndex):
87
+ level0 = df.columns.get_level_values(0).unique().tolist()
88
  fields = df.columns.get_level_values(1).unique().tolist()
89
  field = "Adj Close" if "Adj Close" in fields else ("Close" if "Close" in fields else fields[0])
90
  out = {}
91
+ for t in level0:
92
  col = (t, field)
93
  if col in df.columns:
94
+ out[t] = df[col]
95
+ out_df = pd.DataFrame(out)
96
+ return out_df
97
+ else:
98
+ if "Adj Close" in df.columns:
99
+ return df[["Adj Close"]].rename(columns={"Adj Close": "SINGLE"})
100
+ if "Close" in df.columns:
101
+ return df[["Close"]].rename(columns={"Close": "SINGLE"})
102
+ num_cols = [c for c in df.columns if pd.api.types.is_numeric_dtype(df[c])]
103
+ if num_cols:
104
+ return df[[num_cols[0]]].rename(columns={num_cols[0]: "SINGLE"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  return pd.DataFrame()
106
 
107
+ def fetch_prices_monthly(tickers: List[str], years: int) -> pd.DataFrame:
108
  start = (pd.Timestamp.today(tz="UTC") - pd.DateOffset(years=int(years), days=7)).date()
109
  end = pd.Timestamp.today(tz="UTC").date()
 
110
  df_raw = yf.download(
111
+ list(dict.fromkeys(tickers)),
112
+ start=start, end=end,
113
  interval="1mo", auto_adjust=True, progress=False, group_by="ticker",
114
  threads=True,
115
  )
116
+ df = _to_cols_close(df_raw).copy()
117
  if df.empty:
118
  return df
119
+ if df.shape[1] == 1 and "SINGLE" in df.columns:
 
 
 
 
120
  df.columns = [tickers[0]]
121
+ df = df.dropna(how="all").fillna(method="ffill")
122
+ return df
123
 
124
  def monthly_returns(prices: pd.DataFrame) -> pd.DataFrame:
125
+ return prices.pct_change().dropna()
 
 
126
 
127
  def validate_tickers(symbols: List[str], years: int) -> List[str]:
 
128
  symbols = [s.strip().upper() for s in symbols if s and isinstance(s, str)]
 
 
129
  base = [s for s in symbols if s != MARKET_TICKER]
130
  px = fetch_prices_monthly(base + [MARKET_TICKER], years)
131
+ ok = []
132
+ for s in symbols:
133
+ if s in px.columns:
134
+ ok.append(s)
135
  return ok
136
 
137
  # =========================
 
146
  uniq.append(MARKET_TICKER)
147
  px = fetch_prices_monthly(uniq, years)
148
  rets = monthly_returns(px)
 
 
149
  cols = [c for c in uniq if c in rets.columns]
150
  R = rets[cols].dropna(how="any")
151
  return R.loc[:, ~R.columns.duplicated()]
152
 
153
  def estimate_all_moments_aligned(symbols: List[str], years: int, rf_ann: float):
154
  R = get_aligned_monthly_returns(symbols + [MARKET_TICKER], years)
155
+ if MARKET_TICKER not in R.columns or R.shape[0] < 3:
156
  raise ValueError("Not enough aligned data to estimate moments.")
157
  rf_m = rf_ann / 12.0
158
 
 
173
  ex_s = R[s] - rf_m
174
  cov_sm = float(np.cov(ex_s.values, ex_m.values, ddof=1)[0, 1])
175
  betas[s] = cov_sm / var_m
176
+
177
+ betas[MARKET_TICKER] = 1.0 # by definition
178
 
179
  asset_cols = [c for c in R.columns if c != MARKET_TICKER]
180
  cov_m = np.cov(R[asset_cols].values.T, ddof=1) if asset_cols else np.zeros((0, 0))
 
191
  rf_ann: float,
192
  erp_ann: float) -> Tuple[float, float, float]:
193
  tickers = list(weights.keys())
 
 
194
  w = np.array([weights[t] for t in tickers], dtype=float)
195
  gross = float(np.sum(np.abs(w)))
196
  if gross <= 1e-12:
 
221
  # Plot
222
  # =========================
223
  def _pct_arr(x):
224
+ x = np.asarray(x, dtype=float)
225
+ return x * 100.0
226
+
227
+ def plot_cml(
228
+ rf_ann, erp_ann, sigma_mkt,
229
+ pt_sigma_hist, pt_mu_capm,
230
+ same_sigma_sigma, same_sigma_mu,
231
+ same_mu_sigma, same_mu_mu,
232
+ ) -> Image.Image:
233
  fig = plt.figure(figsize=(6.6, 4.4), dpi=130)
234
 
235
+ xmax = max(
236
+ 0.3,
237
+ sigma_mkt * 2.0,
238
+ pt_sigma_hist * 1.4,
239
+ same_mu_sigma * 1.4,
240
+ same_sigma_sigma * 1.4,
241
+ )
242
+
243
  xs = np.linspace(0, xmax, 160)
244
  slope = erp_ann / max(sigma_mkt, 1e-12)
245
  cml = rf_ann + slope * xs
 
247
  plt.plot(_pct_arr(xs), _pct_arr(cml), label="CML via VOO", linewidth=1.8)
248
  plt.scatter([0.0], [_pct_arr(rf_ann)], label="Risk-free", zorder=5)
249
  plt.scatter([_pct_arr(sigma_mkt)], [_pct_arr(rf_ann + erp_ann)], label="Market (VOO)", zorder=5)
250
+
251
  plt.scatter([_pct_arr(pt_sigma_hist)], [_pct_arr(pt_mu_capm)], label="Your portfolio (CAPM)", zorder=6)
252
+
253
  plt.scatter([_pct_arr(same_sigma_sigma)], [_pct_arr(same_sigma_mu)], label="Efficient: same σ", zorder=5)
254
+ plt.scatter([_pct_arr(same_mu_sigma)], [_pct_arr(same_mu_mu)], label="Efficient: same μ", zorder=5)
255
 
 
256
  plt.plot([_pct_arr(pt_sigma_hist), _pct_arr(same_sigma_sigma)],
257
  [_pct_arr(pt_mu_capm), _pct_arr(same_sigma_mu)],
258
  ls="--", lw=1.1, alpha=0.7, color="gray")
 
289
  rng = np.random.default_rng(seed)
290
  U = [u for u in universe if u != MARKET_TICKER] + [MARKET_TICKER]
291
  rows = []
 
 
292
  for i in range(n_rows):
293
+ k = rng.integers(low=min(2, len(U)), high=min(8, len(U)) + 1)
294
  picks = list(rng.choice(U, size=k, replace=False))
295
+ w = dirichlet_signed(k, rng) # exposure weights (can include short)
296
  gross = float(np.sum(np.abs(w)))
297
  if gross <= 1e-12:
298
  continue
 
307
  "er_capm": float(er_capm_i),
308
  "sigma": float(sigma_i),
309
  })
310
+ df = pd.DataFrame(rows)
311
+ return df
312
 
313
  # =========================
314
  # Embeddings + MMR selection
 
332
  f"beta {row['beta']:.3f}, "
333
  f"exposures {pairs}")
334
 
335
+ def mmr_select(query_emb: np.ndarray,
336
+ cand_embs: np.ndarray,
337
+ k: int = 3,
338
+ lambda_param: float = MMR_LAMBDA) -> List[int]:
339
  if cand_embs.shape[0] <= k:
340
  return list(range(cand_embs.shape[0]))
341
  sim_to_query = st_util.cos_sim(query_emb, cand_embs).cpu().numpy().reshape(-1)
 
347
  while len(chosen) < k and candidate_indices:
348
  max_score = -1e9
349
  max_idx = candidate_indices[0]
 
 
350
  for idx in candidate_indices:
351
  sim_q = sim_to_query[idx]
352
+ sim_d = max(st_util.cos_sim(cand_embs[idx], cand_embs[chosen]).cpu().numpy().reshape(-1))
353
  mmr_score = lambda_param * sim_q - (1.0 - lambda_param) * sim_d
354
  if mmr_score > max_score:
355
  max_score = mmr_score
 
384
  except Exception:
385
  return [f"{query.strip().upper()} | typed symbol | n/a"]
386
 
387
+ _last_matches = [] # updated on each search
388
 
389
  # =========================
390
  # Formatting helpers
391
  # =========================
392
  def fmt_pct(x: float) -> str:
393
+ return f"{x*100:.2f}%"
 
 
 
394
 
395
  def fmt_money(x: float) -> str:
396
+ return f"${x:,.0f}"
 
 
 
397
 
398
  # =========================
399
  # Gradio callbacks
 
417
  return table, "Pick a row from Matches first."
418
 
419
  current = []
420
+ if table is not None and len(table) > 0:
421
  current = [str(x).upper() for x in table["ticker"].tolist() if str(x) != "nan"]
422
 
423
  tickers = current if symbol in current else current + [symbol]
 
427
  tickers = [t for t in tickers if t in val]
428
 
429
  amt_map = {}
430
+ if table is not None and len(table) > 0:
431
  for _, r in table.iterrows():
432
  t = str(r.get("ticker", "")).upper()
433
  if t in tickers:
 
458
  HORIZON_YEARS = y
459
  RF_CODE = code
460
  RF_ANN = rf
461
+ return f"Risk-free series {code}. Latest annual rate {rf:.2%}. Computations will use this.", rf
462
 
463
  def _table_from_weights(weights: Dict[str, float], gross_amt: float) -> pd.DataFrame:
464
  items = []
 
467
  amt = float(w) * gross_amt
468
  items.append({"ticker": t, "weight_%": round(pct * 100.0, 2), "amount_$": round(amt, 2)})
469
  df = pd.DataFrame(items, columns=SUG_COLS)
 
 
470
  df["absw"] = df["weight_%"].abs()
471
  df = df.sort_values("absw", ascending=False).drop(columns=["absw"])
472
  return df
473
 
474
  def _weights_dict_from_row(r: pd.Series) -> Dict[str, float]:
475
+ ts = [t.strip().upper() for t in str(r["tickers"]).split(",")]
476
+ ws = [float(x) for x in str(r["weights"]).split(",")]
 
 
 
 
 
477
  wmap = {}
478
  for i in range(min(len(ts), len(ws))):
479
  wmap[ts[i]] = ws[i]
 
487
  risk_bucket: str,
488
  horizon_years: float):
489
 
490
+ if table is None or len(table) == 0:
491
+ return (None, "Add at least one ticker", "", pd.DataFrame(columns=POS_COLS),
492
+ pd.DataFrame(columns=SUG_COLS), pd.DataFrame(columns=SUG_COLS),
493
+ pd.DataFrame(columns=SUG_COLS), pd.DataFrame(columns=EFF_COLS),
494
+ pd.DataFrame(columns=EFF_COLS), json.dumps([]), 1, "No suggestions yet.")
495
+
496
+ df = table.copy().dropna()
497
+ df["ticker"] = df["ticker"].astype(str).str.upper().str.strip()
498
+ df["amount_usd"] = pd.to_numeric(df["amount_usd"], errors="coerce").fillna(0.0)
499
+
500
+ symbols = [t for t in df["ticker"].tolist() if t]
501
+ symbols = validate_tickers(symbols, lookback_years)
502
+ if len(symbols) == 0:
503
+ return (None, "Could not validate any tickers", "Universe invalid",
504
+ pd.DataFrame(columns=POS_COLS),
505
+ pd.DataFrame(columns=SUG_COLS), pd.DataFrame(columns=SUG_COLS),
506
+ pd.DataFrame(columns=SUG_COLS), pd.DataFrame(columns=EFF_COLS),
507
+ pd.DataFrame(columns=EFF_COLS), json.dumps([]), 1, "No suggestions.")
508
+
509
+ universe = sorted(set([s for s in symbols if s != MARKET_TICKER] + [MARKET_TICKER]))
510
+ df = df[df["ticker"].isin(symbols)].copy()
511
+ amounts = {r["ticker"]: float(r["amount_usd"]) for _, r in df.iterrows()}
512
+ gross_amt = sum(abs(v) for v in amounts.values())
513
+ if gross_amt <= 1e-9:
514
+ return (None, "All amounts are zero", "Universe ok", pd.DataFrame(columns=POS_COLS),
515
+ pd.DataFrame(columns=SUG_COLS), pd.DataFrame(columns=SUG_COLS),
516
+ pd.DataFrame(columns=SUG_COLS), pd.DataFrame(columns=EFF_COLS),
517
+ pd.DataFrame(columns=EFF_COLS), json.dumps([]), 1, "No suggestions.")
518
+
519
+ weights = {k: v / gross_amt for k, v in amounts.items()}
520
+
521
+ rf_code = fred_series_for_horizon(horizon_years)
522
+ rf_ann = fetch_fred_yield_annual(rf_code)
523
+ moms = estimate_all_moments_aligned(universe, lookback_years, rf_ann)
524
+ betas, covA, erp_ann, sigma_mkt = moms["betas"], moms["cov_ann"], moms["erp_ann"], moms["sigma_m_ann"]
525
+
526
+ beta_p, er_capm_p, sigma_p = portfolio_stats(weights, covA, betas, rf_ann, erp_ann)
527
+
528
+ a_sigma, b_sigma, mu_eff_sigma = efficient_same_sigma(sigma_p, rf_ann, erp_ann, sigma_mkt)
529
+ a_mu, b_mu, sigma_eff_mu = efficient_same_return(er_capm_p, rf_ann, erp_ann, sigma_mkt)
530
+
531
+ eff_same_sigma_tbl = _table_from_weights({MARKET_TICKER: a_sigma, BILLS_TICKER: b_sigma}, gross_amt)
532
+ eff_same_mu_tbl = _table_from_weights({MARKET_TICKER: a_mu, BILLS_TICKER: b_mu}, gross_amt)
533
+
534
+ synth = build_synth_dataset(universe, covA, betas, rf_ann, erp_ann, n_rows=N_SYNTH, seed=777)
535
+
536
+ median_sigma = float(synth["sigma"].median()) if len(synth) else sigma_p
537
+ low_max = max(float(synth["sigma"].min()), median_sigma - 0.05)
538
+ high_min = median_sigma + 0.05
539
+
540
+ if risk_bucket == "Low":
541
+ cand_df = synth[synth["sigma"] <= low_max].copy()
542
+ elif risk_bucket == "High":
543
+ cand_df = synth[synth["sigma"] >= high_min].copy()
544
+ else:
545
+ cand_df = synth[(synth["sigma"] > low_max) & (synth["sigma"] < high_min)].copy()
546
+
547
+ if len(cand_df) == 0:
548
+ cand_df = synth.copy()
549
+
550
+ embed = get_embedder()
551
+ cand_sentences = cand_df.apply(row_to_sentence, axis=1).tolist()
552
+
553
+ cur_pairs = ", ".join([f"{k}:{v:+.2f}" for k, v in sorted(weights.items())])
554
+ q_sentence = f"user portfolio ({risk_bucket} risk); capm_target {er_capm_p:.4f}; sigma_hist {sigma_p:.4f}; exposures {cur_pairs}"
555
+
556
+ cand_embs = embed.encode(cand_sentences, convert_to_tensor=True, normalize_embeddings=True, batch_size=64, show_progress_bar=False)
557
+ q_emb = embed.encode([q_sentence], convert_to_tensor=True, normalize_embeddings=True)[0]
558
+
559
+ sims = st_util.cos_sim(q_emb, cand_embs)[0]
560
+ top_idx = sims.topk(k=min(MMR_K, len(cand_df))).indices.cpu().numpy().tolist()
561
+ shortlist_embs = cand_embs[top_idx]
562
+ mmr_local = mmr_select(q_emb, shortlist_embs, k=3, lambda_param=MMR_LAMBDA)
563
+ chosen = [top_idx[i] for i in mmr_local]
564
+ recs = cand_df.iloc[chosen].reset_index(drop=True)
565
+
566
+ suggs = []
567
+ for _, r in recs.iterrows():
568
+ wmap = _weights_dict_from_row(r)
569
+ suggs.append({
570
+ "weights": wmap,
571
+ "er_capm": float(r["er_capm"]),
572
+ "sigma": float(r["sigma"]),
573
+ "beta": float(r["beta"]),
574
+ "table": _table_from_weights(wmap, gross_amt)
575
+ })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
576
 
577
+ img = plot_cml(
578
+ rf_ann, erp_ann, sigma_mkt,
579
+ sigma_p, er_capm_p,
580
+ same_sigma_sigma=sigma_p, same_sigma_mu=mu_eff_sigma,
581
+ same_mu_sigma=sigma_eff_mu, same_mu_mu=er_capm_p
582
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
583
 
584
+ rows = []
585
+ for t in universe:
586
+ if t == MARKET_TICKER:
587
+ continue
588
+ rows.append({
589
+ "ticker": t,
590
+ "amount_usd": round(amounts.get(t, 0.0), 2),
591
+ "weight_exposure": round(weights.get(t, 0.0), 6),
592
+ "beta": round(betas.get(t, np.nan), 4) if t != MARKET_TICKER else 1.0
593
+ })
594
+ pos_table = pd.DataFrame(rows, columns=POS_COLS)
595
+
596
+ info_lines = []
597
+ info_lines.append("### Inputs")
598
+ info_lines.append(f"- Lookback years **{int(lookback_years)}**")
599
+ info_lines.append(f"- Horizon years **{int(round(horizon_years))}**")
600
+ info_lines.append(f"- Risk-free **{fmt_pct(rf_ann)}** from **{rf_code}**")
601
+ info_lines.append(f"- Market ERP **{fmt_pct(erp_ann)}**")
602
+ info_lines.append(f"- Market σ **{fmt_pct(sigma_mkt)}**")
603
+ info_lines.append("")
604
+ info_lines.append("### Your portfolio (plotted as CAPM return, historical σ)")
605
+ info_lines.append(f"- Beta **{beta_p:.2f}**")
606
+ info_lines.append(f"- σ (historical) **{fmt_pct(sigma_p)}**")
607
+ info_lines.append(f"- E[return] (CAPM / SML) **{fmt_pct(er_capm_p)}**")
608
+ info_lines.append("")
609
+ info_lines.append("### Efficient alternatives on CML")
610
+ info_lines.append(f"- Same σ → Market **{a_sigma:.2f}**, Bills **{b_sigma:.2f}**, Return **{fmt_pct(mu_eff_sigma)}**")
611
+ info_lines.append(f"- Same μ → Market **{a_mu:.2f}**, Bills **{b_mu:.2f}**, σ **{fmt_pct(sigma_eff_mu)}**")
612
+ info_lines.append("")
613
+ info_lines.append(f"### Dataset-based suggestions (risk: **{risk_bucket}**)")
614
+ info_lines.append("Use the selector to flip between **Pick #1 / #2 / #3**. Table shows % exposure and $ amounts.")
615
+
616
+ current_idx = 1
617
+ current = suggs[current_idx - 1] if suggs else None
618
+ current_tbl = current["table"] if current else pd.DataFrame(columns=SUG_COLS)
619
+ current_msg = ("Pick #1 — "
620
+ f"E[μ] {fmt_pct(current['er_capm'])}, σ {fmt_pct(current['sigma'])}, β {current['beta']:.2f}"
621
+ ) if current else "No suggestion."
622
+
623
+ return (img,
624
+ "\n".join(info_lines),
625
+ f"Universe set to {', '.join(universe)}",
626
+ pos_table,
627
+ suggs[0]["table"] if len(suggs) >= 1 else pd.DataFrame(columns=SUG_COLS),
628
+ suggs[1]["table"] if len(suggs) >= 2 else pd.DataFrame(columns=SUG_COLS),
629
+ suggs[2]["table"] if len(suggs) >= 3 else pd.DataFrame(columns=SUG_COLS),
630
+ eff_same_sigma_tbl,
631
+ eff_same_mu_tbl,
632
+ json.dumps([{
633
+ "er_capm": s["er_capm"], "sigma": s["sigma"], "beta": s["beta"],
634
+ } for s in suggs]),
635
+ current_idx,
636
+ current_msg)
637
 
638
  def on_pick_change(idx: int, meta_json: str):
639
  try:
 
650
  # =========================
651
  # UI
652
  # =========================
653
+ with gr.Blocks(title="Efficient Portfolio Advisor", css="""
654
+ #small-note {font-size: 12px; color:#666;}
655
+ """) as demo:
656
 
657
  gr.Markdown("## Efficient Portfolio Advisor\n"
658
  "Search symbols, enter **$ amounts**, set your **horizon**. "
 
687
  search_btn.click(fn=do_search, inputs=q, outputs=[search_note, matches])
688
  add_btn.click(fn=add_symbol, inputs=[matches, table], outputs=[table, search_note])
689
  table.change(fn=lock_ticker_column, inputs=table, outputs=table)
690
+ horizon.change(fn=set_horizon, inputs=horizon, outputs=[rf_msg, gr.State()])
691
 
692
  with gr.Tab("Results"):
693
  with gr.Row():
 
747
  )
748
 
749
  if __name__ == "__main__":
750
+ # Important for HF Spaces proxy
751
+ demo.launch(
752
+ server_name="0.0.0.0",
753
+ server_port=int(os.environ.get("PORT", 7860)),
754
+ show_error=True,
755
+ )