dhruv575 commited on
Commit ·
283fbc7
1
Parent(s): 0df0824
Lion
Browse files- .gitattributes +1 -0
- .gitignore +35 -0
- Description.md +330 -0
- Dockerfile +19 -0
- README.md +41 -5
- app.py +323 -0
- assets/static/index.html +157 -0
- assets/static/script.js +533 -0
- assets/static/style.css +495 -0
- benchmarks.py +102 -0
- data/risk_free_data.csv +3 -0
- data/stock_data.csv +3 -0
- data/tickers_by_sector.json +42 -0
- optimization.py +347 -0
- requirements.txt +11 -0
- utils.py +119 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
data/*.csv filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
.pytest_cache/
|
| 6 |
+
*.so
|
| 7 |
+
.Python
|
| 8 |
+
env/
|
| 9 |
+
build/
|
| 10 |
+
develop-eggs/
|
| 11 |
+
dist/
|
| 12 |
+
downloads/
|
| 13 |
+
eggs/
|
| 14 |
+
.eggs/
|
| 15 |
+
lib/
|
| 16 |
+
lib64/
|
| 17 |
+
parts/
|
| 18 |
+
sdist/
|
| 19 |
+
var/
|
| 20 |
+
*.egg-info/
|
| 21 |
+
.installed.cfg
|
| 22 |
+
*.egg
|
| 23 |
+
|
| 24 |
+
# Jupyter Notebook
|
| 25 |
+
.ipynb_checkpoints
|
| 26 |
+
|
| 27 |
+
# VSCode
|
| 28 |
+
.vscode/
|
| 29 |
+
|
| 30 |
+
# PyCharm
|
| 31 |
+
.idea/
|
| 32 |
+
|
| 33 |
+
# OS specific
|
| 34 |
+
.DS_Store
|
| 35 |
+
Thumbs.db
|
Description.md
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
**STAT 4830 Frontend Technical Description**
|
| 2 |
+
---
|
| 3 |
+
|
| 4 |
+
Dhruv Gupta, Kelly Wang, Didrik Wiig-Andersen, Aiden Lee, Frank Ma
|
| 5 |
+
STAT 4830, Project PRISM
|
| 6 |
+
|
| 7 |
+
**Project Proposal**
|
| 8 |
+
|
| 9 |
+
As part of our class, we have created an online gradient ascent based model for portfolio allocation and optimization. We want to make an interactive frontend for the project that allows users to simulate and test performance on several different year ranges, hyperparameter combinations, and stock market universes.
|
| 10 |
+
|
| 11 |
+
They should receive quick and immediate feedback, as well as comparisons with several different benchmarks in terms of both cumulative returns and annualized sharpe ratio.
|
| 12 |
+
|
| 13 |
+
We should try and include as many relevant and valid graphs as possible to make the website visually appealing.
|
| 14 |
+
|
| 15 |
+
**Proposed Technical Stack**
|
| 16 |
+
|
| 17 |
+
* **Hugging Face:** Our Python backend will be hosted in a Huggingface space, which will be in charge of actually running the model and providing back the results
|
| 18 |
+
* **React:** The frontend will be all react. The app has been created using npx create-react-app to begin with.
|
| 19 |
+
|
| 20 |
+
**Backend Setup**
|
| 21 |
+
|
| 22 |
+
We have cloned our Hugging Face space into our project (a blank Gradio template project). We have renamed the folder it is in to be called "backend"
|
| 23 |
+
|
| 24 |
+
**Necessary Dependencies**
|
| 25 |
+
import torch
|
| 26 |
+
import pandas as pd
|
| 27 |
+
import matplotlib as mpl
|
| 28 |
+
import matplotlib.pyplot as plt
|
| 29 |
+
import matplotlib.dates as mdates
|
| 30 |
+
import yfinance as yf
|
| 31 |
+
from datetime import datetime
|
| 32 |
+
import numpy as np
|
| 33 |
+
import seaborn as sns
|
| 34 |
+
import wrds
|
| 35 |
+
import random
|
| 36 |
+
|
| 37 |
+
**Backend Development Steps**
|
| 38 |
+
|
| 39 |
+
These steps are to be followed by Cursor Agent running Claude 3.7 Sonnet. Each step should only be completed one at a time, and after each step is completed, the readme file should be updated accordingly. Do NOT go ahead at all and do not set up extra steps in advance. We have begun by cloning our HuggingFace spcae into our project and named the folder backend (Gradio blank template).
|
| 40 |
+
|
| 41 |
+
1. Set up the file directory and all necessary introductory files for the project. Ensure we have installed and are able to run any necessary dependencies.
|
| 42 |
+
*Completed: Created `backend/requirements.txt` and `backend/app.py`.*
|
| 43 |
+
2. Store the following list of stock tickers, organized by sector, in a JSON file
|
| 44 |
+
*Completed: Created `backend/data/tickers_by_sector.json`.*
|
| 45 |
+
|
| 46 |
+
\[
|
| 47 |
+
{
|
| 48 |
+
"sector": "Technology",
|
| 49 |
+
"tickers": \["AAPL", "MSFT", "NVDA", "GOOGL", "META", "AVGO", "ORCL", "IBM", "CSCO", "TSM", "ASML", "AMD", "TXN", "INTC", "MU", "QCOM", "LRCX", "NXPI", "ADI"\]
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"sector": "Consumer Discretionary",
|
| 53 |
+
"tickers": \["AMZN", "TSLA", "NKE", "MCD", "SBUX", "YUM", "GM", "F", "RIVN", "NIO", "TTWO", "EA", "GME", "AMC"\]
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
"sector": "Financials",
|
| 57 |
+
"tickers": \["JPM", "V", "MA", "GS", "MS", "BAC", "C", "AXP", "SCHW"\]
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
"sector": "Health Care",
|
| 61 |
+
"tickers": \["UNH", "JNJ", "LLY", "PFE", "MRNA", "BMY", "GILD", "CVS", "VRTX", "ISRG"\]
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"sector": "Consumer Staples",
|
| 65 |
+
"tickers": \["WMT", "PG", "TGT", "KO", "PEP", "TSN", "CAG", "SYY", "HRL", "MDLZ"\]
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
"sector": "Energy",
|
| 69 |
+
"tickers": \["XOM", "CVX", "NEE", "DUK", "SO", "D", "ENB", "SLB", "EOG", "PSX"\]
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"sector": "Industrials",
|
| 73 |
+
"tickers": \["DE", "LMT", "RTX", "BA", "CAT", "GE", "HON", "UPS", "EMR", "NOC", "FDX", "CSX", "UNP", "DAL"\]
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
"sector": "Real Estate",
|
| 77 |
+
"tickers": \["PLD", "AMT", "EQIX", "O", "SPG", "VICI", "DLR", "WY", "EQR", "PSA"\]
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
"sector": "Materials",
|
| 81 |
+
"tickers": \["ADM", "BG", "CF", "MOS", "FMC"\]
|
| 82 |
+
},
|
| 83 |
+
{
|
| 84 |
+
"sector": "Communication Services",
|
| 85 |
+
"tickers": \["NFLX", "DIS", "PARA", "WBD", "CMCSA", "SPOT", "LYV"\]
|
| 86 |
+
}
|
| 87 |
+
\]
|
| 88 |
+
|
| 89 |
+
3. We have stored the data for each of these tickers from 1-1-2007 to 4-1-2025 in a file called data/stock_data.csv and the risk free returns values for each day in data/risk_free_data.csv. Please read them in and save them as df for future reference
|
| 90 |
+
*Completed: Loaded data into global DataFrames in `backend/app.py`.*
|
| 91 |
+
4. Create a route that, given optional inputs of start date, end date, and tickers, creates a dataframe that only contains data on the given tickers for the given timeframe
|
| 92 |
+
*Completed: Created `filter_data` function in `backend/utils.py` and test interface in `backend/app.py`.*
|
| 93 |
+
5. Build out a route that runs our OGD on a given dataframe which takes in hyperparameters and returns both the day to day weights and day to day returns (both cumulative and by ticker)
|
| 94 |
+
*Completed: Created `run_ogd` function in `backend/optimization.py` and integrated into Gradio interface in `backend/app.py`.*
|
| 95 |
+
|
| 96 |
+
\# Objective function
|
| 97 |
+
def calculate\_sharpe(
|
| 98 |
+
returns: torch.tensor,
|
| 99 |
+
risk\_free\_rate: torch.tensor \= None
|
| 100 |
+
):
|
| 101 |
+
if risk\_free\_rate is not None:
|
| 102 |
+
excess\_returns \= returns \- risk\_free\_rate
|
| 103 |
+
else:
|
| 104 |
+
excess\_returns \= returns
|
| 105 |
+
sharpe \= torch.mean(excess\_returns, dim=0) / torch.std(excess\_returns, dim=0)
|
| 106 |
+
return sharpe
|
| 107 |
+
|
| 108 |
+
def calculate\_sortino(
|
| 109 |
+
returns: torch.tensor,
|
| 110 |
+
min\_acceptable\_return: torch.tensor
|
| 111 |
+
):
|
| 112 |
+
if min\_acceptable\_return is not None:
|
| 113 |
+
excess\_returns \= returns \- min\_acceptable\_return
|
| 114 |
+
downside\_deviation \= torch.std(
|
| 115 |
+
torch.where(excess\_returns \< 0, excess\_returns, torch.tensor(0.0)),
|
| 116 |
+
)
|
| 117 |
+
sortino \= torch.mean(excess\_returns, dim=0) / (downside\_deviation \+ eps\*\*2)
|
| 118 |
+
return sortino
|
| 119 |
+
|
| 120 |
+
def calculate\_max\_drawdown(
|
| 121 |
+
returns: torch.tensor
|
| 122 |
+
):
|
| 123 |
+
"""calculates max drawdown for the duration of the returns passed
|
| 124 |
+
i.e. expects returns to be trimmed to the period of interest
|
| 125 |
+
|
| 126 |
+
max drawdown is defined to be positive, takes the range \[0, \\infty)
|
| 127 |
+
"""
|
| 128 |
+
cum\_returns \= (returns \+ 1).cumprod(dim=0)
|
| 129 |
+
return (cum\_returns.max() \- cum\_returns\[-1\]) / (cum\_returns.max() \+ eps \*\*2)
|
| 130 |
+
|
| 131 |
+
def calculate\_turnover(
|
| 132 |
+
new\_weights: torch.tensor,
|
| 133 |
+
prev\_weights: torch.tensor
|
| 134 |
+
):
|
| 135 |
+
"""Turnover is defined to be the sum of absolute differences
|
| 136 |
+
between the new weights and the previous weights, divided by 2\.
|
| 137 |
+
Takes the range \[0, \\infty)
|
| 138 |
+
|
| 139 |
+
This value should be minimized
|
| 140 |
+
"""
|
| 141 |
+
return torch.sum(torch.abs(new\_weights \- prev\_weights)) / 2
|
| 142 |
+
|
| 143 |
+
def calculate\_objective\_func(
|
| 144 |
+
returns: torch.tensor,
|
| 145 |
+
risk\_free\_rate: torch.tensor,
|
| 146 |
+
new\_weights,
|
| 147 |
+
prev\_weights,
|
| 148 |
+
alphas \= \[1,1,1\]
|
| 149 |
+
):
|
| 150 |
+
return (
|
| 151 |
+
a\[0\] \* calculate\_sortino(returns, risk\_free\_rate)
|
| 152 |
+
\- a\[1\] \* calculate\_max\_drawdown(returns)
|
| 153 |
+
\- a\[2\] \* calculate\_turnover(
|
| 154 |
+
new\_weights,
|
| 155 |
+
prev\_weights
|
| 156 |
+
)
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
\# set up
|
| 160 |
+
window\_size \= 10
|
| 161 |
+
|
| 162 |
+
return\_logs \= torch.zeros(
|
| 163 |
+
size \= (returns.shape\[0\],),
|
| 164 |
+
dtype=torch.float32
|
| 165 |
+
)
|
| 166 |
+
rolling\_return\_list \= \[\]
|
| 167 |
+
|
| 168 |
+
\# returns.shape\[1\] \- 1 because we don't allow investing in
|
| 169 |
+
\# risk free asset for the moment
|
| 170 |
+
print(f"Initializing optimization...")
|
| 171 |
+
weights \= torch.rand(
|
| 172 |
+
size \= (returns.shape\[1\] \- 1,),
|
| 173 |
+
requires\_grad=True
|
| 174 |
+
)
|
| 175 |
+
optimizer \= torch.optim.SGD(\[weights\], lr=0.5)
|
| 176 |
+
weights\_log \= torch.zeros((returns.shape\[0\], returns.shape\[1\] \- 1))
|
| 177 |
+
|
| 178 |
+
for i, date in enumerate(returns.index):
|
| 179 |
+
if i % 5 \== 0:
|
| 180 |
+
print(f"Step {i} of {returns.shape\[0\]}", end \= '\\r')
|
| 181 |
+
|
| 182 |
+
normalized\_weights \= torch.nn.functional.softmax(weights, dim=0)
|
| 183 |
+
daily\_returns \= torch.tensor(
|
| 184 |
+
returns.loc\[date\].T\[:-1\],
|
| 185 |
+
dtype=torch.float32
|
| 186 |
+
)
|
| 187 |
+
ret \= torch.dot(normalized\_weights, daily\_returns)
|
| 188 |
+
|
| 189 |
+
\# for logging
|
| 190 |
+
return\_logs\[i\] \= ret.detach()
|
| 191 |
+
rolling\_return\_list.append(ret)
|
| 192 |
+
|
| 193 |
+
if len(rolling\_return\_list) \> window\_size:
|
| 194 |
+
rolling\_return\_list.pop(0)
|
| 195 |
+
past\_returns \= torch.stack(rolling\_return\_list)
|
| 196 |
+
past\_rf \= torch.tensor(
|
| 197 |
+
returns.iloc\[max(0, i \- window\_size):i\]\['rf'\].values,
|
| 198 |
+
dtype=torch.float32
|
| 199 |
+
)
|
| 200 |
+
objective \= \-calculate\_objective\_func(
|
| 201 |
+
past\_returns,
|
| 202 |
+
past\_rf,
|
| 203 |
+
normalized\_weights,
|
| 204 |
+
weights\_log\[i \- 1\]
|
| 205 |
+
)
|
| 206 |
+
optimizer.zero\_grad()
|
| 207 |
+
objective.backward(retain\_graph=True)
|
| 208 |
+
optimizer.step()
|
| 209 |
+
|
| 210 |
+
weights\_log\[i\] \= normalized\_weights
|
| 211 |
+
|
| 212 |
+
6. Build out a route that given a dataframe returns the day to day returns for an equal weight portfolio
|
| 213 |
+
7. Build out a route that given a dataframe returns the day to day returns for a "random portfolio" – you fully (and equally) invest in 3 randomly selected stocks on any given day
|
| 214 |
+
8. Build out a unified route that, given a set of hyperparameters, start date, and end date, creates the dataframe, runs OGD, and also runs both the benchmarks, then returns all the data
|
| 215 |
+
9. Bundle it all into a well structured API
|
| 216 |
+
|
| 217 |
+
**Frontend Development Steps**
|
| 218 |
+
|
| 219 |
+
1. Create a file directory with images, components, data and pages. Create a global API variable that is set and can be edited for where the server is hosted
|
| 220 |
+
2. Develop a header and footer for the project
|
| 221 |
+
3. Create the layout for a dashboard that will take up exactly 100vh
|
| 222 |
+
1. On the right 1/4th we should have a list of our 111 stock tickers, grouped by sector. There should be a way to select our stock tickers in batches (i.e. toggle all, toggle by sector, etc) or individually
|
| 223 |
+
4. On the left hand side Top 2/3, we should have 2 graphs; cumulative returns and weight evolution. These graphs must be highly reflexive, running across the run's time and necessary tickers, etc
|
| 224 |
+
2. On the top of the left hand side above the stock tickers, we should have a horizontal menu to set our 4 variables
|
| 225 |
+
3. There should also be a smart and relevant place to run "Allocate Portfolio"
|
| 226 |
+
5. On the bottom 1/3rd of the left, we should have 3 graphs in a row that provide more specific statistics. I will leave it up to you to decide what these graphs should show
|
| 227 |
+
|
| 228 |
+
**Frontend Considerations**
|
| 229 |
+
|
| 230 |
+
1. We want the frontend to be as clean and modern as possible, considering our target audience is 16-24 year olds. Take heavy inspiration from the UI of Notion. Have it be by default in a "dark mode"
|
| 231 |
+
2. We want the frontend to feel responsive and provide micro or fake feedback while we're waiting for the OGD as it may take quite long. Maybe run some fake simulations through geometric brownian motions while we're waiting
|
| 232 |
+
|
| 233 |
+
# Small epsilon for Sharpe calculation
|
| 234 |
+
eps = 1e-8
|
| 235 |
+
ANNUAL_TRADING_DAYS = 252
|
| 236 |
+
|
| 237 |
+
def run_equal_weight(data_df: pd.DataFrame) -> pd.Series:
|
| 238 |
+
"""Calculates daily returns for a static equal-weight portfolio.
|
| 239 |
+
|
| 240 |
+
Args:
|
| 241 |
+
data_df (pd.DataFrame): DataFrame with dates as index, ticker returns,
|
| 242 |
+
and an 'rf' column.
|
| 243 |
+
|
| 244 |
+
Returns:
|
| 245 |
+
pd.Series: Daily returns of the equal-weight portfolio.
|
| 246 |
+
"""
|
| 247 |
+
stock_returns = data_df.drop(columns=['rf'], errors='ignore')
|
| 248 |
+
if stock_returns.empty:
|
| 249 |
+
return pd.Series(dtype=float, name="EqualWeightReturn")
|
| 250 |
+
# Calculate the mean return across all stocks for each day
|
| 251 |
+
daily_returns = stock_returns.mean(axis=1)
|
| 252 |
+
return daily_returns.rename("EqualWeightReturn")
|
| 253 |
+
|
| 254 |
+
def run_random_portfolio(
|
| 255 |
+
data_df: pd.DataFrame,
|
| 256 |
+
num_stocks: int = 3,
|
| 257 |
+
rebalance_days: int = 20
|
| 258 |
+
) -> pd.Series:
|
| 259 |
+
"""Calculates daily returns for a randomly selected portfolio,
|
| 260 |
+
rebalanced periodically.
|
| 261 |
+
|
| 262 |
+
Args:
|
| 263 |
+
data_df (pd.DataFrame): DataFrame with dates as index, ticker returns,
|
| 264 |
+
and an 'rf' column.
|
| 265 |
+
num_stocks (int): Number of stocks to randomly select.
|
| 266 |
+
rebalance_days (int): How often to re-select stocks.
|
| 267 |
+
|
| 268 |
+
Returns:
|
| 269 |
+
pd.Series: Daily returns of the random portfolio.
|
| 270 |
+
"""
|
| 271 |
+
stock_returns = data_df.drop(columns=['rf'], errors='ignore')
|
| 272 |
+
if stock_returns.empty or stock_returns.shape[1] < num_stocks:
|
| 273 |
+
print("Warning: Not enough stocks available for random portfolio.")
|
| 274 |
+
return pd.Series(dtype=float, name="RandomPortfolioReturn")
|
| 275 |
+
|
| 276 |
+
tickers = stock_returns.columns.tolist()
|
| 277 |
+
portfolio_returns = pd.Series(index=data_df.index, dtype=float)
|
| 278 |
+
selected_tickers = []
|
| 279 |
+
|
| 280 |
+
for i, date in enumerate(data_df.index):
|
| 281 |
+
# Rebalance check
|
| 282 |
+
if i % rebalance_days == 0 or not selected_tickers:
|
| 283 |
+
if len(tickers) >= num_stocks:
|
| 284 |
+
selected_tickers = random.sample(tickers, num_stocks)
|
| 285 |
+
else: # Should not happen based on initial check, but safe
|
| 286 |
+
selected_tickers = tickers
|
| 287 |
+
# print(f"Rebalancing Random Portfolio on {date.date()}: {selected_tickers}")
|
| 288 |
+
|
| 289 |
+
# Calculate return for the day using selected tickers
|
| 290 |
+
daily_returns = stock_returns.loc[date, selected_tickers]
|
| 291 |
+
portfolio_returns[date] = daily_returns.mean() # Equal weight among selected
|
| 292 |
+
|
| 293 |
+
return portfolio_returns.rename("RandomPortfolioReturn")
|
| 294 |
+
|
| 295 |
+
# --- Performance Metrics ---
|
| 296 |
+
|
| 297 |
+
def calculate_cumulative_returns(returns_series: pd.Series) -> pd.Series:
|
| 298 |
+
"""Calculates cumulative returns from a daily returns series."""
|
| 299 |
+
return (1 + returns_series.fillna(0)).cumprod()
|
| 300 |
+
|
| 301 |
+
def calculate_performance_metrics(returns_series: pd.Series, rf_series: pd.Series) -> dict:
|
| 302 |
+
"""Calculates annualized Sharpe Ratio and Max Drawdown."""
|
| 303 |
+
if returns_series.empty or returns_series.isnull().all():
|
| 304 |
+
return {"Annualized Sharpe Ratio": 0.0, "Max Drawdown": 0.0, "Cumulative Return": 1.0}
|
| 305 |
+
|
| 306 |
+
cumulative_return = (1 + returns_series.fillna(0)).cumprod().iloc[-1]
|
| 307 |
+
|
| 308 |
+
# Align risk-free rate series to the returns series index
|
| 309 |
+
aligned_rf = rf_series.reindex(returns_series.index).fillna(0)
|
| 310 |
+
|
| 311 |
+
# Calculate Excess Returns
|
| 312 |
+
excess_returns = returns_series - aligned_rf
|
| 313 |
+
|
| 314 |
+
# Annualized Sharpe Ratio
|
| 315 |
+
# Use np.sqrt(ANNUAL_TRADING_DAYS) for annualization factor
|
| 316 |
+
mean_excess_return = excess_returns.mean()
|
| 317 |
+
std_dev_excess_return = excess_returns.std()
|
| 318 |
+
sharpe_ratio = (mean_excess_return / (std_dev_excess_return + eps)) * np.sqrt(ANNUAL_TRADING_DAYS)
|
| 319 |
+
|
| 320 |
+
# Max Drawdown
|
| 321 |
+
cumulative = calculate_cumulative_returns(returns_series)
|
| 322 |
+
peak = cumulative.expanding(min_periods=1).max()
|
| 323 |
+
drawdown = (cumulative - peak) / (peak + eps) # Drawdown is negative or zero
|
| 324 |
+
max_drawdown = abs(drawdown.min()) # Max drawdown is positive
|
| 325 |
+
|
| 326 |
+
return {
|
| 327 |
+
"Annualized Sharpe Ratio": round(sharpe_ratio, 4),
|
| 328 |
+
"Max Drawdown": round(max_drawdown, 4),
|
| 329 |
+
"Cumulative Return": round(cumulative_return, 4)
|
| 330 |
+
}
|
Dockerfile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Copy requirements first for better caching
|
| 6 |
+
COPY requirements.txt /app/
|
| 7 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 8 |
+
|
| 9 |
+
# Copy the rest of the application
|
| 10 |
+
COPY . /app/
|
| 11 |
+
|
| 12 |
+
# Make sure assets directory exists
|
| 13 |
+
RUN mkdir -p /app/assets/static
|
| 14 |
+
|
| 15 |
+
# Expose port
|
| 16 |
+
EXPOSE 8000
|
| 17 |
+
|
| 18 |
+
# Command to run the application
|
| 19 |
+
CMD ["python", "app.py"]
|
README.md
CHANGED
|
@@ -1,10 +1,46 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: gray
|
| 6 |
sdk: docker
|
| 7 |
-
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Portfolio Optimization with OGD
|
| 3 |
+
emoji: 📈
|
| 4 |
+
colorFrom: indigo
|
| 5 |
colorTo: gray
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 8000
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# Portfolio Optimization with OGD
|
| 11 |
+
|
| 12 |
+
An interactive portfolio optimization tool using Online Gradient Descent (OGD) to find optimal stock weights.
|
| 13 |
+
|
| 14 |
+
## Features
|
| 15 |
+
|
| 16 |
+
- **Modern UI**: Sleek dark-themed interface inspired by Notion
|
| 17 |
+
- **Interactive Charts**: Visualize cumulative returns and weight evolution
|
| 18 |
+
- **Stock Selection**: Choose from 100+ stocks grouped by sector
|
| 19 |
+
- **Performance Metrics**: Compare OGD with equal-weight and random portfolios
|
| 20 |
+
- **Responsive Design**: Works on desktop and mobile devices
|
| 21 |
+
|
| 22 |
+
## How to Use
|
| 23 |
+
|
| 24 |
+
1. Select your desired date range and optimization parameters
|
| 25 |
+
2. Choose stocks from the sector lists on the right
|
| 26 |
+
3. Click "Run Allocation" to run the optimization
|
| 27 |
+
4. View results in the interactive charts and metrics panels
|
| 28 |
+
|
| 29 |
+
## Technical Details
|
| 30 |
+
|
| 31 |
+
This application combines:
|
| 32 |
+
- A FastAPI backend for API endpoints and serving static files
|
| 33 |
+
- A custom HTML/CSS/JS frontend for a modern UI experience
|
| 34 |
+
- A Gradio interface (accessible at `/gradio`) for simplified usage
|
| 35 |
+
|
| 36 |
+
## Running Locally
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
# Install dependencies
|
| 40 |
+
pip install -r requirements.txt
|
| 41 |
+
|
| 42 |
+
# Run the application
|
| 43 |
+
python app.py
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
The application will be available at http://localhost:8000
|
app.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import matplotlib.pyplot as plt
|
| 4 |
+
import matplotlib
|
| 5 |
+
matplotlib.use('Agg') # Use Agg backend for non-interactive plotting
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
from fastapi import FastAPI, Request, Response, Body
|
| 9 |
+
from fastapi.responses import JSONResponse, HTMLResponse, FileResponse
|
| 10 |
+
from fastapi.staticfiles import StaticFiles
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
import numpy as np
|
| 13 |
+
import datetime
|
| 14 |
+
|
| 15 |
+
from utils import load_data, filter_data
|
| 16 |
+
from optimization import run_ogd
|
| 17 |
+
# Import benchmark functions and metrics
|
| 18 |
+
from benchmarks import (
|
| 19 |
+
run_equal_weight,
|
| 20 |
+
run_random_portfolio,
|
| 21 |
+
calculate_cumulative_returns,
|
| 22 |
+
calculate_performance_metrics
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
# --- Load Data Globally ---
|
| 26 |
+
stock_data_df, rf_data_df = load_data()
|
| 27 |
+
|
| 28 |
+
if stock_data_df is None or rf_data_df is None:
|
| 29 |
+
print("Exiting application due to data loading error.")
|
| 30 |
+
exit()
|
| 31 |
+
|
| 32 |
+
# --- Create FastAPI app ---
|
| 33 |
+
app = FastAPI()
|
| 34 |
+
|
| 35 |
+
# --- Setup Static Files ---
|
| 36 |
+
static_dir = Path(__file__).parent / "assets" / "static"
|
| 37 |
+
if not static_dir.exists():
|
| 38 |
+
static_dir.mkdir(parents=True, exist_ok=True)
|
| 39 |
+
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
| 40 |
+
|
| 41 |
+
# --- Main Optimization Pipeline Function ---
|
| 42 |
+
def run_optimization_pipeline(
|
| 43 |
+
start_date, end_date, tickers_str,
|
| 44 |
+
window_size, learning_rate,
|
| 45 |
+
alpha_sortino, alpha_max_drawdown, alpha_turnover
|
| 46 |
+
):
|
| 47 |
+
"""Runs the full pipeline: filter data -> run OGD & benchmarks -> calculate metrics & plots -> return results."""
|
| 48 |
+
if not tickers_str:
|
| 49 |
+
tickers = None
|
| 50 |
+
else:
|
| 51 |
+
tickers = [t.strip().upper() for t in tickers_str.split(',')]
|
| 52 |
+
try:
|
| 53 |
+
window_size = int(window_size)
|
| 54 |
+
learning_rate = float(learning_rate)
|
| 55 |
+
alphas = [float(alpha_sortino), float(alpha_max_drawdown), float(alpha_turnover)]
|
| 56 |
+
except ValueError as e:
|
| 57 |
+
return {"error": f"Invalid hyperparameter input. Details: {e}"}
|
| 58 |
+
|
| 59 |
+
print(f"Filtering data: Start={start_date}, End={end_date}, Tickers={tickers}")
|
| 60 |
+
# 2. Filter Data
|
| 61 |
+
filtered_df = filter_data(stock_data_df, rf_data_df, start_date, end_date, tickers)
|
| 62 |
+
|
| 63 |
+
if filtered_df is None or filtered_df.empty:
|
| 64 |
+
return {"error": "Filtering resulted in empty data. Cannot run."}
|
| 65 |
+
|
| 66 |
+
# Extract risk-free series for metric calculation
|
| 67 |
+
rf_series = filtered_df['rf']
|
| 68 |
+
|
| 69 |
+
print(f"Running OGD: Window={window_size}, LR={learning_rate}, Alphas={alphas}")
|
| 70 |
+
# 3. Run OGD
|
| 71 |
+
ogd_weights_df, ogd_returns_series = run_ogd(
|
| 72 |
+
filtered_df, window_size=window_size, learning_rate=learning_rate, alphas=alphas
|
| 73 |
+
)
|
| 74 |
+
if ogd_weights_df.empty or ogd_returns_series.empty:
|
| 75 |
+
return {"error": "OGD failed or returned empty results."}
|
| 76 |
+
|
| 77 |
+
print("Running Benchmarks...")
|
| 78 |
+
# 4. Run Benchmarks
|
| 79 |
+
equal_weight_returns = run_equal_weight(filtered_df)
|
| 80 |
+
random_portfolio_returns = run_random_portfolio(filtered_df)
|
| 81 |
+
|
| 82 |
+
# 5. Calculate Metrics & Cumulative Returns
|
| 83 |
+
ogd_metrics = calculate_performance_metrics(ogd_returns_series, rf_series)
|
| 84 |
+
ew_metrics = calculate_performance_metrics(equal_weight_returns, rf_series)
|
| 85 |
+
rp_metrics = calculate_performance_metrics(random_portfolio_returns, rf_series)
|
| 86 |
+
|
| 87 |
+
# Calculate cumulative returns for charts
|
| 88 |
+
ogd_cumulative = calculate_cumulative_returns(ogd_returns_series)
|
| 89 |
+
ew_cumulative = calculate_cumulative_returns(equal_weight_returns)
|
| 90 |
+
rp_cumulative = calculate_cumulative_returns(random_portfolio_returns)
|
| 91 |
+
|
| 92 |
+
# Convert cumulative returns to chart-friendly format
|
| 93 |
+
ogd_returns_data = [{"date": date.strftime("%Y-%m-%d"), "value": float(value)}
|
| 94 |
+
for date, value in ogd_cumulative.items()]
|
| 95 |
+
ew_returns_data = [{"date": date.strftime("%Y-%m-%d"), "value": float(value)}
|
| 96 |
+
for date, value in ew_cumulative.items()]
|
| 97 |
+
rp_returns_data = [{"date": date.strftime("%Y-%m-%d"), "value": float(value)}
|
| 98 |
+
for date, value in rp_cumulative.items()]
|
| 99 |
+
|
| 100 |
+
# Convert weights to chart-friendly format
|
| 101 |
+
weights_data = []
|
| 102 |
+
for date, row in ogd_weights_df.iterrows():
|
| 103 |
+
# Filter out very small weights to keep chart readable
|
| 104 |
+
significant_weights = {ticker: float(weight) for ticker, weight in row.items()
|
| 105 |
+
if weight > 0.01} # Only include weights > 1%
|
| 106 |
+
weights_data.append({
|
| 107 |
+
"date": date.strftime("%Y-%m-%d"),
|
| 108 |
+
"weights": significant_weights
|
| 109 |
+
})
|
| 110 |
+
|
| 111 |
+
return {
|
| 112 |
+
"success": True,
|
| 113 |
+
"cumulative_returns": {
|
| 114 |
+
"ogd": ogd_returns_data,
|
| 115 |
+
"equal_weight": ew_returns_data,
|
| 116 |
+
"random": rp_returns_data
|
| 117 |
+
},
|
| 118 |
+
"weights": weights_data,
|
| 119 |
+
"metrics": {
|
| 120 |
+
"ogd": {
|
| 121 |
+
"sharpe": float(ogd_metrics["Annualized Sharpe Ratio"]),
|
| 122 |
+
"max_drawdown": float(ogd_metrics["Max Drawdown"]),
|
| 123 |
+
"cumulative_return": float(ogd_metrics["Cumulative Return"])
|
| 124 |
+
},
|
| 125 |
+
"equal_weight": {
|
| 126 |
+
"sharpe": float(ew_metrics["Annualized Sharpe Ratio"]),
|
| 127 |
+
"max_drawdown": float(ew_metrics["Max Drawdown"]),
|
| 128 |
+
"cumulative_return": float(ew_metrics["Cumulative Return"])
|
| 129 |
+
},
|
| 130 |
+
"random": {
|
| 131 |
+
"sharpe": float(rp_metrics["Annualized Sharpe Ratio"]),
|
| 132 |
+
"max_drawdown": float(rp_metrics["Max Drawdown"]),
|
| 133 |
+
"cumulative_return": float(rp_metrics["Cumulative Return"])
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
# --- API Endpoints ---
|
| 139 |
+
@app.get("/")
|
| 140 |
+
async def serve_frontend():
|
| 141 |
+
"""Serve the custom frontend HTML."""
|
| 142 |
+
html_path = Path(__file__).parent / "assets" / "static" / "index.html"
|
| 143 |
+
if html_path.exists():
|
| 144 |
+
with open(html_path) as f:
|
| 145 |
+
content = f.read()
|
| 146 |
+
return HTMLResponse(content=content)
|
| 147 |
+
else:
|
| 148 |
+
return {"error": "Frontend HTML file not found"}
|
| 149 |
+
|
| 150 |
+
@app.get("/api/tickers_by_sector")
|
| 151 |
+
async def get_tickers_by_sector():
|
| 152 |
+
"""Return the tickers grouped by sector."""
|
| 153 |
+
json_path = Path(__file__).parent / "data" / "tickers_by_sector.json"
|
| 154 |
+
if json_path.exists():
|
| 155 |
+
with open(json_path) as f:
|
| 156 |
+
return json.load(f)
|
| 157 |
+
else:
|
| 158 |
+
# Fallback to generating sectors from available tickers
|
| 159 |
+
tickers = stock_data_df.columns.tolist()
|
| 160 |
+
if 'rf' in tickers:
|
| 161 |
+
tickers.remove('rf')
|
| 162 |
+
return [{"sector": "All Available Tickers", "tickers": tickers}]
|
| 163 |
+
|
| 164 |
+
@app.post("/api/run_optimization")
|
| 165 |
+
async def api_run_optimization(data: dict = Body(...)):
|
| 166 |
+
"""API endpoint for running the optimization pipeline."""
|
| 167 |
+
try:
|
| 168 |
+
result = run_optimization_pipeline(
|
| 169 |
+
start_date=data.get('start_date'),
|
| 170 |
+
end_date=data.get('end_date'),
|
| 171 |
+
tickers_str=data.get('tickers', ''),
|
| 172 |
+
window_size=data.get('window_size', 20),
|
| 173 |
+
learning_rate=data.get('learning_rate', 0.1),
|
| 174 |
+
alpha_sortino=data.get('alpha_sortino', 1.0),
|
| 175 |
+
alpha_max_drawdown=data.get('alpha_max_drawdown', 1.0),
|
| 176 |
+
alpha_turnover=data.get('alpha_turnover', 0.1)
|
| 177 |
+
)
|
| 178 |
+
return result
|
| 179 |
+
except Exception as e:
|
| 180 |
+
return {"error": f"An error occurred: {str(e)}"}
|
| 181 |
+
|
| 182 |
+
# --- Gradio Interface ---
|
| 183 |
+
# Create a custom dark theme for Gradio
|
| 184 |
+
dark_theme = gr.themes.Monochrome(
|
| 185 |
+
primary_hue="indigo",
|
| 186 |
+
secondary_hue="slate",
|
| 187 |
+
neutral_hue="slate",
|
| 188 |
+
radius_size=gr.themes.sizes.radius_sm,
|
| 189 |
+
font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"]
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
with gr.Blocks(theme=dark_theme) as demo:
|
| 193 |
+
gr.Markdown("""# Portfolio Optimization with OGD
|
| 194 |
+
*Optimize your portfolio using Online Gradient Descent and compare against benchmarks.*""")
|
| 195 |
+
|
| 196 |
+
# Add a link to the custom frontend
|
| 197 |
+
gr.Markdown("""
|
| 198 |
+
## View Enhanced UI
|
| 199 |
+
|
| 200 |
+
Try our enhanced, modern UI with interactive charts and stock selection:
|
| 201 |
+
|
| 202 |
+
* [Open Modern Interface](/)
|
| 203 |
+
|
| 204 |
+
Below is the basic Gradio interface for quick testing:
|
| 205 |
+
""")
|
| 206 |
+
|
| 207 |
+
with gr.Row():
|
| 208 |
+
with gr.Column(scale=1): # Input Column
|
| 209 |
+
gr.Markdown("### Configure Simulation")
|
| 210 |
+
with gr.Accordion("Data Selection", open=True): # Group data inputs
|
| 211 |
+
start_date_input = gr.Textbox(label="Start Date (YYYY-MM-DD)", placeholder="Default: Earliest", info="Leave blank for earliest available date.")
|
| 212 |
+
end_date_input = gr.Textbox(label="End Date (YYYY-MM-DD)", placeholder="Default: Latest", info="Leave blank for latest available date.")
|
| 213 |
+
tickers_input = gr.Textbox(
|
| 214 |
+
label="Tickers (comma-separated)",
|
| 215 |
+
placeholder="e.g., AAPL, MSFT, GOOGL",
|
| 216 |
+
info="Leave blank to use all available tickers in the date range."
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
with gr.Accordion("OGD Hyperparameters", open=True): # Group hyperparameters
|
| 220 |
+
window_size_input = gr.Number(label="Lookback Window (days)", value=20, minimum=5, step=1, info="Days of past returns used for optimization.")
|
| 221 |
+
learning_rate_input = gr.Number(label="Learning Rate", value=0.1, minimum=0.001, info="Step size for gradient updates.")
|
| 222 |
+
|
| 223 |
+
gr.Markdown("##### Objective Function Weights (Alphas)")
|
| 224 |
+
alpha_sortino_input = gr.Number(label="Sortino Ratio Weight", value=1.0, minimum=0, info="Emphasis on maximizing risk-adjusted returns (downside risk).")
|
| 225 |
+
alpha_max_drawdown_input = gr.Number(label="Max Drawdown Weight", value=1.0, minimum=0, info="Emphasis on minimizing the largest peak-to-trough decline.")
|
| 226 |
+
alpha_turnover_input = gr.Number(label="Turnover Weight", value=0.1, minimum=0, info="Emphasis on minimizing trading frequency/costs.")
|
| 227 |
+
|
| 228 |
+
run_button = gr.Button("Run Optimization", variant="primary", scale=1) # Made button full width within column
|
| 229 |
+
|
| 230 |
+
with gr.Column(scale=3): # Output Column
|
| 231 |
+
gr.Markdown("### Results")
|
| 232 |
+
# Output components:
|
| 233 |
+
run_status_text = gr.Textbox(label="Run Status", interactive=False, lines=1)
|
| 234 |
+
metrics_output_df = gr.DataFrame(label="Performance Metrics Summary", interactive=False)
|
| 235 |
+
plot_output = gr.Plot(label="Cumulative Returns Comparison")
|
| 236 |
+
weights_output_df = gr.DataFrame(label="OGD Portfolio Weights (Daily)", interactive=False) # Removed height parameter
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
# This is the same function as before but wrapped to match the Gradio interface
|
| 240 |
+
def gradio_run_optimization(
|
| 241 |
+
start_date, end_date, tickers_str,
|
| 242 |
+
window_size, learning_rate,
|
| 243 |
+
alpha_sortino, alpha_max_drawdown, alpha_turnover
|
| 244 |
+
):
|
| 245 |
+
result = run_optimization_pipeline(
|
| 246 |
+
start_date, end_date, tickers_str,
|
| 247 |
+
window_size, learning_rate,
|
| 248 |
+
alpha_sortino, alpha_max_drawdown, alpha_turnover
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
if "error" in result:
|
| 252 |
+
return result["error"], None, None, None
|
| 253 |
+
|
| 254 |
+
# Generate the metrics dataframe for Gradio
|
| 255 |
+
metrics_df = pd.DataFrame({
|
| 256 |
+
'OGD Portfolio': {
|
| 257 |
+
'Annualized Sharpe Ratio': result['metrics']['ogd']['sharpe'],
|
| 258 |
+
'Max Drawdown': result['metrics']['ogd']['max_drawdown'],
|
| 259 |
+
'Cumulative Return': result['metrics']['ogd']['cumulative_return']
|
| 260 |
+
},
|
| 261 |
+
'Equal Weight': {
|
| 262 |
+
'Annualized Sharpe Ratio': result['metrics']['equal_weight']['sharpe'],
|
| 263 |
+
'Max Drawdown': result['metrics']['equal_weight']['max_drawdown'],
|
| 264 |
+
'Cumulative Return': result['metrics']['equal_weight']['cumulative_return']
|
| 265 |
+
},
|
| 266 |
+
'Random Portfolio': {
|
| 267 |
+
'Annualized Sharpe Ratio': result['metrics']['random']['sharpe'],
|
| 268 |
+
'Max Drawdown': result['metrics']['random']['max_drawdown'],
|
| 269 |
+
'Cumulative Return': result['metrics']['random']['cumulative_return']
|
| 270 |
+
}
|
| 271 |
+
}).T
|
| 272 |
+
|
| 273 |
+
# Create the matplotlib plot
|
| 274 |
+
fig, ax = plt.subplots(figsize=(10, 6))
|
| 275 |
+
|
| 276 |
+
# Convert API-friendly format back to series for plotting
|
| 277 |
+
ogd_data = pd.Series({datetime.datetime.strptime(d['date'], '%Y-%m-%d').date(): d['value']
|
| 278 |
+
for d in result['cumulative_returns']['ogd']})
|
| 279 |
+
ew_data = pd.Series({datetime.datetime.strptime(d['date'], '%Y-%m-%d').date(): d['value']
|
| 280 |
+
for d in result['cumulative_returns']['equal_weight']})
|
| 281 |
+
rp_data = pd.Series({datetime.datetime.strptime(d['date'], '%Y-%m-%d').date(): d['value']
|
| 282 |
+
for d in result['cumulative_returns']['random']})
|
| 283 |
+
|
| 284 |
+
ogd_data.plot(ax=ax, label='OGD Portfolio')
|
| 285 |
+
ew_data.plot(ax=ax, label='Equal Weight')
|
| 286 |
+
rp_data.plot(ax=ax, label='Random Portfolio')
|
| 287 |
+
ax.set_title('Cumulative Portfolio Returns')
|
| 288 |
+
ax.set_ylabel('Cumulative Return')
|
| 289 |
+
ax.set_xlabel('Date')
|
| 290 |
+
ax.legend()
|
| 291 |
+
ax.grid(True)
|
| 292 |
+
plt.tight_layout()
|
| 293 |
+
|
| 294 |
+
# Convert weights data back to DataFrame for Gradio
|
| 295 |
+
weights_df = pd.DataFrame()
|
| 296 |
+
for day_data in result['weights']:
|
| 297 |
+
date = datetime.datetime.strptime(day_data['date'], '%Y-%m-%d').date()
|
| 298 |
+
weights_df = pd.concat([weights_df, pd.Series(day_data['weights'], name=date)])
|
| 299 |
+
|
| 300 |
+
return "Run successful!", metrics_df, fig, weights_df
|
| 301 |
+
|
| 302 |
+
run_button.click(
|
| 303 |
+
gradio_run_optimization,
|
| 304 |
+
inputs=[
|
| 305 |
+
start_date_input, end_date_input, tickers_input,
|
| 306 |
+
window_size_input, learning_rate_input,
|
| 307 |
+
alpha_sortino_input, alpha_max_drawdown_input, alpha_turnover_input
|
| 308 |
+
],
|
| 309 |
+
outputs=[
|
| 310 |
+
run_status_text,
|
| 311 |
+
metrics_output_df,
|
| 312 |
+
plot_output,
|
| 313 |
+
weights_output_df
|
| 314 |
+
]
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
# --- Mount Gradio app to FastAPI ---
|
| 318 |
+
app = gr.mount_gradio_app(app, demo, path="/gradio")
|
| 319 |
+
|
| 320 |
+
# Run the application
|
| 321 |
+
if __name__ == "__main__":
|
| 322 |
+
import uvicorn
|
| 323 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
assets/static/index.html
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Portfolio Optimizer</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 8 |
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap">
|
| 9 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
| 10 |
+
<script src="https://cdn.jsdelivr.net/npm/luxon@3.0.1/build/global/luxon.min.js"></script>
|
| 11 |
+
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@1.2.0/dist/chartjs-adapter-luxon.min.js"></script>
|
| 12 |
+
</head>
|
| 13 |
+
<body>
|
| 14 |
+
<div class="container">
|
| 15 |
+
<header class="header">
|
| 16 |
+
<div class="title-container">
|
| 17 |
+
<h1>Portfolio Optimizer</h1>
|
| 18 |
+
</div>
|
| 19 |
+
</header>
|
| 20 |
+
|
| 21 |
+
<div class="main-content">
|
| 22 |
+
<div class="left-panel">
|
| 23 |
+
<!-- Configuration Card -->
|
| 24 |
+
<div class="config-card">
|
| 25 |
+
<h2 class="config-title">Portfolio Configuration</h2>
|
| 26 |
+
<div class="config-grid">
|
| 27 |
+
<div class="config-field">
|
| 28 |
+
<label class="config-label">Start Date:</label>
|
| 29 |
+
<input type="date" id="startDate" class="config-input">
|
| 30 |
+
</div>
|
| 31 |
+
<div class="config-field">
|
| 32 |
+
<label class="config-label">End Date:</label>
|
| 33 |
+
<input type="date" id="endDate" class="config-input">
|
| 34 |
+
</div>
|
| 35 |
+
<div class="config-field">
|
| 36 |
+
<label class="config-label">Learning Rate:</label>
|
| 37 |
+
<input type="number" id="learningRate" class="config-input" value="0.1" min="0.001" step="0.001">
|
| 38 |
+
</div>
|
| 39 |
+
<div class="config-field">
|
| 40 |
+
<label class="config-label">Window Size:</label>
|
| 41 |
+
<input type="number" id="windowSize" class="config-input" value="20" min="5">
|
| 42 |
+
</div>
|
| 43 |
+
<button id="runButton" class="run-button">Run Allocation</button>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<div class="alpha-controls">
|
| 47 |
+
<h3 class="alpha-title">Objective Function Weights</h3>
|
| 48 |
+
<div class="alpha-grid">
|
| 49 |
+
<div class="config-field">
|
| 50 |
+
<label class="config-label">Sortino Ratio:</label>
|
| 51 |
+
<input type="number" id="alphaSortino" class="config-input" value="1.0" min="0" step="0.1">
|
| 52 |
+
</div>
|
| 53 |
+
<div class="config-field">
|
| 54 |
+
<label class="config-label">Max Drawdown:</label>
|
| 55 |
+
<input type="number" id="alphaMaxDrawdown" class="config-input" value="1.0" min="0" step="0.1">
|
| 56 |
+
</div>
|
| 57 |
+
<div class="config-field">
|
| 58 |
+
<label class="config-label">Turnover:</label>
|
| 59 |
+
<input type="number" id="alphaTurnover" class="config-input" value="0.1" min="0" step="0.1">
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<!-- Charts -->
|
| 66 |
+
<div class="graph-container">
|
| 67 |
+
<!-- Cumulative Returns Chart -->
|
| 68 |
+
<div class="chart-card">
|
| 69 |
+
<h3 class="chart-title">Cumulative Returns</h3>
|
| 70 |
+
<div class="chart-content">
|
| 71 |
+
<canvas id="returnsChart"></canvas>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
<!-- Weight Evolution Chart -->
|
| 76 |
+
<div class="chart-card">
|
| 77 |
+
<h3 class="chart-title">Weight Evolution</h3>
|
| 78 |
+
<div class="chart-content">
|
| 79 |
+
<canvas id="weightsChart"></canvas>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
<!-- Stats Row -->
|
| 84 |
+
<div class="stats-row">
|
| 85 |
+
<div class="stat-card">
|
| 86 |
+
<div class="stat-title">OGD Portfolio</div>
|
| 87 |
+
<div class="stat-value" id="ogdSharpeRatio">-</div>
|
| 88 |
+
<div class="stat-subtitle">Sharpe Ratio</div>
|
| 89 |
+
</div>
|
| 90 |
+
<div class="stat-card">
|
| 91 |
+
<div class="stat-title">OGD Portfolio</div>
|
| 92 |
+
<div class="stat-value" id="ogdMaxDrawdown">-</div>
|
| 93 |
+
<div class="stat-subtitle">Max Drawdown</div>
|
| 94 |
+
</div>
|
| 95 |
+
<div class="stat-card">
|
| 96 |
+
<div class="stat-title">OGD Portfolio</div>
|
| 97 |
+
<div class="stat-value" id="ogdReturn">-</div>
|
| 98 |
+
<div class="stat-subtitle">Return</div>
|
| 99 |
+
</div>
|
| 100 |
+
<div class="stat-card">
|
| 101 |
+
<div class="stat-title">Equal Weight</div>
|
| 102 |
+
<div class="stat-value" id="ewSharpeRatio">-</div>
|
| 103 |
+
<div class="stat-subtitle">Sharpe Ratio</div>
|
| 104 |
+
</div>
|
| 105 |
+
<div class="stat-card">
|
| 106 |
+
<div class="stat-title">Equal Weight</div>
|
| 107 |
+
<div class="stat-value" id="ewMaxDrawdown">-</div>
|
| 108 |
+
<div class="stat-subtitle">Max Drawdown</div>
|
| 109 |
+
</div>
|
| 110 |
+
<div class="stat-card">
|
| 111 |
+
<div class="stat-title">Equal Weight</div>
|
| 112 |
+
<div class="stat-value" id="ewReturn">-</div>
|
| 113 |
+
<div class="stat-subtitle">Return</div>
|
| 114 |
+
</div>
|
| 115 |
+
<div class="stat-card">
|
| 116 |
+
<div class="stat-title">Random Portfolio</div>
|
| 117 |
+
<div class="stat-value" id="randomSharpeRatio">-</div>
|
| 118 |
+
<div class="stat-subtitle">Sharpe Ratio</div>
|
| 119 |
+
</div>
|
| 120 |
+
<div class="stat-card">
|
| 121 |
+
<div class="stat-title">Random Portfolio</div>
|
| 122 |
+
<div class="stat-value" id="randomMaxDrawdown">-</div>
|
| 123 |
+
<div class="stat-subtitle">Max Drawdown</div>
|
| 124 |
+
</div>
|
| 125 |
+
<div class="stat-card">
|
| 126 |
+
<div class="stat-title">Random Portfolio</div>
|
| 127 |
+
<div class="stat-value" id="randomReturn">-</div>
|
| 128 |
+
<div class="stat-subtitle">Return</div>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
+
<div class="right-panel">
|
| 135 |
+
<div class="panel-header">
|
| 136 |
+
<h3 class="panel-title">Select Tickers</h3>
|
| 137 |
+
<div class="ticker-controls">
|
| 138 |
+
<button id="selectAllBtn" class="ticker-button">Select All</button>
|
| 139 |
+
<button id="deselectAllBtn" class="ticker-button">Deselect All</button>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
<div class="stock-list" id="stockList">
|
| 143 |
+
<!-- Stock sectors and tickers will be populated here -->
|
| 144 |
+
<div class="loading-text">Loading tickers...</div>
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
<div class="loading-overlay" id="loadingOverlay" style="display: none;">
|
| 150 |
+
<div class="spinner"></div>
|
| 151 |
+
<div class="loading-text">Optimizing Portfolio...</div>
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
|
| 155 |
+
<script src="/static/script.js"></script>
|
| 156 |
+
</body>
|
| 157 |
+
</html>
|
assets/static/script.js
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Global variables
|
| 2 |
+
let returnChart = null;
|
| 3 |
+
let weightChart = null;
|
| 4 |
+
let sectorData = null;
|
| 5 |
+
let selectedTickers = new Set();
|
| 6 |
+
|
| 7 |
+
// Fetch tickers grouped by sector
|
| 8 |
+
async function fetchTickersBySector() {
|
| 9 |
+
try {
|
| 10 |
+
const response = await fetch('/api/tickers_by_sector');
|
| 11 |
+
return await response.json();
|
| 12 |
+
} catch (error) {
|
| 13 |
+
console.error('Error fetching tickers:', error);
|
| 14 |
+
return [];
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
// Format date to YYYY-MM-DD
|
| 19 |
+
function formatDate(date) {
|
| 20 |
+
return date.toISOString().split('T')[0];
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
// Populate the stock list with sectors and tickers
|
| 24 |
+
function populateStockList(sectors) {
|
| 25 |
+
const stockListElement = document.getElementById('stockList');
|
| 26 |
+
stockListElement.innerHTML = '';
|
| 27 |
+
|
| 28 |
+
// Setup select/deselect buttons
|
| 29 |
+
document.getElementById('selectAllBtn').addEventListener('click', () => {
|
| 30 |
+
const allCheckboxes = document.querySelectorAll('.stock-checkbox');
|
| 31 |
+
allCheckboxes.forEach(checkbox => {
|
| 32 |
+
checkbox.checked = true;
|
| 33 |
+
selectedTickers.add(checkbox.value);
|
| 34 |
+
});
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
document.getElementById('deselectAllBtn').addEventListener('click', () => {
|
| 38 |
+
const allCheckboxes = document.querySelectorAll('.stock-checkbox');
|
| 39 |
+
allCheckboxes.forEach(checkbox => {
|
| 40 |
+
checkbox.checked = false;
|
| 41 |
+
selectedTickers.delete(checkbox.value);
|
| 42 |
+
});
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
// Create sector groups
|
| 46 |
+
sectors.forEach(sector => {
|
| 47 |
+
const sectorGroup = document.createElement('div');
|
| 48 |
+
sectorGroup.className = 'sector-group';
|
| 49 |
+
|
| 50 |
+
// Sector header
|
| 51 |
+
const sectorHeader = document.createElement('div');
|
| 52 |
+
sectorHeader.className = 'sector-header';
|
| 53 |
+
|
| 54 |
+
const sectorNameContainer = document.createElement('div');
|
| 55 |
+
sectorNameContainer.className = 'sector-name';
|
| 56 |
+
|
| 57 |
+
const sectorArrow = document.createElement('span');
|
| 58 |
+
sectorArrow.className = 'sector-arrow';
|
| 59 |
+
sectorArrow.textContent = '▶';
|
| 60 |
+
sectorArrow.classList.add('open'); // Start with open sections
|
| 61 |
+
|
| 62 |
+
const sectorNameText = document.createElement('span');
|
| 63 |
+
sectorNameText.textContent = sector.sector;
|
| 64 |
+
|
| 65 |
+
sectorNameContainer.appendChild(sectorArrow);
|
| 66 |
+
sectorNameContainer.appendChild(sectorNameText);
|
| 67 |
+
|
| 68 |
+
const sectorToggle = document.createElement('button');
|
| 69 |
+
sectorToggle.className = 'sector-toggle';
|
| 70 |
+
sectorToggle.textContent = 'Toggle All';
|
| 71 |
+
|
| 72 |
+
sectorHeader.appendChild(sectorNameContainer);
|
| 73 |
+
sectorHeader.appendChild(sectorToggle);
|
| 74 |
+
sectorGroup.appendChild(sectorHeader);
|
| 75 |
+
|
| 76 |
+
// Collapsible ticker grid
|
| 77 |
+
const tickerGrid = document.createElement('div');
|
| 78 |
+
tickerGrid.className = 'ticker-grid';
|
| 79 |
+
|
| 80 |
+
// Create stock items in a grid
|
| 81 |
+
sector.tickers.forEach(ticker => {
|
| 82 |
+
const stockItem = document.createElement('div');
|
| 83 |
+
stockItem.className = 'stock-item';
|
| 84 |
+
|
| 85 |
+
const checkbox = document.createElement('input');
|
| 86 |
+
checkbox.type = 'checkbox';
|
| 87 |
+
checkbox.className = 'stock-checkbox';
|
| 88 |
+
checkbox.value = ticker;
|
| 89 |
+
checkbox.id = `ticker-${ticker}`;
|
| 90 |
+
checkbox.addEventListener('change', () => {
|
| 91 |
+
if (checkbox.checked) {
|
| 92 |
+
selectedTickers.add(ticker);
|
| 93 |
+
} else {
|
| 94 |
+
selectedTickers.delete(ticker);
|
| 95 |
+
}
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
const label = document.createElement('label');
|
| 99 |
+
label.className = 'stock-ticker';
|
| 100 |
+
label.textContent = ticker;
|
| 101 |
+
label.htmlFor = `ticker-${ticker}`;
|
| 102 |
+
|
| 103 |
+
stockItem.appendChild(checkbox);
|
| 104 |
+
stockItem.appendChild(label);
|
| 105 |
+
tickerGrid.appendChild(stockItem);
|
| 106 |
+
});
|
| 107 |
+
|
| 108 |
+
sectorGroup.appendChild(tickerGrid);
|
| 109 |
+
stockListElement.appendChild(sectorGroup);
|
| 110 |
+
|
| 111 |
+
// Toggle functionality
|
| 112 |
+
sectorToggle.addEventListener('click', () => {
|
| 113 |
+
const checkboxes = tickerGrid.querySelectorAll('.stock-checkbox');
|
| 114 |
+
const allChecked = Array.from(checkboxes).every(cb => cb.checked);
|
| 115 |
+
|
| 116 |
+
checkboxes.forEach(checkbox => {
|
| 117 |
+
checkbox.checked = !allChecked;
|
| 118 |
+
if (!allChecked) {
|
| 119 |
+
selectedTickers.add(checkbox.value);
|
| 120 |
+
} else {
|
| 121 |
+
selectedTickers.delete(checkbox.value);
|
| 122 |
+
}
|
| 123 |
+
});
|
| 124 |
+
});
|
| 125 |
+
|
| 126 |
+
// Collapsible section
|
| 127 |
+
sectorNameContainer.addEventListener('click', () => {
|
| 128 |
+
sectorArrow.classList.toggle('open');
|
| 129 |
+
tickerGrid.style.display = sectorArrow.classList.contains('open') ? 'grid' : 'none';
|
| 130 |
+
});
|
| 131 |
+
});
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
// Initialize charts
|
| 135 |
+
function initializeCharts() {
|
| 136 |
+
const returnsCtx = document.getElementById('returnsChart').getContext('2d');
|
| 137 |
+
const weightsCtx = document.getElementById('weightsChart').getContext('2d');
|
| 138 |
+
|
| 139 |
+
// Configure Chart.js global defaults for dark theme
|
| 140 |
+
Chart.defaults.color = '#b0b0b8';
|
| 141 |
+
Chart.defaults.scale.grid.color = 'rgba(56, 56, 64, 0.5)';
|
| 142 |
+
Chart.defaults.scale.grid.borderColor = 'rgba(56, 56, 64, 0.8)';
|
| 143 |
+
|
| 144 |
+
// Returns chart configuration
|
| 145 |
+
returnChart = new Chart(returnsCtx, {
|
| 146 |
+
type: 'line',
|
| 147 |
+
data: {
|
| 148 |
+
datasets: [
|
| 149 |
+
{
|
| 150 |
+
label: 'OGD Portfolio',
|
| 151 |
+
borderColor: '#3f88e2',
|
| 152 |
+
backgroundColor: 'rgba(63, 136, 226, 0.1)',
|
| 153 |
+
borderWidth: 1.5,
|
| 154 |
+
pointRadius: 0, // Hide points completely
|
| 155 |
+
tension: 0.1,
|
| 156 |
+
data: []
|
| 157 |
+
},
|
| 158 |
+
{
|
| 159 |
+
label: 'Equal Weight',
|
| 160 |
+
borderColor: '#4caf50',
|
| 161 |
+
backgroundColor: 'rgba(76, 175, 80, 0.1)',
|
| 162 |
+
borderWidth: 1.5,
|
| 163 |
+
pointRadius: 0, // Hide points completely
|
| 164 |
+
tension: 0.1,
|
| 165 |
+
data: []
|
| 166 |
+
},
|
| 167 |
+
{
|
| 168 |
+
label: 'Random Portfolio',
|
| 169 |
+
borderColor: '#e2b53f',
|
| 170 |
+
backgroundColor: 'rgba(226, 181, 63, 0.1)',
|
| 171 |
+
borderWidth: 1.5,
|
| 172 |
+
pointRadius: 0, // Hide points completely
|
| 173 |
+
tension: 0.1,
|
| 174 |
+
data: []
|
| 175 |
+
}
|
| 176 |
+
]
|
| 177 |
+
},
|
| 178 |
+
options: {
|
| 179 |
+
responsive: true,
|
| 180 |
+
maintainAspectRatio: false,
|
| 181 |
+
interaction: {
|
| 182 |
+
mode: 'index',
|
| 183 |
+
intersect: false,
|
| 184 |
+
},
|
| 185 |
+
scales: {
|
| 186 |
+
x: {
|
| 187 |
+
type: 'time',
|
| 188 |
+
time: {
|
| 189 |
+
unit: 'month',
|
| 190 |
+
tooltipFormat: 'MMM dd, yyyy'
|
| 191 |
+
}
|
| 192 |
+
},
|
| 193 |
+
y: {
|
| 194 |
+
title: {
|
| 195 |
+
display: true,
|
| 196 |
+
text: 'Cumulative Return'
|
| 197 |
+
},
|
| 198 |
+
beginAtZero: false
|
| 199 |
+
}
|
| 200 |
+
},
|
| 201 |
+
plugins: {
|
| 202 |
+
legend: {
|
| 203 |
+
position: 'bottom'
|
| 204 |
+
},
|
| 205 |
+
tooltip: {
|
| 206 |
+
mode: 'index',
|
| 207 |
+
intersect: false,
|
| 208 |
+
backgroundColor: 'rgba(42, 42, 48, 0.9)',
|
| 209 |
+
titleColor: '#ffffff',
|
| 210 |
+
bodyColor: '#ffffff',
|
| 211 |
+
borderColor: 'rgba(56, 56, 64, 1)',
|
| 212 |
+
borderWidth: 1
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
});
|
| 217 |
+
|
| 218 |
+
// Weights chart configuration (empty initially)
|
| 219 |
+
weightChart = new Chart(weightsCtx, {
|
| 220 |
+
type: 'line',
|
| 221 |
+
data: {
|
| 222 |
+
datasets: []
|
| 223 |
+
},
|
| 224 |
+
options: {
|
| 225 |
+
responsive: true,
|
| 226 |
+
maintainAspectRatio: false,
|
| 227 |
+
interaction: {
|
| 228 |
+
mode: 'index',
|
| 229 |
+
intersect: false,
|
| 230 |
+
},
|
| 231 |
+
scales: {
|
| 232 |
+
x: {
|
| 233 |
+
type: 'time',
|
| 234 |
+
time: {
|
| 235 |
+
unit: 'month',
|
| 236 |
+
tooltipFormat: 'MMM dd, yyyy'
|
| 237 |
+
}
|
| 238 |
+
},
|
| 239 |
+
y: {
|
| 240 |
+
title: {
|
| 241 |
+
display: true,
|
| 242 |
+
text: 'Weight'
|
| 243 |
+
},
|
| 244 |
+
min: 0,
|
| 245 |
+
suggestedMax: 1
|
| 246 |
+
}
|
| 247 |
+
},
|
| 248 |
+
plugins: {
|
| 249 |
+
legend: {
|
| 250 |
+
position: 'right',
|
| 251 |
+
maxHeight: 200,
|
| 252 |
+
labels: {
|
| 253 |
+
boxWidth: 12,
|
| 254 |
+
boxHeight: 12
|
| 255 |
+
}
|
| 256 |
+
},
|
| 257 |
+
tooltip: {
|
| 258 |
+
mode: 'index',
|
| 259 |
+
intersect: false,
|
| 260 |
+
backgroundColor: 'rgba(42, 42, 48, 0.9)',
|
| 261 |
+
titleColor: '#ffffff',
|
| 262 |
+
bodyColor: '#ffffff',
|
| 263 |
+
borderColor: 'rgba(56, 56, 64, 1)',
|
| 264 |
+
borderWidth: 1
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
});
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
// Function to run the optimization
|
| 272 |
+
async function runOptimization() {
|
| 273 |
+
const startDate = document.getElementById('startDate').value;
|
| 274 |
+
const endDate = document.getElementById('endDate').value || formatDate(new Date());
|
| 275 |
+
const windowSize = document.getElementById('windowSize').value;
|
| 276 |
+
const learningRate = document.getElementById('learningRate').value;
|
| 277 |
+
|
| 278 |
+
// Get alpha values from inputs
|
| 279 |
+
const alphaSortino = document.getElementById('alphaSortino').value;
|
| 280 |
+
const alphaMaxDrawdown = document.getElementById('alphaMaxDrawdown').value;
|
| 281 |
+
const alphaTurnover = document.getElementById('alphaTurnover').value;
|
| 282 |
+
|
| 283 |
+
// Get all selected tickers
|
| 284 |
+
const tickers = Array.from(selectedTickers).join(',');
|
| 285 |
+
|
| 286 |
+
// Validate inputs
|
| 287 |
+
if (!startDate) {
|
| 288 |
+
alert('Please select a start date');
|
| 289 |
+
return;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
if (selectedTickers.size === 0) {
|
| 293 |
+
alert('Please select at least one ticker');
|
| 294 |
+
return;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
try {
|
| 298 |
+
document.getElementById('loadingOverlay').style.display = 'flex';
|
| 299 |
+
runFakeSimulation(); // Start showing fake simulation while waiting
|
| 300 |
+
|
| 301 |
+
const response = await fetch('/api/run_optimization', {
|
| 302 |
+
method: 'POST',
|
| 303 |
+
headers: {
|
| 304 |
+
'Content-Type': 'application/json',
|
| 305 |
+
},
|
| 306 |
+
body: JSON.stringify({
|
| 307 |
+
start_date: startDate,
|
| 308 |
+
end_date: endDate,
|
| 309 |
+
tickers: tickers,
|
| 310 |
+
window_size: windowSize,
|
| 311 |
+
learning_rate: learningRate,
|
| 312 |
+
alpha_sortino: alphaSortino,
|
| 313 |
+
alpha_max_drawdown: alphaMaxDrawdown,
|
| 314 |
+
alpha_turnover: alphaTurnover
|
| 315 |
+
}),
|
| 316 |
+
});
|
| 317 |
+
|
| 318 |
+
const result = await response.json();
|
| 319 |
+
|
| 320 |
+
if (result.error) {
|
| 321 |
+
alert(`Error: ${result.error}`);
|
| 322 |
+
return;
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
updateCharts(result.cumulative_returns, result.weights);
|
| 326 |
+
updateMetrics(result.metrics);
|
| 327 |
+
|
| 328 |
+
} catch (error) {
|
| 329 |
+
console.error('Error running optimization:', error);
|
| 330 |
+
alert('An error occurred while running the optimization. Please try again.');
|
| 331 |
+
} finally {
|
| 332 |
+
document.getElementById('loadingOverlay').style.display = 'none';
|
| 333 |
+
}
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
// Update charts with new data
|
| 337 |
+
function updateCharts(returnsData, weightsData) {
|
| 338 |
+
// Update cumulative returns chart
|
| 339 |
+
returnChart.data.datasets[0].data = returnsData.ogd.map(d => ({ x: d.date, y: d.value }));
|
| 340 |
+
returnChart.data.datasets[1].data = returnsData.equal_weight.map(d => ({ x: d.date, y: d.value }));
|
| 341 |
+
returnChart.data.datasets[2].data = returnsData.random.map(d => ({ x: d.date, y: d.value }));
|
| 342 |
+
returnChart.update();
|
| 343 |
+
|
| 344 |
+
// Update weights chart (clear old datasets first)
|
| 345 |
+
weightChart.data.datasets = [];
|
| 346 |
+
|
| 347 |
+
// Get top N stocks by average weight
|
| 348 |
+
const stocksSummary = {};
|
| 349 |
+
const dates = [];
|
| 350 |
+
let maxWeight = 0; // Track maximum weight for y-axis scaling
|
| 351 |
+
|
| 352 |
+
weightsData.forEach(dataPoint => {
|
| 353 |
+
const date = dataPoint.date;
|
| 354 |
+
dates.push(date);
|
| 355 |
+
|
| 356 |
+
Object.entries(dataPoint.weights).forEach(([ticker, weight]) => {
|
| 357 |
+
if (!stocksSummary[ticker]) {
|
| 358 |
+
stocksSummary[ticker] = { total: 0, count: 0 };
|
| 359 |
+
}
|
| 360 |
+
stocksSummary[ticker].total += weight;
|
| 361 |
+
stocksSummary[ticker].count += 1;
|
| 362 |
+
|
| 363 |
+
// Track the maximum weight seen
|
| 364 |
+
maxWeight = Math.max(maxWeight, weight);
|
| 365 |
+
});
|
| 366 |
+
});
|
| 367 |
+
|
| 368 |
+
// Calculate average weight
|
| 369 |
+
Object.keys(stocksSummary).forEach(ticker => {
|
| 370 |
+
stocksSummary[ticker].average = stocksSummary[ticker].total / stocksSummary[ticker].count;
|
| 371 |
+
});
|
| 372 |
+
|
| 373 |
+
// Sort by average weight and take top 10
|
| 374 |
+
const topStocks = Object.entries(stocksSummary)
|
| 375 |
+
.sort((a, b) => b[1].average - a[1].average)
|
| 376 |
+
.slice(0, 10)
|
| 377 |
+
.map(entry => entry[0]);
|
| 378 |
+
|
| 379 |
+
// Create dataset for each top stock
|
| 380 |
+
const colors = [
|
| 381 |
+
'#3f88e2', '#4caf50', '#e2b53f', '#e24d3f', '#9c27b0',
|
| 382 |
+
'#00bcd4', '#ffeb3b', '#795548', '#607d8b', '#e91e63'
|
| 383 |
+
];
|
| 384 |
+
|
| 385 |
+
topStocks.forEach((ticker, index) => {
|
| 386 |
+
const data = weightsData.map(dataPoint => ({
|
| 387 |
+
x: dataPoint.date,
|
| 388 |
+
y: dataPoint.weights[ticker] || 0
|
| 389 |
+
}));
|
| 390 |
+
|
| 391 |
+
// Track max weight for y-axis scaling
|
| 392 |
+
data.forEach(point => {
|
| 393 |
+
maxWeight = Math.max(maxWeight, point.y);
|
| 394 |
+
});
|
| 395 |
+
|
| 396 |
+
weightChart.data.datasets.push({
|
| 397 |
+
label: ticker,
|
| 398 |
+
borderColor: colors[index % colors.length],
|
| 399 |
+
backgroundColor: colors[index % colors.length] + '33',
|
| 400 |
+
data: data,
|
| 401 |
+
borderWidth: 1.5,
|
| 402 |
+
pointRadius: 0,
|
| 403 |
+
tension: 0.1
|
| 404 |
+
});
|
| 405 |
+
});
|
| 406 |
+
|
| 407 |
+
// Set the y-axis max to be the highest weight + 0.1
|
| 408 |
+
weightChart.options.scales.y.max = maxWeight + 0.1;
|
| 409 |
+
|
| 410 |
+
weightChart.update();
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
// Update performance metrics for all three portfolios
|
| 414 |
+
function updateMetrics(metrics) {
|
| 415 |
+
// OGD Portfolio metrics
|
| 416 |
+
document.getElementById('ogdSharpeRatio').textContent = metrics.ogd.sharpe.toFixed(2);
|
| 417 |
+
document.getElementById('ogdMaxDrawdown').textContent = (metrics.ogd.max_drawdown * 100).toFixed(2) + '%';
|
| 418 |
+
document.getElementById('ogdReturn').textContent = ((metrics.ogd.cumulative_return - 1) * 100).toFixed(2) + '%';
|
| 419 |
+
|
| 420 |
+
// Equal Weight Portfolio metrics
|
| 421 |
+
document.getElementById('ewSharpeRatio').textContent = metrics.equal_weight.sharpe.toFixed(2);
|
| 422 |
+
document.getElementById('ewMaxDrawdown').textContent = (metrics.equal_weight.max_drawdown * 100).toFixed(2) + '%';
|
| 423 |
+
document.getElementById('ewReturn').textContent = ((metrics.equal_weight.cumulative_return - 1) * 100).toFixed(2) + '%';
|
| 424 |
+
|
| 425 |
+
// Random Portfolio metrics
|
| 426 |
+
document.getElementById('randomSharpeRatio').textContent = metrics.random.sharpe.toFixed(2);
|
| 427 |
+
document.getElementById('randomMaxDrawdown').textContent = (metrics.random.max_drawdown * 100).toFixed(2) + '%';
|
| 428 |
+
document.getElementById('randomReturn').textContent = ((metrics.random.cumulative_return - 1) * 100).toFixed(2) + '%';
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
// Create a fake simulation for visual feedback during loading
|
| 432 |
+
function runFakeSimulation() {
|
| 433 |
+
const loadingOverlay = document.getElementById('loadingOverlay');
|
| 434 |
+
if (loadingOverlay.style.display === 'none') return;
|
| 435 |
+
|
| 436 |
+
// Generate fake return data
|
| 437 |
+
const dates = [];
|
| 438 |
+
const today = new Date();
|
| 439 |
+
for (let i = 365; i >= 0; i--) {
|
| 440 |
+
const date = new Date(today);
|
| 441 |
+
date.setDate(today.getDate() - i);
|
| 442 |
+
dates.push(date.toISOString().split('T')[0]);
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
// Generate fake returns
|
| 446 |
+
const generateFakeData = (volatility, bias = 0) => {
|
| 447 |
+
let value = 1.0;
|
| 448 |
+
return dates.map(date => {
|
| 449 |
+
value *= (1 + (Math.random() - 0.45 + bias) * volatility);
|
| 450 |
+
return { date, value };
|
| 451 |
+
});
|
| 452 |
+
};
|
| 453 |
+
|
| 454 |
+
const fakeReturns = {
|
| 455 |
+
ogd: generateFakeData(0.015, 0.02),
|
| 456 |
+
equal_weight: generateFakeData(0.01, 0.01),
|
| 457 |
+
random: generateFakeData(0.02, 0)
|
| 458 |
+
};
|
| 459 |
+
|
| 460 |
+
// Generate fake weights
|
| 461 |
+
const fakeWeights = [];
|
| 462 |
+
const fakeTickers = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'META', 'TSLA', 'NVDA', 'JPM', 'JNJ', 'V'];
|
| 463 |
+
|
| 464 |
+
dates.forEach(date => {
|
| 465 |
+
const weights = {};
|
| 466 |
+
let totalWeight = 0;
|
| 467 |
+
|
| 468 |
+
fakeTickers.forEach(ticker => {
|
| 469 |
+
// Random weight but normalized later
|
| 470 |
+
weights[ticker] = Math.random();
|
| 471 |
+
totalWeight += weights[ticker];
|
| 472 |
+
});
|
| 473 |
+
|
| 474 |
+
// Normalize weights
|
| 475 |
+
fakeTickers.forEach(ticker => {
|
| 476 |
+
weights[ticker] = weights[ticker] / totalWeight;
|
| 477 |
+
});
|
| 478 |
+
|
| 479 |
+
fakeWeights.push({ date, weights });
|
| 480 |
+
});
|
| 481 |
+
|
| 482 |
+
// Update charts with fake data
|
| 483 |
+
updateCharts(fakeReturns, fakeWeights);
|
| 484 |
+
|
| 485 |
+
// Update metrics with fake data
|
| 486 |
+
const fakeMetrics = {
|
| 487 |
+
ogd: {
|
| 488 |
+
sharpe: 1.2 + Math.random() * 0.6,
|
| 489 |
+
max_drawdown: 0.15 + Math.random() * 0.1,
|
| 490 |
+
cumulative_return: 1.6 + Math.random() * 0.8
|
| 491 |
+
},
|
| 492 |
+
equal_weight: {
|
| 493 |
+
sharpe: 0.9 + Math.random() * 0.4,
|
| 494 |
+
max_drawdown: 0.18 + Math.random() * 0.1,
|
| 495 |
+
cumulative_return: 1.3 + Math.random() * 0.5
|
| 496 |
+
},
|
| 497 |
+
random: {
|
| 498 |
+
sharpe: 0.6 + Math.random() * 0.5,
|
| 499 |
+
max_drawdown: 0.25 + Math.random() * 0.15,
|
| 500 |
+
cumulative_return: 1.2 + Math.random() * 0.4
|
| 501 |
+
}
|
| 502 |
+
};
|
| 503 |
+
|
| 504 |
+
updateMetrics(fakeMetrics);
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
// Initialize the application
|
| 508 |
+
async function initialize() {
|
| 509 |
+
try {
|
| 510 |
+
// Initialize charts
|
| 511 |
+
initializeCharts();
|
| 512 |
+
|
| 513 |
+
// Set default dates to match the data range
|
| 514 |
+
document.getElementById('startDate').value = '2007-01-01'; // Jan 1, 2007
|
| 515 |
+
document.getElementById('endDate').value = '2025-04-01'; // Apr 1, 2025
|
| 516 |
+
|
| 517 |
+
// Fetch tickers and populate stock list
|
| 518 |
+
sectorData = await fetchTickersBySector();
|
| 519 |
+
populateStockList(sectorData);
|
| 520 |
+
|
| 521 |
+
// Add event listener to run button
|
| 522 |
+
document.getElementById('runButton').addEventListener('click', runOptimization);
|
| 523 |
+
|
| 524 |
+
// Run fake simulation for initial visual
|
| 525 |
+
runFakeSimulation();
|
| 526 |
+
|
| 527 |
+
} catch (error) {
|
| 528 |
+
console.error('Error initializing application:', error);
|
| 529 |
+
}
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
// Run the initialization when the document is loaded
|
| 533 |
+
document.addEventListener('DOMContentLoaded', initialize);
|
assets/static/style.css
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Main App CSS */
|
| 2 |
+
:root {
|
| 3 |
+
/* Dark theme colors - updated to match image */
|
| 4 |
+
--bg-primary: #1e1e24;
|
| 5 |
+
--bg-secondary: #2a2a30;
|
| 6 |
+
--bg-tertiary: #3a3a42;
|
| 7 |
+
--bg-card: #2a2a30;
|
| 8 |
+
--text-primary: #ffffff;
|
| 9 |
+
--text-secondary: #b0b0b8;
|
| 10 |
+
--accent-primary: #3f88e2;
|
| 11 |
+
--accent-secondary: #5e9aeb;
|
| 12 |
+
--border-color: #383840;
|
| 13 |
+
--grid-color: #383840;
|
| 14 |
+
--success-color: #4caf50;
|
| 15 |
+
--warning-color: #ff9800;
|
| 16 |
+
--danger-color: #f44336;
|
| 17 |
+
--random-color: #e2b53f;
|
| 18 |
+
--equal-weight-color: #4caf50;
|
| 19 |
+
|
| 20 |
+
/* Spacing */
|
| 21 |
+
--spacing-xs: 4px;
|
| 22 |
+
--spacing-sm: 8px;
|
| 23 |
+
--spacing-md: 16px;
|
| 24 |
+
--spacing-lg: 24px;
|
| 25 |
+
--spacing-xl: 32px;
|
| 26 |
+
|
| 27 |
+
/* Typography */
|
| 28 |
+
--font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
| 29 |
+
--font-size-xs: 12px;
|
| 30 |
+
--font-size-sm: 14px;
|
| 31 |
+
--font-size-md: 16px;
|
| 32 |
+
--font-size-lg: 20px;
|
| 33 |
+
--font-size-xl: 24px;
|
| 34 |
+
--font-size-xxl: 32px;
|
| 35 |
+
|
| 36 |
+
/* Chart dimensions */
|
| 37 |
+
--chart-height: 300px;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
* {
|
| 41 |
+
margin: 0;
|
| 42 |
+
padding: 0;
|
| 43 |
+
box-sizing: border-box;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
body {
|
| 47 |
+
font-family: var(--font-family);
|
| 48 |
+
background-color: var(--bg-primary);
|
| 49 |
+
color: var(--text-primary);
|
| 50 |
+
line-height: 1.6;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.container {
|
| 54 |
+
width: 100%;
|
| 55 |
+
height: 100vh;
|
| 56 |
+
display: flex;
|
| 57 |
+
flex-direction: column;
|
| 58 |
+
overflow: hidden;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.header {
|
| 62 |
+
padding: var(--spacing-md) var(--spacing-lg);
|
| 63 |
+
background-color: var(--bg-primary);
|
| 64 |
+
border-bottom: 1px solid var(--border-color);
|
| 65 |
+
display: flex;
|
| 66 |
+
justify-content: space-between;
|
| 67 |
+
align-items: center;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.title-container h1 {
|
| 71 |
+
font-size: var(--font-size-xl);
|
| 72 |
+
font-weight: 600;
|
| 73 |
+
color: var(--text-primary);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.title-container p {
|
| 77 |
+
font-size: var(--font-size-sm);
|
| 78 |
+
color: var(--text-secondary);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.main-content {
|
| 82 |
+
display: flex;
|
| 83 |
+
flex: 1;
|
| 84 |
+
overflow: hidden;
|
| 85 |
+
padding: var(--spacing-lg);
|
| 86 |
+
gap: var(--spacing-lg);
|
| 87 |
+
height: calc(100vh - 80px); /* Account for header height */
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.left-panel {
|
| 91 |
+
flex: 3;
|
| 92 |
+
display: flex;
|
| 93 |
+
flex-direction: column;
|
| 94 |
+
overflow-y: auto; /* Add scroll to left panel */
|
| 95 |
+
gap: var(--spacing-lg);
|
| 96 |
+
max-height: 100%;
|
| 97 |
+
padding-right: var(--spacing-sm); /* Add space for scrollbar */
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.right-panel {
|
| 101 |
+
flex: 1;
|
| 102 |
+
background-color: var(--bg-secondary);
|
| 103 |
+
border-radius: 8px;
|
| 104 |
+
overflow-y: auto;
|
| 105 |
+
max-height: calc(100vh - 120px);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
.config-card {
|
| 109 |
+
background-color: var(--bg-secondary);
|
| 110 |
+
border-radius: 8px;
|
| 111 |
+
padding: var(--spacing-md);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
.config-title {
|
| 115 |
+
text-align: center;
|
| 116 |
+
font-size: var(--font-size-lg);
|
| 117 |
+
margin-bottom: var(--spacing-md);
|
| 118 |
+
color: var(--text-primary);
|
| 119 |
+
font-weight: 500;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.config-grid {
|
| 123 |
+
display: grid;
|
| 124 |
+
grid-template-columns: repeat(4, 1fr) auto;
|
| 125 |
+
grid-gap: var(--spacing-md);
|
| 126 |
+
align-items: center;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.config-field {
|
| 130 |
+
display: flex;
|
| 131 |
+
flex-direction: column;
|
| 132 |
+
gap: var(--spacing-xs);
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.config-label {
|
| 136 |
+
font-size: var(--font-size-sm);
|
| 137 |
+
color: var(--text-secondary);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.config-input {
|
| 141 |
+
background-color: var(--bg-tertiary);
|
| 142 |
+
border: 1px solid var(--border-color);
|
| 143 |
+
color: var(--text-primary);
|
| 144 |
+
padding: var(--spacing-sm);
|
| 145 |
+
border-radius: 4px;
|
| 146 |
+
font-size: var(--font-size-sm);
|
| 147 |
+
outline: none;
|
| 148 |
+
height: 38px;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
.config-input:focus {
|
| 152 |
+
border-color: var(--accent-primary);
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.run-button {
|
| 156 |
+
background-color: var(--accent-primary);
|
| 157 |
+
color: white;
|
| 158 |
+
border: none;
|
| 159 |
+
padding: var(--spacing-sm) var(--spacing-lg);
|
| 160 |
+
border-radius: 4px;
|
| 161 |
+
font-weight: 500;
|
| 162 |
+
cursor: pointer;
|
| 163 |
+
transition: background-color 0.2s;
|
| 164 |
+
height: 38px;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.run-button:hover {
|
| 168 |
+
background-color: var(--accent-secondary);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.graph-container {
|
| 172 |
+
display: flex;
|
| 173 |
+
flex-direction: column;
|
| 174 |
+
gap: var(--spacing-md);
|
| 175 |
+
overflow: visible;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.chart-card {
|
| 179 |
+
background-color: var(--bg-secondary);
|
| 180 |
+
border-radius: 8px;
|
| 181 |
+
padding: var(--spacing-md);
|
| 182 |
+
display: flex;
|
| 183 |
+
flex-direction: column;
|
| 184 |
+
gap: var(--spacing-md);
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.chart-title {
|
| 188 |
+
font-size: var(--font-size-md);
|
| 189 |
+
font-weight: 500;
|
| 190 |
+
color: var(--text-primary);
|
| 191 |
+
text-align: center;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
.chart-content {
|
| 195 |
+
position: relative;
|
| 196 |
+
height: 220px; /* Reduced from 300px */
|
| 197 |
+
width: 100%;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
.stats-row {
|
| 201 |
+
display: grid;
|
| 202 |
+
grid-template-columns: repeat(3, 1fr);
|
| 203 |
+
grid-template-rows: repeat(3, auto);
|
| 204 |
+
gap: var(--spacing-sm);
|
| 205 |
+
margin-top: var(--spacing-sm);
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
.stat-card {
|
| 209 |
+
background-color: var(--bg-secondary);
|
| 210 |
+
border-radius: 8px;
|
| 211 |
+
padding: var(--spacing-sm);
|
| 212 |
+
display: flex;
|
| 213 |
+
flex-direction: column;
|
| 214 |
+
align-items: center;
|
| 215 |
+
justify-content: center;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.stat-title {
|
| 219 |
+
font-size: var(--font-size-xs);
|
| 220 |
+
color: var(--accent-primary);
|
| 221 |
+
margin-bottom: var(--spacing-xs);
|
| 222 |
+
font-weight: 500;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
.stat-value {
|
| 226 |
+
font-size: var(--font-size-md);
|
| 227 |
+
font-weight: 600;
|
| 228 |
+
color: var(--text-primary);
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.stat-subtitle {
|
| 232 |
+
font-size: var(--font-size-xs);
|
| 233 |
+
color: var(--text-secondary);
|
| 234 |
+
margin-top: var(--spacing-xs);
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
/* Ticker Selection Panel */
|
| 238 |
+
.panel-header {
|
| 239 |
+
display: flex;
|
| 240 |
+
justify-content: space-between;
|
| 241 |
+
align-items: center;
|
| 242 |
+
padding: var(--spacing-md);
|
| 243 |
+
border-bottom: 1px solid var(--border-color);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.panel-title {
|
| 247 |
+
font-size: var(--font-size-md);
|
| 248 |
+
font-weight: 500;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.ticker-controls {
|
| 252 |
+
display: flex;
|
| 253 |
+
gap: var(--spacing-sm);
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
.ticker-button {
|
| 257 |
+
background-color: var(--bg-tertiary);
|
| 258 |
+
border: 1px solid var(--border-color);
|
| 259 |
+
color: var(--text-secondary);
|
| 260 |
+
padding: var(--spacing-xs) var(--spacing-sm);
|
| 261 |
+
border-radius: 4px;
|
| 262 |
+
font-size: var(--font-size-xs);
|
| 263 |
+
cursor: pointer;
|
| 264 |
+
transition: all 0.2s;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
.ticker-button:hover {
|
| 268 |
+
background-color: var(--accent-primary);
|
| 269 |
+
color: white;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
.stock-list {
|
| 273 |
+
overflow-y: auto;
|
| 274 |
+
padding: 0 var(--spacing-md) var(--spacing-md);
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
.sector-group {
|
| 278 |
+
margin: var(--spacing-md) 0;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
.sector-header {
|
| 282 |
+
display: flex;
|
| 283 |
+
justify-content: space-between;
|
| 284 |
+
align-items: center;
|
| 285 |
+
padding: var(--spacing-sm) 0;
|
| 286 |
+
border-bottom: 1px solid var(--border-color);
|
| 287 |
+
margin-bottom: var(--spacing-sm);
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
.sector-controls {
|
| 291 |
+
display: flex;
|
| 292 |
+
justify-content: space-between;
|
| 293 |
+
align-items: center;
|
| 294 |
+
margin-bottom: var(--spacing-sm);
|
| 295 |
+
padding: 0 var(--spacing-sm);
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
.sector-name {
|
| 299 |
+
display: flex;
|
| 300 |
+
align-items: center;
|
| 301 |
+
font-size: var(--font-size-sm);
|
| 302 |
+
font-weight: 500;
|
| 303 |
+
gap: var(--spacing-sm);
|
| 304 |
+
cursor: pointer;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
.sector-arrow {
|
| 308 |
+
transition: transform 0.2s;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
.sector-arrow.open {
|
| 312 |
+
transform: rotate(90deg);
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
.sector-toggle {
|
| 316 |
+
font-size: var(--font-size-xs);
|
| 317 |
+
color: var(--accent-primary);
|
| 318 |
+
background: none;
|
| 319 |
+
border: none;
|
| 320 |
+
cursor: pointer;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
.ticker-grid {
|
| 324 |
+
display: grid;
|
| 325 |
+
grid-template-columns: repeat(4, 1fr);
|
| 326 |
+
gap: var(--spacing-xs) var(--spacing-sm);
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
.stock-item {
|
| 330 |
+
display: flex;
|
| 331 |
+
align-items: center;
|
| 332 |
+
padding: var(--spacing-xs);
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
.stock-checkbox {
|
| 336 |
+
margin-right: var(--spacing-xs);
|
| 337 |
+
appearance: none;
|
| 338 |
+
width: 14px;
|
| 339 |
+
height: 14px;
|
| 340 |
+
border: 1px solid var(--border-color);
|
| 341 |
+
border-radius: 3px;
|
| 342 |
+
background-color: var(--bg-tertiary);
|
| 343 |
+
cursor: pointer;
|
| 344 |
+
position: relative;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
.stock-checkbox:checked {
|
| 348 |
+
background-color: var(--accent-primary);
|
| 349 |
+
border-color: var(--accent-primary);
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.stock-checkbox:checked::after {
|
| 353 |
+
content: "";
|
| 354 |
+
position: absolute;
|
| 355 |
+
left: 4px;
|
| 356 |
+
top: 1px;
|
| 357 |
+
width: 4px;
|
| 358 |
+
height: 8px;
|
| 359 |
+
border: solid white;
|
| 360 |
+
border-width: 0 2px 2px 0;
|
| 361 |
+
transform: rotate(45deg);
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
.stock-ticker {
|
| 365 |
+
font-size: var(--font-size-xs);
|
| 366 |
+
cursor: pointer;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
.loading-overlay {
|
| 370 |
+
position: fixed;
|
| 371 |
+
top: 0;
|
| 372 |
+
left: 0;
|
| 373 |
+
right: 0;
|
| 374 |
+
bottom: 0;
|
| 375 |
+
background-color: rgba(30, 30, 36, 0.8);
|
| 376 |
+
display: flex;
|
| 377 |
+
flex-direction: column;
|
| 378 |
+
justify-content: center;
|
| 379 |
+
align-items: center;
|
| 380 |
+
z-index: 100;
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
.spinner {
|
| 384 |
+
width: 40px;
|
| 385 |
+
height: 40px;
|
| 386 |
+
border: 3px solid rgba(255, 255, 255, 0.1);
|
| 387 |
+
border-radius: 50%;
|
| 388 |
+
border-top-color: var(--accent-primary);
|
| 389 |
+
animation: spin 1s ease-in-out infinite;
|
| 390 |
+
margin-bottom: var(--spacing-md);
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
@keyframes spin {
|
| 394 |
+
to {
|
| 395 |
+
transform: rotate(360deg);
|
| 396 |
+
}
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
.loading-text {
|
| 400 |
+
font-size: var(--font-size-md);
|
| 401 |
+
color: var(--text-primary);
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
/* Toggle switch for sectors */
|
| 405 |
+
.toggle-switch {
|
| 406 |
+
display: flex;
|
| 407 |
+
align-items: center;
|
| 408 |
+
gap: var(--spacing-xs);
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
.toggle-checkbox {
|
| 412 |
+
height: 0;
|
| 413 |
+
width: 0;
|
| 414 |
+
visibility: hidden;
|
| 415 |
+
position: absolute;
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
.toggle-label {
|
| 419 |
+
cursor: pointer;
|
| 420 |
+
width: 32px;
|
| 421 |
+
height: 16px;
|
| 422 |
+
background: var(--bg-tertiary);
|
| 423 |
+
display: block;
|
| 424 |
+
border-radius: 100px;
|
| 425 |
+
position: relative;
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
.toggle-label:after {
|
| 429 |
+
content: '';
|
| 430 |
+
position: absolute;
|
| 431 |
+
top: 2px;
|
| 432 |
+
left: 2px;
|
| 433 |
+
width: 12px;
|
| 434 |
+
height: 12px;
|
| 435 |
+
background: var(--text-secondary);
|
| 436 |
+
border-radius: 50%;
|
| 437 |
+
transition: 0.3s;
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
.toggle-checkbox:checked + .toggle-label {
|
| 441 |
+
background: var(--accent-primary);
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
.toggle-checkbox:checked + .toggle-label:after {
|
| 445 |
+
left: calc(100% - 2px);
|
| 446 |
+
transform: translateX(-100%);
|
| 447 |
+
background: white;
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
/* Responsiveness */
|
| 451 |
+
@media (max-width: 1280px) {
|
| 452 |
+
.main-content {
|
| 453 |
+
flex-direction: column;
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
.right-panel {
|
| 457 |
+
max-height: 300px;
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
.config-grid {
|
| 461 |
+
grid-template-columns: 1fr 1fr;
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
.ticker-grid {
|
| 465 |
+
grid-template-columns: repeat(3, 1fr);
|
| 466 |
+
}
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
@media (max-width: 768px) {
|
| 470 |
+
.stats-row {
|
| 471 |
+
grid-template-columns: 1fr;
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
.ticker-grid {
|
| 475 |
+
grid-template-columns: repeat(2, 1fr);
|
| 476 |
+
}
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
.alpha-controls {
|
| 480 |
+
margin-top: var(--spacing-md);
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
.alpha-title {
|
| 484 |
+
font-size: var(--font-size-sm);
|
| 485 |
+
margin-bottom: var(--spacing-sm);
|
| 486 |
+
color: var(--text-primary);
|
| 487 |
+
font-weight: 500;
|
| 488 |
+
text-align: center;
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
.alpha-grid {
|
| 492 |
+
display: grid;
|
| 493 |
+
grid-template-columns: repeat(3, 1fr);
|
| 494 |
+
gap: var(--spacing-md);
|
| 495 |
+
}
|
benchmarks.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import numpy as np
|
| 3 |
+
import random
|
| 4 |
+
|
| 5 |
+
# Small epsilon for Sharpe calculation
|
| 6 |
+
eps = 1e-8
|
| 7 |
+
ANNUAL_TRADING_DAYS = 252
|
| 8 |
+
|
| 9 |
+
def run_equal_weight(data_df: pd.DataFrame) -> pd.Series:
|
| 10 |
+
"""Calculates daily returns for a static equal-weight portfolio.
|
| 11 |
+
|
| 12 |
+
Args:
|
| 13 |
+
data_df (pd.DataFrame): DataFrame with dates as index, ticker returns,
|
| 14 |
+
and an 'rf' column.
|
| 15 |
+
|
| 16 |
+
Returns:
|
| 17 |
+
pd.Series: Daily returns of the equal-weight portfolio.
|
| 18 |
+
"""
|
| 19 |
+
stock_returns = data_df.drop(columns=['rf'], errors='ignore')
|
| 20 |
+
if stock_returns.empty:
|
| 21 |
+
return pd.Series(dtype=float, name="EqualWeightReturn")
|
| 22 |
+
# Calculate the mean return across all stocks for each day
|
| 23 |
+
daily_returns = stock_returns.mean(axis=1)
|
| 24 |
+
return daily_returns.rename("EqualWeightReturn")
|
| 25 |
+
|
| 26 |
+
def run_random_portfolio(
|
| 27 |
+
data_df: pd.DataFrame,
|
| 28 |
+
num_stocks: int = 3,
|
| 29 |
+
rebalance_days: int = 20
|
| 30 |
+
) -> pd.Series:
|
| 31 |
+
"""Calculates daily returns for a randomly selected portfolio,
|
| 32 |
+
rebalanced periodically.
|
| 33 |
+
|
| 34 |
+
Args:
|
| 35 |
+
data_df (pd.DataFrame): DataFrame with dates as index, ticker returns,
|
| 36 |
+
and an 'rf' column.
|
| 37 |
+
num_stocks (int): Number of stocks to randomly select.
|
| 38 |
+
rebalance_days (int): How often to re-select stocks.
|
| 39 |
+
|
| 40 |
+
Returns:
|
| 41 |
+
pd.Series: Daily returns of the random portfolio.
|
| 42 |
+
"""
|
| 43 |
+
stock_returns = data_df.drop(columns=['rf'], errors='ignore')
|
| 44 |
+
if stock_returns.empty or stock_returns.shape[1] < num_stocks:
|
| 45 |
+
print("Warning: Not enough stocks available for random portfolio.")
|
| 46 |
+
return pd.Series(dtype=float, name="RandomPortfolioReturn")
|
| 47 |
+
|
| 48 |
+
tickers = stock_returns.columns.tolist()
|
| 49 |
+
portfolio_returns = pd.Series(index=data_df.index, dtype=float)
|
| 50 |
+
selected_tickers = []
|
| 51 |
+
|
| 52 |
+
for i, date in enumerate(data_df.index):
|
| 53 |
+
# Rebalance check
|
| 54 |
+
if i % rebalance_days == 0 or not selected_tickers:
|
| 55 |
+
if len(tickers) >= num_stocks:
|
| 56 |
+
selected_tickers = random.sample(tickers, num_stocks)
|
| 57 |
+
else: # Should not happen based on initial check, but safe
|
| 58 |
+
selected_tickers = tickers
|
| 59 |
+
# print(f"Rebalancing Random Portfolio on {date.date()}: {selected_tickers}")
|
| 60 |
+
|
| 61 |
+
# Calculate return for the day using selected tickers
|
| 62 |
+
daily_returns = stock_returns.loc[date, selected_tickers]
|
| 63 |
+
portfolio_returns[date] = daily_returns.mean() # Equal weight among selected
|
| 64 |
+
|
| 65 |
+
return portfolio_returns.rename("RandomPortfolioReturn")
|
| 66 |
+
|
| 67 |
+
# --- Performance Metrics ---
|
| 68 |
+
|
| 69 |
+
def calculate_cumulative_returns(returns_series: pd.Series) -> pd.Series:
|
| 70 |
+
"""Calculates cumulative returns from a daily returns series."""
|
| 71 |
+
return (1 + returns_series.fillna(0)).cumprod()
|
| 72 |
+
|
| 73 |
+
def calculate_performance_metrics(returns_series: pd.Series, rf_series: pd.Series) -> dict:
|
| 74 |
+
"""Calculates annualized Sharpe Ratio and Max Drawdown."""
|
| 75 |
+
if returns_series.empty or returns_series.isnull().all():
|
| 76 |
+
return {"Annualized Sharpe Ratio": 0.0, "Max Drawdown": 0.0, "Cumulative Return": 1.0}
|
| 77 |
+
|
| 78 |
+
cumulative_return = (1 + returns_series.fillna(0)).cumprod().iloc[-1]
|
| 79 |
+
|
| 80 |
+
# Align risk-free rate series to the returns series index
|
| 81 |
+
aligned_rf = rf_series.reindex(returns_series.index).fillna(0)
|
| 82 |
+
|
| 83 |
+
# Calculate Excess Returns
|
| 84 |
+
excess_returns = returns_series - aligned_rf
|
| 85 |
+
|
| 86 |
+
# Annualized Sharpe Ratio
|
| 87 |
+
# Use np.sqrt(ANNUAL_TRADING_DAYS) for annualization factor
|
| 88 |
+
mean_excess_return = excess_returns.mean()
|
| 89 |
+
std_dev_excess_return = excess_returns.std()
|
| 90 |
+
sharpe_ratio = (mean_excess_return / (std_dev_excess_return + eps)) * np.sqrt(ANNUAL_TRADING_DAYS)
|
| 91 |
+
|
| 92 |
+
# Max Drawdown
|
| 93 |
+
cumulative = calculate_cumulative_returns(returns_series)
|
| 94 |
+
peak = cumulative.expanding(min_periods=1).max()
|
| 95 |
+
drawdown = (cumulative - peak) / (peak + eps) # Drawdown is negative or zero
|
| 96 |
+
max_drawdown = abs(drawdown.min()) # Max drawdown is positive
|
| 97 |
+
|
| 98 |
+
return {
|
| 99 |
+
"Annualized Sharpe Ratio": round(sharpe_ratio, 4),
|
| 100 |
+
"Max Drawdown": round(max_drawdown, 4),
|
| 101 |
+
"Cumulative Return": round(cumulative_return, 4)
|
| 102 |
+
}
|
data/risk_free_data.csv
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d6d264a5ebe76a9b04e90f27d60d7cb5877632333acdae4e69c4d1ff43da2d46
|
| 3 |
+
size 74722
|
data/stock_data.csv
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ba1a04ffe09a116240c3d7bf2dfd1fac2fc817d83727c3d373f89bc23ec5eb2d
|
| 3 |
+
size 14676155
|
data/tickers_by_sector.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"sector": "Technology",
|
| 4 |
+
"tickers": ["AAPL", "MSFT", "NVDA", "GOOGL", "META", "AVGO", "ORCL", "IBM", "CSCO", "TSM", "ASML", "AMD", "TXN", "INTC", "MU", "QCOM", "LRCX", "NXPI", "ADI"]
|
| 5 |
+
},
|
| 6 |
+
{
|
| 7 |
+
"sector": "Consumer Discretionary",
|
| 8 |
+
"tickers": ["AMZN", "TSLA", "NKE", "MCD", "SBUX", "YUM", "GM", "F", "RIVN", "NIO", "TTWO", "EA", "GME", "AMC"]
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"sector": "Financials",
|
| 12 |
+
"tickers": ["JPM", "V", "MA", "GS", "MS", "BAC", "C", "AXP", "SCHW"]
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"sector": "Health Care",
|
| 16 |
+
"tickers": ["UNH", "JNJ", "LLY", "PFE", "MRNA", "BMY", "GILD", "CVS", "VRTX", "ISRG"]
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
"sector": "Consumer Staples",
|
| 20 |
+
"tickers": ["WMT", "PG", "TGT", "KO", "PEP", "TSN", "CAG", "SYY", "HRL", "MDLZ"]
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"sector": "Energy",
|
| 24 |
+
"tickers": ["XOM", "CVX", "NEE", "DUK", "SO", "D", "ENB", "SLB", "EOG", "PSX"]
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"sector": "Industrials",
|
| 28 |
+
"tickers": ["DE", "LMT", "RTX", "BA", "CAT", "GE", "HON", "UPS", "EMR", "NOC", "FDX", "CSX", "UNP", "DAL"]
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"sector": "Real Estate",
|
| 32 |
+
"tickers": ["PLD", "AMT", "EQIX", "O", "SPG", "VICI", "DLR", "WY", "EQR", "PSA"]
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
"sector": "Materials",
|
| 36 |
+
"tickers": ["ADM", "BG", "CF", "MOS", "FMC"]
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"sector": "Communication Services",
|
| 40 |
+
"tickers": ["NFLX", "DIS", "PARA", "WBD", "CMCSA", "SPOT", "LYV"]
|
| 41 |
+
}
|
| 42 |
+
]
|
optimization.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import numpy as np # Added numpy for potential use
|
| 4 |
+
|
| 5 |
+
# Small epsilon to avoid division by zero - INCREASED for better stability
|
| 6 |
+
eps = 1e-6
|
| 7 |
+
|
| 8 |
+
# --- Objective function components ---
|
| 9 |
+
def calculate_sortino(
|
| 10 |
+
returns: torch.Tensor, # Use torch.Tensor for type hinting
|
| 11 |
+
min_acceptable_return: torch.Tensor
|
| 12 |
+
):
|
| 13 |
+
"""Calculates the Sortino ratio."""
|
| 14 |
+
if min_acceptable_return is not None:
|
| 15 |
+
excess_returns = returns - min_acceptable_return
|
| 16 |
+
else:
|
| 17 |
+
# If no MAR provided, treat 0 as the target
|
| 18 |
+
excess_returns = returns
|
| 19 |
+
|
| 20 |
+
# Calculate downside deviation only on returns below the target
|
| 21 |
+
downside_returns = torch.where(excess_returns < 0, excess_returns, torch.tensor(0.0, device=returns.device))
|
| 22 |
+
downside_deviation = torch.std(downside_returns, dim=0)
|
| 23 |
+
|
| 24 |
+
# More robust division - avoid division by very small numbers
|
| 25 |
+
downside_deviation = torch.clamp(downside_deviation, min=eps)
|
| 26 |
+
|
| 27 |
+
# Calculate Sortino ratio with better stability
|
| 28 |
+
sortino = torch.mean(excess_returns, dim=0) / downside_deviation
|
| 29 |
+
|
| 30 |
+
# Clip extreme values to prevent propagation of extreme gradients
|
| 31 |
+
sortino = torch.clamp(sortino, min=-100.0, max=100.0)
|
| 32 |
+
|
| 33 |
+
return sortino
|
| 34 |
+
|
| 35 |
+
def calculate_max_drawdown(
|
| 36 |
+
returns: torch.Tensor
|
| 37 |
+
):
|
| 38 |
+
"""Calculates max drawdown for the duration of the returns passed.
|
| 39 |
+
Max drawdown is defined to be positive, takes the range [0, \\infty).
|
| 40 |
+
"""
|
| 41 |
+
if returns.numel() == 0:
|
| 42 |
+
return torch.tensor(0.0, device=returns.device) # Handle empty tensor
|
| 43 |
+
|
| 44 |
+
# Handle NaN values in returns if any
|
| 45 |
+
clean_returns = torch.nan_to_num(returns, nan=0.0)
|
| 46 |
+
|
| 47 |
+
cum_returns = (clean_returns + 1).cumprod(dim=0)
|
| 48 |
+
peak = torch.cummax(cum_returns, dim=0).values # Use torch.cummax
|
| 49 |
+
|
| 50 |
+
# Prevent division by zero or very small peaks
|
| 51 |
+
safe_peak = torch.clamp(peak, min=eps)
|
| 52 |
+
|
| 53 |
+
drawdown = (peak - cum_returns) / safe_peak # Calculate drawdown relative to peak
|
| 54 |
+
max_drawdown = torch.max(drawdown)
|
| 55 |
+
|
| 56 |
+
# Clip extreme values
|
| 57 |
+
max_drawdown = torch.clamp(max_drawdown, min=0.0, max=1.0)
|
| 58 |
+
|
| 59 |
+
return max_drawdown
|
| 60 |
+
|
| 61 |
+
def calculate_turnover(
|
| 62 |
+
new_weights: torch.Tensor,
|
| 63 |
+
prev_weights: torch.Tensor
|
| 64 |
+
):
|
| 65 |
+
"""Turnover is defined as the sum of absolute differences
|
| 66 |
+
between new and previous weights, divided by 2.
|
| 67 |
+
Takes the range [0, \\infty).
|
| 68 |
+
"""
|
| 69 |
+
# Safe handling of NaN weights
|
| 70 |
+
new_weights_safe = torch.nan_to_num(new_weights, nan=1.0/new_weights.size(0))
|
| 71 |
+
prev_weights_safe = torch.nan_to_num(prev_weights, nan=1.0/prev_weights.size(0))
|
| 72 |
+
|
| 73 |
+
turnover = torch.sum(torch.abs(new_weights_safe - prev_weights_safe)) / 2.0
|
| 74 |
+
|
| 75 |
+
# Clip to reasonable values
|
| 76 |
+
turnover = torch.clamp(turnover, min=0.0, max=1.0)
|
| 77 |
+
|
| 78 |
+
return turnover
|
| 79 |
+
|
| 80 |
+
def calculate_objective_func(
|
| 81 |
+
returns: torch.Tensor,
|
| 82 |
+
risk_free_rate: torch.Tensor,
|
| 83 |
+
new_weights: torch.Tensor,
|
| 84 |
+
prev_weights: torch.Tensor,
|
| 85 |
+
alphas = [1.0, 1.0, 1.0] # Default alpha values (use floats)
|
| 86 |
+
):
|
| 87 |
+
"""Calculates the weighted objective function to be MINIMIZED.
|
| 88 |
+
Note: Sortino is maximized, drawdown and turnover are minimized.
|
| 89 |
+
"""
|
| 90 |
+
sortino = calculate_sortino(returns, risk_free_rate)
|
| 91 |
+
max_drawdown = calculate_max_drawdown(returns)
|
| 92 |
+
turnover = calculate_turnover(new_weights, prev_weights)
|
| 93 |
+
|
| 94 |
+
# Apply more conservative scaling to individual components
|
| 95 |
+
sortino_scaled = torch.clamp(sortino, min=-10.0, max=10.0)
|
| 96 |
+
max_drawdown_scaled = torch.clamp(max_drawdown, min=0.0, max=1.0)
|
| 97 |
+
turnover_scaled = torch.clamp(turnover, min=0.0, max=1.0)
|
| 98 |
+
|
| 99 |
+
# Objective: Maximize Sortino, Minimize MaxDrawdown, Minimize Turnover
|
| 100 |
+
# We negate Sortino because the optimizer minimizes the objective.
|
| 101 |
+
objective = ( -alphas[0] * sortino_scaled +
|
| 102 |
+
alphas[1] * max_drawdown_scaled +
|
| 103 |
+
alphas[2] * turnover_scaled
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
# Ensure objective is not NaN
|
| 107 |
+
if torch.isnan(objective):
|
| 108 |
+
print("Warning: NaN objective detected, using default value")
|
| 109 |
+
objective = torch.tensor(0.0, requires_grad=True)
|
| 110 |
+
|
| 111 |
+
return objective
|
| 112 |
+
|
| 113 |
+
# --- Main OGD Optimization Function ---
|
| 114 |
+
def run_ogd(
|
| 115 |
+
data_df: pd.DataFrame,
|
| 116 |
+
window_size: int = 20, # Default hyperparameter
|
| 117 |
+
learning_rate: float = 0.01, # Default hyperparameter (REDUCED for stability)
|
| 118 |
+
alphas: list[float] = [1.0, 1.0, 0.1] # Default hyperparameter (reduced turnover weight slightly)
|
| 119 |
+
):
|
| 120 |
+
"""Runs the Online Gradient Descent (OGD) portfolio optimization.
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
data_df (pd.DataFrame): DataFrame with dates as index, ticker returns as columns,
|
| 124 |
+
and a final column named 'rf' for the risk-free rate.
|
| 125 |
+
window_size (int): Lookback window for objective calculation.
|
| 126 |
+
learning_rate (float): Learning rate for the SGD optimizer.
|
| 127 |
+
alphas (list[float]): Weights for [Sortino, MaxDrawdown, Turnover] in the objective.
|
| 128 |
+
|
| 129 |
+
Returns:
|
| 130 |
+
tuple[pd.DataFrame, pd.DataFrame]:
|
| 131 |
+
- weights_df: DataFrame of daily portfolio weights (dates index, tickers columns).
|
| 132 |
+
- returns_series: Series of daily portfolio returns (dates index).
|
| 133 |
+
"""
|
| 134 |
+
if data_df.empty or len(data_df) <= window_size:
|
| 135 |
+
print("Warning: Dataframe too small for OGD with the given window size.")
|
| 136 |
+
return pd.DataFrame(), pd.Series(dtype=float)
|
| 137 |
+
|
| 138 |
+
# --- Add data validation ---
|
| 139 |
+
# Check for NaN values in the input data
|
| 140 |
+
num_nan_values = data_df.isna().sum().sum()
|
| 141 |
+
if num_nan_values > 0:
|
| 142 |
+
print(f"WARNING: Input data contains {num_nan_values} NaN values. Filling with 0.")
|
| 143 |
+
data_df = data_df.fillna(0)
|
| 144 |
+
|
| 145 |
+
# --- Print diagnostic info ---
|
| 146 |
+
print(f"Data shape: {data_df.shape}")
|
| 147 |
+
print(f"Sample data (first few rows):")
|
| 148 |
+
print(data_df.iloc[:3, :5]) # Show first 3 rows, first 5 columns
|
| 149 |
+
|
| 150 |
+
# Check for any columns with all zeros or NaNs
|
| 151 |
+
zero_cols = (data_df == 0).all()
|
| 152 |
+
if zero_cols.any():
|
| 153 |
+
zero_count = zero_cols.sum()
|
| 154 |
+
print(f"WARNING: {zero_count} columns contain all zeros.")
|
| 155 |
+
|
| 156 |
+
# Separate stock returns and risk-free rate
|
| 157 |
+
returns = data_df.drop(columns=['rf'])
|
| 158 |
+
rf = data_df['rf']
|
| 159 |
+
tickers = returns.columns.tolist()
|
| 160 |
+
num_assets = len(tickers)
|
| 161 |
+
num_days = len(data_df)
|
| 162 |
+
|
| 163 |
+
# Convert to PyTorch tensors with explicit handling of NaN values
|
| 164 |
+
# Replace NaN values with 0 during tensor conversion
|
| 165 |
+
returns_tensor = torch.tensor(returns.fillna(0).values, dtype=torch.float32)
|
| 166 |
+
rf_tensor = torch.tensor(rf.fillna(0).values, dtype=torch.float32)
|
| 167 |
+
|
| 168 |
+
# Check if returns_tensor contains any NaN values (after conversion)
|
| 169 |
+
if torch.isnan(returns_tensor).any():
|
| 170 |
+
print("WARNING: returns_tensor contains NaN values after conversion. Replacing with zeros.")
|
| 171 |
+
returns_tensor = torch.nan_to_num(returns_tensor, nan=0.0)
|
| 172 |
+
|
| 173 |
+
# Initialize weights as logits (will be converted to probabilities via softmax)
|
| 174 |
+
# Starting with zeros gives equal weights after softmax
|
| 175 |
+
weights = torch.zeros((num_assets,), requires_grad=True)
|
| 176 |
+
|
| 177 |
+
# Use Adam optimizer with reduced learning rate
|
| 178 |
+
optimizer = torch.optim.Adam([weights], lr=learning_rate)
|
| 179 |
+
|
| 180 |
+
# Logging structures
|
| 181 |
+
weights_log = torch.zeros((num_days, num_assets), dtype=torch.float32)
|
| 182 |
+
portfolio_returns_log = torch.zeros((num_days,), dtype=torch.float32)
|
| 183 |
+
rolling_portfolio_returns = [] # Store recent portfolio returns for objective calc
|
| 184 |
+
|
| 185 |
+
print(f"Starting OGD optimization for {num_days} days, {num_assets} assets...")
|
| 186 |
+
|
| 187 |
+
# Initial weights distribution - equal weights
|
| 188 |
+
initial_weights = torch.full((num_assets,), 1.0/num_assets)
|
| 189 |
+
|
| 190 |
+
for i in range(num_days):
|
| 191 |
+
# Check for NaN in weights and reset if needed
|
| 192 |
+
if torch.isnan(weights).any():
|
| 193 |
+
print(f"WARNING: NaN detected in weights at day {i}, resetting to uniform weights")
|
| 194 |
+
with torch.no_grad():
|
| 195 |
+
weights.copy_(torch.zeros((num_assets,)))
|
| 196 |
+
|
| 197 |
+
# More restrictive clamping for numerical stability
|
| 198 |
+
clamped_weights = torch.clamp(weights, min=-5, max=5)
|
| 199 |
+
normalized_weights = torch.nn.functional.softmax(clamped_weights, dim=0)
|
| 200 |
+
|
| 201 |
+
# Verify normalized weights are valid probabilities
|
| 202 |
+
if torch.isnan(normalized_weights).any() or torch.sum(normalized_weights) < 0.99:
|
| 203 |
+
print(f"WARNING: Invalid normalized weights at day {i}, using uniform weights")
|
| 204 |
+
normalized_weights = initial_weights.clone()
|
| 205 |
+
|
| 206 |
+
# Get daily asset returns and check for NaN values
|
| 207 |
+
daily_asset_returns = returns_tensor[i, :]
|
| 208 |
+
if torch.isnan(daily_asset_returns).any():
|
| 209 |
+
print(f"WARNING: NaN detected in asset returns at day {i}, replacing with zeros")
|
| 210 |
+
daily_asset_returns = torch.nan_to_num(daily_asset_returns, nan=0.0)
|
| 211 |
+
|
| 212 |
+
# Calculate portfolio return for the current day
|
| 213 |
+
daily_portfolio_return = torch.dot(normalized_weights, daily_asset_returns)
|
| 214 |
+
|
| 215 |
+
# Check for NaN in portfolio return
|
| 216 |
+
if torch.isnan(daily_portfolio_return):
|
| 217 |
+
print(f"WARNING: NaN detected in portfolio return at day {i}, using zero")
|
| 218 |
+
daily_portfolio_return = torch.tensor(0.0)
|
| 219 |
+
|
| 220 |
+
# Debug information - print sample weights and returns to diagnose the issue
|
| 221 |
+
if i < 5 or i % 50 == 0: # Print for first few days and then occasionally
|
| 222 |
+
print(f" Debug info for day {i}:")
|
| 223 |
+
print(f" Sample weights: {normalized_weights[:5].tolist()}")
|
| 224 |
+
print(f" Sample returns: {daily_asset_returns[:5].tolist()}")
|
| 225 |
+
print(f" Sum of weights: {torch.sum(normalized_weights).item()}")
|
| 226 |
+
nan_count = torch.isnan(daily_asset_returns).sum().item()
|
| 227 |
+
print(f" NaN count in returns: {nan_count}/{len(daily_asset_returns)}")
|
| 228 |
+
|
| 229 |
+
# Log weights and returns (use detach() to prevent tracking history)
|
| 230 |
+
weights_log[i, :] = normalized_weights.detach()
|
| 231 |
+
portfolio_returns_log[i] = daily_portfolio_return.detach()
|
| 232 |
+
|
| 233 |
+
# Add current return to rolling list for objective calculation
|
| 234 |
+
# Detach returns when storing to break gradient history
|
| 235 |
+
rolling_portfolio_returns.append(daily_portfolio_return.detach())
|
| 236 |
+
|
| 237 |
+
# --- Objective Calculation and Optimization Step ---
|
| 238 |
+
# Wait until we have enough data for the lookback window
|
| 239 |
+
if len(rolling_portfolio_returns) > window_size:
|
| 240 |
+
rolling_portfolio_returns.pop(0) # Remove oldest return
|
| 241 |
+
|
| 242 |
+
# Verify we don't have all zeros in our portfolio returns
|
| 243 |
+
all_zeros = all(r.item() == 0 for r in rolling_portfolio_returns)
|
| 244 |
+
if all_zeros:
|
| 245 |
+
print(f"WARNING: All portfolio returns are zero at day {i}, skipping optimization")
|
| 246 |
+
continue
|
| 247 |
+
|
| 248 |
+
# Prepare tensors for objective function
|
| 249 |
+
past_portfolio_returns = torch.stack(rolling_portfolio_returns[:-1] + [daily_portfolio_return])
|
| 250 |
+
|
| 251 |
+
# Get corresponding risk-free rates for the window
|
| 252 |
+
start_idx = max(0, i - window_size + 1)
|
| 253 |
+
past_rf = rf_tensor[start_idx : i + 1]
|
| 254 |
+
|
| 255 |
+
# Get previous day's weights for turnover calculation
|
| 256 |
+
prev_weights = weights_log[i-1, :] if i > 0 else normalized_weights.detach()
|
| 257 |
+
|
| 258 |
+
# Zero out gradients before computation
|
| 259 |
+
optimizer.zero_grad()
|
| 260 |
+
|
| 261 |
+
try:
|
| 262 |
+
# Recompute normalized weights for fresh gradient computation
|
| 263 |
+
clamped_weights = torch.clamp(weights, min=-5, max=5)
|
| 264 |
+
current_norm_weights = torch.nn.functional.softmax(clamped_weights, dim=0)
|
| 265 |
+
|
| 266 |
+
# Recalculate today's return for gradient computation
|
| 267 |
+
current_return = torch.dot(current_norm_weights, daily_asset_returns)
|
| 268 |
+
|
| 269 |
+
# Create list with detached historical returns + current gradient-connected return
|
| 270 |
+
historical_returns = rolling_portfolio_returns[:-1]
|
| 271 |
+
new_returns_list = historical_returns + [current_return]
|
| 272 |
+
past_portfolio_returns = torch.stack(new_returns_list)
|
| 273 |
+
|
| 274 |
+
# Calculate objective with robust error handling
|
| 275 |
+
objective = calculate_objective_func(
|
| 276 |
+
past_portfolio_returns,
|
| 277 |
+
past_rf,
|
| 278 |
+
current_norm_weights,
|
| 279 |
+
prev_weights,
|
| 280 |
+
alphas
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
# Check if objective computation produced valid result
|
| 284 |
+
if not torch.isnan(objective):
|
| 285 |
+
# Check objective is not just a default zero
|
| 286 |
+
if objective.item() != 0.0 or i % 50 == 0: # Allow some zeros through for logging
|
| 287 |
+
# Compute and apply gradients
|
| 288 |
+
objective.backward()
|
| 289 |
+
|
| 290 |
+
# --- Enhanced Logging ---
|
| 291 |
+
log_interval = 50
|
| 292 |
+
if (i + 1) % log_interval == 0 or num_days - (i + 1) < 5:
|
| 293 |
+
print(f"\n--- Step {i+1}/{num_days} Log ---")
|
| 294 |
+
print(f" Objective: {objective.item():.6f}")
|
| 295 |
+
|
| 296 |
+
# Log average gradient magnitude rather than all gradients
|
| 297 |
+
if weights.grad is not None:
|
| 298 |
+
avg_grad = torch.mean(torch.abs(weights.grad)).item()
|
| 299 |
+
print(f" Average Gradient Magnitude: {avg_grad:.6f}")
|
| 300 |
+
|
| 301 |
+
# Record some sample weights before update
|
| 302 |
+
weights_before = weights.detach().clone()
|
| 303 |
+
|
| 304 |
+
# Apply gradient update
|
| 305 |
+
optimizer.step()
|
| 306 |
+
|
| 307 |
+
# Record weights after update
|
| 308 |
+
weights_after = weights.detach().clone()
|
| 309 |
+
weight_change = torch.sum(torch.abs(weights_after - weights_before)).item()
|
| 310 |
+
print(f" Weight Change (Sum Abs): {weight_change:.6f}")
|
| 311 |
+
|
| 312 |
+
# Display a few normalized weights as a sample
|
| 313 |
+
print(f" Sample Normalized Weights: {[f'{w:.4f}' for w in normalized_weights[:5].tolist()]}")
|
| 314 |
+
else:
|
| 315 |
+
# Update weights without detailed logging
|
| 316 |
+
optimizer.step()
|
| 317 |
+
|
| 318 |
+
# Apply gradient clipping after optimizer step
|
| 319 |
+
with torch.no_grad():
|
| 320 |
+
if weights.grad is not None and torch.isnan(weights.grad).any():
|
| 321 |
+
print(f" WARNING: NaN gradient detected at day {i}, zeroing gradients")
|
| 322 |
+
weights.grad.zero_()
|
| 323 |
+
else:
|
| 324 |
+
print(f" WARNING: Zero objective at day {i}, skipping gradient update")
|
| 325 |
+
else:
|
| 326 |
+
print(f" WARNING: NaN objective at day {i}, skipping gradient update")
|
| 327 |
+
|
| 328 |
+
except Exception as e:
|
| 329 |
+
print(f" Optimization error at day {i}: {e}")
|
| 330 |
+
# Skip this day rather than propagating errors
|
| 331 |
+
|
| 332 |
+
print("OGD optimization finished.")
|
| 333 |
+
|
| 334 |
+
# Final check for validity of results
|
| 335 |
+
if torch.isnan(weights_log).any():
|
| 336 |
+
print("WARNING: Final weights contain NaN values")
|
| 337 |
+
weights_log = torch.nan_to_num(weights_log, nan=1.0/num_assets)
|
| 338 |
+
|
| 339 |
+
if torch.isnan(portfolio_returns_log).any():
|
| 340 |
+
print("WARNING: Final portfolio returns contain NaN values")
|
| 341 |
+
portfolio_returns_log = torch.nan_to_num(portfolio_returns_log, nan=0.0)
|
| 342 |
+
|
| 343 |
+
# Convert logs back to pandas DataFrames/Series with original index
|
| 344 |
+
weights_df = pd.DataFrame(weights_log.numpy(), index=data_df.index, columns=tickers)
|
| 345 |
+
returns_series = pd.Series(portfolio_returns_log.numpy(), index=data_df.index, name="PortfolioReturn")
|
| 346 |
+
|
| 347 |
+
return weights_df, returns_series
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch
|
| 2 |
+
pandas
|
| 3 |
+
matplotlib
|
| 4 |
+
yfinance
|
| 5 |
+
numpy
|
| 6 |
+
seaborn
|
| 7 |
+
gradio>=3.50.0
|
| 8 |
+
fastapi>=0.95.0
|
| 9 |
+
uvicorn>=0.22.0
|
| 10 |
+
jinja2>=3.1.2
|
| 11 |
+
python-multipart>=0.0.6
|
utils.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import os
|
| 3 |
+
from datetime import datetime # Import datetime
|
| 4 |
+
|
| 5 |
+
# Define data path relative to this file's location
|
| 6 |
+
DATA_DIR = os.path.join(os.path.dirname(__file__), 'data')
|
| 7 |
+
STOCK_DATA_PATH = os.path.join(DATA_DIR, "stock_data.csv")
|
| 8 |
+
RF_DATA_PATH = os.path.join(DATA_DIR, "risk_free_data.csv")
|
| 9 |
+
TICKERS_PATH = os.path.join(DATA_DIR, "tickers_by_sector.json") # Added for potential future use
|
| 10 |
+
|
| 11 |
+
def load_data():
|
| 12 |
+
"""Loads stock and risk-free rate data from CSV files.
|
| 13 |
+
Stock data is pivoted to wide format (date index, ticker columns).
|
| 14 |
+
Handles duplicate date/ticker entries by averaging returns.
|
| 15 |
+
"""
|
| 16 |
+
try:
|
| 17 |
+
# Load stock data (long format)
|
| 18 |
+
stock_data_long = pd.read_csv(STOCK_DATA_PATH, parse_dates=['date']) # Use lowercase 'date'
|
| 19 |
+
|
| 20 |
+
# Check for duplicates before pivoting
|
| 21 |
+
duplicates = stock_data_long[stock_data_long.duplicated(subset=['date', 'ticker'], keep=False)]
|
| 22 |
+
if not duplicates.empty:
|
| 23 |
+
print(f"Warning: Found {len(duplicates)} duplicate date/ticker entries in stock_data.csv.")
|
| 24 |
+
print("Aggregating returns using 'mean'. First few duplicates:")
|
| 25 |
+
print(duplicates.head())
|
| 26 |
+
|
| 27 |
+
# Pivot to wide format using pivot_table with mean aggregation
|
| 28 |
+
stock_data_df = stock_data_long.pivot_table(
|
| 29 |
+
index='date',
|
| 30 |
+
columns='ticker',
|
| 31 |
+
values='ret',
|
| 32 |
+
aggfunc='mean' # Aggregate duplicates by taking the mean return
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# Load risk-free data
|
| 36 |
+
# Use lowercase 'date', set as index directly
|
| 37 |
+
rf_data_df = pd.read_csv(RF_DATA_PATH, parse_dates=['date'], index_col='date')
|
| 38 |
+
|
| 39 |
+
print("Data loaded. Stock data pivoted successfully (duplicates averaged).")
|
| 40 |
+
return stock_data_df, rf_data_df
|
| 41 |
+
except FileNotFoundError as e:
|
| 42 |
+
print(f"Error loading data: {e}")
|
| 43 |
+
print(f"Please ensure '{STOCK_DATA_PATH}' and '{RF_DATA_PATH}' exist.")
|
| 44 |
+
return None, None
|
| 45 |
+
except KeyError as e:
|
| 46 |
+
print(f"Error processing data: Missing expected column - {e}")
|
| 47 |
+
print("Please ensure CSV files have 'date', 'ticker', 'ret' (for stock) and 'date', 'rf' (for risk-free)." )
|
| 48 |
+
return None, None
|
| 49 |
+
except Exception as e: # Catch other potential errors during pivoting etc.
|
| 50 |
+
print(f"An unexpected error occurred during data loading: {e}")
|
| 51 |
+
return None, None
|
| 52 |
+
|
| 53 |
+
# --- Data filtering function (should work with pivoted data) ---
|
| 54 |
+
def filter_data(stock_df, rf_df, start_date_str=None, end_date_str=None, tickers=None):
|
| 55 |
+
"""Filters stock (wide format) and risk-free data based on date range and tickers.
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
stock_df (pd.DataFrame): DataFrame with stock returns (Date index, tickers as columns).
|
| 59 |
+
rf_df (pd.DataFrame): DataFrame with risk-free rates (Date index, 'rf' column).
|
| 60 |
+
start_date_str (str, optional): Start date in 'YYYY-MM-DD' format. Defaults to None (start of data).
|
| 61 |
+
end_date_str (str, optional): End date in 'YYYY-MM-DD' format. Defaults to None (end of data).
|
| 62 |
+
tickers (list, optional): List of ticker symbols to include. Defaults to None (all tickers).
|
| 63 |
+
|
| 64 |
+
Returns:
|
| 65 |
+
pd.DataFrame: Combined DataFrame with filtered stock returns and risk-free rate ('rf' column),
|
| 66 |
+
or None if filtering results in an empty DataFrame.
|
| 67 |
+
"""
|
| 68 |
+
filtered_stock_df = stock_df.copy()
|
| 69 |
+
filtered_rf_df = rf_df.copy()
|
| 70 |
+
|
| 71 |
+
# Convert date strings to datetime objects
|
| 72 |
+
start_date = pd.to_datetime(start_date_str) if start_date_str else None
|
| 73 |
+
end_date = pd.to_datetime(end_date_str) if end_date_str else None
|
| 74 |
+
|
| 75 |
+
# Filter by date
|
| 76 |
+
if start_date:
|
| 77 |
+
filtered_stock_df = filtered_stock_df[filtered_stock_df.index >= start_date]
|
| 78 |
+
filtered_rf_df = filtered_rf_df[filtered_rf_df.index >= start_date]
|
| 79 |
+
if end_date:
|
| 80 |
+
filtered_stock_df = filtered_stock_df[filtered_stock_df.index <= end_date]
|
| 81 |
+
filtered_rf_df = filtered_rf_df[filtered_rf_df.index <= end_date]
|
| 82 |
+
|
| 83 |
+
# Filter by tickers
|
| 84 |
+
if tickers:
|
| 85 |
+
# Ensure only requested tickers that exist in the dataframe are selected
|
| 86 |
+
valid_tickers = [t for t in tickers if t in filtered_stock_df.columns]
|
| 87 |
+
if not valid_tickers:
|
| 88 |
+
print(f"Warning: None of the requested tickers {tickers} found in the data.")
|
| 89 |
+
return None
|
| 90 |
+
# Select only valid tickers that exist in columns
|
| 91 |
+
filtered_stock_df = filtered_stock_df[valid_tickers]
|
| 92 |
+
else:
|
| 93 |
+
# If no tickers specified, use all available tickers from the wide format
|
| 94 |
+
valid_tickers = filtered_stock_df.columns.tolist()
|
| 95 |
+
|
| 96 |
+
# Combine stock data and risk-free rate
|
| 97 |
+
combined_df = filtered_stock_df.join(filtered_rf_df, how='inner') # Use inner join to ensure dates match
|
| 98 |
+
|
| 99 |
+
# Ensure 'rf' column exists (already correct based on rf_data.csv header)
|
| 100 |
+
if 'rf' not in combined_df.columns:
|
| 101 |
+
print("Warning: Risk-free rate column ('rf') not found after join.")
|
| 102 |
+
# Attempt rename (as fallback, though likely unnecessary now)
|
| 103 |
+
if 'Daily Treasury Yield Curve Rate' in combined_df.columns:
|
| 104 |
+
print("Renaming 'Daily Treasury Yield Curve Rate' to 'rf'")
|
| 105 |
+
combined_df = combined_df.rename(columns={'Daily Treasury Yield Curve Rate': 'rf'})
|
| 106 |
+
else:
|
| 107 |
+
print("Could not find 'rf' or alternative name.")
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
# Reorder columns to have tickers first, then 'rf'
|
| 111 |
+
# Ensure 'rf' is included if it exists
|
| 112 |
+
final_columns = valid_tickers + [col for col in ['rf'] if col in combined_df.columns]
|
| 113 |
+
combined_df = combined_df[final_columns]
|
| 114 |
+
|
| 115 |
+
if combined_df.empty:
|
| 116 |
+
print("Warning: Filtering resulted in an empty DataFrame.")
|
| 117 |
+
return None
|
| 118 |
+
|
| 119 |
+
return combined_df
|