Tulitula commited on
Commit
1d99074
·
verified ·
1 Parent(s): 3e82655

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +75 -39
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
- # yfinance docs https://pypi.org/project/yfinance
 
 
 
 
71
  start = pd.Timestamp.today(tz="UTC") - pd.DateOffset(years=years, days=7)
72
  end = pd.Timestamp.today(tz="UTC")
73
- df = yf.download(
74
- list(dict.fromkeys(tickers)),
75
- start=start.date(),
76
- end=end.date(),
77
- interval="1mo",
78
- auto_adjust=True,
79
- progress=False
80
- )["Close"]
81
- if isinstance(df, pd.Series):
82
- df = df.to_frame()
83
- df = df.dropna(how="all").fillna(method="ffill")
84
- return df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- ok, df = [], fetch_prices_monthly(list(set(symbols)), years)
121
- for s in symbols:
122
- if s in df.columns:
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
- tickers = tickers[:MAX_TICKERS]
504
- new_table = pd.DataFrame({
505
- "ticker": tickers,
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().strip() for x in tb["ticker"].tolist()]
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[:MAX_TICKERS], "amount_usd": amounts[:MAX_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 (check symbols or network)", "Universe invalid", empty_positions_df(), empty_suggest_df(), None
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]