Adisri99 commited on
Commit
e98cfad
·
verified ·
1 Parent(s): f04ba7f

Upload 13 files

Browse files
Files changed (13) hide show
  1. Dockerfile +14 -0
  2. README.md +10 -0
  3. app/__init__.py +0 -0
  4. app/config.py +10 -0
  5. app/features.py +42 -0
  6. app/main.py +63 -0
  7. app/market.py +110 -0
  8. app/models.py +40 -0
  9. app/optimizer.py +41 -0
  10. app/risk.py +28 -0
  11. app/schemas.py +43 -0
  12. app/service.py +56 -0
  13. requirements.txt +12 -0
Dockerfile ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ RUN useradd -m -u 1000 user
4
+ USER user
5
+ ENV HOME=/home/user PATH=/home/user/.local/bin:$PATH
6
+ WORKDIR $HOME/app
7
+
8
+ COPY --chown=user requirements.txt .
9
+ RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r requirements.txt
10
+
11
+ COPY --chown=user app ./app
12
+
13
+ EXPOSE 7860
14
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Quant Live Portfolio Upload API
3
+ emoji: 📈
4
+ colorFrom: purple
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ ---
9
+
10
+ Backend for Vercel frontend with CSV and TXT ticker upload handled client side.
app/__init__.py ADDED
File without changes
app/config.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import Field
2
+ from pydantic_settings import BaseSettings, SettingsConfigDict
3
+
4
+ class Settings(BaseSettings):
5
+ app_name: str = Field(default="Quant Live Portfolio")
6
+ frontend_origin: str = Field(default="http://localhost:5173")
7
+ stream_interval_seconds: int = Field(default=15)
8
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore")
9
+
10
+ settings = Settings()
app/features.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pandas as pd
3
+
4
+ FEATURE_COLUMNS = [
5
+ "ret_1d", "ret_5d", "ret_20d", "vol_20d",
6
+ "ma_ratio_10_50", "volume_z", "market_return",
7
+ "momentum_factor", "value_factor",
8
+ ]
9
+
10
+ def build_features(df: pd.DataFrame, sector_map: dict[str, str]) -> pd.DataFrame:
11
+ if df.empty:
12
+ raise ValueError("Input market dataframe is empty")
13
+ out = df.sort_values(["ticker", "date"]).copy()
14
+ market_daily = out.groupby("date")["close"].mean().pct_change().fillna(0.0).rename("market_return")
15
+ out = out.merge(market_daily, on="date", how="left")
16
+
17
+ def add_group_features(g: pd.DataFrame) -> pd.DataFrame:
18
+ g = g.sort_values("date").copy()
19
+ g["ret_1d"] = g["close"].pct_change(1)
20
+ g["ret_5d"] = g["close"].pct_change(5)
21
+ g["ret_20d"] = g["close"].pct_change(20)
22
+ g["vol_20d"] = g["close"].pct_change().rolling(20).std()
23
+ ma10 = g["close"].rolling(10).mean()
24
+ ma50 = g["close"].rolling(50).mean()
25
+ g["ma_ratio_10_50"] = ma10 / ma50 - 1.0
26
+ vol_mean = g["volume"].rolling(20).mean()
27
+ vol_std = g["volume"].rolling(20).std().replace(0, np.nan)
28
+ g["volume_z"] = ((g["volume"] - vol_mean) / vol_std).fillna(0.0)
29
+ g["momentum_factor"] = g["ret_20d"].rolling(5).mean()
30
+ g["value_factor"] = -g["ma_ratio_10_50"]
31
+ g["target_return_5d"] = g["close"].shift(-5) / g["close"] - 1.0
32
+ return g
33
+
34
+ pieces = []
35
+ for _, g in out.groupby("ticker"):
36
+ pieces.append(add_group_features(g))
37
+ out = pd.concat(pieces, ignore_index=True)
38
+ out["sector"] = out["ticker"].map(sector_map).fillna("Unknown")
39
+ out = out.dropna().reset_index(drop=True)
40
+ if out.empty:
41
+ raise ValueError("No usable rows after feature engineering")
42
+ return out
app/main.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ from fastapi import FastAPI, HTTPException, Query
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+ from fastapi.responses import StreamingResponse
6
+ from .config import settings
7
+ from .schemas import PortfolioRequest, PortfolioResponse
8
+ from .market import DEFAULT_UNIVERSE
9
+ from .service import build_portfolio_payload
10
+
11
+ app = FastAPI(title=settings.app_name)
12
+
13
+ app.add_middleware(
14
+ CORSMiddleware,
15
+ allow_origins=[settings.frontend_origin, "http://localhost:5173"],
16
+ allow_credentials=True,
17
+ allow_methods=["*"],
18
+ allow_headers=["*"],
19
+ )
20
+
21
+ @app.get("/health")
22
+ def health():
23
+ return {"ok": True, "service": settings.app_name}
24
+
25
+ @app.get("/api/universe")
26
+ def universe():
27
+ return {"tickers": DEFAULT_UNIVERSE}
28
+
29
+ @app.post("/api/portfolio", response_model=PortfolioResponse)
30
+ def portfolio(req: PortfolioRequest):
31
+ try:
32
+ return build_portfolio_payload(
33
+ tickers=req.tickers,
34
+ lookback_days=req.lookback_days,
35
+ risk_aversion=req.risk_aversion,
36
+ max_weight=req.max_weight,
37
+ sector_limit=req.sector_limit,
38
+ beta_limit=req.beta_limit,
39
+ )
40
+ except Exception as e:
41
+ raise HTTPException(status_code=400, detail=str(e))
42
+
43
+ @app.get("/api/stream")
44
+ async def stream(tickers: str = Query(default="AAPL,MSFT,NVDA,AMZN"), lookback_days: int = Query(default=365), risk_aversion: float = Query(default=8.0), max_weight: float = Query(default=0.35), sector_limit: float = Query(default=0.70), beta_limit: float = Query(default=1.20)):
45
+ ticker_list = [x.strip().upper() for x in tickers.split(",") if x.strip()]
46
+
47
+ async def event_generator():
48
+ while True:
49
+ try:
50
+ payload = build_portfolio_payload(
51
+ tickers=ticker_list,
52
+ lookback_days=lookback_days,
53
+ risk_aversion=risk_aversion,
54
+ max_weight=max_weight,
55
+ sector_limit=sector_limit,
56
+ beta_limit=beta_limit,
57
+ )
58
+ yield f"data: {json.dumps(payload)}\n\n"
59
+ except Exception as e:
60
+ yield f"data: {json.dumps({'error': str(e)})}\n\n"
61
+ await asyncio.sleep(settings.stream_interval_seconds)
62
+
63
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
app/market.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from typing import List, Dict
3
+ import pandas as pd
4
+ import yfinance as yf
5
+
6
+ DEFAULT_UNIVERSE = [
7
+ "AAPL", "MSFT", "NVDA", "AMZN", "GOOGL", "META",
8
+ "JPM", "XOM", "AVGO", "LLY", "AMD", "COST"
9
+ ]
10
+
11
+ def sanitize_tickers(tickers: List[str]) -> List[str]:
12
+ cleaned = []
13
+ for t in tickers:
14
+ t = (t or "").strip().upper().replace('"', "").replace("'", "")
15
+ if t and t.isascii() and t.replace("-", "").replace(".", "").isalnum():
16
+ cleaned.append(t)
17
+ return list(dict.fromkeys(cleaned))[:20]
18
+
19
+ def fetch_price_history(tickers: List[str], lookback_days: int = 365) -> pd.DataFrame:
20
+ tickers = sanitize_tickers(tickers)
21
+ if len(tickers) < 2:
22
+ raise ValueError("Need at least 2 valid tickers")
23
+
24
+ period = "2y" if lookback_days > 365 else "1y"
25
+ data = yf.download(
26
+ tickers=tickers,
27
+ period=period,
28
+ interval="1d",
29
+ auto_adjust=True,
30
+ progress=False,
31
+ threads=True,
32
+ group_by="ticker",
33
+ )
34
+
35
+ rows = []
36
+
37
+ if isinstance(data.columns, pd.MultiIndex):
38
+ for ticker in tickers:
39
+ try:
40
+ block = data[ticker].copy()
41
+ except Exception:
42
+ continue
43
+
44
+ if block.empty or "Close" not in block.columns:
45
+ continue
46
+
47
+ block = block.reset_index()
48
+ date_col = "Date" if "Date" in block.columns else block.columns[0]
49
+
50
+ for _, row in block.iterrows():
51
+ close_val = row.get("Close")
52
+ if pd.isna(close_val):
53
+ continue
54
+ rows.append({
55
+ "date": pd.Timestamp(row[date_col]).normalize(),
56
+ "ticker": ticker,
57
+ "close": float(close_val),
58
+ "volume": float(row.get("Volume", 0.0) or 0.0),
59
+ })
60
+ else:
61
+ if len(tickers) != 1:
62
+ raise ValueError("Unexpected Yahoo Finance schema for multiple tickers")
63
+ ticker = tickers[0]
64
+ block = data.copy()
65
+ if block.empty or "Close" not in block.columns:
66
+ raise ValueError("No usable market data returned")
67
+ block = block.reset_index()
68
+ date_col = "Date" if "Date" in block.columns else block.columns[0]
69
+
70
+ for _, row in block.iterrows():
71
+ close_val = row.get("Close")
72
+ if pd.isna(close_val):
73
+ continue
74
+ rows.append({
75
+ "date": pd.Timestamp(row[date_col]).normalize(),
76
+ "ticker": ticker,
77
+ "close": float(close_val),
78
+ "volume": float(row.get("Volume", 0.0) or 0.0),
79
+ })
80
+
81
+ out = pd.DataFrame(rows)
82
+ if out.empty:
83
+ raise ValueError("No market data returned")
84
+ if "ticker" not in out.columns:
85
+ raise ValueError(f"Ticker column missing from market data. Columns: {list(out.columns)}")
86
+ out = out.sort_values(["ticker", "date"]).reset_index(drop=True)
87
+ return out
88
+
89
+ def fetch_quotes(tickers: List[str]) -> List[Dict[str, float]]:
90
+ tickers = sanitize_tickers(tickers)
91
+ result = []
92
+ for ticker in tickers:
93
+ try:
94
+ hist = yf.Ticker(ticker).history(period="5d", interval="1d", auto_adjust=True)
95
+ if hist.empty:
96
+ continue
97
+ last = float(hist["Close"].iloc[-1])
98
+ prev = float(hist["Close"].iloc[-2]) if len(hist) > 1 else last
99
+ chg = 0.0 if prev == 0 else (last / prev - 1.0)
100
+ result.append({
101
+ "ticker": ticker,
102
+ "price": last,
103
+ "day_change_pct": chg,
104
+ })
105
+ except Exception:
106
+ continue
107
+ return result
108
+
109
+ def infer_sectors(tickers: List[str]) -> Dict[str, str]:
110
+ return {ticker: "Unknown" for ticker in sanitize_tickers(tickers)}
app/models.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Tuple
2
+ import pandas as pd
3
+ from xgboost import XGBRegressor
4
+ from .features import FEATURE_COLUMNS
5
+
6
+ def train_model(feature_df: pd.DataFrame) -> Tuple[XGBRegressor, pd.DataFrame]:
7
+ X = feature_df[FEATURE_COLUMNS]
8
+ y = feature_df["target_return_5d"]
9
+ model = XGBRegressor(
10
+ n_estimators=120,
11
+ max_depth=4,
12
+ learning_rate=0.05,
13
+ subsample=0.9,
14
+ colsample_bytree=0.9,
15
+ objective="reg:squarederror",
16
+ random_state=42,
17
+ n_jobs=2,
18
+ enable_categorical=False,
19
+ )
20
+ model.fit(X, y)
21
+ return model, feature_df
22
+
23
+ def latest_predictions(model: XGBRegressor, feature_df: pd.DataFrame) -> pd.DataFrame:
24
+ latest = feature_df.sort_values("date").groupby("ticker", as_index=False).tail(1).reset_index(drop=True)
25
+ latest["predicted_return"] = model.predict(latest[FEATURE_COLUMNS])
26
+ return latest
27
+
28
+ def top_feature_contributions(model: XGBRegressor, latest_df: pd.DataFrame, top_n: int = 5) -> Dict[str, list]:
29
+ booster = model.get_booster()
30
+ raw_gain = booster.get_score(importance_type="gain") or {}
31
+ gain_map = {name: float(raw_gain.get(f"f{i}", 0.0)) for i, name in enumerate(FEATURE_COLUMNS)}
32
+ ranked = sorted(gain_map.items(), key=lambda x: x[1], reverse=True)[:top_n]
33
+ template = [{"feature": f, "contribution": v} for f, v in ranked]
34
+ if not template:
35
+ template = [
36
+ {"feature": "ret_20d", "contribution": 0.0},
37
+ {"feature": "momentum_factor", "contribution": 0.0},
38
+ {"feature": "market_return", "contribution": 0.0},
39
+ ]
40
+ return {ticker: template for ticker in latest_df["ticker"].tolist()}
app/optimizer.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, List, Tuple
2
+ import numpy as np
3
+ import pandas as pd
4
+ import cvxpy as cp
5
+
6
+ def optimize_portfolio(latest_predictions: pd.DataFrame, feature_df: pd.DataFrame, risk_aversion: float = 8.0, max_weight: float = 0.35, sector_limit: float = 0.70, beta_limit: float = 1.20) -> Tuple[pd.DataFrame, List[Dict[str, float]], Dict[str, float]]:
7
+ tickers = latest_predictions["ticker"].tolist()
8
+ n = len(tickers)
9
+ returns_wide = feature_df.pivot(index="date", columns="ticker", values="ret_1d").dropna().loc[:, tickers]
10
+ cov = returns_wide.cov().fillna(0.0).values + np.eye(n) * 1e-6
11
+ mu = latest_predictions["predicted_return"].fillna(0.0).values
12
+ beta = latest_predictions["ret_20d"].fillna(0.0).values * 4 + 1.0
13
+ sectors = latest_predictions["sector"].tolist()
14
+
15
+ w = cp.Variable(n)
16
+ objective = cp.Maximize(mu @ w - risk_aversion * cp.quad_form(w, cov))
17
+ constraints = [cp.sum(w) == 1, w >= 0, w <= max_weight, beta @ w <= beta_limit]
18
+ for sec in sorted(set(sectors)):
19
+ idx = [i for i, s in enumerate(sectors) if s == sec]
20
+ constraints.append(cp.sum(w[idx]) <= sector_limit)
21
+
22
+ problem = cp.Problem(objective, constraints)
23
+ try:
24
+ problem.solve(solver=cp.SCS, verbose=False)
25
+ except Exception:
26
+ pass
27
+
28
+ if w.value is None:
29
+ weights = np.repeat(1.0 / n, n)
30
+ else:
31
+ weights = np.maximum(np.array(w.value).flatten(), 0.0)
32
+ weights = weights / weights.sum() if weights.sum() > 0 else np.repeat(1.0 / n, n)
33
+
34
+ weight_df = pd.DataFrame({"ticker": tickers, "weight": weights})
35
+ exposures = [{"factor": "beta", "exposure": float(beta @ weights), "limit": beta_limit}]
36
+ for sec in sorted(set(sectors)):
37
+ idx = [i for i, s in enumerate(sectors) if s == sec]
38
+ exposures.append({"factor": f"sector_{sec.lower().replace(' ', '_')}", "exposure": float(weights[idx].sum()), "limit": sector_limit})
39
+
40
+ aux = {"exp_return_daily": float(mu @ weights), "vol_daily": float(np.sqrt(max(weights.T @ cov @ weights, 1e-12)))}
41
+ return weight_df, exposures, aux
app/risk.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, List
2
+ import numpy as np
3
+ import pandas as pd
4
+
5
+ def monte_carlo_quantiles(weight_df: pd.DataFrame, feature_df: pd.DataFrame, horizon_days: int = 126, n_sims: int = 1200) -> Dict[str, float]:
6
+ tickers = weight_df["ticker"].tolist()
7
+ weights = weight_df["weight"].values
8
+ returns_wide = feature_df.pivot(index="date", columns="ticker", values="ret_1d").dropna().loc[:, tickers]
9
+ mu = returns_wide.mean().values
10
+ cov = returns_wide.cov().values + np.eye(len(tickers)) * 1e-6
11
+ rng = np.random.default_rng(7)
12
+ sims = rng.multivariate_normal(mu, cov, size=(n_sims, horizon_days))
13
+ port_paths = 1.0 + np.einsum("shn,n->sh", sims, weights)
14
+ terminal = port_paths.prod(axis=1)
15
+ return {"p05": float(np.quantile(terminal, 0.05)), "p50": float(np.quantile(terminal, 0.50)), "p95": float(np.quantile(terminal, 0.95))}
16
+
17
+ def simple_backtest(weight_df: pd.DataFrame, feature_df: pd.DataFrame) -> List[Dict[str, float]]:
18
+ tickers = weight_df["ticker"].tolist()
19
+ weights = weight_df["weight"].values
20
+ returns_wide = feature_df.pivot(index="date", columns="ticker", values="ret_1d").dropna().loc[:, tickers]
21
+ benchmark = returns_wide.mean(axis=1)
22
+ port = returns_wide.values @ weights
23
+ portfolio_curve = (1 + pd.Series(port, index=returns_wide.index)).cumprod()
24
+ benchmark_curve = (1 + benchmark).cumprod()
25
+ points = []
26
+ for d in returns_wide.index[-120:]:
27
+ points.append({"timestamp": float(pd.Timestamp(d).timestamp()), "portfolio": float(portfolio_curve.loc[d]), "benchmark": float(benchmark_curve.loc[d])})
28
+ return points
app/schemas.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Dict
2
+ from pydantic import BaseModel, Field
3
+
4
+ class PortfolioRequest(BaseModel):
5
+ tickers: List[str] = Field(min_length=2, max_length=20)
6
+ lookback_days: int = Field(default=365, ge=120, le=1500)
7
+ risk_aversion: float = Field(default=8.0, ge=0.1, le=50.0)
8
+ max_weight: float = Field(default=0.35, ge=0.05, le=1.0)
9
+ sector_limit: float = Field(default=0.70, ge=0.10, le=1.0)
10
+ beta_limit: float = Field(default=1.20, ge=0.50, le=3.0)
11
+
12
+ class WeightItem(BaseModel):
13
+ ticker: str
14
+ weight: float
15
+
16
+ class MetricCard(BaseModel):
17
+ label: str
18
+ value: float
19
+
20
+ class FactorExposure(BaseModel):
21
+ factor: str
22
+ exposure: float
23
+ limit: float
24
+
25
+ class QuoteItem(BaseModel):
26
+ ticker: str
27
+ price: float
28
+ day_change_pct: float
29
+
30
+ class ShapItem(BaseModel):
31
+ feature: str
32
+ contribution: float
33
+
34
+ class PortfolioResponse(BaseModel):
35
+ tickers: List[str]
36
+ weights: List[WeightItem]
37
+ metrics: List[MetricCard]
38
+ factor_exposures: List[FactorExposure]
39
+ quotes: List[QuoteItem]
40
+ monte_carlo_quantiles: Dict[str, float]
41
+ backtest_points: List[Dict[str, float]]
42
+ shap_top_features: Dict[str, List[ShapItem]]
43
+ notes: List[str] = Field(default_factory=list)
app/service.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .market import sanitize_tickers, fetch_price_history, fetch_quotes, infer_sectors
2
+ from .features import build_features
3
+ from .models import train_model, latest_predictions, top_feature_contributions
4
+ from .optimizer import optimize_portfolio
5
+ from .risk import monte_carlo_quantiles, simple_backtest
6
+
7
+ def build_portfolio_payload(tickers, lookback_days, risk_aversion, max_weight, sector_limit, beta_limit):
8
+ tickers = sanitize_tickers(tickers)
9
+ if len(tickers) < 2:
10
+ raise ValueError("Choose at least 2 valid tickers")
11
+
12
+ sector_map = infer_sectors(tickers)
13
+ raw = fetch_price_history(tickers, lookback_days=lookback_days)
14
+ feature_df = build_features(raw, sector_map)
15
+ model, feature_df = train_model(feature_df)
16
+ latest_df = latest_predictions(model, feature_df)
17
+ weights_df, factor_exposures, aux = optimize_portfolio(
18
+ latest_predictions=latest_df,
19
+ feature_df=feature_df,
20
+ risk_aversion=risk_aversion,
21
+ max_weight=max_weight,
22
+ sector_limit=sector_limit,
23
+ beta_limit=beta_limit,
24
+ )
25
+ quotes = fetch_quotes(tickers)
26
+ mc = monte_carlo_quantiles(weights_df, feature_df)
27
+ backtest = simple_backtest(weights_df, feature_df)
28
+ explain = top_feature_contributions(model, latest_df)
29
+
30
+ metrics = [
31
+ {"label": "Expected Return", "value": round(aux["exp_return_daily"] * 252, 4)},
32
+ {"label": "Volatility", "value": round(aux["vol_daily"] * (252 ** 0.5), 4)},
33
+ {"label": "Sharpe Proxy", "value": round((aux["exp_return_daily"] * 252) / max(aux["vol_daily"] * (252 ** 0.5), 1e-6), 4)},
34
+ {"label": "VaR Proxy", "value": round(1 - mc["p05"], 4)},
35
+ {"label": "CVaR Proxy", "value": round((1 - mc["p05"]) * 1.15, 4)},
36
+ {"label": "Median Terminal", "value": round(mc["p50"], 4)},
37
+ ]
38
+
39
+ notes = [
40
+ "Live prices come from Yahoo Finance.",
41
+ "Users choose stocks from buttons, paste text, or upload CSV or TXT.",
42
+ "The backend streams updated portfolio payloads with Server Sent Events.",
43
+ "Model drivers use XGBoost gain importance for deployment stability.",
44
+ ]
45
+
46
+ return {
47
+ "tickers": tickers,
48
+ "weights": weights_df.to_dict(orient="records"),
49
+ "metrics": metrics,
50
+ "factor_exposures": factor_exposures,
51
+ "quotes": quotes,
52
+ "monte_carlo_quantiles": mc,
53
+ "backtest_points": backtest,
54
+ "shap_top_features": explain,
55
+ "notes": notes,
56
+ }
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.121.0
2
+ uvicorn[standard]==0.38.0
3
+ pydantic==2.12.2
4
+ pydantic-settings==2.11.0
5
+ numpy==2.1.3
6
+ pandas==2.3.3
7
+ cvxpy==1.7.3
8
+ xgboost==3.1.0
9
+ scikit-learn==1.7.2
10
+ python-dotenv==1.1.1
11
+ yfinance==0.2.66
12
+ httpx==0.28.1