Tulitula commited on
Commit
0e6cd4c
·
verified ·
1 Parent(s): 7e7ea16

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +119 -11
app.py CHANGED
@@ -2,6 +2,7 @@ import os, io, math, warnings
2
  warnings.filterwarnings("ignore")
3
 
4
  from typing import List, Tuple, Dict, Optional
 
5
 
6
  import numpy as np
7
  import pandas as pd
@@ -23,8 +24,9 @@ MAX_TICKERS = 30
23
  DEFAULT_LOOKBACK_YEARS = 5
24
  MARKET_TICKER = "VOO"
25
 
26
- # column schema (weights shown in percent in UI tables)
27
  POS_COLS = ["ticker", "amount_usd", "weight_%", "beta"]
 
28
 
29
  FRED_MAP = [
30
  (1, "DGS1"),
@@ -45,6 +47,9 @@ def ensure_data_dir():
45
  def empty_positions_df():
46
  return pd.DataFrame(columns=POS_COLS)
47
 
 
 
 
48
  def fred_series_for_horizon(years: float) -> str:
49
  y = max(1.0, min(100.0, float(years)))
50
  for cutoff, code in FRED_MAP:
@@ -76,7 +81,6 @@ def fetch_prices_monthly(tickers: List[str], years: int) -> pd.DataFrame:
76
  )["Close"]
77
  if isinstance(df, pd.Series):
78
  df = df.to_frame()
79
- # yfinance sometimes returns MultiIndex columns
80
  if isinstance(df.columns, pd.MultiIndex):
81
  df.columns = [c[-1] if isinstance(c, tuple) else str(c) for c in df.columns]
82
  else:
