Khiry McCurn commited on
Commit
3cbd1bd
Β·
1 Parent(s): dc03c48

Add app.py from HuggingFace Space

Browse files
Files changed (1) hide show
  1. app.py +1299 -0
app.py ADDED
@@ -0,0 +1,1299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HORIZON PORTFOLIO BACKTESTER
3
+ A Portfolio Visualizer clone built with Gradio
4
+ Deploy to Hugging Face Spaces
5
+
6
+ Features:
7
+ - Interactive UI for portfolio backtesting
8
+ - REST API endpoint for programmatic access
9
+ - Claude AI integration for natural language queries
10
+ """
11
+
12
+ import gradio as gr
13
+ import yfinance as yf
14
+ import pandas as pd
15
+ import numpy as np
16
+ import plotly.graph_objects as go
17
+ import plotly.express as px
18
+ from plotly.subplots import make_subplots
19
+ from datetime import datetime, timedelta
20
+ import warnings
21
+ import json
22
+ import os
23
+ import re
24
+ import httpx
25
+ warnings.filterwarnings('ignore')
26
+
27
+ # Claude API configuration
28
+ ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
29
+ CLAUDE_MODEL = "claude-sonnet-4-20250514"
30
+
31
+ # Preset portfolios
32
+ PRESETS = {
33
+ "Custom": [],
34
+ "Horizon Growth": [
35
+ 'BLK', 'COST', 'GS', 'SPOT', 'META', 'CRWD', 'MSFT', 'V', 'GOOGL', 'AAPL',
36
+ 'COIN', 'TTWO', 'AMZN', 'HWM', 'NET', 'NVDA', 'PLTR', 'FUTU', 'RY', 'WMT',
37
+ 'HOOD', 'NFLX', 'UBER', 'SFTBY', 'TQQQ'
38
+ ],
39
+ "FAANG": ['META', 'AAPL', 'AMZN', 'NFLX', 'GOOGL'],
40
+ "Magnificent 7": ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'NVDA', 'META', 'TSLA'],
41
+ "Classic 60/40": ['VTI', 'BND'],
42
+ "Three Fund Portfolio": ['VTI', 'VXUS', 'BND'],
43
+ "All Weather": ['VTI', 'TLT', 'IEF', 'GLD', 'DBC'],
44
+ }
45
+
46
+ DEFAULT_BENCHMARKS = ['QQQ', 'SPY', 'VTI', 'IWM', 'DIA', 'VOO']
47
+
48
+ # Local data directory
49
+ DATA_DIR = os.path.join(os.path.dirname(__file__), 'data')
50
+
51
+
52
+ def get_available_tickers():
53
+ """Get list of all tickers available in the local data directory"""
54
+ tickers = []
55
+ if os.path.exists(DATA_DIR):
56
+ for filename in os.listdir(DATA_DIR):
57
+ if filename.endswith('.csv'):
58
+ ticker = filename.replace('.csv', '')
59
+ tickers.append(ticker)
60
+ # Sort alphabetically, but put common benchmarks first
61
+ benchmark_order = ['QQQ', 'SPY', 'VTI', 'IWM', 'DIA', 'VOO']
62
+ sorted_tickers = []
63
+ for b in benchmark_order:
64
+ if b in tickers:
65
+ sorted_tickers.append(b)
66
+ tickers.remove(b)
67
+ sorted_tickers.extend(sorted(tickers))
68
+ return sorted_tickers if sorted_tickers else DEFAULT_BENCHMARKS
69
+
70
+
71
+ # Dynamic benchmark list from available data
72
+ BENCHMARKS = get_available_tickers()
73
+
74
+ # Request queue file
75
+ REQUESTS_FILE = os.path.join(os.path.dirname(__file__), 'requests.txt')
76
+
77
+
78
+ def is_ticker_available(ticker: str) -> bool:
79
+ """Check if a ticker is available in the local dataset"""
80
+ ticker = ticker.strip().upper()
81
+ filepath = os.path.join(DATA_DIR, f'{ticker}.csv')
82
+ return os.path.exists(filepath)
83
+
84
+
85
+ def request_ticker(ticker: str) -> dict:
86
+ """
87
+ Add a ticker to the request queue.
88
+ Returns status of the request.
89
+ """
90
+ ticker = ticker.strip().upper()
91
+
92
+ # Validate ticker format
93
+ if not ticker or not ticker.isalpha() or len(ticker) > 5:
94
+ return {
95
+ "success": False,
96
+ "message": f"Invalid ticker format: {ticker}. Use 1-5 letters only."
97
+ }
98
+
99
+ # Check if already available
100
+ if is_ticker_available(ticker):
101
+ return {
102
+ "success": False,
103
+ "message": f"{ticker} is already available in the dataset!"
104
+ }
105
+
106
+ # Check if already requested
107
+ pending = get_pending_requests()
108
+ if ticker in pending:
109
+ return {
110
+ "success": False,
111
+ "message": f"{ticker} is already in the request queue."
112
+ }
113
+
114
+ # Add to request file
115
+ try:
116
+ with open(REQUESTS_FILE, 'a') as f:
117
+ f.write(f"{ticker}\n")
118
+ return {
119
+ "success": True,
120
+ "message": f"βœ… {ticker} added to request queue! It will be available after the next data update (daily at ~9:30 PM UTC)."
121
+ }
122
+ except Exception as e:
123
+ return {
124
+ "success": False,
125
+ "message": f"Error adding request: {e}"
126
+ }
127
+
128
+
129
+ def get_pending_requests() -> list:
130
+ """Get list of pending ticker requests"""
131
+ if not os.path.exists(REQUESTS_FILE):
132
+ return []
133
+ try:
134
+ with open(REQUESTS_FILE, 'r') as f:
135
+ return [line.strip().upper() for line in f if line.strip()]
136
+ except Exception:
137
+ return []
138
+
139
+
140
+ def check_tickers_availability(tickers: list) -> dict:
141
+ """
142
+ Check which tickers are available and which are missing.
143
+ Returns dict with 'available' and 'missing' lists.
144
+ """
145
+ available = []
146
+ missing = []
147
+ for ticker in tickers:
148
+ ticker = ticker.strip().upper()
149
+ if is_ticker_available(ticker):
150
+ available.append(ticker)
151
+ else:
152
+ missing.append(ticker)
153
+ return {"available": available, "missing": missing}
154
+
155
+
156
+ def load_local_data(ticker, start_date, end_date):
157
+ """Load data from local CSV if available"""
158
+ filepath = os.path.join(DATA_DIR, f'{ticker}.csv')
159
+ if os.path.exists(filepath):
160
+ try:
161
+ df = pd.read_csv(filepath, index_col=0, parse_dates=True)
162
+ # Filter to date range
163
+ df = df[(df.index >= start_date) & (df.index <= end_date)]
164
+ if not df.empty:
165
+ return df
166
+ except Exception:
167
+ pass
168
+ return None
169
+
170
+
171
+ def get_price_data(tickers, start_date, end_date):
172
+ """
173
+ Get price data - tries local files first, falls back to yfinance.
174
+ Returns DataFrame with Close prices.
175
+ """
176
+ close_data = {}
177
+ missing_tickers = []
178
+
179
+ # Try local data first
180
+ for ticker in tickers:
181
+ local = load_local_data(ticker, start_date, end_date)
182
+ if local is not None and 'Close' in local.columns:
183
+ close_data[ticker] = local['Close']
184
+ else:
185
+ missing_tickers.append(ticker)
186
+
187
+ # Download missing tickers from yfinance
188
+ if missing_tickers:
189
+ try:
190
+ data = yf.download(
191
+ missing_tickers,
192
+ start=start_date,
193
+ end=end_date,
194
+ auto_adjust=True,
195
+ progress=False,
196
+ threads=False
197
+ )
198
+ if not data.empty:
199
+ if len(missing_tickers) == 1:
200
+ if 'Close' in data.columns:
201
+ close_data[missing_tickers[0]] = data['Close']
202
+ else:
203
+ for ticker in missing_tickers:
204
+ if ticker in data['Close'].columns:
205
+ close_data[ticker] = data['Close'][ticker]
206
+ except Exception as e:
207
+ print(f"yfinance error: {e}")
208
+
209
+ # Combine into DataFrame
210
+ if close_data:
211
+ result = pd.DataFrame(close_data)
212
+ result = result.dropna(how='all')
213
+ return result
214
+ return pd.DataFrame()
215
+
216
+
217
+ # ===== CLAUDE API INTEGRATION =====
218
+
219
+ def call_claude_api(prompt: str, system_prompt: str = None) -> str:
220
+ """Call Claude API to parse natural language backtest requests"""
221
+
222
+ if not ANTHROPIC_API_KEY:
223
+ return None
224
+
225
+ headers = {
226
+ "x-api-key": ANTHROPIC_API_KEY,
227
+ "content-type": "application/json",
228
+ "anthropic-version": "2023-06-01"
229
+ }
230
+
231
+ system = system_prompt or """You are a portfolio backtesting assistant.
232
+ Parse user requests into structured backtest parameters.
233
+ Always respond with valid JSON only, no other text."""
234
+
235
+ data = {
236
+ "model": CLAUDE_MODEL,
237
+ "max_tokens": 1024,
238
+ "system": system,
239
+ "messages": [{"role": "user", "content": prompt}]
240
+ }
241
+
242
+ try:
243
+ with httpx.Client(timeout=30) as client:
244
+ response = client.post(
245
+ "https://api.anthropic.com/v1/messages",
246
+ headers=headers,
247
+ json=data
248
+ )
249
+ response.raise_for_status()
250
+ result = response.json()
251
+ return result["content"][0]["text"]
252
+ except Exception as e:
253
+ print(f"Claude API error: {e}")
254
+ return None
255
+
256
+
257
+ def parse_natural_language_request(user_request: str) -> dict:
258
+ """Use Claude to parse a natural language backtest request into parameters"""
259
+
260
+ system_prompt = """You are a portfolio backtesting assistant. Parse the user's request into backtest parameters.
261
+
262
+ Available presets: "Custom", "Horizon Growth", "FAANG", "Magnificent 7", "Classic 60/40", "Three Fund Portfolio", "All Weather"
263
+ Benchmark: Any stock ticker can be used as benchmark (common ones: QQQ, SPY, VTI, IWM, NVDA, AAPL, etc.)
264
+ Contribution frequencies: "None", "Weekly", "Monthly", "Quarterly"
265
+ Rebalancing frequencies: "None", "Monthly", "Quarterly", "Annually"
266
+
267
+ Respond ONLY with a JSON object in this exact format (no other text):
268
+ {
269
+ "preset": "preset name or Custom",
270
+ "tickers": ["AAPL", "MSFT"] or null if using preset,
271
+ "benchmark": "QQQ",
272
+ "start_date": "2024-01-01" or "1y" or "5y",
273
+ "initial_investment": 10000,
274
+ "contribution_amount": 1000,
275
+ "contribution_freq": "Weekly",
276
+ "rebalance_freq": "None",
277
+ "explanation": "Brief explanation of what you understood"
278
+ }
279
+
280
+ If the user mentions specific stocks, use "Custom" preset and list them in tickers.
281
+ If they mention a preset name, use that preset.
282
+ Any ticker can be used as benchmark - if user says "compare against NVDA" or "benchmark to AAPL", use that ticker.
283
+ Default to reasonable values if not specified."""
284
+
285
+ response = call_claude_api(user_request, system_prompt)
286
+
287
+ if not response:
288
+ # Return defaults if Claude API fails
289
+ return {
290
+ "preset": "Horizon Growth",
291
+ "tickers": None,
292
+ "benchmark": "QQQ",
293
+ "start_date": "2024-01-01",
294
+ "initial_investment": 10000,
295
+ "contribution_amount": 1000,
296
+ "contribution_freq": "Weekly",
297
+ "rebalance_freq": "None",
298
+ "explanation": "Using defaults (Claude API not available)"
299
+ }
300
+
301
+ try:
302
+ # Clean up response - remove markdown code blocks if present
303
+ cleaned = response.strip()
304
+ if cleaned.startswith("```"):
305
+ cleaned = re.sub(r'^```json?\n?', '', cleaned)
306
+ cleaned = re.sub(r'\n?```$', '', cleaned)
307
+ return json.loads(cleaned)
308
+ except json.JSONDecodeError:
309
+ return {
310
+ "preset": "Horizon Growth",
311
+ "tickers": None,
312
+ "benchmark": "QQQ",
313
+ "start_date": "2024-01-01",
314
+ "initial_investment": 10000,
315
+ "contribution_amount": 1000,
316
+ "contribution_freq": "Weekly",
317
+ "rebalance_freq": "None",
318
+ "explanation": f"Could not parse response, using defaults. Raw: {response[:200]}"
319
+ }
320
+
321
+
322
+ def run_backtest_from_params(params: dict) -> dict:
323
+ """Run backtest from a parameters dictionary (for API use)"""
324
+
325
+ preset = params.get("preset", "Custom")
326
+ tickers = params.get("tickers")
327
+ benchmark = params.get("benchmark", "QQQ")
328
+ start_date = params.get("start_date", "2024-01-01")
329
+ initial_investment = params.get("initial_investment", 10000)
330
+ contribution_amount = params.get("contribution_amount", 1000)
331
+ contribution_freq = params.get("contribution_freq", "Weekly")
332
+ rebalance_freq = params.get("rebalance_freq", "None")
333
+
334
+ # Get tickers
335
+ if preset != "Custom" and preset in PRESETS:
336
+ ticker_list = PRESETS[preset]
337
+ elif tickers:
338
+ ticker_list = [t.strip().upper() for t in tickers] if isinstance(tickers, list) else [t.strip().upper() for t in tickers.split(',')]
339
+ else:
340
+ return {"error": "No tickers specified"}
341
+
342
+ # Parse dates
343
+ start = parse_date(start_date)
344
+ end = datetime.now().strftime('%Y-%m-%d')
345
+
346
+ # Download data
347
+ all_tickers = list(set(ticker_list + [benchmark]))
348
+
349
+ # Use hybrid data loading (local first, then yfinance)
350
+ close = get_price_data(all_tickers, start, end)
351
+
352
+ if close.empty:
353
+ return {"error": f"No data returned. Tickers: {all_tickers}, Start: {start}, End: {end}"}
354
+
355
+ # Check we have all needed tickers
356
+ missing = [t for t in ticker_list if t not in close.columns]
357
+ if missing:
358
+ return {"error": f"Missing data for tickers: {missing}. These tickers are not in our dataset. You can request them in the 'Request Ticker' tab."}
359
+
360
+ trading_dates = close.index
361
+ num_stocks = len(ticker_list)
362
+
363
+ # Contribution dates
364
+ if contribution_freq == "Weekly":
365
+ contrib_dates = [d for d in trading_dates if d.weekday() == 2]
366
+ elif contribution_freq == "Monthly":
367
+ contrib_dates = close.resample('M').last().index
368
+ elif contribution_freq == "Quarterly":
369
+ contrib_dates = close.resample('Q').last().index
370
+ else:
371
+ contrib_dates = [trading_dates[0]] if len(trading_dates) > 0 else []
372
+
373
+ # Rebalance dates
374
+ if rebalance_freq == "Monthly":
375
+ rebal_dates = close.resample('M').last().index
376
+ elif rebalance_freq == "Quarterly":
377
+ rebal_dates = close.resample('Q').last().index
378
+ elif rebalance_freq == "Annually":
379
+ rebal_dates = close.resample('Y').last().index
380
+ else:
381
+ rebal_dates = []
382
+
383
+ # Initialize
384
+ shares = {t: 0.0 for t in ticker_list}
385
+ cost_basis = 0.0
386
+ bench_shares = 0.0
387
+
388
+ # Initial investment
389
+ if initial_investment > 0 and len(trading_dates) > 0:
390
+ first_date = trading_dates[0]
391
+ per_stock = initial_investment / num_stocks
392
+ for t in ticker_list:
393
+ if t in close.columns and not pd.isna(close.loc[first_date, t]):
394
+ shares[t] += per_stock / close.loc[first_date, t]
395
+ cost_basis += initial_investment
396
+ if benchmark in close.columns:
397
+ bench_shares += initial_investment / close.loc[first_date, benchmark]
398
+
399
+ # Simulation
400
+ for date in trading_dates:
401
+ if date in contrib_dates and contribution_amount > 0:
402
+ per_stock = contribution_amount / num_stocks
403
+ for t in ticker_list:
404
+ if t in close.columns and not pd.isna(close.loc[date, t]):
405
+ shares[t] += per_stock / close.loc[date, t]
406
+ cost_basis += contribution_amount
407
+ if benchmark in close.columns and not pd.isna(close.loc[date, benchmark]):
408
+ bench_shares += contribution_amount / close.loc[date, benchmark]
409
+
410
+ if date in rebal_dates and rebalance_freq != "None":
411
+ total_val = sum(shares[t] * close.loc[date, t] for t in ticker_list
412
+ if t in close.columns and not pd.isna(close.loc[date, t]))
413
+ if total_val > 0:
414
+ target = total_val / num_stocks
415
+ for t in ticker_list:
416
+ if t in close.columns and not pd.isna(close.loc[date, t]):
417
+ shares[t] = target / close.loc[date, t]
418
+
419
+ # Final calculations
420
+ last_date = trading_dates[-1]
421
+ final_port = sum(shares[t] * close.loc[last_date, t] for t in ticker_list
422
+ if t in close.columns and not pd.isna(close.loc[last_date, t]))
423
+ final_bench = bench_shares * close.loc[last_date, benchmark] if benchmark in close.columns else 0
424
+
425
+ port_return = (final_port - cost_basis) / cost_basis * 100
426
+ bench_return = (final_bench - cost_basis) / cost_basis * 100
427
+ alpha = port_return - bench_return
428
+
429
+ # Holdings breakdown
430
+ holdings = {}
431
+ for t in ticker_list:
432
+ if t in close.columns and not pd.isna(close.loc[last_date, t]):
433
+ value = shares[t] * close.loc[last_date, t]
434
+ holdings[t] = {
435
+ "shares": round(shares[t], 4),
436
+ "price": round(close.loc[last_date, t], 2),
437
+ "value": round(value, 2),
438
+ "weight": round(value / final_port * 100, 2) if final_port > 0 else 0
439
+ }
440
+
441
+ return {
442
+ "success": True,
443
+ "period": {"start": start, "end": end},
444
+ "parameters": {
445
+ "preset": preset,
446
+ "tickers": ticker_list,
447
+ "benchmark": benchmark,
448
+ "initial_investment": initial_investment,
449
+ "contribution_amount": contribution_amount,
450
+ "contribution_freq": contribution_freq,
451
+ "rebalance_freq": rebalance_freq
452
+ },
453
+ "results": {
454
+ "total_invested": round(cost_basis, 2),
455
+ "portfolio_value": round(final_port, 2),
456
+ "benchmark_value": round(final_bench, 2),
457
+ "portfolio_return": round(port_return, 2),
458
+ "benchmark_return": round(bench_return, 2),
459
+ "alpha": round(alpha, 2)
460
+ },
461
+ "holdings": holdings
462
+ }
463
+
464
+
465
+ def natural_language_backtest(user_request: str) -> str:
466
+ """
467
+ Process a natural language backtest request.
468
+
469
+ Examples:
470
+ - "Backtest the Magnificent 7 for the last 2 years with $500 weekly contributions"
471
+ - "Compare AAPL, MSFT, GOOGL against SPY starting from 2023 with quarterly rebalancing"
472
+ - "Run Horizon Growth portfolio from January 2024 with $10k initial and $1000 monthly DCA"
473
+
474
+ Returns JSON with backtest results.
475
+ """
476
+
477
+ if not user_request.strip():
478
+ return json.dumps({"error": "Please provide a backtest request"}, indent=2)
479
+
480
+ # Parse the request using Claude
481
+ params = parse_natural_language_request(user_request)
482
+
483
+ # Run the backtest
484
+ results = run_backtest_from_params(params)
485
+
486
+ # Add the parsed interpretation
487
+ results["interpretation"] = params.get("explanation", "")
488
+
489
+ return json.dumps(results, indent=2, default=str)
490
+
491
+
492
+ def api_backtest(
493
+ preset: str = "Horizon Growth",
494
+ tickers: str = "",
495
+ benchmark: str = "QQQ",
496
+ start_date: str = "2024-01-01",
497
+ initial_investment: float = 10000,
498
+ contribution_amount: float = 1000,
499
+ contribution_freq: str = "Weekly",
500
+ rebalance_freq: str = "None"
501
+ ) -> str:
502
+ """
503
+ Programmatic API endpoint for backtesting.
504
+
505
+ Parameters:
506
+ - preset: Portfolio preset name or "Custom"
507
+ - tickers: Comma-separated tickers (only used if preset is "Custom")
508
+ - benchmark: Benchmark ticker (QQQ, SPY, VTI, etc.)
509
+ - start_date: Start date (YYYY-MM-DD) or relative (1y, 3m, 5y)
510
+ - initial_investment: Initial investment amount
511
+ - contribution_amount: Periodic contribution amount
512
+ - contribution_freq: None, Weekly, Monthly, Quarterly
513
+ - rebalance_freq: None, Monthly, Quarterly, Annually
514
+
515
+ Returns: JSON string with backtest results
516
+ """
517
+
518
+ params = {
519
+ "preset": preset,
520
+ "tickers": [t.strip() for t in tickers.split(',')] if tickers and preset == "Custom" else None,
521
+ "benchmark": benchmark,
522
+ "start_date": start_date,
523
+ "initial_investment": initial_investment,
524
+ "contribution_amount": contribution_amount,
525
+ "contribution_freq": contribution_freq,
526
+ "rebalance_freq": rebalance_freq
527
+ }
528
+
529
+ results = run_backtest_from_params(params)
530
+ return json.dumps(results, indent=2, default=str)
531
+
532
+
533
+ def parse_date(date_str):
534
+ """Parse relative date strings like '3m', '1y', '5y'"""
535
+ today = datetime.now()
536
+ if date_str.endswith('m'):
537
+ months = int(date_str[:-1])
538
+ return (today - timedelta(days=months*30)).strftime('%Y-%m-%d')
539
+ elif date_str.endswith('y'):
540
+ years = int(date_str[:-1])
541
+ return (today - timedelta(days=years*365)).strftime('%Y-%m-%d')
542
+ else:
543
+ return date_str
544
+
545
+
546
+ def calculate_metrics(returns, risk_free_rate=0.04):
547
+ """Calculate comprehensive performance metrics"""
548
+ if len(returns) < 2:
549
+ return {}
550
+
551
+ # Basic stats
552
+ total_return = (1 + returns).prod() - 1
553
+ cagr = (1 + total_return) ** (252 / len(returns)) - 1
554
+ volatility = returns.std() * np.sqrt(252)
555
+
556
+ # Drawdown
557
+ cumulative = (1 + returns).cumprod()
558
+ running_max = cumulative.cummax()
559
+ drawdown = (cumulative - running_max) / running_max
560
+ max_drawdown = drawdown.min()
561
+
562
+ # Risk-adjusted returns
563
+ excess_returns = returns - risk_free_rate/252
564
+ sharpe = excess_returns.mean() / returns.std() * np.sqrt(252) if returns.std() > 0 else 0
565
+
566
+ # Sortino (downside deviation)
567
+ downside = returns[returns < 0]
568
+ downside_std = downside.std() * np.sqrt(252) if len(downside) > 0 else 0
569
+ sortino = (cagr - risk_free_rate) / downside_std if downside_std > 0 else 0
570
+
571
+ # Best/Worst
572
+ best_day = returns.max()
573
+ worst_day = returns.min()
574
+
575
+ # Win rate
576
+ positive_days = (returns > 0).sum()
577
+ total_days = len(returns)
578
+ win_rate = positive_days / total_days if total_days > 0 else 0
579
+
580
+ return {
581
+ 'Total Return': f"{total_return*100:.2f}%",
582
+ 'CAGR': f"{cagr*100:.2f}%",
583
+ 'Volatility': f"{volatility*100:.2f}%",
584
+ 'Max Drawdown': f"{max_drawdown*100:.2f}%",
585
+ 'Sharpe Ratio': f"{sharpe:.2f}",
586
+ 'Sortino Ratio': f"{sortino:.2f}",
587
+ 'Best Day': f"{best_day*100:.2f}%",
588
+ 'Worst Day': f"{worst_day*100:.2f}%",
589
+ 'Win Rate': f"{win_rate*100:.1f}%",
590
+ }
591
+
592
+
593
+ def run_backtest(
594
+ preset_name,
595
+ custom_tickers,
596
+ benchmark,
597
+ start_date,
598
+ initial_investment,
599
+ contribution_amount,
600
+ contribution_freq,
601
+ rebalance_freq,
602
+ progress=gr.Progress()
603
+ ):
604
+ """Main backtest function"""
605
+
606
+ progress(0, desc="Starting backtest...")
607
+
608
+ # Get tickers
609
+ if preset_name == "Custom":
610
+ tickers = [t.strip().upper() for t in custom_tickers.split(',') if t.strip()]
611
+ else:
612
+ tickers = PRESETS.get(preset_name, [])
613
+
614
+ if not tickers:
615
+ return None, None, None, "❌ Error: No tickers specified", None
616
+
617
+ # Parse dates
618
+ start = parse_date(start_date)
619
+ end = datetime.now().strftime('%Y-%m-%d')
620
+
621
+ progress(0.1, desc="Downloading market data...")
622
+
623
+ # Download data
624
+ all_tickers = list(set(tickers + [benchmark]))
625
+
626
+ # Use hybrid data loading (local first, then yfinance)
627
+ close = get_price_data(all_tickers, start, end)
628
+
629
+ if close.empty:
630
+ return None, None, None, f"❌ Error: No data returned. Tried: {all_tickers}", None
631
+
632
+ # Check we have the tickers we need
633
+ missing = [t for t in tickers if t not in close.columns]
634
+ if missing:
635
+ return None, None, None, f"❌ Error: Missing data for: {missing}", None
636
+
637
+ progress(0.3, desc="Running simulation...")
638
+
639
+ trading_dates = close.index
640
+ num_stocks = len(tickers)
641
+
642
+ # Determine contribution frequency
643
+ if contribution_freq == "Weekly":
644
+ contrib_dates = [d for d in trading_dates if d.weekday() == 2] # Wednesdays
645
+ elif contribution_freq == "Monthly":
646
+ contrib_dates = close.resample('M').last().index
647
+ elif contribution_freq == "Quarterly":
648
+ contrib_dates = close.resample('Q').last().index
649
+ else: # None
650
+ contrib_dates = [trading_dates[0]] if len(trading_dates) > 0 else []
651
+
652
+ # Determine rebalance dates
653
+ if rebalance_freq == "Monthly":
654
+ rebal_dates = close.resample('M').last().index
655
+ elif rebalance_freq == "Quarterly":
656
+ rebal_dates = close.resample('Q').last().index
657
+ elif rebalance_freq == "Annually":
658
+ rebal_dates = close.resample('Y').last().index
659
+ else: # None
660
+ rebal_dates = []
661
+
662
+ # Initialize portfolio
663
+ shares = {t: 0.0 for t in tickers}
664
+ cost_basis = 0.0
665
+ history = []
666
+
667
+ # Benchmark tracking
668
+ bench_shares = 0.0
669
+ bench_history = []
670
+
671
+ # Initial investment
672
+ if initial_investment > 0 and len(trading_dates) > 0:
673
+ first_date = trading_dates[0]
674
+ per_stock = initial_investment / num_stocks
675
+ for t in tickers:
676
+ if t in close.columns and not pd.isna(close.loc[first_date, t]):
677
+ shares[t] += per_stock / close.loc[first_date, t]
678
+ cost_basis += initial_investment
679
+
680
+ if benchmark in close.columns:
681
+ bench_shares += initial_investment / close.loc[first_date, benchmark]
682
+
683
+ progress(0.5, desc="Processing contributions and rebalancing...")
684
+
685
+ # Run simulation
686
+ for i, date in enumerate(trading_dates):
687
+ # Contributions
688
+ if date in contrib_dates and contribution_amount > 0:
689
+ per_stock = contribution_amount / num_stocks
690
+ for t in tickers:
691
+ if t in close.columns and not pd.isna(close.loc[date, t]):
692
+ shares[t] += per_stock / close.loc[date, t]
693
+ cost_basis += contribution_amount
694
+
695
+ if benchmark in close.columns and not pd.isna(close.loc[date, benchmark]):
696
+ bench_shares += contribution_amount / close.loc[date, benchmark]
697
+
698
+ # Rebalancing
699
+ if date in rebal_dates and rebalance_freq != "None":
700
+ total_val = sum(shares[t] * close.loc[date, t] for t in tickers
701
+ if t in close.columns and not pd.isna(close.loc[date, t]))
702
+ if total_val > 0:
703
+ target = total_val / num_stocks
704
+ for t in tickers:
705
+ if t in close.columns and not pd.isna(close.loc[date, t]):
706
+ shares[t] = target / close.loc[date, t]
707
+
708
+ # Calculate values
709
+ port_val = sum(shares[t] * close.loc[date, t] for t in tickers
710
+ if t in close.columns and not pd.isna(close.loc[date, t]))
711
+ bench_val = bench_shares * close.loc[date, benchmark] if benchmark in close.columns else 0
712
+
713
+ history.append({'date': date, 'value': port_val, 'cost_basis': cost_basis})
714
+ bench_history.append({'date': date, 'value': bench_val})
715
+
716
+ progress(0.7, desc="Calculating metrics...")
717
+
718
+ port_df = pd.DataFrame(history).set_index('date')
719
+ bench_df = pd.DataFrame(bench_history).set_index('date')
720
+
721
+ # Calculate returns
722
+ port_returns = port_df['value'].pct_change().dropna()
723
+ bench_returns = bench_df['value'].pct_change().dropna()
724
+
725
+ # Calculate metrics
726
+ port_metrics = calculate_metrics(port_returns)
727
+ bench_metrics = calculate_metrics(bench_returns)
728
+
729
+ # Final values
730
+ final_port = port_df['value'].iloc[-1]
731
+ final_bench = bench_df['value'].iloc[-1]
732
+ total_invested = port_df['cost_basis'].iloc[-1]
733
+
734
+ progress(0.85, desc="Creating visualizations...")
735
+
736
+ # ===== CREATE CHARTS =====
737
+
738
+ # Chart 1: Portfolio Value Over Time
739
+ fig1 = go.Figure()
740
+ fig1.add_trace(go.Scatter(x=port_df.index, y=port_df['value'], name='Portfolio',
741
+ line=dict(color='#00D4AA', width=2)))
742
+ fig1.add_trace(go.Scatter(x=bench_df.index, y=bench_df['value'], name=benchmark,
743
+ line=dict(color='#FF6B6B', width=2, dash='dash')))
744
+ fig1.add_trace(go.Scatter(x=port_df.index, y=port_df['cost_basis'], name='Cost Basis',
745
+ line=dict(color='#4A90D9', width=1, dash='dot')))
746
+ fig1.update_layout(
747
+ title='Portfolio Value Over Time',
748
+ xaxis_title='Date',
749
+ yaxis_title='Value ($)',
750
+ template='plotly_dark',
751
+ hovermode='x unified',
752
+ yaxis_tickformat='$,.0f'
753
+ )
754
+
755
+ # Chart 2: Drawdown
756
+ port_cummax = port_df['value'].cummax()
757
+ port_dd = (port_df['value'] - port_cummax) / port_cummax * 100
758
+ bench_cummax = bench_df['value'].cummax()
759
+ bench_dd = (bench_df['value'] - bench_cummax) / bench_cummax * 100
760
+
761
+ fig2 = go.Figure()
762
+ fig2.add_trace(go.Scatter(x=port_df.index, y=port_dd, fill='tozeroy', name='Portfolio',
763
+ line=dict(color='#00D4AA')))
764
+ fig2.add_trace(go.Scatter(x=bench_df.index, y=bench_dd, name=benchmark,
765
+ line=dict(color='#FF6B6B', dash='dash')))
766
+ fig2.update_layout(
767
+ title='Drawdown Analysis',
768
+ xaxis_title='Date',
769
+ yaxis_title='Drawdown (%)',
770
+ template='plotly_dark',
771
+ hovermode='x unified'
772
+ )
773
+
774
+ # Chart 3: Holdings breakdown (current weights)
775
+ current_values = {}
776
+ last_date = trading_dates[-1]
777
+ for t in tickers:
778
+ if t in close.columns and not pd.isna(close.loc[last_date, t]):
779
+ current_values[t] = shares[t] * close.loc[last_date, t]
780
+
781
+ fig3 = go.Figure(data=[go.Pie(
782
+ labels=list(current_values.keys()),
783
+ values=list(current_values.values()),
784
+ hole=0.4,
785
+ textinfo='label+percent',
786
+ hovertemplate='%{label}: $%{value:,.2f}<extra></extra>'
787
+ )])
788
+ fig3.update_layout(
789
+ title='Current Holdings Breakdown',
790
+ template='plotly_dark'
791
+ )
792
+
793
+ # Chart 4: Monthly returns heatmap
794
+ port_df['returns'] = port_df['value'].pct_change()
795
+ monthly = port_df['returns'].resample('M').apply(lambda x: (1+x).prod()-1) * 100
796
+
797
+ # Create year-month matrix
798
+ monthly_df = monthly.to_frame('return')
799
+ monthly_df['year'] = monthly_df.index.year
800
+ monthly_df['month'] = monthly_df.index.month
801
+ pivot = monthly_df.pivot(index='year', columns='month', values='return')
802
+
803
+ fig4 = go.Figure(data=go.Heatmap(
804
+ z=pivot.values,
805
+ x=['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
806
+ y=pivot.index,
807
+ colorscale='RdYlGn',
808
+ zmid=0,
809
+ text=np.round(pivot.values, 1),
810
+ texttemplate='%{text:.1f}%',
811
+ textfont={"size": 10},
812
+ hovertemplate='%{y} %{x}: %{z:.2f}%<extra></extra>'
813
+ ))
814
+ fig4.update_layout(
815
+ title='Monthly Returns Heatmap (%)',
816
+ template='plotly_dark'
817
+ )
818
+
819
+ progress(0.95, desc="Generating report...")
820
+
821
+ # Build results summary
822
+ port_return = (final_port - total_invested) / total_invested * 100
823
+ bench_return = (final_bench - total_invested) / total_invested * 100
824
+ alpha = port_return - bench_return
825
+
826
+ summary = f"""
827
+ ## πŸ“Š Backtest Results
828
+
829
+ **Period:** {start} to {end}
830
+ **Strategy:** {preset_name if preset_name != "Custom" else "Custom Portfolio"}
831
+ **Tickers:** {', '.join(tickers[:10])}{'...' if len(tickers) > 10 else ''} ({len(tickers)} total)
832
+ **Benchmark:** {benchmark}
833
+
834
+ ---
835
+
836
+ ### πŸ’° Performance Summary
837
+
838
+ | Metric | Portfolio | {benchmark} |
839
+ |--------|-----------|-------------|
840
+ | **Total Invested** | ${total_invested:,.2f} | ${total_invested:,.2f} |
841
+ | **Final Value** | ${final_port:,.2f} | ${final_bench:,.2f} |
842
+ | **Total Return** | {port_return:+.2f}% | {bench_return:+.2f}% |
843
+ | **CAGR** | {port_metrics.get('CAGR', 'N/A')} | {bench_metrics.get('CAGR', 'N/A')} |
844
+ | **Max Drawdown** | {port_metrics.get('Max Drawdown', 'N/A')} | {bench_metrics.get('Max Drawdown', 'N/A')} |
845
+ | **Sharpe Ratio** | {port_metrics.get('Sharpe Ratio', 'N/A')} | {bench_metrics.get('Sharpe Ratio', 'N/A')} |
846
+ | **Sortino Ratio** | {port_metrics.get('Sortino Ratio', 'N/A')} | {bench_metrics.get('Sortino Ratio', 'N/A')} |
847
+ | **Volatility** | {port_metrics.get('Volatility', 'N/A')} | {bench_metrics.get('Volatility', 'N/A')} |
848
+ | **Win Rate** | {port_metrics.get('Win Rate', 'N/A')} | {bench_metrics.get('Win Rate', 'N/A')} |
849
+
850
+ ---
851
+
852
+ ### {'πŸ†' if alpha > 0 else 'πŸ“‰'} Alpha vs {benchmark}: **{alpha:+.2f}%**
853
+
854
+ """
855
+
856
+ # Holdings table
857
+ holdings_data = []
858
+ for t in tickers:
859
+ if t in close.columns:
860
+ start_price = close[t].dropna().iloc[0]
861
+ end_price = close[t].dropna().iloc[-1]
862
+ price_chg = (end_price / start_price - 1) * 100
863
+ value = shares[t] * end_price
864
+ weight = value / final_port * 100 if final_port > 0 else 0
865
+ holdings_data.append({
866
+ 'Ticker': t,
867
+ 'Shares': f"{shares[t]:.4f}",
868
+ 'Price': f"${end_price:.2f}",
869
+ 'Value': f"${value:,.2f}",
870
+ 'Weight': f"{weight:.2f}%",
871
+ 'Return': f"{price_chg:+.2f}%"
872
+ })
873
+
874
+ holdings_df = pd.DataFrame(holdings_data)
875
+
876
+ progress(1.0, desc="Done!")
877
+
878
+ return fig1, fig2, fig3, summary, holdings_df
879
+
880
+
881
+ def update_tickers(preset_name):
882
+ """Update ticker textbox when preset changes"""
883
+ if preset_name == "Custom":
884
+ return gr.update(value="", interactive=True, visible=True)
885
+ else:
886
+ tickers = PRESETS.get(preset_name, [])
887
+ return gr.update(value=", ".join(tickers), interactive=False, visible=True)
888
+
889
+
890
+ # ===== BUILD GRADIO INTERFACE =====
891
+
892
+ with gr.Blocks(
893
+ title="Horizon Portfolio Backtester",
894
+ theme=gr.themes.Soft(
895
+ primary_hue="emerald",
896
+ secondary_hue="slate",
897
+ ),
898
+ css="""
899
+ .gradio-container { max-width: 1400px !important; }
900
+ .metric-box { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
901
+ border-radius: 10px; padding: 20px; margin: 10px 0; }
902
+ """
903
+ ) as demo:
904
+
905
+ gr.Markdown("""
906
+ # πŸš€ Horizon Portfolio Backtester
907
+
908
+ A comprehensive portfolio backtesting tool inspired by Portfolio Visualizer.
909
+ Analyze historical performance, risk metrics, and compare against benchmarks.
910
+
911
+ ---
912
+ """)
913
+
914
+ with gr.Row():
915
+ # Left column - Inputs
916
+ with gr.Column(scale=1):
917
+ gr.Markdown("### βš™οΈ Portfolio Configuration")
918
+
919
+ preset_dropdown = gr.Dropdown(
920
+ choices=list(PRESETS.keys()),
921
+ value="Horizon Growth",
922
+ label="Portfolio Preset",
923
+ info="Select a preset or choose Custom"
924
+ )
925
+
926
+ custom_tickers = gr.Textbox(
927
+ label="Tickers (comma-separated)",
928
+ value=", ".join(PRESETS["Horizon Growth"]),
929
+ placeholder="AAPL, MSFT, GOOGL...",
930
+ interactive=False,
931
+ lines=3
932
+ )
933
+
934
+ benchmark = gr.Dropdown(
935
+ choices=BENCHMARKS,
936
+ value="QQQ",
937
+ label="Benchmark",
938
+ info="Compare against any ticker in the dataset"
939
+ )
940
+
941
+ gr.Markdown("### πŸ“… Time Period")
942
+
943
+ start_date = gr.Textbox(
944
+ label="Start Date",
945
+ value="2024-01-01",
946
+ placeholder="YYYY-MM-DD or 1y, 3m, 5y",
947
+ info="Use '3m', '1y', '5y' for relative dates"
948
+ )
949
+
950
+ gr.Markdown("### πŸ’΅ Investment Strategy")
951
+
952
+ initial_investment = gr.Number(
953
+ label="Initial Investment ($)",
954
+ value=10000,
955
+ minimum=0
956
+ )
957
+
958
+ contribution_amount = gr.Number(
959
+ label="Periodic Contribution ($)",
960
+ value=1000,
961
+ minimum=0
962
+ )
963
+
964
+ contribution_freq = gr.Dropdown(
965
+ choices=["None", "Weekly", "Monthly", "Quarterly"],
966
+ value="Weekly",
967
+ label="Contribution Frequency"
968
+ )
969
+
970
+ rebalance_freq = gr.Dropdown(
971
+ choices=["None", "Monthly", "Quarterly", "Annually"],
972
+ value="None",
973
+ label="Rebalancing Frequency"
974
+ )
975
+
976
+ run_btn = gr.Button("πŸš€ Run Backtest", variant="primary", size="lg")
977
+
978
+ # Right column - Results
979
+ with gr.Column(scale=2):
980
+ gr.Markdown("### πŸ“ˆ Results")
981
+
982
+ summary_output = gr.Markdown()
983
+
984
+ with gr.Tabs():
985
+ with gr.TabItem("πŸ“Š Performance"):
986
+ chart1 = gr.Plot(label="Portfolio Value")
987
+
988
+ with gr.TabItem("πŸ“‰ Drawdown"):
989
+ chart2 = gr.Plot(label="Drawdown Analysis")
990
+
991
+ with gr.TabItem("πŸ₯§ Holdings"):
992
+ chart3 = gr.Plot(label="Current Allocation")
993
+
994
+ with gr.TabItem("πŸ“‹ Holdings Table"):
995
+ holdings_table = gr.Dataframe(
996
+ headers=['Ticker', 'Shares', 'Price', 'Value', 'Weight', 'Return'],
997
+ label="Individual Holdings"
998
+ )
999
+
1000
+ # Event handlers
1001
+ preset_dropdown.change(
1002
+ fn=update_tickers,
1003
+ inputs=[preset_dropdown],
1004
+ outputs=[custom_tickers]
1005
+ )
1006
+
1007
+ run_btn.click(
1008
+ fn=run_backtest,
1009
+ inputs=[
1010
+ preset_dropdown,
1011
+ custom_tickers,
1012
+ benchmark,
1013
+ start_date,
1014
+ initial_investment,
1015
+ contribution_amount,
1016
+ contribution_freq,
1017
+ rebalance_freq
1018
+ ],
1019
+ outputs=[chart1, chart2, chart3, summary_output, holdings_table]
1020
+ )
1021
+
1022
+ gr.Markdown("""
1023
+ ---
1024
+
1025
+ ### πŸ“– Quick Start Guide
1026
+
1027
+ 1. **Select a preset** or enter custom tickers
1028
+ 2. **Choose a benchmark** (QQQ, SPY, etc.)
1029
+ 3. **Set your time period** (e.g., "2024-01-01" or "5y" for 5 years)
1030
+ 4. **Configure contributions** (initial + periodic)
1031
+ 5. **Set rebalancing** (None, Monthly, Quarterly, Annually)
1032
+ 6. **Click Run Backtest!**
1033
+
1034
+ ---
1035
+
1036
+ *Built with ❀️ using Gradio | Data from Yahoo Finance*
1037
+ """)
1038
+
1039
+ # ===== API TAB =====
1040
+ gr.Markdown("""
1041
+ ---
1042
+
1043
+ ## πŸ”Œ API Access
1044
+
1045
+ This Space exposes API endpoints for programmatic access. Use these to integrate backtesting into your workflows.
1046
+ """)
1047
+
1048
+ with gr.Tabs():
1049
+ with gr.TabItem("πŸ€– Natural Language (Claude)"):
1050
+ gr.Markdown("""
1051
+ **Ask Claude to run a backtest in plain English!**
1052
+
1053
+ Examples:
1054
+ - "Backtest the Magnificent 7 for the last 2 years with $500 weekly contributions"
1055
+ - "Compare AAPL, MSFT, GOOGL against SPY starting from 2023"
1056
+ - "Run Horizon Growth from January 2024 with monthly rebalancing"
1057
+ """)
1058
+
1059
+ nl_input = gr.Textbox(
1060
+ label="Your backtest request",
1061
+ placeholder="e.g., Backtest FAANG stocks for 5 years with $1000 monthly DCA",
1062
+ lines=3
1063
+ )
1064
+ nl_button = gr.Button("πŸš€ Run with Claude", variant="primary")
1065
+ nl_output = gr.Code(label="Results (JSON)", language="json")
1066
+
1067
+ nl_button.click(
1068
+ fn=natural_language_backtest,
1069
+ inputs=[nl_input],
1070
+ outputs=[nl_output],
1071
+ api_name="natural_language_backtest"
1072
+ )
1073
+
1074
+ with gr.TabItem("⚑ Direct API"):
1075
+ gr.Markdown("""
1076
+ **Call the API directly with parameters.**
1077
+
1078
+ Endpoint: `/api/backtest`
1079
+
1080
+ ```python
1081
+ import requests
1082
+
1083
+ response = requests.post(
1084
+ "https://YOUR-SPACE.hf.space/api/backtest",
1085
+ json={
1086
+ "preset": "Horizon Growth",
1087
+ "benchmark": "QQQ",
1088
+ "start_date": "2024-01-01",
1089
+ "initial_investment": 10000,
1090
+ "contribution_amount": 1000,
1091
+ "contribution_freq": "Weekly",
1092
+ "rebalance_freq": "None"
1093
+ }
1094
+ )
1095
+ print(response.json())
1096
+ ```
1097
+ """)
1098
+
1099
+ with gr.Row():
1100
+ with gr.Column():
1101
+ api_preset = gr.Dropdown(
1102
+ choices=list(PRESETS.keys()),
1103
+ value="Horizon Growth",
1104
+ label="Preset"
1105
+ )
1106
+ api_tickers = gr.Textbox(
1107
+ label="Custom Tickers (if preset=Custom)",
1108
+ placeholder="AAPL, MSFT, GOOGL"
1109
+ )
1110
+ api_benchmark = gr.Dropdown(
1111
+ choices=BENCHMARKS,
1112
+ value="QQQ",
1113
+ label="Benchmark",
1114
+ info="Any ticker in the dataset"
1115
+ )
1116
+ api_start = gr.Textbox(
1117
+ label="Start Date",
1118
+ value="2024-01-01"
1119
+ )
1120
+
1121
+ with gr.Column():
1122
+ api_initial = gr.Number(
1123
+ label="Initial Investment",
1124
+ value=10000
1125
+ )
1126
+ api_contrib = gr.Number(
1127
+ label="Contribution Amount",
1128
+ value=1000
1129
+ )
1130
+ api_contrib_freq = gr.Dropdown(
1131
+ choices=["None", "Weekly", "Monthly", "Quarterly"],
1132
+ value="Weekly",
1133
+ label="Contribution Frequency"
1134
+ )
1135
+ api_rebal = gr.Dropdown(
1136
+ choices=["None", "Monthly", "Quarterly", "Annually"],
1137
+ value="None",
1138
+ label="Rebalancing"
1139
+ )
1140
+
1141
+ api_button = gr.Button("πŸ“Š Run API Backtest", variant="secondary")
1142
+ api_output = gr.Code(label="API Response (JSON)", language="json")
1143
+
1144
+ api_button.click(
1145
+ fn=api_backtest,
1146
+ inputs=[api_preset, api_tickers, api_benchmark, api_start,
1147
+ api_initial, api_contrib, api_contrib_freq, api_rebal],
1148
+ outputs=[api_output],
1149
+ api_name="backtest"
1150
+ )
1151
+
1152
+ with gr.TabItem("πŸ“š API Documentation"):
1153
+ gr.Markdown("""
1154
+ ## API Endpoints
1155
+
1156
+ ### 1. Natural Language Backtest
1157
+
1158
+ **Endpoint:** `POST /api/natural_language_backtest`
1159
+
1160
+ Uses Claude AI to parse your request and run a backtest.
1161
+
1162
+ **Request:**
1163
+ ```json
1164
+ {
1165
+ "user_request": "Backtest the Magnificent 7 for 2 years with $500 weekly DCA"
1166
+ }
1167
+ ```
1168
+
1169
+ **Response:**
1170
+ ```json
1171
+ {
1172
+ "success": true,
1173
+ "interpretation": "Running Magnificent 7 preset from 2023-01-01...",
1174
+ "results": {
1175
+ "total_invested": 62000,
1176
+ "portfolio_value": 85432.10,
1177
+ "portfolio_return": 37.79,
1178
+ "alpha": 12.45
1179
+ },
1180
+ "holdings": {...}
1181
+ }
1182
+ ```
1183
+
1184
+ ---
1185
+
1186
+ ### 2. Direct Backtest API
1187
+
1188
+ **Endpoint:** `POST /api/backtest`
1189
+
1190
+ **Parameters:**
1191
+ | Parameter | Type | Default | Description |
1192
+ |-----------|------|---------|-------------|
1193
+ | preset | string | "Horizon Growth" | Preset name or "Custom" |
1194
+ | tickers | string | "" | Comma-separated tickers (for Custom) |
1195
+ | benchmark | string | "QQQ" | Any ticker in the dataset |
1196
+ | start_date | string | "2024-01-01" | Start date or relative (1y, 5y) |
1197
+ | initial_investment | number | 10000 | Initial investment |
1198
+ | contribution_amount | number | 1000 | Periodic contribution |
1199
+ | contribution_freq | string | "Weekly" | None/Weekly/Monthly/Quarterly |
1200
+ | rebalance_freq | string | "None" | None/Monthly/Quarterly/Annually |
1201
+
1202
+ ---
1203
+
1204
+ ### Python Client Example
1205
+
1206
+ ```python
1207
+ from gradio_client import Client
1208
+
1209
+ client = Client("YOUR-USERNAME/horizon-backtester")
1210
+
1211
+ # Natural language
1212
+ result = client.predict(
1213
+ user_request="Backtest FAANG for 3 years",
1214
+ api_name="/natural_language_backtest"
1215
+ )
1216
+
1217
+ # Direct API
1218
+ result = client.predict(
1219
+ preset="Magnificent 7",
1220
+ tickers="",
1221
+ benchmark="SPY",
1222
+ start_date="2022-01-01",
1223
+ initial_investment=10000,
1224
+ contribution_amount=500,
1225
+ contribution_freq="Weekly",
1226
+ rebalance_freq="Quarterly",
1227
+ api_name="/backtest"
1228
+ )
1229
+ ```
1230
+
1231
+ ---
1232
+
1233
+ ### cURL Example
1234
+
1235
+ ```bash
1236
+ curl -X POST "https://YOUR-SPACE.hf.space/api/backtest" \\
1237
+ -H "Content-Type: application/json" \\
1238
+ -d '{
1239
+ "preset": "FAANG",
1240
+ "benchmark": "QQQ",
1241
+ "start_date": "2023-01-01",
1242
+ "initial_investment": 10000,
1243
+ "contribution_amount": 1000,
1244
+ "contribution_freq": "Weekly"
1245
+ }'
1246
+ ```
1247
+ """)
1248
+
1249
+ with gr.TabItem("πŸ“₯ Request Ticker"):
1250
+ gr.Markdown("""
1251
+ ## Request a New Ticker
1252
+
1253
+ Can't find a ticker in our dataset? Request it here!
1254
+ Requested tickers are added during the next daily data update (~9:30 PM UTC).
1255
+
1256
+ **Current dataset:** ~613 tickers including S&P 500, Nasdaq 100, major ETFs, and recent IPOs.
1257
+ """)
1258
+
1259
+ with gr.Row():
1260
+ with gr.Column():
1261
+ request_input = gr.Textbox(
1262
+ label="Ticker Symbol",
1263
+ placeholder="e.g., PLTR, SOFI, RKLB",
1264
+ info="Enter a valid stock ticker (1-5 letters)"
1265
+ )
1266
+ request_btn = gr.Button("πŸ“₯ Request Ticker", variant="primary")
1267
+ request_output = gr.Markdown()
1268
+
1269
+ with gr.Column():
1270
+ gr.Markdown("### πŸ“‹ Pending Requests")
1271
+ pending_display = gr.Markdown()
1272
+ refresh_btn = gr.Button("πŸ”„ Refresh", variant="secondary")
1273
+
1274
+ def handle_request(ticker):
1275
+ result = request_ticker(ticker)
1276
+ pending = get_pending_requests()
1277
+ pending_text = ", ".join(pending) if pending else "None"
1278
+ return result["message"], f"**Queue:** {pending_text}"
1279
+
1280
+ def refresh_pending():
1281
+ pending = get_pending_requests()
1282
+ pending_text = ", ".join(pending) if pending else "None"
1283
+ return f"**Queue:** {pending_text}"
1284
+
1285
+ request_btn.click(
1286
+ fn=handle_request,
1287
+ inputs=[request_input],
1288
+ outputs=[request_output, pending_display]
1289
+ )
1290
+
1291
+ refresh_btn.click(
1292
+ fn=refresh_pending,
1293
+ inputs=[],
1294
+ outputs=[pending_display]
1295
+ )
1296
+
1297
+
1298
+ if __name__ == "__main__":
1299
+ demo.launch(show_error=True)