dhruv575 commited on
Commit
283fbc7
·
1 Parent(s): 0df0824
.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: Opt
3
- emoji: 📉
4
- colorFrom: purple
5
  colorTo: gray
6
  sdk: docker
7
- pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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