@@ -225,7 +229,7 @@ def plot_cml(
225
  plt.scatter([same_sigma_sigma], [same_sigma_mu], label="Efficient same sigma")
226
  plt.scatter([same_mu_sigma], [same_mu_mu], label="Efficient same return")
227
  if targ_sigma is not None and targ_mu is not None:
228
- plt.scatter([targ_sigma], [targ_mu], label="Target suggestion")
229
 
230
  # Guides + annotations (in percent)
231
  plt.plot([pt_sigma, same_sigma_sigma], [pt_mu, same_sigma_mu],
@@ -258,7 +262,7 @@ def plot_cml(
258
  buf.seek(0)
259
  return Image.open(buf)
260
 
261
- # -------------- synthetic dataset (for the optional predictor) --------------
262
  def synth_profile(seed: int) -> str:
263
  rng = np.random.default_rng(seed)
264
  risk = rng.choice(["cautious", "balanced", "moderate", "growth", "aggressive"])
@@ -339,6 +343,73 @@ def predict_from_surrogate(amounts_map: Dict[str, float], universe: List[str],
339
  er_hat, sigma_hat, beta_hat = float(yhat[0]), float(yhat[1]), float(yhat[2])
340
  return er_hat, sigma_hat, beta_hat
341
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  # -------------- summary --------------
343
  def fmt_pct(x: float) -> str:
344
  return f"{x*100:.2f}%"
@@ -453,11 +524,11 @@ def compute(years_lookback: int, table: pd.DataFrame, use_synth: bool):
453
 
454
  symbols = [t for t in df["ticker"].tolist() if t]
455
  if len(symbols) == 0:
456
- return None, "Add at least one ticker", "Universe empty", empty_positions_df(), None
457
 
458
  symbols = validate_tickers(symbols, years_lookback)
459
  if len(symbols) == 0:
460
- return None, "Could not validate any tickers", "Universe invalid", empty_positions_df(), None
461
 
462
  global UNIVERSE
463
  UNIVERSE = list(sorted(set([s for s in symbols if s != MARKET_TICKER] + [MARKET_TICKER])))[:MAX_TICKERS]
@@ -471,7 +542,7 @@ def compute(years_lookback: int, table: pd.DataFrame, use_synth: bool):
471
 
472
  gross = sum(abs(v) for v in amounts.values())
473
  if gross == 0:
474
- return None, "All amounts are zero", "Universe ok", empty_positions_df(), None
475
  weights = {k: v / gross for k, v in amounts.items()}
476
 
477
  beta_p, er_p, sigma_p = portfolio_stats(weights, covA, betas, rf_ann, erp_ann)
@@ -479,7 +550,7 @@ def compute(years_lookback: int, table: pd.DataFrame, use_synth: bool):
479
  a_sigma, b_sigma, mu_eff_sigma = efficient_same_sigma(sigma_p, rf_ann, erp_ann, sigma_mkt)
480
  a_mu, b_mu, sigma_eff_mu = efficient_same_return(er_p, rf_ann, erp_ann, sigma_mkt)
481
 
482
- # ensure synthetic dataset exists once (for predictor only)
483
  if not os.path.exists(DATASET_PATH):
484
  synth_df = build_synthetic_dataset(
485
  universe=list(sorted(set(symbols + [MARKET_TICKER]))),
@@ -530,8 +601,22 @@ def compute(years_lookback: int, table: pd.DataFrame, use_synth: bool):
530
  })
531
  pos_table = pd.DataFrame(rows, columns=POS_COLS)
532
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
533
  uni_msg = f"Universe set to {', '.join(UNIVERSE)}"
534
- return img, info, uni_msg, pos_table, csv_path
535
 
536
  # -------------- UI --------------
537
  ensure_data_dir()
@@ -540,9 +625,12 @@ with gr.Blocks(title="Efficient Portfolio Advisor") as demo:
540
  gr.Markdown(
541
  "## Efficient Portfolio Advisor\n"
542
  "Search symbols, enter dollar amounts, set your horizon. "
543
- "Prices come from Yahoo Finance. Risk free comes from FRED."
 
544
  )
545
 
 
 
546
  with gr.Row():
547
  with gr.Column(scale=1):
548
  q = gr.Textbox(label="Search symbol")
@@ -565,6 +653,12 @@ with gr.Blocks(title="Efficient Portfolio Advisor") as demo:
565
  use_synth = gr.Checkbox(label="Use synthetic predictor (fast check)", value=True)
566
 
567
  run_btn = gr.Button("Compute and suggest")
 
 
 
 
 
 
568
  with gr.Column(scale=1):
569
  plot = gr.Image(label="Capital Market Line", type="pil")
570
  summary = gr.Markdown(label="Summary")
@@ -577,6 +671,15 @@ with gr.Blocks(title="Efficient Portfolio Advisor") as demo:
577
  value=empty_positions_df(),
578
  interactive=False
579
  )
 
 
 
 
 
 
 
 
 
580
  dl = gr.File(label="Session CSV path (synthetic predictor data)", value=None, visible=True)
581
 
582
  def do_search(query):
@@ -591,8 +694,13 @@ with gr.Blocks(title="Efficient Portfolio Advisor") as demo:
591
  run_btn.click(
592
  fn=compute,
593
  inputs=[lookback, table, use_synth],
594
- outputs=[plot, summary, universe_msg, positions, dl]
595
  )
596
 
 
 
 
 
 
597
  if __name__ == "__main__":
598
  demo.launch()
 
2
  warnings.filterwarnings("ignore")
3
 
4
  from typing import List, Tuple, Dict, Optional
5
+ from functools import partial
6
 
7
  import numpy as np
8
  import pandas as pd
 
24
  DEFAULT_LOOKBACK_YEARS = 5
25
  MARKET_TICKER = "VOO"
26
 
27
+ # column schemas (weights shown in percent in UI tables)
28
  POS_COLS = ["ticker", "amount_usd", "weight_%", "beta"]
29
+ SUG_RISK_COLS = ["ticker", "suggested_weight_%"]
30
 
31
  FRED_MAP = [
32
  (1, "DGS1"),
 
47
  def empty_positions_df():
48
  return pd.DataFrame(columns=POS_COLS)
49
 
50
+ def empty_risk_df():
51
+ return pd.DataFrame(columns=SUG_RISK_COLS)
52
+
53
  def fred_series_for_horizon(years: float) -> str:
54
  y = max(1.0, min(100.0, float(years)))
55
  for cutoff, code in FRED_MAP:
 
81
  )["Close"]
82
  if isinstance(df, pd.Series):
83
  df = df.to_frame()
 
84
  if isinstance(df.columns, pd.MultiIndex):
85
  df.columns = [c[-1] if isinstance(c, tuple) else str(c) for c in df.columns]
86
  else:
 
229
  plt.scatter([same_sigma_sigma], [same_sigma_mu], label="Efficient same sigma")
230
  plt.scatter([same_mu_sigma], [same_mu_mu], label="Efficient same return")
231
  if targ_sigma is not None and targ_mu is not None:
232
+ plt.scatter([targ_sigma], [targ_mu], label="Dataset suggestion")
233
 
234
  # Guides + annotations (in percent)
235
  plt.plot([pt_sigma, same_sigma_sigma], [pt_mu, same_sigma_mu],
 
262
  buf.seek(0)
263
  return Image.open(buf)
264
 
265
+ # -------------- synthetic dataset (for predictor + risk buttons) --------------
266
  def synth_profile(seed: int) -> str:
267
  rng = np.random.default_rng(seed)
268
  risk = rng.choice(["cautious", "balanced", "moderate", "growth", "aggressive"])
 
343
  er_hat, sigma_hat, beta_hat = float(yhat[0]), float(yhat[1]), float(yhat[2])
344
  return er_hat, sigma_hat, beta_hat
345
 
346
+ # ---- dataset risk buttons helpers (purely CSV-based) ----
347
+ def pick_row_by_risk(df: pd.DataFrame, level: str) -> Optional[pd.Series]:
348
+ df = df.dropna(subset=["sigma_p"])
349
+ if df.empty:
350
+ return None
351
+ if level == "low":
352
+ return df.loc[df["sigma_p"].idxmin()]
353
+ if level == "high":
354
+ return df.loc[df["sigma_p"].idxmax()]
355
+ # medium: closest to median sigma
356
+ med = float(df["sigma_p"].median())
357
+ idx = (df["sigma_p"] - med).abs().idxmin()
358
+ return df.loc[idx]
359
+
360
+ def row_to_suggestion(row: pd.Series, universe: List[str]) -> Optional[Dict]:
361
+ x = _row_to_exposures(row, universe)
362
+ if x is None:
363
+ return None
364
+ wmap = {universe[i]: float(x[i]) for i in range(len(universe)) if abs(float(x[i])) > 1e-4}
365
+ # sort top exposures
366
+ wmap = dict(sorted(wmap.items(), key=lambda kv: -abs(kv[1]))[:12])
367
+ return {
368
+ "weights": wmap,
369
+ "er": float(row["er_p"]),
370
+ "sigma": float(row["sigma_p"]),
371
+ "beta": float(row["beta_p"]),
372
+ }
373
+
374
+ def suggest_by_risk(level: str, state: dict):
375
+ # State must come from a previous "Compute"
376
+ if not isinstance(state, dict) or not state.get("csv_path") or not os.path.exists(state["csv_path"]):
377
+ return gr.update(), empty_risk_df(), "Run analysis first to build the dataset."
378
+
379
+ try:
380
+ df = pd.read_csv(state["csv_path"])
381
+ except Exception:
382
+ return gr.update(), empty_risk_df(), "Could not read dataset."
383
+
384
+ row = pick_row_by_risk(df, {"low":"low","med":"med","high":"high"}[level])
385
+ if row is None:
386
+ return gr.update(), empty_risk_df(), "Dataset is empty."
387
+
388
+ cand = row_to_suggestion(row, UNIVERSE)
389
+ if cand is None:
390
+ return gr.update(), empty_risk_df(), "No suggestion available."
391
+
392
+ # Build table in percents
393
+ rows = [{"ticker": k, "suggested_weight_%": v * 100.0} for k, v in cand["weights"].items()]
394
+ risk_table = pd.DataFrame(rows, columns=SUG_RISK_COLS)
395
+
396
+ # Overlay the dataset suggestion on the existing CML
397
+ img = plot_cml(
398
+ state["rf_ann"], state["erp_ann"], state["sigma_mkt"],
399
+ state["pt_sigma"], state["pt_mu"],
400
+ state["same_sigma_sigma"], state["same_sigma_mu"],
401
+ state["same_mu_sigma"], state["same_mu_mu"],
402
+ targ_sigma=cand["sigma"], targ_mu=cand["er"]
403
+ )
404
+
405
+ msg = (
406
+ f"**Dataset suggestion ({'Lowest' if level=='low' else 'Medium' if level=='med' else 'Highest'} risk)** \n"
407
+ f"- Predicted expected return: {fmt_pct(cand['er'])} \n"
408
+ f"- Predicted sigma: {fmt_pct(cand['sigma'])} \n"
409
+ f"- Predicted beta: {cand['beta']:.2f}"
410
+ )
411
+ return img, risk_table, msg
412
+
413
  # -------------- summary --------------
414
  def fmt_pct(x: float) -> str:
415
  return f"{x*100:.2f}%"
 
524
 
525
  symbols = [t for t in df["ticker"].tolist() if t]
526
  if len(symbols) == 0:
527
+ return None, "Add at least one ticker", "Universe empty", empty_positions_df(), None, {}
528
 
529
  symbols = validate_tickers(symbols, years_lookback)
530
  if len(symbols) == 0:
531
+ return None, "Could not validate any tickers", "Universe invalid", empty_positions_df(), None, {}
532
 
533
  global UNIVERSE
534
  UNIVERSE = list(sorted(set([s for s in symbols if s != MARKET_TICKER] + [MARKET_TICKER])))[:MAX_TICKERS]
 
542
 
543
  gross = sum(abs(v) for v in amounts.values())
544
  if gross == 0:
545
+ return None, "All amounts are zero", "Universe ok", empty_positions_df(), None, {}
546
  weights = {k: v / gross for k, v in amounts.items()}
547
 
548
  beta_p, er_p, sigma_p = portfolio_stats(weights, covA, betas, rf_ann, erp_ann)
 
550
  a_sigma, b_sigma, mu_eff_sigma = efficient_same_sigma(sigma_p, rf_ann, erp_ann, sigma_mkt)
551
  a_mu, b_mu, sigma_eff_mu = efficient_same_return(er_p, rf_ann, erp_ann, sigma_mkt)
552
 
553
+ # ensure synthetic dataset exists once (for predictor + risk buttons)
554
  if not os.path.exists(DATASET_PATH):
555
  synth_df = build_synthetic_dataset(
556
  universe=list(sorted(set(symbols + [MARKET_TICKER]))),
 
601
  })
602
  pos_table = pd.DataFrame(rows, columns=POS_COLS)
603
 
604
+ # Pack state for risk buttons
605
+ state = {
606
+ "csv_path": csv_path,
607
+ "rf_ann": rf_ann,
608
+ "erp_ann": erp_ann,
609
+ "sigma_mkt": sigma_mkt,
610
+ "pt_sigma": sigma_p,
611
+ "pt_mu": er_p,
612
+ "same_sigma_sigma": sigma_p,
613
+ "same_sigma_mu": mu_eff_sigma,
614
+ "same_mu_sigma": sigma_eff_mu,
615
+ "same_mu_mu": er_p,
616
+ }
617
+
618
  uni_msg = f"Universe set to {', '.join(UNIVERSE)}"
619
+ return img, info, uni_msg, pos_table, csv_path, state
620
 
621
  # -------------- UI --------------
622
  ensure_data_dir()
 
625
  gr.Markdown(
626
  "## Efficient Portfolio Advisor\n"
627
  "Search symbols, enter dollar amounts, set your horizon. "
628
+ "Prices come from Yahoo Finance. Risk free comes from FRED.\n\n"
629
+ "**New:** Dataset-only risk suggestions (Low / Medium / High) from the 1,000-row synthetic CSV."
630
  )
631
 
632
+ app_state = gr.State({})
633
+
634
  with gr.Row():
635
  with gr.Column(scale=1):
636
  q = gr.Textbox(label="Search symbol")
 
653
  use_synth = gr.Checkbox(label="Use synthetic predictor (fast check)", value=True)
654
 
655
  run_btn = gr.Button("Compute and suggest")
656
+
657
+ gr.Markdown("### Dataset-based risk suggestions")
658
+ with gr.Row():
659
+ btn_low = gr.Button("Lowest risk (dataset)")
660
+ btn_med = gr.Button("Medium risk (dataset)")
661
+ btn_high = gr.Button("Highest risk (dataset)")
662
  with gr.Column(scale=1):
663
  plot = gr.Image(label="Capital Market Line", type="pil")
664
  summary = gr.Markdown(label="Summary")
 
671
  value=empty_positions_df(),
672
  interactive=False
673
  )
674
+ risk_table = gr.Dataframe(
675
+ label="Suggested portfolio from dataset",
676
+ headers=SUG_RISK_COLS,
677
+ datatype=["str", "number"],
678
+ col_count=(len(SUG_RISK_COLS), "fixed"),
679
+ value=empty_risk_df(),
680
+ interactive=False
681
+ )
682
+ risk_msg = gr.Markdown(label="Suggestion metrics")
683
  dl = gr.File(label="Session CSV path (synthetic predictor data)", value=None, visible=True)
684
 
685
  def do_search(query):
 
694
  run_btn.click(
695
  fn=compute,
696
  inputs=[lookback, table, use_synth],
697
+ outputs=[plot, summary, universe_msg, positions, dl, app_state]
698
  )
699
 
700
+ # Risk buttons (purely dataset-driven)
701
+ btn_low.click(fn=partial(suggest_by_risk, "low"), inputs=[app_state], outputs=[plot, risk_table, risk_msg])
702
+ btn_med.click(fn=partial(suggest_by_risk, "med"), inputs=[app_state], outputs=[plot, risk_table, risk_msg])
703
+ btn_high.click(fn=partial(suggest_by_risk, "high"), inputs=[app_state], outputs=[plot, risk_table, risk_msg])
704
+
705
  if __name__ == "__main__":
706
  demo.launch()