Tulitula commited on
Commit
65a3db4
·
verified ·
1 Parent(s): 8295760

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +190 -336
app.py CHANGED
@@ -1,16 +1,12 @@
1
  # app.py
2
- # Efficient Portfolio Advisor — CAPM-on-CML plot + 1,000-mix dataset + 3x3 suggestions
3
- # - X axis: historical sigma (from covariances over lookback)
4
- # - Y axis: CAPM E[r] = rf + beta * ERP
5
- # - Plot includes two efficient CML mixes: same-σ and same-μ as the user portfolio
6
- # - Dataset: 1,000 long-only candidate mixes from *current* universe (incl. VOO)
7
- # - Suggestions: Tabs Low/Medium/High, 3 picks each, chosen by exposure+embedding sim with MMR
8
- # - Embeddings: FinLang/finance-embeddings-investopedia
9
- # - Score = α * exposure_similarity + (1-α) * embedding_similarity (α=0.6); MMR λ=0.7
10
- # - CSV of dataset downloadable.
11
- import os, io, math, time, json, warnings
12
  warnings.filterwarnings("ignore")
13
 
 
 
 
 
 
14
  from typing import List, Tuple, Dict, Optional
15
 
16
  import numpy as np
@@ -27,19 +23,14 @@ os.makedirs(DATA_DIR, exist_ok=True)
27
 
28
  MAX_TICKERS = 30
29
  DEFAULT_LOOKBACK_YEARS = 10
30
- MARKET_TICKER = "VOO" # market proxy on CML
31
- BILLS_LABEL = "Bills" # label for risk-free leg in efficient mixes (display only)
32
 
33
- SYNTH_ROWS = 1000 # dataset size for suggestions
34
- EMB_MODEL = "FinLang/finance-embeddings-investopedia"
35
- ALPHA = 0.60 # exposure-vs-embedding blend
36
- MMR_LAMBDA = 0.70 # MMR diversity strength
37
- SHORTLIST_K = 40 # shortlist before MMR per band
38
 
39
- # Globals updated with horizon changes
40
  HORIZON_YEARS = 10
41
  RF_CODE = "DGS10"
42
- RF_ANN = 0.0375 # initialized at launch
43
 
44
  # ---------------- helpers ----------------
45
  def fred_series_for_horizon(years: float) -> str:
@@ -70,7 +61,8 @@ def fetch_prices_monthly(tickers: List[str], years: int) -> pd.DataFrame:
70
 
71
  df = yf.download(
72
  tickers,
73
- start=start, end=end,
 
74
  interval="1mo",
75
  auto_adjust=True,
76
  actions=False,
@@ -79,7 +71,7 @@ def fetch_prices_monthly(tickers: List[str], years: int) -> pd.DataFrame:
79
  threads=False,
80
  )
81
 
82
- # Normalize to: columns = tickers, values = prices
83
  if isinstance(df, pd.Series):
84
  df = df.to_frame()
85
  if isinstance(df.columns, pd.MultiIndex):
@@ -126,7 +118,7 @@ def validate_tickers(symbols: List[str], years: int) -> List[str]:
126
  px = fetch_prices_monthly(base + [MARKET_TICKER], years)
127
  ok = [s for s in base if s in px.columns]
128
  if MARKET_TICKER not in px.columns:
129
- return [] # need market for aligned CAPM
130
  return ok
131
 
132
  # -------------- aligned moments --------------
