Spaces:
Sleeping
Sleeping
Upload 13 files
Browse files- Dockerfile +14 -0
- README.md +10 -0
- app/__init__.py +0 -0
- app/config.py +10 -0
- app/features.py +42 -0
- app/main.py +63 -0
- app/market.py +110 -0
- app/models.py +40 -0
- app/optimizer.py +41 -0
- app/risk.py +28 -0
- app/schemas.py +43 -0
- app/service.py +56 -0
- 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
|