Spaces:
Sleeping
Sleeping
Khiry McCurn commited on
Commit Β·
3cbd1bd
1
Parent(s): dc03c48
Add app.py from HuggingFace Space
Browse files
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)
|