CJRobert commited on
Commit
7dd9f44
·
verified ·
1 Parent(s): 1ef9197

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +394 -38
src/streamlit_app.py CHANGED
@@ -1,40 +1,396 @@
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 yfinance as yf
3
+ import pandas as pd
4
+ import ta
5
+ import plotly.graph_objects as go
6
+ from plotly.subplots import make_subplots
7
+ from datetime import datetime
8
+ import io
9
+
10
+ # 設定頁面配置
11
+ st.set_page_config(
12
+ page_title="股票技術分析系統",
13
+ page_icon="📈",
14
+ layout="wide"
15
+ )
16
+
17
+ def analyze_stock(ticker_input):
18
+ """分析單一股票的技術指標"""
19
+ try:
20
+ ticker = yf.Ticker(ticker_input)
21
+ df = ticker.history(period="200d")
22
+ if df.empty:
23
+ return None, f"無法取得 {ticker_input} 的資料,請確認股票代碼是否正確。"
24
+ except Exception as e:
25
+ return None, f"取得 {ticker_input} 資料時發生錯誤: {e}"
26
+
27
+ # 處理時區和數據
28
+ df.index = df.index.tz_localize(None)
29
+ df = df.tail(200)
30
+
31
+ # 計算技術指標
32
+ df['MA5'] = df['Close'].rolling(window=5).mean()
33
+ df['MA10'] = df['Close'].rolling(window=10).mean()
34
+ df['MA20'] = df['Close'].rolling(window=20).mean()
35
+ df['RSI'] = ta.momentum.RSIIndicator(df['Close']).rsi()
36
+ df['OBV'] = ta.volume.OnBalanceVolumeIndicator(df['Close'], df['Volume']).on_balance_volume()
37
+
38
+ # 布林帶
39
+ bb = ta.volatility.BollingerBands(df['Close'])
40
+ df['BB_High'] = bb.bollinger_hband()
41
+ df['BB_Low'] = bb.bollinger_lband()
42
+ df['%B'] = (df['Close'] - df['BB_Low']) / (df['BB_High'] - df['BB_Low'])
43
+ df['BBW'] = (df['BB_High'] - df['BB_Low']) / df['MA20']
44
+
45
+ # KD指標
46
+ kd = ta.momentum.StochasticOscillator(df['High'], df['Low'], df['Close'])
47
+ df['K'] = kd.stoch()
48
+ df['D'] = kd.stoch_signal()
49
+
50
+ # MACD
51
+ macd = ta.trend.MACD(df['Close'])
52
+ df['MACD'] = macd.macd()
53
+ df['MACD_Signal'] = macd.macd_signal()
54
+ df['MACD_Diff'] = macd.macd_diff()
55
+
56
+ # CCI和黃金交叉
57
+ df['CCI'] = ta.trend.CCIIndicator(df['High'], df['Low'], df['Close']).cci()
58
+ df['Golden_Cross'] = df['MA5'] > df['MA20']
59
+
60
+ return df, None
61
+
62
+ def create_stock_chart(df, ticker):
63
+ """創建股票圖表"""
64
+ fig = make_subplots(
65
+ rows=4, cols=1,
66
+ subplot_titles=('價格與移動平均線', 'RSI', 'MACD', 'KD指標'),
67
+ vertical_spacing=0.08,
68
+ row_heights=[0.5, 0.2, 0.2, 0.2]
69
+ )
70
+
71
+ # 價格與移動平均線
72
+ fig.add_trace(go.Candlestick(
73
+ x=df.index,
74
+ open=df['Open'],
75
+ high=df['High'],
76
+ low=df['Low'],
77
+ close=df['Close'],
78
+ name='價格'
79
+ ), row=1, col=1)
80
+
81
+ fig.add_trace(go.Scatter(x=df.index, y=df['MA5'], name='MA5', line=dict(color='orange')), row=1, col=1)
82
+ fig.add_trace(go.Scatter(x=df.index, y=df['MA10'], name='MA10', line=dict(color='blue')), row=1, col=1)
83
+ fig.add_trace(go.Scatter(x=df.index, y=df['MA20'], name='MA20', line=dict(color='red')), row=1, col=1)
84
+ fig.add_trace(go.Scatter(x=df.index, y=df['BB_High'], name='布林帶上軌', line=dict(color='gray', dash='dash')), row=1, col=1)
85
+ fig.add_trace(go.Scatter(x=df.index, y=df['BB_Low'], name='布林帶下軌', line=dict(color='gray', dash='dash')), row=1, col=1)
86
+
87
+ # RSI
88
+ fig.add_trace(go.Scatter(x=df.index, y=df['RSI'], name='RSI', line=dict(color='purple')), row=2, col=1)
89
+ fig.add_hline(y=70, line_dash="dash", line_color="red", row=2, col=1)
90
+ fig.add_hline(y=30, line_dash="dash", line_color="green", row=2, col=1)
91
+
92
+ # MACD
93
+ fig.add_trace(go.Scatter(x=df.index, y=df['MACD'], name='MACD', line=dict(color='blue')), row=3, col=1)
94
+ fig.add_trace(go.Scatter(x=df.index, y=df['MACD_Signal'], name='MACD Signal', line=dict(color='red')), row=3, col=1)
95
+ fig.add_trace(go.Bar(x=df.index, y=df['MACD_Diff'], name='MACD Histogram', marker_color='gray'), row=3, col=1)
96
+
97
+ # KD指標
98
+ fig.add_trace(go.Scatter(x=df.index, y=df['K'], name='K', line=dict(color='blue')), row=4, col=1)
99
+ fig.add_trace(go.Scatter(x=df.index, y=df['D'], name='D', line=dict(color='red')), row=4, col=1)
100
+ fig.add_hline(y=80, line_dash="dash", line_color="red", row=4, col=1)
101
+ fig.add_hline(y=20, line_dash="dash", line_color="green", row=4, col=1)
102
+
103
+ fig.update_layout(
104
+ title=f'{ticker} 技術分析圖表',
105
+ height=800,
106
+ showlegend=True,
107
+ xaxis_rangeslider_visible=False
108
+ )
109
+
110
+ return fig
111
+
112
+ def get_investment_advice(df, ticker):
113
+ """獲取投資建議"""
114
+ advice_list = []
115
+ latest_data = df.iloc[-1]
116
+
117
+ # RSI建議
118
+ if latest_data['RSI'] < 30:
119
+ advice_list.append({
120
+ 'type': '買進建議',
121
+ 'reason': f'RSI = {latest_data["RSI"]:.2f} < 30 (超賣)',
122
+ 'color': 'success'
123
+ })
124
+ elif latest_data['RSI'] > 70:
125
+ advice_list.append({
126
+ 'type': '賣出建議',
127
+ 'reason': f'RSI = {latest_data["RSI"]:.2f} > 70 (超買)',
128
+ 'color': 'error'
129
+ })
130
+
131
+ # %B建議
132
+ if latest_data['%B'] < 0:
133
+ advice_list.append({
134
+ 'type': '買進建議',
135
+ 'reason': f'%B = {latest_data["%B"]:.2f} < 0 (價格低於布林帶下軌)',
136
+ 'color': 'success'
137
+ })
138
+ elif latest_data['%B'] > 1:
139
+ advice_list.append({
140
+ 'type': '賣出建議',
141
+ 'reason': f'%B = {latest_data["%B"]:.2f} > 1 (價格高於布林帶上軌)',
142
+ 'color': 'error'
143
+ })
144
+
145
+ # 黃金交叉
146
+ if latest_data['Golden_Cross']:
147
+ advice_list.append({
148
+ 'type': '看多信號',
149
+ 'reason': '短期均線突破長期均線 (黃金交叉)',
150
+ 'color': 'info'
151
+ })
152
+
153
+ return advice_list
154
+
155
+ def create_excel_download(df_dict):
156
+ """創建Excel下載檔案"""
157
+ from openpyxl import Workbook
158
+ from openpyxl.styles import PatternFill
159
+ from openpyxl.utils.dataframe import dataframe_to_rows
160
+
161
+ wb = Workbook()
162
+ wb.remove(wb.active)
163
+
164
+ yellow_fill = PatternFill(start_color="FFFF00", end_color="FFFF00", fill_type="solid")
165
+ red_fill = PatternFill(start_color="FF0000", end_color="FF0000", fill_type="solid")
166
+ green_fill = PatternFill(start_color="00FF00", end_color="00FF00", fill_type="solid")
167
+ purple_fill = PatternFill(start_color="800080", end_color="800080", fill_type="solid")
168
+
169
+ advice_rows = []
170
+
171
+ for ticker_input, df in df_dict.items():
172
+ ws = wb.create_sheet(title=ticker_input)
173
+ ws.append(['股票代碼:', ticker_input])
174
+ ws.append(['分析日期:', datetime.now().strftime('%Y-%m-%d %H:%M:%S')])
175
+ ws.append([])
176
+
177
+ for r in dataframe_to_rows(df, index=True, header=True):
178
+ ws.append(r)
179
+
180
+ # 格式化和標色邏輯(保持原有邏輯)
181
+ header_row = 4
182
+ headers = {cell.value: cell.column for cell in ws[header_row]}
183
+ rsi_col = headers.get('RSI')
184
+ percentage_b_col = headers.get('%B')
185
+
186
+ if rsi_col and percentage_b_col:
187
+ for row in range(header_row + 1, ws.max_row + 1):
188
+ rsi_val = ws.cell(row=row, column=rsi_col).value
189
+ pct_b_val = ws.cell(row=row, column=percentage_b_col).value
190
+
191
+ if rsi_val is not None:
192
+ rsi_cell = ws.cell(row=row, column=rsi_col)
193
+ if rsi_val < 20:
194
+ rsi_cell.fill = yellow_fill
195
+ elif rsi_val > 70:
196
+ rsi_cell.fill = red_fill
197
+
198
+ if pct_b_val is not None:
199
+ b_cell = ws.cell(row=row, column=percentage_b_col)
200
+ if pct_b_val < 0:
201
+ b_cell.fill = green_fill
202
+ elif pct_b_val > 1:
203
+ b_cell.fill = purple_fill
204
+
205
+ # 儲存到記憶體
206
+ output = io.BytesIO()
207
+ wb.save(output)
208
+ output.seek(0)
209
+ return output.getvalue()
210
+
211
+ # Streamlit 應用程式主體
212
+ def main():
213
+ st.title("📈 股票技術分析系統")
214
+ st.markdown("---")
215
+
216
+ # 側邊欄設定
217
+ st.sidebar.header("分析設定")
218
+
219
+ # 選擇分析模式
220
+ analysis_mode = st.sidebar.radio(
221
+ "選擇分析模式",
222
+ ["單一股票分析", "批量股票分析", "從Google Sheets匯入"]
223
+ )
224
+
225
+ if analysis_mode == "單一股票分析":
226
+ st.header("🎯 單一股票分析")
227
+
228
+ ticker_input = st.text_input("請輸入股票代碼 (例如: AAPL, 2330.TW):", value="AAPL")
229
+
230
+ if st.button("開始分析", type="primary"):
231
+ if ticker_input:
232
+ with st.spinner(f"正在分析 {ticker_input} ..."):
233
+ df, error = analyze_stock(ticker_input)
234
+
235
+ if error:
236
+ st.error(error)
237
+ else:
238
+ st.success(f"成功分析 {ticker_input}")
239
+
240
+ # 顯示基本資訊
241
+ col1, col2, col3, col4 = st.columns(4)
242
+ latest_data = df.iloc[-1]
243
+
244
+ with col1:
245
+ st.metric("最新收盤價", f"${latest_data['Close']:.2f}")
246
+ with col2:
247
+ st.metric("RSI", f"{latest_data['RSI']:.2f}")
248
+ with col3:
249
+ st.metric("%B", f"{latest_data['%B']:.2f}")
250
+ with col4:
251
+ st.metric("成交量", f"{latest_data['Volume']:,}")
252
+
253
+ # 顯示圖表
254
+ fig = create_stock_chart(df, ticker_input)
255
+ st.plotly_chart(fig, use_container_width=True)
256
+
257
+ # 投資建議
258
+ st.subheader("💡 投資建議")
259
+ advice_list = get_investment_advice(df, ticker_input)
260
+
261
+ if advice_list:
262
+ for advice in advice_list:
263
+ if advice['color'] == 'success':
264
+ st.success(f"**{advice['type']}**: {advice['reason']}")
265
+ elif advice['color'] == 'error':
266
+ st.error(f"**{advice['type']}**: {advice['reason']}")
267
+ else:
268
+ st.info(f"**{advice['type']}**: {advice['reason']}")
269
+ else:
270
+ st.info("目前沒有明確的買賣建議")
271
+
272
+ # 顯示詳細數據
273
+ with st.expander("查看詳細數據"):
274
+ st.dataframe(df.tail(20))
275
+
276
+ elif analysis_mode == "批量股票分析":
277
+ st.header("📊 批量股票分析")
278
+
279
+ ticker_text = st.text_area(
280
+ "請輸入股票代碼 (每行一個):",
281
+ value="AAPL\nMSFT\nGOOGL\n2330.TW\n2317.TW",
282
+ height=150
283
+ )
284
+
285
+ if st.button("開始批量分析", type="primary"):
286
+ tickers = [ticker.strip() for ticker in ticker_text.split('\n') if ticker.strip()]
287
+
288
+ if tickers:
289
+ progress_bar = st.progress(0)
290
+ status_text = st.empty()
291
+ all_data = {}
292
+
293
+ for i, ticker in enumerate(tickers):
294
+ status_text.text(f"正在分析 {ticker} ({i+1}/{len(tickers)})")
295
+ progress_bar.progress((i + 1) / len(tickers))
296
+
297
+ df, error = analyze_stock(ticker)
298
+ if error:
299
+ st.warning(f"{ticker}: {error}")
300
+ else:
301
+ all_data[ticker] = df
302
+
303
+ status_text.text("分析完成!")
304
+
305
+ if all_data:
306
+ st.success(f"成功分析 {len(all_data)} 支股票")
307
+
308
+ # 顯示摘要表格
309
+ summary_data = []
310
+ for ticker, df in all_data.items():
311
+ latest = df.iloc[-1]
312
+ summary_data.append({
313
+ '股票代碼': ticker,
314
+ '最新價格': f"${latest['Close']:.2f}",
315
+ 'RSI': f"{latest['RSI']:.2f}",
316
+ '%B': f"{latest['%B']:.2f}",
317
+ '黃金交叉': '是' if latest['Golden_Cross'] else '否'
318
+ })
319
+
320
+ summary_df = pd.DataFrame(summary_data)
321
+ st.subheader("📋 分析摘要")
322
+ st.dataframe(summary_df, use_container_width=True)
323
+
324
+ # 提供Excel下載
325
+ excel_data = create_excel_download(all_data)
326
+ st.download_button(
327
+ label="📥 下載完整分析報告 (Excel)",
328
+ data=excel_data,
329
+ file_name=f"技術分析總表_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx",
330
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
331
+ )
332
+
333
+ else: # Google Sheets 匯入
334
+ st.header("📑 從Google Sheets匯入")
335
+
336
+ sheet_id = st.text_input(
337
+ "請輸入Google Sheets ID:",
338
+ value="1CZlw53z9Fns3XCQmlY_PXF2qOv2Wyqyn7mRrIzDRQMg"
339
+ )
340
+
341
+ if st.button("從Google Sheets匯入並分析", type="primary"):
342
+ try:
343
+ sheet_url = f"https://docs.google.com/spreadsheets/d/{sheet_id}/export?format=csv"
344
+ df_tickers = pd.read_csv(sheet_url)
345
+ tickers = df_tickers["股號"].dropna().astype(str).tolist()
346
+
347
+ st.info(f"從Google Sheets讀取到 {len(tickers)} 個股票代碼")
348
+
349
+ progress_bar = st.progress(0)
350
+ status_text = st.empty()
351
+ all_data = {}
352
+
353
+ for i, ticker in enumerate(tickers):
354
+ status_text.text(f"正在分析 {ticker} ({i+1}/{len(tickers)})")
355
+ progress_bar.progress((i + 1) / len(tickers))
356
+
357
+ df, error = analyze_stock(ticker)
358
+ if error:
359
+ st.warning(f"{ticker}: {error}")
360
+ else:
361
+ all_data[ticker] = df
362
+
363
+ status_text.text("分析完成!")
364
+
365
+ if all_data:
366
+ st.success(f"成功分析 {len(all_data)} 支股票")
367
+
368
+ # Excel下載
369
+ excel_data = create_excel_download(all_data)
370
+ st.download_button(
371
+ label="📥 下載完整分析報告 (Excel)",
372
+ data=excel_data,
373
+ file_name=f"技術分析總表_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx",
374
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
375
+ )
376
+
377
+ except Exception as e:
378
+ st.error(f"讀取Google Sheets時發生錯誤: {e}")
379
+
380
+ # 說明資訊
381
+ with st.sidebar.expander("📖 使用說明"):
382
+ st.markdown("""
383
+ **技術指標說明:**
384
+ - **RSI < 30**: 超賣,考慮買進
385
+ - **RSI > 70**: 超買,考慮賣出
386
+ - **%B < 0**: 價格低於布林帶下軌
387
+ - **%B > 1**: 價格高於布林帶上軌
388
+ - **黃金交叉**: 短期均線突破長期均線
389
+
390
+ **股票代碼格式:**
391
+ - 美股: AAPL, MSFT, GOOGL
392
+ - 台股: 2330.TW, 2317.TW
393
+ """)
394
 
395
+ if __name__ == "__main__":
396
+ main()