Update app.py
Browse files
app.py
CHANGED
|
@@ -66,22 +66,69 @@ def fetch_fred_yield_annual(code: str) -> float:
|
|
| 66 |
except Exception:
|
| 67 |
return 0.03
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
def fetch_prices_monthly(tickers: List[str], years: int) -> pd.DataFrame:
|
| 70 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
start = pd.Timestamp.today(tz="UTC") - pd.DateOffset(years=years, days=7)
|
| 72 |
end = pd.Timestamp.today(tz="UTC")
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
def monthly_returns(prices: pd.DataFrame) -> pd.DataFrame:
|
| 87 |
return prices.pct_change().dropna()
|
|
@@ -117,11 +164,9 @@ def yahoo_search(query: str):
|
|
| 117 |
return [{"symbol": query.strip().upper(), "name": "typed symbol", "exchange": "n a"}]
|
| 118 |
|
| 119 |
def validate_tickers(symbols: List[str], years: int) -> List[str]:
|
| 120 |
-
|
| 121 |
-
for s in symbols
|
| 122 |
-
|
| 123 |
-
ok.append(s)
|
| 124 |
-
return ok
|
| 125 |
|
| 126 |
# -------------- aligned moments --------------
|
| 127 |
def get_aligned_monthly_returns(symbols: List[str], years: int) -> pd.DataFrame:
|
|
@@ -481,44 +526,36 @@ def search_tickers_cb(q: str):
|
|
| 481 |
opts = [f"{h['symbol']} | {h['name']} | {h['exchange']}" for h in hits]
|
| 482 |
return "Select a symbol and click Add", opts
|
| 483 |
|
| 484 |
-
# PATCH 1: lenient add (no validation/network)
|
| 485 |
def add_symbol(selection: str, table: pd.DataFrame):
|
| 486 |
if not selection:
|
| 487 |
return table, "Pick a row from Matches first"
|
| 488 |
symbol = selection.split("|")[0].strip().upper()
|
| 489 |
-
|
| 490 |
-
current = [] if table is None or len(table) == 0 else [
|
| 491 |
-
str(x).upper() for x in table["ticker"].tolist() if str(x) != "nan"
|
| 492 |
-
]
|
| 493 |
-
# de-dup and append without validation here
|
| 494 |
tickers = current if symbol in current else current + [symbol]
|
| 495 |
-
|
|
|
|
| 496 |
amt_map = {}
|
| 497 |
if table is not None and len(table) > 0:
|
| 498 |
for _, r in table.iterrows():
|
| 499 |
t = str(r.get("ticker", "")).upper()
|
| 500 |
if t in tickers:
|
| 501 |
amt_map[t] = float(pd.to_numeric(r.get("amount_usd", 0.0), errors="coerce") or 0.0)
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
new_table
|
| 505 |
-
|
| 506 |
-
"amount_usd": [amt_map.get(t, 0.0) for t in tickers]
|
| 507 |
-
})
|
| 508 |
-
|
| 509 |
-
msg = f"Added {symbol}" if symbol in new_table["ticker"].tolist() else f"{symbol} not added"
|
| 510 |
-
if len(current) + 1 > MAX_TICKERS and symbol not in current:
|
| 511 |
msg = f"Reached max of {MAX_TICKERS}"
|
| 512 |
return new_table, msg
|
| 513 |
|
| 514 |
-
# PATCH 2: keep user entries; no validation here
|
| 515 |
def lock_ticker_column(tb: pd.DataFrame):
|
| 516 |
if tb is None or len(tb) == 0:
|
| 517 |
return pd.DataFrame(columns=["ticker", "amount_usd"])
|
| 518 |
-
tickers = [str(x).upper()
|
| 519 |
amounts = pd.to_numeric(tb["amount_usd"], errors="coerce").fillna(0.0).tolist()
|
|
|
|
|
|
|
| 520 |
amounts = amounts[:len(tickers)] + [0.0] * max(0, len(tickers) - len(amounts))
|
| 521 |
-
return pd.DataFrame({"ticker": tickers
|
| 522 |
|
| 523 |
def set_horizon(years: float):
|
| 524 |
y = max(1.0, min(100.0, float(years)))
|
|
@@ -541,10 +578,9 @@ def compute(years_lookback: int, table: pd.DataFrame,
|
|
| 541 |
if len(symbols) == 0:
|
| 542 |
return None, "Add at least one ticker", "Universe empty", empty_positions_df(), empty_suggest_df(), None
|
| 543 |
|
| 544 |
-
# Validation happens HERE (may hit network). If none validate, we'll message the user.
|
| 545 |
symbols = validate_tickers(symbols, years_lookback)
|
| 546 |
if len(symbols) == 0:
|
| 547 |
-
return None, "Could not validate any tickers
|
| 548 |
|
| 549 |
global UNIVERSE
|
| 550 |
UNIVERSE = list(sorted(set([s for s in symbols if s != MARKET_TICKER] + [MARKET_TICKER])))[:MAX_TICKERS]
|
|
|
|
| 66 |
except Exception:
|
| 67 |
return 0.03
|
| 68 |
|
| 69 |
+
# ---------- offline fallback (synthetic prices) ----------
|
| 70 |
+
def _offline_prices(tickers: List[str], years: int) -> pd.DataFrame:
|
| 71 |
+
# Build a synthetic monthly price panel so the app remains usable offline.
|
| 72 |
+
months = max(12 * int(max(1, years)), 6)
|
| 73 |
+
idx = pd.date_range(end=pd.Timestamp.today(tz="UTC").normalize(), periods=months, freq="M")
|
| 74 |
+
|
| 75 |
+
rng = np.random.default_rng(42)
|
| 76 |
+
# Market process
|
| 77 |
+
ann_mu_mkt, ann_vol_mkt = 0.08, 0.18
|
| 78 |
+
mu_m = ann_mu_mkt / 12.0
|
| 79 |
+
vol_m = ann_vol_mkt / (12.0 ** 0.5)
|
| 80 |
+
mkt_rets = rng.normal(mu_m, vol_m, size=months)
|
| 81 |
+
mkt_prices = 100.0 * np.cumprod(1.0 + mkt_rets)
|
| 82 |
+
|
| 83 |
+
df = pd.DataFrame(index=idx)
|
| 84 |
+
cols = list(dict.fromkeys(tickers))
|
| 85 |
+
if MARKET_TICKER not in cols:
|
| 86 |
+
cols.append(MARKET_TICKER)
|
| 87 |
+
|
| 88 |
+
for t in cols:
|
| 89 |
+
if t == MARKET_TICKER:
|
| 90 |
+
df[t] = mkt_prices
|
| 91 |
+
else:
|
| 92 |
+
beta = float(rng.uniform(0.6, 1.4))
|
| 93 |
+
idio_vol = float(rng.uniform(0.05, 0.20)) / (12.0 ** 0.5)
|
| 94 |
+
rets = beta * mkt_rets + rng.normal(0.0, idio_vol, size=months)
|
| 95 |
+
df[t] = 100.0 * np.cumprod(1.0 + rets)
|
| 96 |
+
return df
|
| 97 |
+
|
| 98 |
def fetch_prices_monthly(tickers: List[str], years: int) -> pd.DataFrame:
|
| 99 |
+
# Robust monthly downloader with per-ticker fetch and offline fallback
|
| 100 |
+
tickers = list(dict.fromkeys([t for t in tickers if isinstance(t, str) and t.strip()]))
|
| 101 |
+
if not tickers:
|
| 102 |
+
return pd.DataFrame()
|
| 103 |
+
|
| 104 |
start = pd.Timestamp.today(tz="UTC") - pd.DateOffset(years=years, days=7)
|
| 105 |
end = pd.Timestamp.today(tz="UTC")
|
| 106 |
+
|
| 107 |
+
frames = []
|
| 108 |
+
for t in tickers:
|
| 109 |
+
try:
|
| 110 |
+
s = yf.download(
|
| 111 |
+
t,
|
| 112 |
+
start=start.date(),
|
| 113 |
+
end=end.date(),
|
| 114 |
+
interval="1mo",
|
| 115 |
+
auto_adjust=True,
|
| 116 |
+
progress=False
|
| 117 |
+
)["Close"]
|
| 118 |
+
if isinstance(s, pd.Series) and s.dropna().size > 0:
|
| 119 |
+
frames.append(s.rename(t))
|
| 120 |
+
except Exception:
|
| 121 |
+
# skip this ticker; will fallback if insufficient data
|
| 122 |
+
pass
|
| 123 |
+
|
| 124 |
+
if frames:
|
| 125 |
+
df = pd.concat(frames, axis=1).sort_index().dropna(how="all").fillna(method="ffill")
|
| 126 |
+
# If we have enough aligned data and market exists, use it
|
| 127 |
+
if MARKET_TICKER in df.columns and df.dropna(how="any").shape[0] >= 3:
|
| 128 |
+
return df
|
| 129 |
+
|
| 130 |
+
# Fallback: synthetic panel ensures the app works even if Yahoo is down
|
| 131 |
+
return _offline_prices(tickers, years)
|
| 132 |
|
| 133 |
def monthly_returns(prices: pd.DataFrame) -> pd.DataFrame:
|
| 134 |
return prices.pct_change().dropna()
|
|
|
|
| 164 |
return [{"symbol": query.strip().upper(), "name": "typed symbol", "exchange": "n a"}]
|
| 165 |
|
| 166 |
def validate_tickers(symbols: List[str], years: int) -> List[str]:
|
| 167 |
+
# Pass-through validation to avoid network dependency during add/edit.
|
| 168 |
+
uniq = [s.strip().upper() for s in symbols if s and isinstance(s, str)]
|
| 169 |
+
return list(dict.fromkeys(uniq))[:MAX_TICKERS]
|
|
|
|
|
|
|
| 170 |
|
| 171 |
# -------------- aligned moments --------------
|
| 172 |
def get_aligned_monthly_returns(symbols: List[str], years: int) -> pd.DataFrame:
|
|
|
|
| 526 |
opts = [f"{h['symbol']} | {h['name']} | {h['exchange']}" for h in hits]
|
| 527 |
return "Select a symbol and click Add", opts
|
| 528 |
|
|
|
|
| 529 |
def add_symbol(selection: str, table: pd.DataFrame):
|
| 530 |
if not selection:
|
| 531 |
return table, "Pick a row from Matches first"
|
| 532 |
symbol = selection.split("|")[0].strip().upper()
|
| 533 |
+
current = [] if table is None or len(table) == 0 else [str(x).upper() for x in table["ticker"].tolist() if str(x) != "nan"]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
tickers = current if symbol in current else current + [symbol]
|
| 535 |
+
val = validate_tickers(tickers, years=DEFAULT_LOOKBACK_YEARS)
|
| 536 |
+
tickers = [t for t in tickers if t in val]
|
| 537 |
amt_map = {}
|
| 538 |
if table is not None and len(table) > 0:
|
| 539 |
for _, r in table.iterrows():
|
| 540 |
t = str(r.get("ticker", "")).upper()
|
| 541 |
if t in tickers:
|
| 542 |
amt_map[t] = float(pd.to_numeric(r.get("amount_usd", 0.0), errors="coerce") or 0.0)
|
| 543 |
+
new_table = pd.DataFrame({"ticker": tickers, "amount_usd": [amt_map.get(t, 0.0) for t in tickers]})
|
| 544 |
+
msg = f"Added {symbol}" if symbol in tickers else f"{symbol} not valid"
|
| 545 |
+
if len(new_table) > MAX_TICKERS:
|
| 546 |
+
new_table = new_table.iloc[:MAX_TICKERS]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 547 |
msg = f"Reached max of {MAX_TICKERS}"
|
| 548 |
return new_table, msg
|
| 549 |
|
|
|
|
| 550 |
def lock_ticker_column(tb: pd.DataFrame):
|
| 551 |
if tb is None or len(tb) == 0:
|
| 552 |
return pd.DataFrame(columns=["ticker", "amount_usd"])
|
| 553 |
+
tickers = [str(x).upper() for x in tb["ticker"].tolist()]
|
| 554 |
amounts = pd.to_numeric(tb["amount_usd"], errors="coerce").fillna(0.0).tolist()
|
| 555 |
+
val = validate_tickers(tickers, years=DEFAULT_LOOKBACK_YEARS)
|
| 556 |
+
tickers = [t for t in tickers if t in val]
|
| 557 |
amounts = amounts[:len(tickers)] + [0.0] * max(0, len(tickers) - len(amounts))
|
| 558 |
+
return pd.DataFrame({"ticker": tickers, "amount_usd": amounts})
|
| 559 |
|
| 560 |
def set_horizon(years: float):
|
| 561 |
y = max(1.0, min(100.0, float(years)))
|
|
|
|
| 578 |
if len(symbols) == 0:
|
| 579 |
return None, "Add at least one ticker", "Universe empty", empty_positions_df(), empty_suggest_df(), None
|
| 580 |
|
|
|
|
| 581 |
symbols = validate_tickers(symbols, years_lookback)
|
| 582 |
if len(symbols) == 0:
|
| 583 |
+
return None, "Could not validate any tickers", "Universe invalid", empty_positions_df(), empty_suggest_df(), None
|
| 584 |
|
| 585 |
global UNIVERSE
|
| 586 |
UNIVERSE = list(sorted(set([s for s in symbols if s != MARKET_TICKER] + [MARKET_TICKER])))[:MAX_TICKERS]
|