galbendavids commited on
Commit
f96f76d
ยท
verified ยท
1 Parent(s): 58c71e4

changes app

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +688 -38
src/streamlit_app.py CHANGED
@@ -1,40 +1,690 @@
1
- import altair as alt
2
- import numpy as np
3
- import pandas as pd
4
  import streamlit as st
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- """
7
- # Welcome to Streamlit!
8
-
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
12
-
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
15
-
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
- ))
 
 
 
 
1
  import streamlit as st
2
+ import pandas as pd
3
+ import yfinance as yf
4
+ import plotly.graph_objects as go
5
+ import plotly.express as px
6
+ from datetime import datetime, timedelta
7
+ import numpy as np
8
+ from reportlab.lib.pagesizes import letter
9
+ from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
10
+ from reportlab.lib.styles import getSampleStyleSheet
11
+ from reportlab.lib import colors
12
+ import io
13
+ import base64
14
+ from math import erf, sqrt as msqrt
15
+ import os
16
+
17
+ # Ensure Streamlit writes to a writable location (fixes permission issues on some hosts like HF Spaces)
18
+ os.environ.setdefault("HOME", "/tmp")
19
+ os.environ.setdefault("XDG_CACHE_HOME", "/tmp")
20
+ os.environ["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
21
+ try:
22
+ os.makedirs(os.path.join(os.environ["HOME"], ".streamlit"), exist_ok=True)
23
+ except Exception:
24
+ pass
25
+
26
+ # Page configuration
27
+ st.set_page_config(
28
+ page_title="WhatIfWealth - Backtesting Portfolio",
29
+ page_icon="๐Ÿ“ˆ",
30
+ layout="wide"
31
+ )
32
+
33
+ # Title and description
34
+ st.title("๐Ÿ“ˆ WhatIfWealth - ืกื™ืžื•ืœืฆื™ื™ืช ื”ืฉืงืขื” ืจื˜ืจื•ืืงื˜ื™ื‘ื™ืช")
35
+ st.markdown("""
36
+ ืืคืœื™ืงืฆื™ื” ืœื ื™ืชื•ื— ื‘ื™ืฆื•ืขื™ ืชื™ืง ื”ืฉืงืขื•ืช ื”ื™ืกื˜ื•ืจื™ ืขื ื”ืฉื•ื•ืื” ืœ-benchmarks
37
+ """)
38
+
39
+ # Sidebar for input
40
+ st.sidebar.header("ื”ื’ื“ืจื•ืช ืชื™ืง ื”ืฉืงืขื•ืช")
41
+
42
+ # Date inputs
43
+ col1, col2 = st.sidebar.columns(2)
44
+ with col1:
45
+ start_date = st.date_input(
46
+ "ืชืืจื™ืš ื”ืชื—ืœื”",
47
+ value=datetime.now() - timedelta(days=365),
48
+ max_value=datetime.now()
49
+ )
50
+ with col2:
51
+ end_date = st.date_input(
52
+ "ืชืืจื™ืš ืกื™ื•ื",
53
+ value=datetime.now(),
54
+ max_value=datetime.now()
55
+ )
56
+
57
+ # Portfolio input
58
+ st.sidebar.subheader("ืชื™ืง ื”ืฉืงืขื•ืช")
59
+ st.sidebar.markdown("ื”ื›ื ืก ืžื ื™ื•ืช ื•ืื—ื•ื–ื™ ื”ืฉืงืขื” (ืกื”ืดื› ืฆืจื™ืš ืœื”ื™ื•ืช 100%)")
60
+
61
+ # Sample portfolio for demonstration
62
+ sample_portfolio = {
63
+ "AAPL": 30,
64
+ "MSFT": 25,
65
+ "GOOGL": 20,
66
+ "AMZN": 15,
67
+ "TSLA": 10
68
+ }
69
+
70
+ # Portfolio input interface
71
+ portfolio = {}
72
+ total_percentage = 0
73
+
74
+ # Check if optimized portfolio exists in session state
75
+ if 'optimized_portfolio' in st.session_state:
76
+ sample_portfolio = st.session_state.optimized_portfolio
77
+ # Clear the session state after using it
78
+ del st.session_state.optimized_portfolio
79
+ else:
80
+ sample_portfolio = {
81
+ "QQQ": 30,
82
+ "MAGS": 10,
83
+ "XAR": 20,
84
+ "VXUS": 15,
85
+ "SPY": 10,
86
+ "XLV": 15
87
+ }
88
+
89
+ for i, (ticker, percentage) in enumerate(sample_portfolio.items()):
90
+ col1, col2 = st.sidebar.columns([3, 1])
91
+ with col1:
92
+ new_ticker = st.text_input(f"ืžื ื™ื” {i+1}", value=ticker, key=f"ticker_{i}")
93
+ with col2:
94
+ new_percentage = st.number_input(f"ืื—ื•ื– {i+1}", value=percentage, min_value=0, max_value=100, key=f"perc_{i}")
95
+
96
+ if new_ticker and new_percentage > 0:
97
+ portfolio[new_ticker.upper()] = new_percentage
98
+ total_percentage += new_percentage
99
+
100
+ # Add more stocks
101
+ num_additional = st.sidebar.number_input("ืžืกืคืจ ืžื ื™ื•ืช ื ื•ืกืคื•ืช", min_value=0, max_value=10, value=0)
102
+
103
+ for i in range(num_additional):
104
+ col1, col2 = st.sidebar.columns([3, 1])
105
+ with col1:
106
+ ticker = st.text_input(f"ืžื ื™ื” ื ื•ืกืคืช {i+1}", key=f"add_ticker_{i}")
107
+ with col2:
108
+ percentage = st.number_input(f"ืื—ื•ื– {i+1}", min_value=0, max_value=100, key=f"add_perc_{i}")
109
+
110
+ if ticker and percentage > 0:
111
+ portfolio[ticker.upper()] = percentage
112
+ total_percentage += percentage
113
+
114
+ # Display total percentage
115
+ st.sidebar.markdown(f"**ืกื”ืดื› ืื—ื•ื–ื™ื: {total_percentage}%**")
116
+
117
+ if total_percentage != 100:
118
+ st.sidebar.warning(f"ืกื”ืดื› ื”ืื—ื•ื–ื™ื ืฆืจื™ืš ืœื”ื™ื•ืช 100%. ื›ืจื’ืข: {total_percentage}%")
119
+
120
+ # Benchmark selection
121
+ st.sidebar.subheader("Benchmark ืœื”ืฉื•ื•ืื”")
122
+ benchmark = st.sidebar.selectbox(
123
+ "ื‘ื—ืจ benchmark",
124
+ ["SPY", "QQQ", "IWM", "TLT", "GLD", "BTC-USD"],
125
+ help="SPY = S&P 500, QQQ = NASDAQ, IWM = Russell 2000, TLT = Treasury Bonds, GLD = Gold, BTC-USD = Bitcoin"
126
+ )
127
+
128
+ # Safety slider: user's confidence not to lose money in alternative portfolios
129
+ st.sidebar.subheader("ืจืžืช ื‘ื™ื˜ื—ื•ืŸ ืฉืœื ืืคืกื™ื“ ื›ืกืฃ (ืชื™ืงื™ื ื—ืœื•ืคื™ื™ื)")
130
+ safety_level = st.sidebar.slider(
131
+ "safety",
132
+ min_value=0,
133
+ max_value=100,
134
+ value=50,
135
+ help="0 = ืœื ืื›ืคืช ืœื™ ืœื”ืคืกื™ื“, 100 = ื‘ื™ื˜ื—ื•ืŸ ืžืœื ืฉืœื ืืคืกื™ื“"
136
+ )
137
+
138
+ # Lock specific tickers for alternative suggestions
139
+ st.sidebar.subheader("ื ืขื™ืœื”: ืืœ ืชืฉื ื” ืื—ื•ื–ื™ื ื‘ืžื ื™ื•ืช ื”ื ื‘ื—ืจื•ืช")
140
+ locked_tickers = st.sidebar.multiselect(
141
+ "ื‘ื—ืจ ืžื ื™ื•ืช ืœื ืขื™ืœื”",
142
+ options=list(portfolio.keys()),
143
+ help="ื”ืžื ื™ื•ืช ืฉื ื‘ื—ืจื• ื™ืฉืžืจื• ืขืœ ืื—ื•ื– ื”ื”ืฉืงืขื” ื”ื ื•ื›ื—ื™ ื‘ืื•ืคื˜ื™ืžื™ื–ืฆื™ื”"
144
+ )
145
+
146
+ # Instructions
147
+ with st.expander("ื”ื•ืจืื•ืช ืฉื™ืžื•ืฉ"):
148
+ st.markdown("""
149
+ ### ืื™ืš ืœื”ืฉืชืžืฉ ื‘ืืคืœื™ืงืฆื™ื”:
150
+
151
+ 1. **ื”ื’ื“ืจ ืชืืจื™ื›ื™ื**: ื‘ื—ืจ ืชืืจื™ืš ื”ืชื—ืœื” ื•ืกื™ื•ื ืœื ื™ืชื•ื—
152
+ 2. **ื”ื›ื ืก ืชื™ืง ื”ืฉืงืขื•ืช**: ื”ื•ืกืฃ ืžื ื™ื•ืช ื•ืื—ื•ื–ื™ ื”ืฉืงืขื” (ืกื”ืดื› 100%)
153
+ 3. **ื‘ื—ืจ Benchmark**: ื‘ื—ืจ ืžื“ื“ ืœื”ืฉื•ื•ืื” (SPY, QQQ, ื•ื›ื•ืณ)
154
+ 4. **ื”ืจืฅ ื ื™ืชื•ื—**: ืœื—ืฅ ืขืœ ื›ืคืชื•ืจ "ื”ืจืฅ ื ื™ืชื•ื—"
155
+ 5. **ื”ืฆืข ืฉื™ืœื•ื‘ ื—ื“ืฉ**: ืœื—ืฅ ืขืœ "ื”ืฆืข ืฉื™ืœื•ื‘ ื—ื“ืฉ" ืœืงื‘ืœืช ืื•ืคื˜ื™ืžื™ื–ืฆื™ื”
156
+ 6. **ืฆืคื” ื‘ืชื•ืฆืื•ืช**: ื’ืจืคื™ื, ืžื“ื“ื™ ื‘ื™ืฆื•ืข, ื•ื”ืฉื•ื•ืื•ืช
157
+ 7. **ื™ื™ืฆื ื“ื•ื—**: ื”ื•ืจื“ ื“ื•ื— PDF ืžืคื•ืจื˜
158
+
159
+ ### ืžื“ื“ื™ ื‘ื™ืฆื•ืข:
160
+ - **ืชืฉื•ืื” ื›ื•ืœืœืช**: ื”ืจื•ื•ื—/ื”ืคืกื“ ื”ื›ื•ืœืœ ื‘ืชืงื•ืคื”
161
+ - **ืชืฉื•ืื” ืฉื ืชื™ืช**: ืชื ื•ื“ืชื™ื•ืช ืฉื ืชื™ืช
162
+ - **Sharpe Ratio**: ื™ื—ืก ืชืฉื•ืื” ืœืกื™ื›ื•ืŸ
163
+ - **Max Drawdown**: ื”ื™ืจื™ื“ื” ื”ืžืงืกื™ืžืœื™ืช ืžื”ืฉื™ื
164
+
165
+ ### ืื•ืคื˜ื™ืžื™ื–ืฆื™ื”:
166
+ - ** ื ื‘ื“ืงื™ื 5,000 ืฉื™ื ื•ื™ื™ื ืืงืจืื™ื™ื ื‘ืชื™ืง, ืœืœื ื”ื•ืกืคืช ืจื›ื™ื‘ื™ื ื—ื“ืฉื™ื
167
+ - **Sharpe Ratio**: ืงืจื™ื˜ืจื™ื•ืŸ ืจืืฉื™ (70%)
168
+ - **ืชืฉื•ืื” ื›ื•ืœืœืช**: ืงืจื™ื˜ืจื™ื•ืŸ ืžืฉื ื™ (30%)
169
+ - **ืžื ื™ื•ืช ื–ืžื™ื ื•ืช**: ืชื™ืง ื ื•ื›ื—ื™ + ืจื›ื™ื‘ื™ benchmark
170
+ """)
171
+
172
+
173
+ # Run analysis button
174
+ run_analysis = st.sidebar.button("ื”ืจืฅ ื ื™ืชื•ื—", type="primary")
175
+
176
+ # Suggest new combination button
177
+ suggest_combination = st.sidebar.button("ื”ืฆืข ืฉื™ืœื•ื‘ ื—ื“ืฉ", type="secondary")
178
+
179
+ if run_analysis and portfolio and total_percentage == 100:
180
+ with st.spinner("ืžื—ืฉื‘ ื‘ื™ืฆื•ืขื™ ืชื™ืง..."):
181
+
182
+ # Fetch historical data
183
+ @st.cache_data
184
+ def fetch_data(tickers, start, end):
185
+ data = {}
186
+ for ticker in tickers:
187
+ try:
188
+ stock = yf.Ticker(ticker)
189
+ hist = stock.history(start=start, end=end)
190
+ if not hist.empty:
191
+ data[ticker] = hist['Close']
192
+ except Exception as e:
193
+ st.error(f"ืฉื’ื™ืื” ื‘ื˜ืขื™ื ืช {ticker}: {e}")
194
+ return data
195
+
196
+ # Get portfolio and benchmark data
197
+ portfolio_data = fetch_data(list(portfolio.keys()), start_date, end_date)
198
+ benchmark_data = fetch_data([benchmark], start_date, end_date)
199
+
200
+ if portfolio_data and benchmark_data:
201
+
202
+ # Calculate portfolio returns
203
+ portfolio_df = pd.DataFrame(portfolio_data)
204
+ portfolio_df = portfolio_df.fillna(method='ffill')
205
+
206
+ # Calculate weighted returns
207
+ weights = np.array(list(portfolio.values())) / 100
208
+ portfolio_returns = portfolio_df.pct_change().dropna()
209
+ weighted_returns = (portfolio_returns * weights).sum(axis=1)
210
+
211
+ # Calculate cumulative returns
212
+ cumulative_returns = (1 + weighted_returns).cumprod()
213
+
214
+ # Benchmark returns
215
+ benchmark_returns = benchmark_data[benchmark].pct_change().dropna()
216
+ benchmark_cumulative = (1 + benchmark_returns).cumprod()
217
+
218
+ # Performance metrics
219
+ total_return = (cumulative_returns.iloc[-1] - 1) * 100
220
+ benchmark_total_return = (benchmark_cumulative.iloc[-1] - 1) * 100
221
+
222
+ # Volatility (annualized)
223
+ volatility = weighted_returns.std() * np.sqrt(252) * 100
224
+ benchmark_volatility = benchmark_returns.std() * np.sqrt(252) * 100
225
+
226
+ # Sharpe ratio (assuming risk-free rate of 2%)
227
+ risk_free_rate = 0.02
228
+ sharpe_ratio = (weighted_returns.mean() * 252 - risk_free_rate) / (weighted_returns.std() * np.sqrt(252))
229
+ benchmark_sharpe = (benchmark_returns.mean() * 252 - risk_free_rate) / (benchmark_returns.std() * np.sqrt(252))
230
+
231
+ # Maximum drawdown
232
+ rolling_max = cumulative_returns.expanding().max()
233
+ drawdown = (cumulative_returns - rolling_max) / rolling_max
234
+ max_drawdown = drawdown.min() * 100
235
+
236
+ benchmark_rolling_max = benchmark_cumulative.expanding().max()
237
+ benchmark_drawdown = (benchmark_cumulative - benchmark_rolling_max) / benchmark_rolling_max
238
+ benchmark_max_drawdown = benchmark_drawdown.min() * 100
239
+
240
+ # Probability of not losing money over selected period
241
+ log_returns_main = np.log1p(weighted_returns)
242
+ trading_days_main = len(log_returns_main)
243
+ mu_log_main = log_returns_main.mean()
244
+ sigma_log_main = log_returns_main.std()
245
+ if sigma_log_main == 0:
246
+ p_no_loss_main = 1.0 if mu_log_main > 0 else 0.0
247
+ else:
248
+ mean_sum_main = mu_log_main * trading_days_main
249
+ std_sum_main = sigma_log_main * np.sqrt(trading_days_main)
250
+ z_main = (0 - mean_sum_main) / std_sum_main
251
+ cdf_main = 0.5 * (1 + erf(z_main / msqrt(2)))
252
+ p_no_loss_main = 1 - cdf_main
253
+
254
+ # Display results
255
+ st.header("๐Ÿ“Š ืชื•ืฆืื•ืช ื”ื ื™ืชื•ื—")
256
+
257
+ # Performance comparison
258
+ col1, col2, col3, col4, col5 = st.columns(5)
259
+
260
+ with col1:
261
+ st.metric("ืชืฉื•ืื” ื›ื•ืœืœืช", f"{total_return:.2f}%", f"{total_return - benchmark_total_return:.2f}%")
262
+
263
+ with col2:
264
+ st.metric("ืชืฉื•ืื” ืฉื ืชื™ืช", f"{volatility:.2f}%", f"{volatility - benchmark_volatility:.2f}%")
265
+
266
+ with col3:
267
+ st.metric("Sharpe Ratio", f"{sharpe_ratio:.2f}", f"{sharpe_ratio - benchmark_sharpe:.2f}")
268
+
269
+ with col4:
270
+ st.metric("Max Drawdown", f"{max_drawdown:.2f}%", f"{max_drawdown - benchmark_max_drawdown:.2f}%")
271
+ with col5:
272
+ st.metric("ืกื™ื›ื•ื™ ืœื ืœื”ืคืกื™ื“", f"{p_no_loss_main*100:.1f}%")
273
+ if p_no_loss_main * 100 < safety_level:
274
+ st.warning(f"ืจืžืช ื”ื‘ื™ื˜ื—ื•ืŸ ืžื—ื•ืฉื‘ืช ({p_no_loss_main*100:.1f}%) ื ืžื•ื›ื” ืžื”ืกืฃ ืฉื ื‘ื—ืจ ({safety_level}%). ืฉืงื•ืœ ืœืฉื ื•ืช ื”ืงืฆืื•ืช ืื• ืœื”ืคื—ื™ืช ืกื™ื›ื•ืŸ.")
275
+
276
+ # Portfolio composition
277
+ st.subheader("ื”ืจื›ื‘ ื”ืชื™ืง")
278
+ portfolio_df_display = pd.DataFrame({
279
+ 'ืžื ื™ื”': list(portfolio.keys()),
280
+ 'ืื—ื•ื– ื”ืฉืงืขื”': list(portfolio.values()),
281
+ 'ืชืฉื•ืื” ื›ื•ืœืœืช': [((portfolio_df[ticker].iloc[-1] / portfolio_df[ticker].iloc[0]) - 1) * 100
282
+ for ticker in portfolio.keys()]
283
+ })
284
+ st.dataframe(portfolio_df_display, use_container_width=True)
285
+
286
+ # Performance chart
287
+ st.subheader("ื’ืจืฃ ื‘ื™ืฆื•ืขื™ื")
288
+
289
+ fig = go.Figure()
290
+
291
+ # Portfolio line
292
+ fig.add_trace(go.Scatter(
293
+ x=cumulative_returns.index,
294
+ y=cumulative_returns.values * 100,
295
+ mode='lines',
296
+ name='ืชื™ืง ื”ืฉืงืขื•ืช',
297
+ line=dict(color='blue', width=2)
298
+ ))
299
+
300
+ # Benchmark line
301
+ fig.add_trace(go.Scatter(
302
+ x=benchmark_cumulative.index,
303
+ y=benchmark_cumulative.values * 100,
304
+ mode='lines',
305
+ name=f'Benchmark ({benchmark})',
306
+ line=dict(color='red', width=2)
307
+ ))
308
+
309
+ fig.update_layout(
310
+ title="ื”ืฉื•ื•ืืช ื‘ื™ืฆื•ืขื™ื",
311
+ xaxis_title="ืชืืจื™ืš",
312
+ yaxis_title="ืชืฉื•ืื” ืžืฆื˜ื‘ืจืช (%)",
313
+ hovermode='x unified'
314
+ )
315
+
316
+ st.plotly_chart(fig, use_container_width=True)
317
+
318
+ # Drawdown chart
319
+ st.subheader("ื’ืจืฃ Drawdown")
320
+
321
+ fig_dd = go.Figure()
322
+
323
+ fig_dd.add_trace(go.Scatter(
324
+ x=drawdown.index,
325
+ y=drawdown.values * 100,
326
+ mode='lines',
327
+ name='ืชื™ืง ื”ืฉืงืขื•ืช',
328
+ fill='tonexty',
329
+ line=dict(color='blue')
330
+ ))
331
+
332
+ fig_dd.add_trace(go.Scatter(
333
+ x=benchmark_drawdown.index,
334
+ y=benchmark_drawdown.values * 100,
335
+ mode='lines',
336
+ name=f'Benchmark ({benchmark})',
337
+ fill='tonexty',
338
+ line=dict(color='red')
339
+ ))
340
+
341
+ fig_dd.update_layout(
342
+ title="Drawdown ืœืื•ืจืš ื–ืžืŸ",
343
+ xaxis_title="ืชืืจื™ืš",
344
+ yaxis_title="Drawdown (%)",
345
+ hovermode='x unified'
346
+ )
347
+
348
+ st.plotly_chart(fig_dd, use_container_width=True)
349
+
350
+ # Detailed metrics table
351
+ st.subheader("ืžื“ื“ื™ ื‘ื™ืฆื•ืข ืžืคื•ืจื˜ื™ื")
352
+
353
+ metrics_df = pd.DataFrame({
354
+ 'ืžื“ื“': ['ืชืฉื•ืื” ื›ื•ืœืœืช', 'ืชืฉื•ืื” ืฉื ืชื™ืช', 'Sharpe Ratio', 'Max Drawdown', 'Beta'],
355
+ 'ืชื™ืง ื”ืฉืงืขื•ืช': [f"{total_return:.2f}%", f"{volatility:.2f}%", f"{sharpe_ratio:.2f}", f"{max_drawdown:.2f}%", "N/A"],
356
+ f'Benchmark ({benchmark})': [f"{benchmark_total_return:.2f}%", f"{benchmark_volatility:.2f}%", f"{benchmark_sharpe:.2f}", f"{benchmark_max_drawdown:.2f}%", "1.00"],
357
+ 'ื”ืคืจืฉ': [f"{total_return - benchmark_total_return:.2f}%", f"{volatility - benchmark_volatility:.2f}%", f"{sharpe_ratio - benchmark_sharpe:.2f}", f"{max_drawdown - benchmark_max_drawdown:.2f}%", "N/A"]
358
+ })
359
+
360
+ st.dataframe(metrics_df, use_container_width=True)
361
+
362
+ # Additional comparative analyses: 5, 10, 15, 20 years (portfolio vs benchmark)
363
+ st.subheader("ื ื™ืชื•ื—ื™ื ื ื•ืกืคื™ื: ื”ืฉื•ื•ืืช ืชื™ืง ืžื•ืœ Benchmark (5/10/15/20 ืฉื ื™ื)")
364
+ compare_periods = {
365
+ '5 ืฉื ื™ื': 1260,
366
+ '10 ืฉื ื™ื': 2520,
367
+ '15 ืฉื ื™ื': 3780,
368
+ '20 ืฉื ื™ื': 5040
369
+ }
370
+ for label_ep, days_ep in compare_periods.items():
371
+ ep_end = pd.Timestamp(end_date)
372
+ ep_start = ep_end - pd.Timedelta(days=days_ep)
373
+ # fetch data
374
+ ep_portfolio_data = fetch_data(list(portfolio.keys()), ep_start, ep_end)
375
+ ep_bench_data = fetch_data([benchmark], ep_start, ep_end)
376
+ if not ep_portfolio_data or not ep_bench_data:
377
+ st.info(f"{label_ep}: ืื™ืŸ ืžืกืคื™ืง ื ืชื•ื ื™ื ืœื›ืœ ื”ืจื›ื™ื‘ื™ื.")
378
+ continue
379
+ # Portfolio metrics
380
+ ep_df = pd.DataFrame(ep_portfolio_data).fillna(method='ffill')
381
+ ep_returns = ep_df.pct_change().dropna()
382
+ if ep_returns.empty:
383
+ st.info(f"{label_ep}: ืื™ืŸ ื ืชื•ื ื™ ืชืฉื•ืื•ืช ืชืงืคื™ื ืœืชื™ืง.")
384
+ continue
385
+ ep_weights = np.array(list(portfolio.values())) / 100
386
+ if ep_returns.shape[1] != len(ep_weights):
387
+ try:
388
+ ep_df_aligned = ep_df[list(portfolio.keys())]
389
+ ep_returns = ep_df_aligned.pct_change().dropna()
390
+ except Exception:
391
+ st.info(f"{label_ep}: ืื™ ื”ืชืืžื” ื‘ื™ืŸ ืžืฉืงื•ืœื•ืช ืœืขืžื•ื“ื•ืช ื ืชื•ื ื™ื.")
392
+ continue
393
+ ep_port_ret = (ep_returns * ep_weights).sum(axis=1)
394
+ ep_cum = (1 + ep_port_ret).cumprod()
395
+ ep_total_return = (ep_cum.iloc[-1] - 1) * 100
396
+ ep_vol = ep_port_ret.std() * np.sqrt(252) * 100
397
+ ep_sharpe = (ep_port_ret.mean() * 252 - risk_free_rate) / (ep_port_ret.std() * np.sqrt(252))
398
+ ep_log = np.log1p(ep_port_ret)
399
+ ep_t = len(ep_log)
400
+ mu_ep = ep_log.mean()
401
+ sig_ep = ep_log.std()
402
+ if sig_ep == 0:
403
+ p_no_loss_ep = 1.0 if mu_ep > 0 else 0.0
404
+ else:
405
+ mean_sum_ep = mu_ep * ep_t
406
+ std_sum_ep = sig_ep * np.sqrt(ep_t)
407
+ z_ep = (0 - mean_sum_ep) / std_sum_ep
408
+ cdf_ep = 0.5 * (1 + erf(z_ep / msqrt(2)))
409
+ p_no_loss_ep = 1 - cdf_ep
410
+ # Benchmark metrics
411
+ ep_bench_series = pd.Series(ep_bench_data[benchmark]).dropna()
412
+ ep_bench_ret = ep_bench_series.pct_change().dropna()
413
+ if ep_bench_ret.empty:
414
+ st.info(f"{label_ep}: ืื™ืŸ ื ืชื•ื ื™ ืชืฉื•ืื•ืช ืชืงืคื™ื ืœ-Benchmark.")
415
+ continue
416
+ ep_bench_cum = (1 + ep_bench_ret).cumprod()
417
+ ep_bench_total_return = (ep_bench_cum.iloc[-1] - 1) * 100
418
+ ep_bench_vol = ep_bench_ret.std() * np.sqrt(252) * 100
419
+ ep_bench_sharpe = (ep_bench_ret.mean() * 252 - risk_free_rate) / (ep_bench_ret.std() * np.sqrt(252))
420
+ ep_bench_log = np.log1p(ep_bench_ret)
421
+ ep_bt = len(ep_bench_log)
422
+ mu_b = ep_bench_log.mean()
423
+ sig_b = ep_bench_log.std()
424
+ if sig_b == 0:
425
+ p_no_loss_bench = 1.0 if mu_b > 0 else 0.0
426
+ else:
427
+ mean_sum_b = mu_b * ep_bt
428
+ std_sum_b = sig_b * np.sqrt(ep_bt)
429
+ z_b = (0 - mean_sum_b) / std_sum_b
430
+ cdf_b = 0.5 * (1 + erf(z_b / msqrt(2)))
431
+ p_no_loss_bench = 1 - cdf_b
432
+ # Display side-by-side
433
+ st.markdown(f"**{label_ep}**")
434
+ c1, c2, c3, c4, c5 = st.columns(5)
435
+ with c1:
436
+ st.markdown("**ืžื“ื“**")
437
+ st.write("ืชืฉื•ืื” ื›ื•ืœืœืช")
438
+ st.write("Sharpe Ratio")
439
+ st.write("ืชื ื•ื“ืชื™ื•ืช")
440
+ st.write("ืกื™ื›ื•ื™ ืœื ืœื”ืคืกื™ื“")
441
+ with c2:
442
+ st.markdown("**ืชื™ืง**")
443
+ st.write(f"{ep_total_return:.2f}%")
444
+ st.write(f"{ep_sharpe:.2f}")
445
+ st.write(f"{ep_vol:.2f}%")
446
+ st.write(f"{p_no_loss_ep*100:.1f}%")
447
+ with c3:
448
+ st.markdown("**Benchmark**")
449
+ st.write(f"{ep_bench_total_return:.2f}%")
450
+ st.write(f"{ep_bench_sharpe:.2f}")
451
+ st.write(f"{ep_bench_vol:.2f}%")
452
+ st.write(f"{p_no_loss_bench*100:.1f}%")
453
+ with c4:
454
+ st.markdown("**ื”ืคืจืฉ (ืชื™ืง - Benchmark)**")
455
+ st.write(f"{ep_total_return - ep_bench_total_return:.2f}%")
456
+ st.write(f"{ep_sharpe - ep_bench_sharpe:.2f}")
457
+ st.write(f"{ep_vol - ep_bench_vol:.2f}%")
458
+ st.write(f"{(p_no_loss_ep - p_no_loss_bench)*100:.1f}%")
459
+ with c5:
460
+ st.empty()
461
+
462
+ # PDF Export
463
+ st.subheader("ื™ื™ืฆื•ื PDF")
464
+
465
+ def generate_pdf():
466
+ buffer = io.BytesIO()
467
+ doc = SimpleDocTemplate(buffer, pagesize=letter)
468
+ story = []
469
+
470
+ # Title
471
+ styles = getSampleStyleSheet()
472
+ title = Paragraph("ื“ื•ื— ื‘ื™ืฆื•ืขื™ ืชื™ืง ื”ืฉืงืขื•ืช - WhatIfWealth", styles['Title'])
473
+ story.append(title)
474
+ story.append(Spacer(1, 12))
475
+
476
+ # Summary
477
+ summary = Paragraph(f"""
478
+ <b>ืกื™ื›ื•ื:</b><br/>
479
+ ืชืืจื™ืš ื”ืชื—ืœื”: {start_date}<br/>
480
+ ืชืืจื™ืš ืกื™ื•ื: {end_date}<br/>
481
+ ืชืฉื•ืื” ื›ื•ืœืœืช: {total_return:.2f}%<br/>
482
+ Benchmark: {benchmark} ({benchmark_total_return:.2f}%)<br/>
483
+ """, styles['Normal'])
484
+ story.append(summary)
485
+ story.append(Spacer(1, 12))
486
+
487
+ # Portfolio composition
488
+ story.append(Paragraph("<b>ื”ืจื›ื‘ ื”ืชื™ืง:</b>", styles['Heading2']))
489
+ portfolio_data_for_table = [['ืžื ื™ื”', 'ืื—ื•ื– ื”ืฉืงืขื”', 'ืชืฉื•ืื” ื›ื•ืœืœืช']]
490
+ for ticker, weight in portfolio.items():
491
+ ticker_return = ((portfolio_df[ticker].iloc[-1] / portfolio_df[ticker].iloc[0]) - 1) * 100
492
+ portfolio_data_for_table.append([ticker, f"{weight}%", f"{ticker_return:.2f}%"])
493
+
494
+ portfolio_table = Table(portfolio_data_for_table)
495
+ portfolio_table.setStyle(TableStyle([
496
+ ('BACKGROUND', (0, 0), (-1, 0), colors.grey),
497
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
498
+ ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
499
+ ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
500
+ ('FONTSIZE', (0, 0), (-1, 0), 14),
501
+ ('BOTTOMPADDING', (0, 0), (-1, 0), 12),
502
+ ('BACKGROUND', (0, 1), (-1, -1), colors.beige),
503
+ ('GRID', (0, 0), (-1, -1), 1, colors.black)
504
+ ]))
505
+ story.append(portfolio_table)
506
+ story.append(Spacer(1, 12))
507
+
508
+ # Performance metrics
509
+ story.append(Paragraph("<b>ืžื“ื“ื™ ื‘ื™ืฆื•ืข:</b>", styles['Heading2']))
510
+ metrics_data_for_table = [['ืžื“ื“', 'ืชื™ืง ื”ืฉืงืขื•ืช', f'Benchmark ({benchmark})', 'ื”ืคืจืฉ']]
511
+ metrics_data_for_table.extend([
512
+ ['ืชืฉื•ืื” ื›ื•ืœืœืช', f"{total_return:.2f}%", f"{benchmark_total_return:.2f}%", f"{total_return - benchmark_total_return:.2f}%"],
513
+ ['ืชืฉื•ืื” ืฉื ืชื™ืช', f"{volatility:.2f}%", f"{benchmark_volatility:.2f}%", f"{volatility - benchmark_volatility:.2f}%"],
514
+ ['Sharpe Ratio', f"{sharpe_ratio:.2f}", f"{benchmark_sharpe:.2f}", f"{sharpe_ratio - benchmark_sharpe:.2f}"],
515
+ ['Max Drawdown', f"{max_drawdown:.2f}%", f"{benchmark_max_drawdown:.2f}%", f"{max_drawdown - benchmark_max_drawdown:.2f}%"]
516
+ ])
517
+
518
+ metrics_table = Table(metrics_data_for_table)
519
+ metrics_table.setStyle(TableStyle([
520
+ ('BACKGROUND', (0, 0), (-1, 0), colors.grey),
521
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
522
+ ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
523
+ ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
524
+ ('FONTSIZE', (0, 0), (-1, 0), 14),
525
+ ('BOTTOMPADDING', (0, 0), (-1, 0), 12),
526
+ ('BACKGROUND', (0, 1), (-1, -1), colors.beige),
527
+ ('GRID', (0, 0), (-1, -1), 1, colors.black)
528
+ ]))
529
+ story.append(metrics_table)
530
+
531
+ doc.build(story)
532
+ buffer.seek(0)
533
+ return buffer
534
+
535
+ pdf_buffer = generate_pdf()
536
+ st.download_button(
537
+ label="ื”ื•ืจื“ ื“ื•ื— PDF",
538
+ data=pdf_buffer.getvalue(),
539
+ file_name=f"portfolio_analysis_{start_date}_{end_date}.pdf",
540
+ mime="application/pdf"
541
+ )
542
+
543
+ elif run_analysis:
544
+ if not portfolio:
545
+ st.error("ืื ื ื”ื›ื ืก ืœืคื—ื•ืช ืžื ื™ื” ืื—ืช")
546
+ elif total_percentage != 100:
547
+ st.error(f"ืกื”ืดื› ื”ืื—ื•ื–ื™ื ืฆืจื™ืš ืœื”ื™ื•ืช 100%. ื›ืจื’ืข: {total_percentage}%")
548
+
549
+ # Portfolio optimization section
550
+ if suggest_combination and portfolio and total_percentage == 100:
551
+ with st.spinner("ืžื—ืคืฉ ืฉื™ืœื•ื‘ื™ื ืื•ืคื˜ื™ืžืœื™ื™ื ืœืชืงื•ืคื•ืช ืฉื•ื ื•ืช..."):
552
+ periods = {
553
+ '3 ื—ื•ื“ืฉื™ื': 63, # ~63 trading days
554
+ '6 ื—ื•ื“ืฉื™ื': 126, # ~126 trading days
555
+ '1 ืฉื ื”': 252, # ~252 trading days
556
+ '2 ืฉื ื™ื': 504, # ~504 trading days
557
+ '5 ืฉื ื™ื': 1260, # ~1260 trading days
558
+ '10 ืฉื ื™ื': 2520, # ~2520 trading days
559
+ '15 ืฉื ื™ื': 3780 # ~3780 trading days
560
+ }
561
+ results = {}
562
+ for label, days in periods.items():
563
+ # Limit the date range for each period
564
+ period_end = pd.Timestamp(end_date)
565
+ period_start = period_end - pd.Timedelta(days=days)
566
+ # Fetch data for user stocks only
567
+ @st.cache_data
568
+ def fetch_period_data(stocks, start, end):
569
+ data = {}
570
+ for stock in stocks:
571
+ try:
572
+ ticker = yf.Ticker(stock)
573
+ hist = ticker.history(start=start, end=end)
574
+ if not hist.empty:
575
+ data[stock] = hist['Close']
576
+ except Exception as e:
577
+ continue
578
+ return data
579
+ period_data = fetch_period_data(list(portfolio.keys()), period_start, period_end)
580
+ if len(period_data) < 2:
581
+ continue
582
+ returns_df = pd.DataFrame(period_data).pct_change().dropna()
583
+ best_score = -999
584
+ best_portfolio = None
585
+ best_metrics = None
586
+ trading_days = len(returns_df)
587
+ # Build baseline weights vector aligned to available columns and normalized to 100
588
+ cols = list(returns_df.columns)
589
+ baseline_raw = np.array([portfolio.get(sym, 0) for sym in cols], dtype=float)
590
+ baseline_sum = baseline_raw.sum()
591
+ if baseline_sum == 0:
592
+ baseline_weights = np.zeros_like(baseline_raw)
593
+ else:
594
+ baseline_weights = baseline_raw / baseline_sum * 100
595
+ # Build locking mask aligned to available columns
596
+ locked_mask = np.array([1 if sym in locked_tickers else 0 for sym in cols], dtype=int)
597
+ locked_weights = baseline_weights * locked_mask
598
+ locked_total = locked_weights.sum()
599
+ all_locked = int((locked_mask == 1).all())
600
+ for i in range(5000):
601
+ if all_locked:
602
+ # No suggestion possible if everything is locked
603
+ continue
604
+ # Randomize only the unlocked portion and normalize to remaining budget
605
+ rand = np.random.random(len(cols))
606
+ rand = rand * (1 - locked_mask) # zero for locked
607
+ if rand.sum() == 0:
608
+ # if random produced zeros for all unlocked, try again
609
+ continue
610
+ rand = rand / rand.sum() * max(0.0, 100.0 - locked_total)
611
+ weights = locked_weights + rand
612
+ portfolio_returns = (returns_df * (weights / 100)).sum(axis=1)
613
+ risk_free_rate = 0.02
614
+ sharpe = (portfolio_returns.mean() * 252 - risk_free_rate) / (portfolio_returns.std() * np.sqrt(252))
615
+ total_return = ((1 + portfolio_returns).cumprod().iloc[-1] - 1) * 100
616
+ volatility = portfolio_returns.std() * np.sqrt(252) * 100
617
+ cumulative = (1 + portfolio_returns).cumprod()
618
+ rolling_max = cumulative.expanding().max()
619
+ drawdown = (cumulative - rolling_max) / rolling_max
620
+ max_dd = drawdown.min() * 100
621
+ # Estimate probability of not losing money over the period using normal approximation on log-returns
622
+ log_returns = np.log1p(portfolio_returns)
623
+ mu_log = log_returns.mean()
624
+ sigma_log = log_returns.std()
625
+ if sigma_log == 0:
626
+ p_no_loss = 1.0 if mu_log > 0 else 0.0
627
+ else:
628
+ mean_sum = mu_log * trading_days
629
+ std_sum = sigma_log * np.sqrt(trading_days)
630
+ z = (0 - mean_sum) / std_sum
631
+ # Standard normal CDF via erf
632
+ cdf = 0.5 * (1 + erf(z / msqrt(2)))
633
+ p_no_loss = 1 - cdf
634
+ # Filter by user-selected safety level
635
+ if p_no_loss * 100 < safety_level:
636
+ continue
637
+ # Enforce max 45% total change (L1 distance in percentage points)
638
+ l1_change = float(np.abs(weights - baseline_weights).sum())
639
+ if l1_change > 45:
640
+ continue
641
+ # Score: 60% Sharpe, 40% total return
642
+ score = sharpe * 0.6 + (total_return / 100) * 0.4
643
+ if score > best_score:
644
+ best_score = score
645
+ best_portfolio = dict(zip(returns_df.columns, weights))
646
+ best_metrics = {
647
+ 'sharpe': sharpe,
648
+ 'total_return': total_return,
649
+ 'volatility': volatility,
650
+ 'max_drawdown': max_dd,
651
+ 'p_no_loss': p_no_loss * 100
652
+ }
653
+ if best_portfolio:
654
+ results[label] = {
655
+ 'portfolio': best_portfolio,
656
+ 'metrics': best_metrics
657
+ }
658
+ if results:
659
+ st.success("โœ… ื ืžืฆืื• ื”ืžืœืฆื•ืช ืื•ืคื˜ื™ืžืœื™ื•ืช ืœื›ืœ ื”ืชืงื•ืคื•ืช!")
660
+ for label, res in results.items():
661
+ st.subheader(f"{label} - ื”ืฉื™ืœื•ื‘ ื”ืžื•ืžืœืฅ")
662
+ df = pd.DataFrame({
663
+ 'ืžื ื™ื”': list(res['portfolio'].keys()),
664
+ 'ืื—ื•ื– ื”ืฉืงืขื” ืžื•ืฆืข': [f"{w:.1f}%" for w in res['portfolio'].values()]
665
+ })
666
+ st.dataframe(df, use_container_width=True)
667
+ col1, col2, col3, col4, col5 = st.columns(5)
668
+ with col1:
669
+ st.metric("Sharpe Ratio", f"{res['metrics']['sharpe']:.2f}")
670
+ with col2:
671
+ st.metric("ืชืฉื•ืื” ื›ื•ืœืœืช", f"{res['metrics']['total_return']:.2f}%")
672
+ with col3:
673
+ st.metric("ืชื ื•ื“ืชื™ื•ืช", f"{res['metrics']['volatility']:.2f}%")
674
+ with col4:
675
+ st.metric("Max Drawdown", f"{res['metrics']['max_drawdown']:.2f}%")
676
+ with col5:
677
+ st.metric("ืกื™ื›ื•ื™ ืœื ืœื”ืคืกื™ื“", f"{res['metrics']['p_no_loss']:.1f}%")
678
+ else:
679
+ st.error("ืœื ื ื™ืชืŸ ืœื‘ืฆืข ืื•ืคื˜ื™ืžื™ื–ืฆื™ื” - ื ื“ืจืฉื•ืช ืœืคื—ื•ืช 2 ืžื ื™ื•ืช ืขื ื ืชื•ื ื™ื ื–ืžื™ื ื™ื ื‘ื›ืœ ืชืงื•ืคื”")
680
+
681
+ elif suggest_combination:
682
+ if not portfolio:
683
+ st.error("ืื ื ื”ื›ื ืก ืœืคื—ื•ืช ืžื ื™ื” ืื—ืช")
684
+ elif total_percentage != 100:
685
+ st.error(f"ืกื”ืดื› ื”ืื—ื•ื–ื™ื ืฆืจื™ืš ืœื”ื™ื•ืช 100%. ื›ืจื’ืข: {total_percentage}%")
686
+
687
 
688
+ # Footer
689
+ st.markdown("---")
690
+ st.markdown("**WhatIfWealth** - ื›ืœื™ ืœื ื™ืชื•ื— ื‘ื™ืฆื•ืขื™ ืชื™ืง ื”ืฉืงืขื•ืช ื”ื™ืกื˜ื•ืจื™")