@@ -189,9 +181,8 @@ def portfolio_stats(weights: Dict[str, float],
189
  mu_capm = capm_er(beta_p, rf_ann, erp_ann)
190
  cov = cov_ann.reindex(index=tickers, columns=tickers).fillna(0.0).to_numpy()
191
  sigma_hist = float(max(w_expo.T @ cov @ w_expo, 0.0)) ** 0.5
192
- return beta_p, mu_capm, sigma_hist
193
 
194
- # -------------- efficient CML mixes --------------
195
  def efficient_same_sigma(sigma_target: float, rf_ann: float, erp_ann: float, sigma_mkt: float):
196
  if sigma_mkt <= 1e-12:
197
  return 0.0, 1.0, rf_ann
@@ -205,11 +196,15 @@ def efficient_same_return(mu_target: float, rf_ann: float, erp_ann: float, sigma
205
  return a, 1.0 - a, abs(a) * sigma_mkt
206
 
207
  # -------------- plotting (CAPM on CML) --------------
208
- def _pct(x): return np.asarray(x, dtype=float) * 100.0
209
-
210
- def plot_cml(rf_ann, erp_ann, sigma_mkt,
211
- sigma_hist, mu_capm,
212
- sugg_mu=None, sugg_sigma=None) -> Image.Image:
 
 
 
 
213
  fig = plt.figure(figsize=(6, 4), dpi=120)
214
 
215
  xmax = max(0.3, sigma_mkt * 2.2, (sigma_hist or 0.0) * 1.6, (sugg_sigma or 0.0) * 1.6)
@@ -218,9 +213,15 @@ def plot_cml(rf_ann, erp_ann, sigma_mkt,
218
 
219
  plt.plot(_pct(xs), _pct(cml), label="CML via Market", linewidth=1.8)
220
  plt.scatter([_pct(0)], [_pct(rf_ann)], label="Risk-free")
221
- plt.scatter([_pct(sigma_mkt)], [_pct(rf_ann + erp_ann)], label="Market (VOO)")
 
 
222
  plt.scatter([_pct(sigma_hist)], [_pct(mu_capm)], label="Your CAPM point", marker="o")
223
 
 
 
 
 
224
  if sugg_mu is not None and sugg_sigma is not None:
225
  plt.scatter([_pct(sugg_sigma)], [_pct(sugg_mu)], label="Selected Suggestion", marker="X", s=60)
226
 
@@ -235,7 +236,7 @@ def plot_cml(rf_ann, erp_ann, sigma_mkt,
235
  buf.seek(0)
236
  return Image.open(buf)
237
 
238
- # -------------- synthetic dataset (from current universe) --------------
239
  def build_synthetic_dataset(universe: List[str],
240
  covA: pd.DataFrame,
241
  betas: Dict[str, float],
@@ -244,23 +245,19 @@ def build_synthetic_dataset(universe: List[str],
244
  sigma_mkt: float,
245
  n_rows: int = SYNTH_ROWS) -> pd.DataFrame:
246
  rng = np.random.default_rng(12345)
247
- U = list(universe)
248
- if not U:
249
- U = [MARKET_TICKER]
250
 
251
  rows = []
252
- for i in range(n_rows):
253
- k = int(rng.integers(low=2, high=min(8, len(U)) + 1))
254
- picks = list(rng.choice(U, size=k, replace=False))
255
- w = rng.dirichlet(np.ones(k)) # long-only, sums to 1
256
-
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
-
260
  sub = covA.reindex(index=picks, columns=picks).fillna(0.0).to_numpy()
261
  sigma_hist = float(max(w.T @ sub @ w, 0.0)) ** 0.5
262
-
263
- # CAPM "equivalent" sigma on CML for the same expected return
264
  sigma_capm = abs(beta_p) * sigma_mkt
265
 
266
  rows.append({
@@ -273,140 +270,48 @@ def build_synthetic_dataset(universe: List[str],
273
  })
274
  return pd.DataFrame(rows)
275
 
276
- # -------------- banding by σ (CAPM) --------------
277
  def _band_bounds(sigma_mkt: float, band: str) -> Tuple[float, float]:
278
- b = (band or "Medium").strip().lower()
279
- if b.startswith("low"): return 0.0, 0.8 * sigma_mkt
280
- if b.startswith("high"): return 1.2 * sigma_mkt, 3.0 * sigma_mkt
 
 
281
  return 0.8 * sigma_mkt, 1.2 * sigma_mkt
282
 
283
- def slice_band(df: pd.DataFrame, band: str, sigma_mkt: float) -> pd.DataFrame:
284
  lo, hi = _band_bounds(sigma_mkt, band)
285
  pick = df[(df["sigma_capm"] >= lo) & (df["sigma_capm"] <= hi)].copy()
286
- return pick if not pick.empty else df.copy()
287
-
288
- # -------------- embeddings + exposure similarity + MMR --------------
289
- _embedder = None
290
- def get_embedder():
291
- global _embedder
292
- if _embedder is None:
 
 
293
  from sentence_transformers import SentenceTransformer
294
- _embedder = SentenceTransformer(EMB_MODEL)
295
- return _embedder
296
-
297
- def _weights_dict_from_row(r: pd.Series) -> Dict[str, float]:
298
- ts = [t.strip().upper() for t in str(r["tickers"]).split(",")]
299
- ws = [float(x) for x in str(r["weights"]).split(",")]
300
- wmap = {ts[i]: ws[i] for i in range(min(len(ts), len(ws)))}
301
- s = sum(wmap.values()) or 1.0
302
- return {k: max(0.0, v) / s for k, v in wmap.items()} # ensure long-only normalized
303
-
304
- def _aligned_vec(universe: List[str], wmap: Dict[str, float]) -> np.ndarray:
305
- # vector in the same order
306
- return np.array([float(wmap.get(t, 0.0)) for t in universe], dtype=float)
307
-
308
- def cosine_sim(a: np.ndarray, b: np.ndarray) -> float:
309
- na = np.linalg.norm(a); nb = np.linalg.norm(b)
310
- if na == 0 or nb == 0: return 0.0
311
- return float(np.dot(a, b) / (na * nb))
312
-
313
- def portfolio_embedding(weights: Dict[str, float]) -> np.ndarray:
314
- # weighted average of ticker embeddings
315
- model = get_embedder()
316
- toks = list(weights.keys())
317
- if not toks: return np.zeros((get_embedder().get_sentence_embedding_dimension(),), dtype=float)
318
- embs = model.encode(toks, convert_to_numpy=True, normalize_embeddings=True)
319
- w = np.array([weights[t] for t in toks], dtype=float)
320
- w = w / (w.sum() or 1.0)
321
- vec = (embs * w[:, None]).sum(axis=0)
322
- # normalize
323
- n = np.linalg.norm(vec)
324
- return vec / (n if n else 1.0)
325
-
326
- def mmr(query_vec: np.ndarray, cand_vecs: np.ndarray, k: int, lam: float) -> List[int]:
327
- # classic MMR on cosine sim
328
- if len(cand_vecs) <= k: return list(range(len(cand_vecs)))
329
- sims_q = cand_vecs @ query_vec
330
- chosen = [int(np.argmax(sims_q))]
331
- candidates = set(range(len(cand_vecs))) - set(chosen)
332
- while len(chosen) < k and candidates:
333
- best_i, best_score = None, -1e9
334
- for i in list(candidates):
335
- sim_q = sims_q[i]
336
- sim_d = max(float(cand_vecs[i] @ cand_vecs[j]) for j in chosen)
337
- score = lam * sim_q - (1.0 - lam) * sim_d
338
- if score > best_score:
339
- best_score = score; best_i = i
340
- chosen.append(best_i); candidates.remove(best_i)
341
- return chosen
342
-
343
- def pick_3_for_band(synth: pd.DataFrame,
344
- band: str,
345
- sigma_mkt: float,
346
- uni: List[str],
347
- user_w: Dict[str, float]) -> Tuple[List[Dict], List[pd.DataFrame]]:
348
- # shortlist by top CAPM returns within band
349
- band_df = slice_band(synth, band, sigma_mkt)
350
- band_df = band_df.sort_values("mu_capm", ascending=False).head(SHORTLIST_K).reset_index(drop=True)
351
- if band_df.empty:
352
- return [], []
353
-
354
- # exposure vectors
355
- user_vec = _aligned_vec(uni, user_w)
356
-
357
- # portfolio embedding
358
- q_emb = portfolio_embedding(user_w)
359
-
360
- # candidate embeddings (weighted avg of ticker embeddings)
361
- c_wmaps = [ _weights_dict_from_row(r) for _, r in band_df.iterrows() ]
362
- toks_list = [list(wm.keys()) for wm in c_wmaps]
363
- # flatten encode unique tokens once
364
- tok_set = sorted(set(t for toks in toks_list for t in toks))
365
- model = get_embedder()
366
- tok_embs = model.encode(tok_set, convert_to_numpy=True, normalize_embeddings=True)
367
- tok_idx = {t:i for i,t in enumerate(tok_set)}
368
-
369
- cand_vecs = []
370
- expo_sims = []
371
- for wm in c_wmaps:
372
- # exposure sim (cosine on aligned vectors)
373
- c_vec = _aligned_vec(uni, wm)
374
- expo_sims.append(cosine_sim(user_vec, c_vec))
375
- # weighted-avg ticker embedding
376
- if wm:
377
- w = np.array([wm[t] for t in wm.keys()], dtype=float)
378
- w = w / (w.sum() or 1.0)
379
- e = np.vstack([tok_embs[tok_idx[t]] for t in wm.keys()])
380
- v = (e * w[:,None]).sum(axis=0)
381
- v = v / (np.linalg.norm(v) or 1.0)
382
- cand_vecs.append(v)
383
- else:
384
- cand_vecs.append(np.zeros_like(tok_embs[0]))
385
-
386
- cand_vecs = np.vstack(cand_vecs)
387
- # embedding sim: dot with q_emb (already normalized)
388
- emb_sims = cand_vecs @ q_emb
389
-
390
- # blended score
391
- scores = ALPHA * np.array(expo_sims) + (1.0 - ALPHA) * np.array(emb_sims)
392
- short_idx = np.argsort(-scores)[:min(12, len(scores))]
393
-
394
- # MMR on the short list to get 3 diverse
395
- mmr_idx_local = mmr(q_emb, cand_vecs[short_idx], k=3, lam=MMR_LAMBDA)
396
- chosen = [int(short_idx[i]) for i in mmr_idx_local]
397
- picks = band_df.iloc[chosen].reset_index(drop=True)
398
-
399
- # tables (% and $) for each pick
400
- gross_amt = sum(abs(v) for v in user_w.values()) or 1.0
401
- tbls = []
402
- metas = []
403
- for _, r in picks.iterrows():
404
- wm = _weights_dict_from_row(r)
405
- rows = [{"ticker": t, "weight_%": round(w*100.0, 2), "amount_$": round(w*gross_amt, 2)} for t, w in wm.items()]
406
- df = pd.DataFrame(rows, columns=["ticker", "weight_%", "amount_$"]).sort_values("weight_%", ascending=False)
407
- tbls.append(df.reset_index(drop=True))
408
- metas.append({"mu": float(r["mu_capm"]), "sigma": float(r["sigma_capm"])})
409
- return metas, tbls
410
 
411
  # -------------- UI helpers --------------
412
  def empty_positions_df():
@@ -472,93 +377,87 @@ UNIVERSE: List[str] = [MARKET_TICKER, "QQQ", "VTI", "SOXX", "IBIT"]
472
  def compute(
473
  years_lookback: int,
474
  table: Optional[pd.DataFrame],
475
- pick_low: int,
476
- pick_med: int,
477
- pick_high: int
478
  ):
 
479
  # sanitize table
480
  if isinstance(table, pd.DataFrame):
481
  df = table.copy()
482
  else:
483
  df = pd.DataFrame(columns=["ticker", "amount_usd"])
484
  df = df.dropna(how="all")
485
- for col in ("ticker","amount_usd"):
486
- if col not in df.columns: df[col] = []
487
  df["ticker"] = df["ticker"].astype(str).str.upper().str.strip()
488
  df["amount_usd"] = pd.to_numeric(df["amount_usd"], errors="coerce").fillna(0.0)
489
 
490
  symbols = [t for t in df["ticker"].tolist() if t]
491
  if len(symbols) == 0:
492
- empty = empty_positions_df()
493
- e = "Add at least one ticker."
494
- return None, e, "Universe empty.", empty, empty_suggestion_df(), empty_suggestion_df(), empty_suggestion_df(), empty_suggestion_df(), empty_suggestion_df(), empty_suggestion_df(), json.dumps({}), e
495
 
496
  symbols = validate_tickers(symbols, years_lookback)
 
497
  if len(symbols) == 0:
498
- empty = empty_positions_df()
499
- e = "Could not validate any tickers."
500
- return None, e, "Universe invalid.", empty, empty_suggestion_df(), empty_suggestion_df(), empty_suggestion_df(), empty_suggestion_df(), empty_suggestion_df(), empty_suggestion_df(), json.dumps({}), e
501
 
502
  global UNIVERSE
503
  UNIVERSE = list(sorted(set([s for s in symbols if s != MARKET_TICKER] + [MARKET_TICKER])))[:MAX_TICKERS]
504
 
505
  df = df[df["ticker"].isin(symbols)].copy()
506
  amounts = {r["ticker"]: float(r["amount_usd"]) for _, r in df.iterrows()}
507
- gross = sum(abs(v) for v in amounts.values())
508
- if gross <= 1e-12:
509
- empty = empty_positions_df()
510
- e = "All amounts are zero."
511
- return None, e, "Universe ok.", empty, *(empty_suggestion_df() for _ in range(6)), json.dumps({}), e
512
-
513
- weights = {k: v / gross for k, v in amounts.items()}
514
  rf_ann = RF_ANN
515
 
516
  # Moments
517
  moms = estimate_all_moments_aligned(symbols, years_lookback, rf_ann)
518
  betas, covA, erp_ann, sigma_mkt = moms["betas"], moms["cov_ann"], moms["erp_ann"], moms["sigma_m_ann"]
 
519
 
520
- # Portfolio CAPM stats (Y) vs historical σ (X)
 
 
 
 
 
 
521
  beta_p, mu_capm, sigma_hist = portfolio_stats(weights, covA, betas, rf_ann, erp_ann)
522
- sigma_capm = abs(beta_p) * sigma_mkt # for info only
523
 
524
- # Efficient alternatives on CML
525
  a_sigma, b_sigma, mu_eff_sigma = efficient_same_sigma(sigma_hist, rf_ann, erp_ann, sigma_mkt)
526
  a_mu, b_mu, sigma_eff_mu = efficient_same_return(mu_capm, rf_ann, erp_ann, sigma_mkt)
527
 
528
- # Dataset (1,000 mixes) and save CSV
529
  synth = build_synthetic_dataset(UNIVERSE, covA, betas, rf_ann, erp_ann, sigma_mkt, n_rows=SYNTH_ROWS)
530
  csv_path = os.path.join(DATA_DIR, f"investor_profiles_{int(time.time())}.csv")
531
- try:
532
- synth.to_csv(csv_path, index=False)
533
- except Exception:
534
- csv_path = None
535
-
536
- # Picks per band (Low/Medium/High)
537
- meta_low, tbls_low = pick_3_for_band(synth, "Low", sigma_mkt, UNIVERSE, weights)
538
- meta_med, tbls_med = pick_3_for_band(synth, "Medium", sigma_mkt, UNIVERSE, weights)
539
- meta_high, tbls_high = pick_3_for_band(synth, "High", sigma_mkt, UNIVERSE, weights)
540
-
541
- # fallbacks if any band empty
542
- def ensure_three(meta, tbls):
543
- while len(meta) < 3:
544
- meta.append({"mu": mu_capm, "sigma": sigma_capm})
545
- tbls.append(empty_suggestion_df())
546
- return meta[:3], tbls[:3]
547
-
548
- meta_low, tbls_low = ensure_three(meta_low, tbls_low)
549
- meta_med, tbls_med = ensure_three(meta_med, tbls_med)
550
- meta_high, tbls_high = ensure_three(meta_high, tbls_high)
551
-
552
- # clamp pick indices to 1..3
553
- pick_low = int(max(1, min(3, pick_low or 1)))
554
- pick_med = int(max(1, min(3, pick_med or 1)))
555
- pick_high = int(max(1, min(3, pick_high or 1)))
556
-
557
- # default highlighted suggestion: Medium / chosen index
558
- sel = meta_med[pick_med-1]
559
- img = plot_cml(rf_ann, erp_ann, sigma_mkt, sigma_hist, mu_capm, sel["mu"], sel["sigma"])
560
-
561
- # positions table (computed)
562
  pos_table = pd.DataFrame(
563
  [{
564
  "ticker": t,
@@ -569,17 +468,14 @@ def compute(
569
  columns=["ticker", "amount_usd", "weight_exposure", "beta"]
570
  )
571
 
572
- # efficient mixes tables (display-only)
573
- eff_same_sigma_tbl = pd.DataFrame([
574
- {"ticker": MARKET_TICKER, "weight_%": round(a_sigma*100,2), "amount_$": round(a_sigma*gross,2)},
575
- {"ticker": BILLS_LABEL, "weight_%": round(b_sigma*100,2), "amount_$": round(b_sigma*gross,2)},
576
- ])
577
- eff_same_mu_tbl = pd.DataFrame([
578
- {"ticker": MARKET_TICKER, "weight_%": round(a_mu*100,2), "amount_$": round(a_mu*gross,2)},
579
- {"ticker": BILLS_LABEL, "weight_%": round(b_mu*100,2), "amount_$": round(b_mu*gross,2)},
580
- ])
581
 
582
- # info summary
583
  info = "\n".join([
584
  "### Inputs",
585
  f"- Lookback years {years_lookback}",
@@ -588,68 +484,35 @@ def compute(
588
  f"- Market ERP {erp_ann:.2%}",
589
  f"- Market σ {sigma_mkt:.2%}",
590
  "",
591
- "### Your portfolio (CAPM on CML plot)",
592
  f"- Beta {beta_p:.2f}",
593
  f"- Expected return (CAPM / SML) {mu_capm:.2%}",
594
  f"- σ (historical) {sigma_hist:.2%}",
 
595
  "",
596
  "### Efficient alternatives on CML",
597
- f"- Same σ: Market {a_sigma:.2f}, Bills {b_sigma:.2f}, E[r] {mu_eff_sigma:.2%}",
598
- f"- Same μ: Market {a_mu:.2f}, Bills {b_mu:.2f}, σ {sigma_eff_mu:.2%}",
 
 
 
599
  "",
600
- "### Suggestions",
601
- "Three tabs (Low/Medium/High). Select a pick to highlight it on the plot.",
602
- "_Plot is **always** CAPM E[r] vs historical σ; your CAPM point will never exceed the CML._"
603
  ])
604
 
605
- # pack suggestion meta for quick plot refresh on band selection
606
- meta = {
607
- "low": meta_low,
608
- "med": meta_med,
609
- "high": meta_high,
610
- "plot": {"rf": rf_ann, "erp": erp_ann, "sigma_mkt": sigma_mkt, "sigma_hist": sigma_hist, "mu_capm": mu_capm}
611
- }
612
-
613
  uni_msg = f"Universe set to: {', '.join(UNIVERSE)}"
614
-
615
- # outputs:
616
- # plot, summary, universe, positions,
617
- # low tables (3), medium tables (3), high tables (3),
618
- # efficient tables (same σ, same μ),
619
- # meta json, status
620
- return (
621
- img, info, uni_msg, pos_table,
622
- tbls_low[0], tbls_low[1], tbls_low[2],
623
- tbls_med[0], tbls_med[1], tbls_med[2],
624
- tbls_high[0], tbls_high[1], tbls_high[2],
625
- eff_same_sigma_tbl, eff_same_mu_tbl,
626
- json.dumps(meta), (csv_path or "")
627
- )
628
-
629
- def highlight_from_pick(meta_json: str, band: str, pick_idx: int):
630
- try:
631
- meta = json.loads(meta_json)
632
- plotp = meta.get("plot", {})
633
- rf = float(plotp["rf"]); erp = float(plotp["erp"]); sigma_mkt = float(plotp["sigma_mkt"])
634
- sigma_hist = float(plotp["sigma_hist"]); mu_capm = float(plotp["mu_capm"])
635
- arr = meta["low" if band=="Low" else "med" if band=="Medium" else "high"]
636
- i = int(max(1, min(3, pick_idx or 1))) - 1
637
- sel = arr[i]
638
- return plot_cml(rf, erp, sigma_mkt, sigma_hist, mu_capm, sel["mu"], sel["sigma"])
639
- except Exception as e:
640
- # if anything fails, fall back to no suggestion highlighted
641
- return None
642
 
643
  # -------------- UI --------------
644
- def clamp13(i: int): return int(max(1, min(3, int(i or 1))))
 
645
 
646
- with gr.Blocks(title="Efficient Portfolio Advisor") as demo:
647
  gr.Markdown(
648
  "## Efficient Portfolio Advisor\n"
649
- "Search symbols, enter **dollar amounts**, set horizon. Data uses Yahoo monthly prices; risk-free from FRED.\n\n"
650
- "**Plot:** CAPM E[r] vs historical σ on the **CML**.\n"
651
- "**Efficient mixes:** CML portfolio with **same σ** and CML portfolio with **same E[r]** as yours.\n"
652
- "**Suggestions:** 1,000 long-only mixes from your universe → 3 picks per risk band using exposure+embeddings with MMR diversity."
653
  )
654
 
655
  with gr.Row():
@@ -660,65 +523,51 @@ with gr.Blocks(title="Efficient Portfolio Advisor") as demo:
660
  search_btn = gr.Button("Search")
661
  add_btn = gr.Button("Add selected to portfolio")
662
 
663
- gr.Markdown("### Portfolio positions (enter $ amounts; negatives allowed for your input)")
664
  table = gr.Dataframe(
665
  headers=["ticker", "amount_usd"],
666
  datatype=["str", "number"],
 
667
  row_count=0,
668
- col_count=(2, "fixed"),
669
- type="pandas" # Gradio 5-friendly
670
  )
671
 
672
  horizon = gr.Number(label="Horizon in years (1–100)", value=HORIZON_YEARS, precision=0)
673
  lookback = gr.Slider(1, 15, value=DEFAULT_LOOKBACK_YEARS, step=1, label="Lookback years for betas & covariances")
674
 
 
 
 
 
 
 
 
 
 
675
  run_btn = gr.Button("Compute (build dataset & suggest)")
676
  with gr.Column(scale=1):
677
  plot = gr.Image(label="Capital Market Line (CAPM)", type="pil")
678
  summary = gr.Markdown(label="Inputs & Results")
679
- universe_msg = gr.Textbox(label="Universe status / Horizon", interactive=False)
680
-
681
  positions = gr.Dataframe(
682
  label="Computed positions",
683
  headers=["ticker", "amount_usd", "weight_exposure", "beta"],
684
  datatype=["str", "number", "number", "number"],
 
685
  col_count=(4, "fixed"),
686
  value=empty_positions_df(),
687
- interactive=False,
688
- type="pandas"
689
  )
690
-
691
- # Suggestions area: three tabs, each 3 picks
692
- meta_box = gr.Textbox(value="{}", visible=False, label="meta")
693
- csv_path = gr.File(label="Generated dataset CSV", value=None, visible=True)
694
-
695
- with gr.Tab("Low"):
696
- with gr.Row():
697
- low1 = gr.Dataframe(label="Pick #1", interactive=False, type="pandas")
698
- low2 = gr.Dataframe(label="Pick #2", interactive=False, type="pandas")
699
- low3 = gr.Dataframe(label="Pick #3", interactive=False, type="pandas")
700
- pick_low = gr.Slider(1, 3, value=1, step=1, label="Highlight pick")
701
- low_btn = gr.Button("Show on plot")
702
-
703
- with gr.Tab("Medium"):
704
- with gr.Row():
705
- med1 = gr.Dataframe(label="Pick #1", interactive=False, type="pandas")
706
- med2 = gr.Dataframe(label="Pick #2", interactive=False, type="pandas")
707
- med3 = gr.Dataframe(label="Pick #3", interactive=False, type="pandas")
708
- pick_med = gr.Slider(1, 3, value=1, step=1, label="Highlight pick")
709
- med_btn = gr.Button("Show on plot")
710
-
711
- with gr.Tab("High"):
712
- with gr.Row():
713
- high1 = gr.Dataframe(label="Pick #1", interactive=False, type="pandas")
714
- high2 = gr.Dataframe(label="Pick #2", interactive=False, type="pandas")
715
- high3 = gr.Dataframe(label="Pick #3", interactive=False, type="pandas")
716
- pick_high = gr.Slider(1, 3, value=1, step=1, label="Highlight pick")
717
- high_btn = gr.Button("Show on plot")
718
-
719
- gr.Markdown("### Efficient alternatives on the CML")
720
- eff_same_sigma_tbl = gr.Dataframe(label="Efficient: Same σ", interactive=False, type="pandas")
721
- eff_same_mu_tbl = gr.Dataframe(label="Efficient: Same μ", interactive=False, type="pandas")
722
 
723
  # wire search / add / locking / horizon
724
  search_btn.click(fn=search_tickers_cb, inputs=q, outputs=[search_note, matches])
@@ -726,29 +575,34 @@ with gr.Blocks(title="Efficient Portfolio Advisor") as demo:
726
  table.change(fn=lock_ticker_column, inputs=table, outputs=table)
727
  horizon.change(fn=set_horizon, inputs=horizon, outputs=universe_msg)
728
 
 
 
 
 
 
 
 
 
 
 
 
 
729
  # main compute
730
  run_btn.click(
731
  fn=compute,
732
- inputs=[lookback, table, gr.State(1), gr.State(1), gr.State(1)],
733
- outputs=[
734
- plot, summary, universe_msg, positions,
735
- low1, low2, low3,
736
- med1, med2, med3,
737
- high1, high2, high3,
738
- eff_same_sigma_tbl, eff_same_mu_tbl,
739
- meta_box, csv_path
740
- ]
741
  )
742
 
743
- # highlight buttons refresh plot with selected suggestion
744
- low_btn.click(fn=highlight_from_pick, inputs=[meta_box, gr.State("Low"), pick_low], outputs=plot)
745
- med_btn.click(fn=highlight_from_pick, inputs=[meta_box, gr.State("Medium"), pick_med], outputs=plot)
746
- high_btn.click(fn=highlight_from_pick, inputs=[meta_box, gr.State("High"), pick_high], outputs=plot)
747
-
748
  # initialize risk-free at launch
749
  RF_CODE = fred_series_for_horizon(HORIZON_YEARS)
750
  RF_ANN = fetch_fred_yield_annual(RF_CODE)
751
 
752
  if __name__ == "__main__":
753
- # On Hugging Face Spaces you don't need share=True; binding to 0.0.0.0 is fine
754
- demo.launch(server_name="0.0.0.0", server_port=7860)
 
 
 
 
 
 
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
 
23
 
24
  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:
 
61
 
62
  df = yf.download(
63
  tickers,
64
+ start=start,
65
+ end=end,
66
  interval="1mo",
67
  auto_adjust=True,
68
  actions=False,
 
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
  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 --------------
 
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
 
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)
 
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
 
 
236
  buf.seek(0)
237
  return Image.open(buf)
238
 
239
+ # -------------- synthetic dataset --------------
240
  def build_synthetic_dataset(universe: List[str],
241
  covA: pd.DataFrame,
242
  betas: Dict[str, float],
 
245
  sigma_mkt: float,
246
  n_rows: int = SYNTH_ROWS) -> pd.DataFrame:
247
  rng = np.random.default_rng(12345)
248
+ assets = [t for t in universe if t != MARKET_TICKER]
249
+ if not assets:
250
+ assets = [MARKET_TICKER]
251
 
252
  rows = []
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({
 
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
277
+ if band.startswith("high"):
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():
 
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:
389
  df = pd.DataFrame(columns=["ticker", "amount_usd"])
390
  df = df.dropna(how="all")
391
+ if "ticker" not in df.columns: df["ticker"] = []
392
+ if "amount_usd" not in df.columns: df["amount_usd"] = []
393
  df["ticker"] = df["ticker"].astype(str).str.upper().str.strip()
394
  df["amount_usd"] = pd.to_numeric(df["amount_usd"], errors="coerce").fillna(0.0)
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]
407
 
408
  df = df[df["ticker"].isin(symbols)].copy()
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(
462
  [{
463
  "ticker": t,
 
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}",
 
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():
 
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])
 
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
598
  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
+ )