Update app.py
Browse files
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
|
| 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="
|
| 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
|
| 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
|
| 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()
|