AlanRex commited on
Commit
944a027
·
verified ·
1 Parent(s): 9a0b168

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +506 -764
app.py CHANGED
@@ -1,5 +1,4 @@
1
  # HUGING_FACE_V3.1.1.py (多頁面版本)
2
-
3
  # 系統套件
4
  import os
5
  from datetime import datetime, timedelta
@@ -14,36 +13,23 @@ from plotly.subplots import make_subplots
14
  import re
15
  from bs4 import BeautifulSoup
16
  import requests
17
-
18
- # 引用您組員的預測器程式 (假設路徑正確)
19
- # from Bert_predict import BertPredictor
20
- # 為了讓程式碼可以獨立執行,這裡暫時移除對 BertPredictor 的引用
21
- # 如果您有 BertPredictor.py,請取消上面這行的註解,並確保它在同一個目錄下
22
- # 並且為 predictor 變數提供一個模擬值,以便程式能順利執行
23
- class BertPredictor:
24
- def __init__(self, max_news_per_keyword=5):
25
- print("模擬 BertPredictor 初始化...")
26
- self.max_news_per_keyword = max_news_per_keyword
27
- self.mock_sentiment_score = np.random.uniform(-1, 1) # 模擬一個隨機情緒分數
28
- self.mock_news = [
29
- "模擬新聞標題 1:市場樂觀情緒高漲。",
30
- "模擬新聞標題 2:某公司財報亮眼,股價預期上漲。",
31
- "模擬新聞標題 3:經濟數據顯示復甦跡象。"
32
- ]
33
-
34
- def get_news_index(self):
35
- print(f"模擬獲取新聞情緒分數: {self.mock_sentiment_score:.2f}")
36
- return self.mock_sentiment_score
37
-
38
- def get_news(self):
39
- print(f"模擬獲取新聞列表 (最多 {self.max_news_per_keyword} 則)")
40
- return self.mock_news[:self.max_news_per_keyword]
41
-
42
- predictor = BertPredictor(max_news_per_keyword=5)
43
- print("模擬新聞情緒分析模型初始化完成。")
44
-
45
-
46
- # 台股代號對應表 (移除台指期,因為它現在是獨立區塊)
47
  TAIWAN_STOCKS = {
48
  '元大台灣50': '0050.TW', # 新增
49
  '台積電': '2330.TW',
@@ -60,205 +46,202 @@ TAIWAN_STOCKS = {
60
  '慧洋-KY': '2637.TW',
61
  '上銀': '2049.TW',
62
  '台泥': '1101.TW',
63
- '南亞科': '2408.TW',
64
- '旺宏': '2337.TW',
65
- '譜瑞-KY': '4966.TWO',
66
- '貿聯-KY': '3665.TW',
67
- '騰雲': '6870.TWO',
68
- '穩懋': '3105.TWO'
 
 
 
 
 
69
  }
70
 
71
- # 產業分類
72
  INDUSTRY_MAPPING = {
73
- '0050.TW': 'ETF', # 新增
74
- '2330.TW': '半導體',
75
- '2454.TW': '半導體',
76
- '2317.TW': '電子組件',
77
- '1301.TW': '塑膠',
78
- '2412.TW': '電信',
79
- '2881.TW': '金融',
80
- '2882.TW': '金融',
81
- '2308.TW': '電子',
82
- '1216.TW': '食品',
83
- '3711.TW': '半導體',
84
- '2603.TW': '航運',
85
- '2637.TW': '散裝航運',
86
- '2049.TW': '工具機',
87
- '1101.TW': '營建',
88
- '2408.TW': 'DRAM',
89
- '2337.TW': 'NFLSH',
90
- # '1101.TW': '營建', # 已存在,移除重複
91
- '4966.TWO': '高速傳輸',
92
- '3665.TW': '連接器',
93
- '6870.TWO': '軟體整合',
94
- '3105.TWO': 'PA功率'
95
  }
96
 
97
- def get_stock_data(symbol, period='1y'):
98
- """獲取股票資料"""
99
  try:
100
- stock = yf.Ticker(symbol)
101
  data = stock.history(period=period)
102
- # 處理台指期特殊情況
103
- if data.empty and symbol == '^TWII':
104
- print("嘗試獲取 ^TWII 資料失敗,嘗試獲取 0050.TW...")
105
- stock = yf.Ticker('0050.TW')
106
- data = stock.history(period=period)
107
- if data.empty:
108
- print("嘗試獲取 0050.TW 資料失敗,嘗試獲取 ^TWII...")
109
- stock = yf.Ticker('^TWII') # 再次嘗試 ^TWII,以防萬一
110
- data = stock.history(period=period)
111
  return data
112
  except Exception as e:
113
- print(f"獲取股票資料時發生錯誤 ({symbol}): {e}")
114
  return pd.DataFrame()
115
 
116
- def simple_lstm_predict(data, predict_days=5):
117
- """簡化的LSTM預測模型 (使用統計方法模擬)"""
118
- if len(data) < 60:
119
- return None
120
- prices = data['Close'].values
121
- ma_short = np.mean(prices[-5:])
122
- ma_medium = np.mean(prices[-20:])
123
- ma_long = np.mean(prices[-60:])
124
- recent_trend = np.polyfit(range(20), prices[-20:], 1)[0]
125
- volatility = np.std(prices[-20:]) / np.mean(prices[-20:])
126
- base_change = recent_trend * predict_days
127
- trend_factor = 1.0
128
- if ma_short > ma_medium > ma_long:
129
- trend_factor = 1.02
130
- elif ma_short < ma_medium < ma_long:
131
- trend_factor = 0.98
132
- else:
133
- trend_factor = 1.0
134
- noise_factor = np.random.normal(1, volatility * 0.1)
135
- predicted_price = prices[-1] * trend_factor + base_change + (prices[-1] * noise_factor * 0.01)
136
- change_pct = ((predicted_price - prices[-1]) / prices[-1]) * 100
137
- return {
138
- 'predicted_price': predicted_price,
139
- 'change_pct': change_pct,
140
- 'confidence': max(0.6, 1 - volatility * 2)
141
- }
142
 
143
- def calculate_technical_indicators(df):
144
- """計算技術指標"""
145
- if df.empty: return df
146
- df['MA5'] = df['Close'].rolling(window=5).mean()
147
- df['MA20'] = df['Close'].rolling(window=20).mean()
 
 
 
 
 
 
 
148
  delta = df['Close'].diff()
149
- gain = (delta.where(delta > 0, 0)).rolling(window=14).mean()
150
- loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
151
- # 避免除以零
152
- rs = gain / loss.replace(0, 1e-9)
 
153
  df['RSI'] = 100 - (100 / (1 + rs))
154
- exp1 = df['Close'].ewm(span=12).mean()
155
- exp2 = df['Close'].ewm(span=26).mean()
 
 
156
  df['MACD'] = exp1 - exp2
157
- df['MACD_Signal'] = df['MACD'].ewm(span=9).mean()
158
- df['MACD_Histogram'] = df['MACD'] - df['MACD_Signal']
159
- df['BB_Middle'] = df['Close'].rolling(window=20).mean()
160
- bb_std = df['Close'].rolling(window=20).std()
161
- df['BB_Upper'] = df['BB_Middle'] + (bb_std * 2)
162
- df['BB_Lower'] = df['BB_Middle'] - (bb_std * 2)
163
- low_min = df['Low'].rolling(window=9).min()
164
- high_max = df['High'].rolling(window=9).max()
165
- # 避免除以零
166
- rsv = (df['Close'] - low_min) / (high_max - low_min).replace(0, 1e-9) * 100
167
- df['K'] = rsv.ewm(com=2).mean()
168
- df['D'] = df['K'].ewm(com=2).mean()
169
- low_min_14 = df['Low'].rolling(window=14).min()
170
- high_max_14 = df['High'].rolling(window=14).max()
171
- # 避免除以零
172
- df['Williams_R'] = -100 * (high_max_14 - df['Close']) / (high_max_14 - low_min_14).replace(0, 1e-9)
173
- df['up_move'] = df['High'] - df['High'].shift(1)
174
- df['down_move'] = df['Low'].shift(1) - df['Low']
175
- df['+DM'] = np.where((df['up_move'] > df['down_move']) & (df['up_move'] > 0), df['up_move'], 0)
176
- df['-DM'] = np.where((df['down_move'] > df['up_move']) & (df['down_move'] > 0), df['down_move'], 0)
177
- # TR 計算
178
- df['TR'] = np.maximum.reduce([
179
- df['High'] - df['Low'],
180
- abs(df['High'] - df['Close'].shift(1)),
181
- abs(df['Low'] - df['Close'].shift(1))
182
- ])
183
- # 避免除以零
184
- df['+DI'] = (df['+DM'].ewm(com=13, adjust=False).mean() / df['TR'].ewm(com=13, adjust=False).mean().replace(0, 1e-9)) * 100
185
- df['-DI'] = (df['-DM'].ewm(com=13, adjust=False).mean() / df['TR'].ewm(com=13, adjust=False).mean().replace(0, 1e-9)) * 100
186
- df['DX'] = abs(df['+DI'] - df['-DI']) / (df['+DI'] + df['-DI']).replace(0, 1e-9) * 100
187
- df['ADX'] = df['DX'].ewm(com=13, adjust=False).mean()
188
  return df
189
 
190
- def calculate_volume_profile(df, num_bins=50):
191
- if df.empty or 'High' not in df.columns or 'Low' not in df.columns or 'Volume' not in df.columns: return None, None, None
192
- all_prices = np.concatenate([df['High'].values, df['Low'].values])
193
- min_price, max_price = all_prices.min(), all_prices.max()
194
- price_for_volume = (df['High'] + df['Low'] + df['Close']) / 3
195
- df_vol_profile = df.copy()
196
- df_vol_profile['Price_Indicator'] = price_for_volume
197
- # 確保 range 符合實際數據範圍
198
- hist, bin_edges = np.histogram(df_vol_profile['Price_Indicator'], bins=num_bins, range=(min_price, max_price), weights=df_vol_profile['Volume'])
199
- price_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
200
- return bin_edges, hist, price_centers
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
 
202
- def get_business_climate_data():
203
- """嘗試從 CSV 讀取景氣燈號資料"""
204
- try:
205
- if not os.path.exists('business_climate.csv'):
206
- print("business_climate.csv 檔案不存在,返回空 DataFrame。")
207
- return pd.DataFrame()
208
- df = pd.read_csv('business_climate.csv')
209
- # 嘗試自動識別欄位名稱
210
- if 'Date' not in df.columns and '日期' not in df.columns:
211
- if len(df.columns) == 2:
212
- df.columns = ['Date', 'Index']
213
- print("自動設定景氣燈號欄位為 'Date', 'Index'")
214
- else:
215
- print("景氣燈號 CSV 格式不正確,無法識別 'Date' 或 'Index' 欄位。")
216
- return pd.DataFrame()
217
- else:
218
- if '日期' in df.columns: df = df.rename(columns={'日期': 'Date'})
219
- if '燈號分數' in df.columns: df = df.rename(columns={'燈號分數': 'Index'})
220
-
221
- if 'Date' in df.columns:
222
- try: df['Date'] = pd.to_datetime(df['Date'], errors='coerce')
223
- except Exception as e:
224
- print(f"轉換景氣燈號日期時出錯: {e}")
225
- df['Date'] = pd.to_datetime(df['Date'].str.replace('-01', ''), errors='coerce') # 嘗試移除 -01
226
-
227
- df = df.dropna(subset=['Date', 'Index']) # 確保都有日期和分數
228
- return df
229
- except Exception as e:
230
- print(f"無法獲取景氣燈號資料: {str(e)}")
231
- return pd.DataFrame()
232
 
233
- def get_pmi_data():
234
- """嘗試從 CSV 讀取 PMI 資料"""
235
  try:
236
- if not os.path.exists('taiwan_pmi.csv'):
237
- print("taiwan_pmi.csv 檔案不存在,返回空 DataFrame。")
238
- return pd.DataFrame()
239
- df = pd.read_csv('taiwan_pmi.csv')
240
- if 'DATE' in df.columns: df = df.rename(columns={'DATE': 'Date', 'INDEX': 'Index'})
241
- elif len(df.columns) == 2: df.columns = ['Date', 'Index']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
 
243
- if 'Date' in df.columns:
244
- try: df['Date'] = pd.to_datetime(df['Date'], errors='coerce')
245
- except Exception as e:
246
- print(f"轉換 PMI 日期時出錯: {e}")
247
- df['Date'] = pd.to_datetime(df['Date'].str.replace('-01', ''), errors='coerce') # 嘗試移除 -01
248
 
249
- df = df.dropna(subset=['Date', 'Index']) # 確保都有日期和分數
250
- return df
251
- except Exception as e:
252
- print(f"無法獲取 PMI 資料: {str(e)}")
253
- return pd.DataFrame()
254
-
255
- # --- 頁面佈局定義 ---
256
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  # 首頁:預測與總經
258
  homepage_layout = html.Div([
259
- html.H1("🤖 AI 預測與總體經濟分析", style={'text-align': 'center','color': '#FFCC22','margin-bottom': '25px'}),
260
  html.Div([
261
- html.H2("📈 台指期指數預測", style={'text-align': 'center','color': 'white','margin-bottom': '25px'}),
262
  html.Div([
263
  html.Div([
264
  html.Label("預測期間:", style={'font-weight': 'bold', 'color': '#FFCC22'}),
@@ -289,7 +272,7 @@ homepage_layout = html.Div([
289
  ], style={'margin-top': '30px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
290
 
291
  html.Div([
292
- html.H3("📊 總體經濟指標", style={'color': '#2C3E50', 'margin-bottom': '20px'}),
293
  html.Div([
294
  html.Div([dcc.Graph(id='business-climate-chart')], style={'width': '48%', 'display': 'inline-block'}),
295
  html.Div([dcc.Graph(id='pmi-chart')], style={'width': '48%', 'display': 'inline-block', 'margin-left': '2%'})
@@ -300,27 +283,24 @@ homepage_layout = html.Div([
300
 
301
  # 個股分析頁面
302
  stock_page_layout = html.Div([
303
- html.H1("📈 個股深度分析", style={'text-align': 'center', 'margin-bottom': '30px'}),
304
  html.Div([
305
  html.Div([
306
- html.Label("選擇股票:", style={'font-weight': 'bold', 'margin-right': '10px'}),
307
- dcc.Dropdown(id='stock-dropdown', options=[{'label': name, 'value': symbol} for name, symbol in TAIWAN_STOCKS.items()], value='2330.TW', style={'width': '70%', 'display': 'inline-block'})
308
  ], style={'width': '30%', 'display': 'inline-block', 'vertical-align': 'top'}),
309
  html.Div([
310
- html.Label("時間範圍:", style={'font-weight': 'bold', 'margin-right': '10px'}),
311
  dcc.Dropdown(id='period-dropdown',
312
  options=[{'label': '1個月', 'value': '1mo'},{'label': '3個月', 'value': '3mo'},{'label': '6個月', 'value': '6mo'},{'label': '1年', 'value': '1y'},{'label': '2年', 'value': '2y'}],
313
- value='6mo', style={'width': '70%', 'display': 'inline-block'})
314
  ], style={'width': '30%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'}),
315
  html.Div([
316
- html.Label("圖表類型:", style={'font-weight': 'bold', 'margin-right': '10px'}),
317
- dcc.Dropdown(id='chart-type', options=[{'label': '線圖', 'value': 'line'},{'label': '蠟燭圖', 'value': 'candlestick'}], value='candlestick', style={'width': '70%', 'display': 'inline-block'})
318
  ], style={'width': '30%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'})
319
- ], style={'margin-bottom': '30px', 'padding': '20px', 'background': '#f8f9fa', 'border-radius': '10px'}),
320
-
321
  html.Div(id='stock-info-cards', style={'margin-bottom': '30px'}),
322
- html.Div([dcc.Graph(id='price-chart')], style={'margin-top': '20px', 'padding': '20px', 'background': 'white', 'border-radius': '10px', 'box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
323
-
324
  html.Div([
325
  html.H3("📊 進階技術指標分析", style={'margin-bottom': '20px'}),
326
  html.Div([
@@ -331,14 +311,11 @@ stock_page_layout = html.Div([
331
  value='RSI', style={'width': '100%'})
332
  ], style={'margin-bottom': '20px'}),
333
  html.Div([dcc.Graph(id='advanced-technical-chart')])
334
- ], style={'margin-top': '30px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
335
-
336
- html.Div([dcc.Graph(id='volume-chart')], style={'margin-top': '30px', 'padding': '20px', 'background': 'white', 'border-radius': '10px', 'box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
337
-
338
- html.Div([html.H3("📊 產業表現分析"), dcc.Graph(id='industry-analysis')], style={'margin-top': '30px', 'padding': '20px', 'background': 'white', 'border-radius': '10px', 'box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
339
-
340
  html.Div([
341
- html.H3("📈 分析師觀點與市場解讀", style={'color': '#2E86AB', 'margin-bottom': '20px'}),
342
  html.Div([
343
  html.Div([
344
  html.H4("🔍 技術面分析", style={'color': '#A23B72', 'margin-bottom': '15px'}),
@@ -354,7 +331,6 @@ stock_page_layout = html.Div([
354
  html.Div(id='market-outlook-text', style={'background': 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)','color': 'white','padding': '20px','border-radius': '10px','min-height': '100px','font-size': '15px','line-height': '1.7','box-shadow': '0 4px 15px rgba(0,0,0,0.1)'})
355
  ])
356
  ], style={'margin-top': '30px','padding': '25px','background': 'white','border-radius': '12px','box-shadow': '0 4px 20px rgba(0,0,0,0.08)','border': '1px solid #e9ecef'}),
357
-
358
  html.Div([
359
  html.H3("📊 多檔股票比較分析", style={'margin-bottom': '20px'}),
360
  html.Div([
@@ -377,15 +353,14 @@ stock_page_layout = html.Div([
377
 
378
  # --- 主要應用程式佈局 ---
379
  app.layout = html.Div([
380
- dcc.Location(id='url', refresh=False), # 用於追蹤 URL
381
- html.H1("台股趨勢分析儀表板", style={'text-align': 'center', 'margin-bottom': '10px', 'color': '#2c3e50'}),
382
- # 導航列
383
  html.Div([
384
- dcc.Link('市場總覽 📈', href='/', style={'margin-right': '20px', 'font-size': '18px', 'text-decoration': 'none', 'color': '#3498db'}),
385
- dcc.Link('個股分析 🔍', href='/stock-analysis', style={'font-size': '18px', 'text-decoration': 'none', 'color': '#e67e22'}),
386
- ], style={'text-align': 'center', 'margin-bottom': '30px', 'padding': '10px', 'background-color': '#f8f9fa', 'border-radius': '8px'}),
387
- html.Hr(style={'border-top': '1px solid #e0e0e0'}),
388
- html.Div(id='page-content') # 這裡將動態載入頁面內容
389
  ])
390
 
391
 
@@ -395,601 +370,368 @@ app.layout = html.Div([
395
  [dash.dependencies.Input('url', 'pathname')]
396
  )
397
  def display_page(pathname):
398
- """根據 URL 路徑顯示對應的頁面���容"""
399
  if pathname == '/stock-analysis':
400
  return stock_page_layout
401
- else: # 預設顯示首頁
402
  return homepage_layout
403
 
404
- # --- 以下是所有回調函數 ---
405
- # 這些回調函數保持與您原程式碼相同的功能,但現在它們由 display_page 根據 URL 決定是否被觸發。
406
 
407
- # 台指期獨立預測回調函數
408
  @app.callback(
409
- [dash.dependencies.Output('taiex-prediction-results', 'children'),
410
- dash.dependencies.Output('taiex-prediction-chart', 'figure')],
411
  [dash.dependencies.Input('taiex-prediction-period', 'value')]
412
  )
413
- def update_taiex_prediction(predict_days):
414
- data = get_stock_data('^TWII', '2y')
415
- if data.empty: return html.Div("無法獲取台指期資料", style={'color': 'red'}), {}
416
- final_prediction = simple_lstm_predict(data, predict_days)
417
- if final_prediction is None: return html.Div("資料不足,無法進行預測", style={'color': 'orange'}), {}
418
-
419
- current_price, last_date = data['Close'].iloc[-1], data.index[-1]
420
- predicted_price, change_pct, confidence = final_prediction['predicted_price'], final_prediction['change_pct'], final_prediction['confidence']
421
-
422
- # 為了讓圖表更平滑,我們預測幾個點
423
- prediction_intervals = [1, 5, 10, 20, 60] # 預測的間隔天數
424
- prediction_dates, prediction_prices = [last_date], [current_price]
425
-
426
- # 根據使用者選擇的 predict_days,決定要顯示哪些預測點
427
- intervals_to_show = sorted([d for d in prediction_intervals if d <= predict_days] + [predict_days])
428
-
429
- for days in intervals_to_show:
430
- # 這裡假設 simple_lstm_predict 可以處理任意間隔
431
- # 如果是真實的 LSTM 模型,可能需要更複雜的邏輯來生成多步預測
432
- interim_prediction = simple_lstm_predict(data, days)
433
- if interim_prediction:
434
- prediction_dates.append(last_date + timedelta(days=days))
435
- prediction_prices.append(interim_prediction['predicted_price'])
436
-
437
- color, arrow = ('#4CAF50', '📈') if change_pct >= 0 else ('#F44336', '📉') # 綠色和紅色
438
- result_card = html.Div([
439
- html.H4(f"{predict_days}日後預測結果", style={'margin': '0 0 15px 0', 'color': 'white'}),
440
- html.Div([html.Span(f"{arrow} ", style={'font-size': '24px'}), html.Span(f"{change_pct:+.2f}%", style={'font-size': '28px','font-weight': 'bold','color': color})], style={'margin': '10px 0'}),
441
- html.P(f"目前價格: {current_price:.2f}", style={'margin': '5px 0'}),
442
- html.P(f"預測價格: {predicted_price:.2f}", style={'margin': '5px 0'}),
443
- html.P(f"信心度: {confidence:.1%}", style={'margin': '5px 0', 'font-size': '14px'})
444
- ], style={'background': 'rgba(255,255,255,0.1)','padding': '20px','border-radius': '10px','border': '1px solid rgba(255,255,255,0.2)'})
445
 
446
- fig = go.Figure()
447
- # 顯示最近 60 天的歷史數據
448
- recent_data = data.tail(60)
449
- fig.add_trace(go.Scatter(x=recent_data.index, y=recent_data['Close'], mode='lines', name='歷史價格', line=dict(color='#FFA726', width=2)))
 
 
 
 
 
 
450
 
451
- # 顯示預測路徑
452
- fig.add_trace(go.Scatter(x=prediction_dates, y=prediction_prices, mode='lines+markers', name=f'{predict_days}日預測路徑', line=dict(color=color, width=3, dash='dash'), marker=dict(size=8)))
 
 
 
 
 
 
 
 
 
 
453
 
454
  fig.update_layout(
455
- title=f'台指期 {predict_days}日預測走勢',
456
  xaxis_title='日期',
457
- yaxis_title='指數點位',
458
- height=350,
459
- plot_bgcolor='rgba(0,0,0,0)', # 透明背景
460
- paper_bgcolor='rgba(0,0,0,0)', # 透明背景
461
- font=dict(color='white')
462
  )
463
- return result_card, fig
464
-
465
- # 更新股價資訊卡片
466
- @app.callback(
467
- dash.dependencies.Output('stock-info-cards', 'children'),
468
- [dash.dependencies.Input('stock-dropdown', 'value')]
469
- )
470
- def update_stock_info(selected_stock):
471
- data = get_stock_data(selected_stock, '5d') # 獲取最近5天的數據
472
- if data.empty: return html.Div("無法獲取股票資料", style={'color': 'red'})
473
-
474
- current_price = data['Close'].iloc[-1]
475
- prev_price = data['Close'].iloc[-2] if len(data) > 1 else current_price
476
- change = current_price - prev_price
477
- change_pct = (change / prev_price) * 100 if prev_price != 0 else 0
478
-
479
- stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
480
- color, arrow = ('#F44336', '▲') if change >= 0 else ('#4CAF50', '▼') # 紅色上漲, 綠色下跌
481
 
482
- # 確保最高、最低、成交量有值
483
- today_high = data['High'].iloc[-1] if not data.empty else 0
484
- today_low = data['Low'].iloc[-1] if not data.empty else 0
485
- today_volume = data['Volume'].iloc[-1] if not data.empty else 0
486
 
487
- return html.Div([
488
- html.Div([
489
- html.H3(f"{stock_name} ({selected_stock})", style={'margin': '0'}),
490
- html.H2(f"${current_price:.2f}", style={'margin': '5px 0', 'color': color}),
491
- html.P(f"{arrow} {change:+.2f} ({change_pct:+.2f}%)", style={'margin': '0', 'color': color, 'font-weight': 'bold'})
492
- ], style={'background': 'white','padding': '20px','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)','display': 'inline-block','margin-right': '20px', 'width': '30%'}),
493
- html.Div([
494
- html.H4("今日統計", style={'margin': '0 0 10px 0'}),
495
- html.P(f"最高: ${today_high:.2f}", style={'margin': '5px 0'}),
496
- html.P(f"最低: ${today_low:.2f}", style={'margin': '5px 0'}),
497
- html.P(f"成交量: {today_volume:,.0f}", style={'margin': '5px 0'})
498
- ], style={'background': 'white','padding': '20px','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)','display': 'inline-block', 'width': '30%'})
499
- ], style={'display': 'flex', 'justify-content': 'flex-start', 'gap': '20px'})
500
-
501
- # 更新主要圖表 (股價與成交量分佈)
502
  @app.callback(
503
- dash.dependencies.Output('price-chart', 'figure'),
504
- [dash.dependencies.Input('stock-dropdown', 'value'),
505
- dash.dependencies.Input('period-dropdown', 'value'),
506
- dash.dependencies.Input('chart-type', 'value')]
507
  )
508
- def update_price_chart(selected_stock, period, chart_type):
509
- data = get_stock_data(selected_stock, period)
510
- if data.empty: return {}
511
- data = calculate_technical_indicators(data)
512
- stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
513
-
514
- # 創建子圖
515
- fig = make_subplots(rows=1, cols=2, shared_yaxes=True, column_widths=[0.8, 0.2], horizontal_spacing=0.01)
 
 
 
 
516
 
517
- # 添加股價圖 (蠟燭圖或線圖)
518
- if chart_type == 'candlestick':
519
- fig.add_trace(go.Candlestick(x=data.index, open=data['Open'], high=data['High'], low=data['Low'], close=data['Close'], name=stock_name, increasing_line_color='red', decreasing_line_color='green'), row=1, col=1)
520
- else: # line chart
521
- fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name=stock_name, line=dict(color='#3498db')), row=1, col=1)
522
-
523
- # 添加移動平均線
524
- if 'MA5' in data.columns:
525
- fig.add_trace(go.Scatter(x=data.index, y=data['MA5'], mode='lines', name='MA5', line=dict(color='orange', width=1)), row=1, col=1)
526
- if 'MA20' in data.columns:
527
- fig.add_trace(go.Scatter(x=data.index, y=data['MA20'], mode='lines', name='MA20', line=dict(color='blue', width=1)), row=1, col=1)
528
-
529
- # 添加成交量分佈圖 (Volume Profile)
530
- bin_edges, volume_per_bin, price_centers = calculate_volume_profile(data, num_bins=50)
531
- if volume_per_bin is not None and price_centers is not None and len(price_centers) == len(volume_per_bin):
532
- fig.add_trace(go.Bar(orientation='h', y=price_centers, x=volume_per_bin, name='Volume Profile',
533
- text=[f'{vol/1000:.0f}k' for vol in volume_per_bin], textposition='auto',
534
- marker=dict(color='rgba(173, 216, 230, 0.6)', line=dict(color='rgba(30, 144, 255, 0.8)', width=1))), row=1, col=2)
 
535
  else:
536
- # 如果沒有成交量資料,則不顯示 Volume Profile
537
- print("無成交量資料,跳過 Volume Profile 顯示")
538
- fig.update_layout(column_widths=[1.0]) # 調整為單欄佈局
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
539
 
540
- fig.update_layout(
541
- title_text=f'{stock_name} 股價走勢與成交量分佈',
542
- height=500,
543
- showlegend=True,
544
- xaxis1=dict(title='日期', type='date', rangeslider_visible=False),
545
- yaxis1=dict(title='價格 (TWD)'),
546
- # x-axis for volume profile, y-axis for volume profile is shared with price chart
547
- xaxis2=dict(title='成交量', showticklabels=True),
548
- yaxis2=dict(showticklabels=False), # 隱藏 Y 軸標籤,因為它與左邊共享
549
- bargap=0.05, # 調整柱狀圖間隔
550
- margin=dict(l=50, r=20, t=50, b=50)
551
- )
552
- return fig
553
 
554
- # 更新進階技術指標圖表
555
  @app.callback(
556
- dash.dependencies.Output('advanced-technical-chart', 'figure'),
557
- [dash.dependencies.Input('technical-indicator-selector', 'value'),
558
- dash.dependencies.Input('stock-dropdown', 'value'),
559
- dash.dependencies.Input('period-dropdown', 'value')]
560
  )
561
- def update_advanced_technical_chart(indicator, selected_stock, period):
562
- data = get_stock_data(selected_stock, period)
563
- if data.empty: return {}
564
- data = calculate_technical_indicators(data)
565
- stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
566
-
567
- fig = go.Figure() # 預設圖形
568
-
569
- if indicator == 'RSI':
570
- fig.add_trace(go.Scatter(x=data.index, y=data['RSI'], mode='lines', name='RSI', line=dict(color='purple', width=2)))
571
- fig.add_hline(y=70, line_dash="dash", line_color="green", annotation_text="超買線 (70)")
572
- fig.add_hline(y=30, line_dash="dash", line_color="red", annotation_text="超賣線 (30)")
573
- fig.add_hline(y=50, line_dash="dot", line_color="gray", annotation_text="中線 (50)")
574
- fig.update_layout(title=f'{stock_name} - RSI 相對強弱指標', xaxis_title='日期', yaxis_title='RSI', height=450, yaxis=dict(range=[0, 100]))
575
-
576
- elif indicator == 'MACD':
577
- fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1, row_heights=[0.6, 0.4], subplot_titles=('收盤價', 'MACD 指標'))
578
- fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價', line=dict(color='black', width=1.5)), row=1, col=1)
579
- fig.add_trace(go.Scatter(x=data.index, y=data['MACD'], mode='lines', name='MACD', line=dict(color='blue', width=2)), row=2, col=1)
580
- fig.add_trace(go.Scatter(x=data.index, y=data['MACD_Signal'], mode='lines', name='Signal', line=dict(color='red', width=2)), row=2, col=1)
581
- colors = ['red' if x >= 0 else 'green' for x in data['MACD_Histogram']]
582
- fig.add_trace(go.Bar(x=data.index, y=data['MACD_Histogram'], name='Histogram', marker_color=colors), row=2, col=1)
583
- fig.update_layout(title_text=f'{stock_name} - MACD 指數平滑異同移動平均線', height=550)
584
-
585
- elif indicator == 'BB':
586
- fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價', line=dict(color='black', width=2)))
587
- fig.add_trace(go.Scatter(x=data.index, y=data['BB_Upper'], mode='lines', name='上軌', line=dict(color='red', width=1, dash='dash')))
588
- fig.add_trace(go.Scatter(x=data.index, y=data['BB_Middle'], mode='lines', name='中軌 (MA20)', line=dict(color='blue', width=1)))
589
- fig.add_trace(go.Scatter(x=data.index, y=data['BB_Lower'], mode='lines', name='下軌', line=dict(color='green', width=1, dash='dash')))
590
- fig.update_layout(title=f'{stock_name} - 布林通道 (20日, 2σ)', xaxis_title='日期', yaxis_title='價格 (TWD)', height=450)
591
 
592
- elif indicator == 'KD':
593
- fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1, row_heights=[0.6, 0.4], subplot_titles=('收盤價', 'KD 指標'))
594
- fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價', line=dict(color='black', width=1)), row=1, col=1)
595
- fig.add_trace(go.Scatter(x=data.index, y=data['K'], mode='lines', name='K線', line=dict(color='blue', width=2)), row=2, col=1)
596
- fig.add_trace(go.Scatter(x=data.index, y=data['D'], mode='lines', name='D線', line=dict(color='red', width=2)), row=2, col=1)
597
- fig.add_hline(y=80, line_dash="dash", line_color="green", annotation_text="超買線 (80)", row=2, col=1)
598
- fig.add_hline(y=20, line_dash="dash", line_color="red", annotation_text="超賣線 (20)", row=2, col=1)
599
- fig.update_layout(title=f'{stock_name} - KD 隨機指標 (9,3,3)', height=500, yaxis2_range=[0, 100])
600
 
601
- elif indicator == 'WR':
602
- fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1, row_heights=[0.6, 0.4], subplot_titles=('收盤價', '威廉指標 %R'))
603
- fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價', line=dict(color='black', width=1)), row=1, col=1)
604
- fig.add_trace(go.Scatter(x=data.index, y=data['Williams_R'], mode='lines', name='威廉%R', line=dict(color='purple', width=2)), row=2, col=1)
605
- fig.add_hline(y=-20, line_dash="dash", line_color="green", annotation_text="超買線 (-20)", row=2, col=1)
606
- fig.add_hline(y=-80, line_dash="dash", line_color="red", annotation_text="超賣線 (-80)", row=2, col=1)
607
- fig.update_layout(title=f'{stock_name} - 威廉指標 %R (14日)', height=500, yaxis2_range=[-100, 0])
608
 
609
- elif indicator == 'DMI':
610
- fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1, row_heights=[0.6, 0.4], subplot_titles=('收盤價', 'DMI 指標'))
611
- # DMI 通常需要前 14 天的數據,所以從第 14 天開始繪製
612
- data_filtered = data.iloc[14:] if len(data) > 14 else data
613
- fig.add_trace(go.Scatter(x=data_filtered.index, y=data_filtered['Close'], mode='lines', name='收盤價', line=dict(color='black', width=1)), row=1, col=1)
614
- fig.add_trace(go.Scatter(x=data_filtered.index, y=data_filtered['+DI'], mode='lines', name='+DI', line=dict(color='red', width=2)), row=2, col=1)
615
- fig.add_trace(go.Scatter(x=data_filtered.index, y=data_filtered['-DI'], mode='lines', name='-DI', line=dict(color='green', width=2)), row=2, col=1)
616
- fig.add_trace(go.Scatter(x=data_filtered.index, y=data_filtered['ADX'], mode='lines', name='ADX', line=dict(color='blue', width=2, dash='dot')), row=2, col=1)
617
- fig.update_layout(title=f'{stock_name} - DMI 動向指標 (14日)', height=500, showlegend=True, yaxis2_range=[0, 100])
618
-
619
  return fig
620
 
621
- # 更新成交量圖表
622
  @app.callback(
623
- dash.dependencies.Output('volume-chart', 'figure'),
624
- [dash.dependencies.Input('stock-dropdown', 'value'),
625
- dash.dependencies.Input('period-dropdown', 'value')]
626
  )
627
- def update_volume_chart(selected_stock, period):
628
- data = get_stock_data(selected_stock, period)
629
- if data.empty: return {}
630
- stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
631
 
632
- # 根據開盤價與收盤價決定柱狀圖顏色
633
- colors = ['#F44336' if data['Close'].iloc[i] >= data['Open'].iloc[i] else '#4CAF50' for i in range(len(data))]
634
 
635
- fig = go.Figure(go.Bar(x=data.index, y=data['Volume'], marker_color=colors, name='成交量'))
636
- fig.update_layout(title=f'{stock_name} 成交量', xaxis_title='日期', yaxis_title='成交量', height=300)
637
  return fig
638
 
639
- # 更新產業分析圖表 (僅顯示前10檔股票的月報酬率比較)
640
- @app.callback(
641
- dash.dependencies.Output('industry-analysis', 'figure'),
642
- [dash.dependencies.Input('stock-dropdown', 'value')] # 觸發條件,確保圖表會更新
643
- )
644
- def update_industry_analysis(selected_stock):
645
- industry_data = []
646
- # 僅取列表中的前10支股票進行比較,避免圖表過於擁擠
647
- stocks_to_analyze = list(TAIWAN_STOCKS.items())[:10]
648
-
649
- for name, symbol in stocks_to_analyze:
650
- data = get_stock_data(symbol, '1mo') # 獲取一個月的數據
651
- if not data.empty:
652
- try:
653
- return_pct = ((data['Close'].iloc[-1] - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
654
- industry = INDUSTRY_MAPPING.get(symbol, '其他')
655
- industry_data.append({'股票': name, '代碼': symbol, '月報酬率(%)': return_pct, '產業': industry})
656
- except ZeroDivisionError:
657
- print(f"注意:{name} ({symbol}) 的起始股價為 0,無法計算報酬率。")
658
- industry_data.append({'股票': name, '代碼': symbol, '月報酬率(%)': 0, '產業': industry})
659
-
660
- if not industry_data:
661
- fig = go.Figure().add_annotation(text="無股票資料可供分析", showarrow=False)
662
- fig.update_layout(title="產業表現分析 (月報酬率)", height=400)
663
- return fig
664
-
665
- df_industry = pd.DataFrame(industry_data)
666
-
667
- # 創建圓餅圖
668
- fig = px.pie(df_industry, values='月報酬率(%)', names='股票', title='前10檔股票月報酬率比較',
669
- color_discrete_sequence=px.colors.qualitative.Pastel) # 使用 Pastel 調色盤
670
-
671
- fig.update_layout(height=400, margin=dict(t=50, b=0, l=0, r=0))
672
- return fig
673
 
674
- # 更新景氣燈號圖表
675
  @app.callback(
676
- dash.dependencies.Output('business-climate-chart', 'figure'),
677
- [dash.dependencies.Input('url', 'pathname')] # 觸發條件:當使用者進入首頁時更新
 
 
 
 
 
 
 
 
 
 
 
 
678
  )
679
- def update_business_climate_chart(pathname):
680
- if pathname != '/': return {} # 確保只在首頁觸發
681
- df = get_business_climate_data()
682
- if df.empty:
683
- fig = go.Figure().add_annotation(text="無法載入景氣燈號資料", showarrow=False)
684
- fig.update_layout(title="台灣景氣燈號", height=300)
685
- return fig
686
-
687
- # 定義燈號顏色
688
- def get_light_color(score):
689
- if score >= 32: return 'red' # 紅燈
690
- elif score >= 24: return 'orange' # 黃紅燈
691
- elif score >= 17: return 'yellow' # 黃藍燈
692
- elif score >= 10: return 'lightgreen' # 綠燈
693
- else: return 'blue' # 藍燈
694
-
695
- colors = [get_light_color(score) for score in df['Index']]
696
 
697
- fig = go.Figure()
698
- fig.add_trace(go.Scatter(
699
- x=df['Date'],
700
- y=df['Index'],
701
- mode='lines+markers',
702
- name='景氣燈號分數',
703
- line=dict(color='#2E86C1', width=2), # 深藍色線
704
- marker=dict(size=8, color=colors, line=dict(width=2, color='#2E86C1')) # 標記點顏色隨燈號變化
705
- ))
706
-
707
- # 添加參考線
708
- fig.add_hline(y=32, line_dash="dash", line_color="red", annotation_text="紅燈 (32)", annotation_position="bottom right")
709
- fig.add_hline(y=17, line_dash="dash", line_color="yellow", annotation_text="黃藍燈 (17)", annotation_position="bottom right")
710
- fig.add_hline(y=10, line_dash="dash", line_color="lightgreen", annotation_text="綠燈 (10)", annotation_position="bottom right")
711
 
712
- fig.update_layout(
713
- title="台灣景氣燈號走勢",
714
- xaxis_title='日期',
715
- yaxis_title='燈號分數',
716
- height=300,
717
- yaxis=dict(range=[0, 40]), # 調整 Y 軸範圍
718
- margin=dict(l=50, r=20, t=50, b=50)
719
- )
720
- return fig
 
721
 
722
- # 更新分析師觀點
723
- @app.callback(
724
- [dash.dependencies.Output('technical-analysis-text', 'children'),
725
- dash.dependencies.Output('fundamental-analysis-text', 'children'),
726
- dash.dependencies.Output('market-outlook-text', 'children')],
727
- [dash.dependencies.Input('stock-dropdown', 'value'),
728
- dash.dependencies.Input('period-dropdown', 'value')]
729
- )
730
- def update_analysis_text(selected_stock, period):
731
- data = get_stock_data(selected_stock, period)
732
- stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
733
 
734
- if data.empty:
735
- return "無法獲取資料", "無法獲取資料", "無法獲取資料"
736
-
737
- data = calculate_technical_indicators(data)
738
-
739
- # 確保有足夠數據計算
740
- if len(data) < 2: return "數據不足", "數據不足", "數據不足"
741
-
742
- current_price = data['Close'].iloc[-1]
743
- first_price = data['Close'].iloc[0]
744
- price_change = ((current_price - first_price) / first_price) * 100 if first_price != 0 else 0
745
-
746
- rsi_current = data['RSI'].iloc[-1] if not pd.isna(data['RSI'].iloc[-1]) else 50
747
- macd_current = data['MACD'].iloc[-1] if not pd.isna(data['MACD'].iloc[-1]) else 0
748
- macd_signal_current = data['MACD_Signal'].iloc[-1] if not pd.isna(data['MACD_Signal'].iloc[-1]) else 0
749
-
750
- # --- 技術面分析 ---
751
- trend_desc = "上漲" if price_change > 5 else "下跌" if price_change < -5 else "盤整"
752
- trend_color = '#F44336' if price_change > 5 else '#4CAF50' if price_change < -5 else '#FF9800'
753
- rsi_desc = "處於超買區間" if rsi_current > 70 else "處於超賣區間" if rsi_current < 30 else "在正常範圍內"
754
- rsi_color = '#F44336' if rsi_current > 70 else '#4CAF50' if rsi_current < 30 else '#2196F3'
755
- macd_desc = "多頭" if macd_current > macd_signal_current else "空頭"
756
- macd_color = '#F44336' if macd_current > macd_signal_current else '#4CAF50'
757
-
758
- technical_text = html.Div([
759
- html.P([html.Strong("價格趨勢:"), f"近期 {period} 期間內,{stock_name} 呈現",
760
- html.Span(f"{trend_desc}", style={'color': trend_color, 'font-weight': 'bold'}),
761
- f"走勢,累計變動 {price_change:+.1f}%。"]),
762
- html.P([html.Strong("RSI指標:"), f"目前為 {rsi_current:.1f},",
763
- html.Span(f"{rsi_desc}", style={'color': rsi_color, 'font-weight': 'bold'}), "。"]),
764
- html.P([html.Strong("MACD指標:"), f"MACD線({macd_current:.3f})",
765
- html.Span("高於" if macd_current > macd_signal_current else "低於", style={'color': macd_color, 'font-weight': 'bold'}),
766
- f"信號線({macd_signal_current:.3f}),顯示", html.Span(f"{macd_desc}", style={'color': macd_color, 'font-weight': 'bold'}), "格局。"]),
767
- ])
768
 
769
- # --- 基本面分析 ---
770
- industry = INDUSTRY_MAPPING.get(selected_stock, '綜合')
771
- fundamental_text = html.Div([
772
- html.P([html.Strong("產業地位:"), f"{stock_name} 屬於 {industry} 產業,在產業鏈中具有",
773
- html.Span("重要地位" if selected_stock in ['2330.TW', '2454.TW', '2317.TW'] else "一定影響力", style={'font-weight': 'bold'}), "。"]),
774
- html.P([html.Strong("營運展望:"), f"建議持續關注公司最新財報、新聞動態及產業趨勢,以掌握其長期發展潛力。"]),
775
- html.P([html.Strong("風險提示:"), f"基本面分析僅供參考,實際投資決策請獨立判斷��"])
776
- ])
 
 
 
 
777
 
778
- # --- 市場展望與投資建議 ---
779
- outlook_tone = "謹慎樂觀" if price_change > 10 else "保守觀望" if price_change < -10 else "中性持平"
780
- outlook_color = '#4CAF50' if price_change > 10 else '#FF9800' if price_change < -10 else '#757575'
781
 
782
- market_outlook = html.Div([
783
- html.P([html.Strong("整體評估:"), f"基於技術面與基本面綜合考量,對 {stock_name} 目前採取",
784
- html.Span(f"{outlook_tone}", style={'color': outlook_color, 'font-weight': 'bold'}), "態度。"]),
785
- html.P([html.Strong("投資建議:"), "短期交易者可關注技術指標訊號,長期投資者應深入研究其基本面與產業前景。請注意,市場波動風險始終存在,務必做好風險控管。"]),
786
- ])
 
 
 
 
 
 
 
 
 
 
 
787
 
788
- return technical_text, fundamental_text, market_outlook
 
 
 
 
789
 
790
- # 更新PMI圖表
791
  @app.callback(
792
- dash.dependencies.Output('pmi-chart', 'figure'),
793
- [dash.dependencies.Input('url', 'pathname')] # 觸發條件:當使用者進入首頁時更新
 
 
 
 
794
  )
795
- def update_pmi_chart(pathname):
796
- if pathname != '/': return {} # 確保只在首頁觸發
797
- df = get_pmi_data()
798
- if df.empty:
799
- fig = go.Figure().add_annotation(text="無法載入 PMI 資料", showarrow=False)
800
- fig.update_layout(title="台灣 PMI 指數", height=300)
801
- return fig
802
-
803
- # 根據 PMI 值決定柱狀圖顏色
804
- colors = ['#F44336' if value >= 50 else '#4CAF50' for value in df['Index']]
805
 
 
 
 
806
  fig = go.Figure()
807
- fig.add_trace(go.Scatter(
808
- x=df['Date'],
809
- y=df['Index'],
810
- mode='lines+markers',
811
- name='PMI 指數',
812
- line=dict(color='#34495E', width=2), # 深灰色線
813
- marker=dict(size=8, color=colors, line=dict(width=2, color='#34495E'))
814
- ))
815
 
816
- fig.add_hline(y=50, line_dash="dash", line_color="black", annotation_text="榮枯線 (50)", annotation_position="bottom right")
 
 
 
 
817
 
818
- fig.update_layout(
819
- title="台灣 PMI 指數走勢",
820
- xaxis_title='日期',
821
- yaxis_title='PMI 指數',
822
- height=300,
823
- yaxis=dict(range=[35, 60]), # 調整 Y 軸範圍
824
- margin=dict(l=50, r=20, t=50, b=50)
825
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
826
  return fig
827
 
828
- # 更新多檔股票比較
829
  @app.callback(
830
- [dash.dependencies.Output('comparison-chart', 'figure'),
831
- dash.dependencies.Output('comparison-table', 'children')],
832
- [dash.dependencies.Input('comparison-stocks', 'value'),
833
- dash.dependencies.Input('comparison-period', 'value')]
 
 
 
 
834
  )
835
- def update_comparison_analysis(selected_stocks, period):
836
- fixed_stock = '0050.TW' # 固定比較基準
837
-
838
- # 處理使用者選擇的股票
839
- if not selected_stocks:
840
- display_stocks = [fixed_stock] # 如果沒選,預設顯示 0050
841
- elif fixed_stock not in selected_stocks:
842
- display_stocks = [fixed_stock] + selected_stocks # 如果沒選 0050,則加入
843
- else:
844
- display_stocks = selected_stocks
845
-
846
- display_stocks = list(set(display_stocks))[:5] # 去重並限制最多 5 檔
 
 
 
847
 
848
  fig = go.Figure()
849
- comparison_data = []
850
-
851
- for stock in display_stocks:
852
- data = get_stock_data(stock, period)
853
- if not data.empty:
854
- stock_name = next((name for name, symbol in TAIWAN_STOCKS.items() if symbol == stock), stock)
855
-
856
- # 計算相對績效 (以第一天為基準 100)
857
- normalized_prices = (data['Close'] / data['Close'].iloc[0]) * 100 if data['Close'].iloc[0] != 0 else data['Close'] * 0
858
- fig.add_trace(go.Scatter(x=data.index, y=normalized_prices, mode='lines', name=stock_name, line=dict(width=2)))
859
-
860
- # 計算總報酬率和波動率
861
- total_return = ((data['Close'].iloc[-1] / data['Close'].iloc[0]) - 1) * 100 if data['Close'].iloc[0] != 0 else 0
862
- # 波動率 (年化標準差)
863
- pct_change = data['Close'].pct_change().dropna()
864
- volatility = pct_change.std() * np.sqrt(252) * 100 if not pct_change.empty else 0 # 假設一年有 252 個交易日
865
-
866
- comparison_data.append({
867
- 'name': stock_name,
868
- 'return': total_return,
869
- 'volatility': volatility,
870
- 'current_price': data['Close'].iloc[-1]
871
- })
872
-
873
  fig.update_layout(
874
- title=f'股票績效比較 ({period})',
875
- xaxis_title='日期',
876
- yaxis_title='相對績效 (基期=100)',
877
- height=400,
878
- hovermode='x unified', # 滑鼠懸停時顯示所有線的資訊
879
- margin=dict(l=50, r=20, t=50, b=50)
880
  )
881
 
882
- # 創建比較結果表格
883
- table_rows = []
884
- if comparison_data:
885
- # 按報酬率排序 (由高到低)
886
- sorted_data = sorted(comparison_data, key=lambda x: x['return'], reverse=True)
887
- for item in sorted_data:
888
- return_color = '#F44336' if item['return'] > 0 else '#4CAF50' # 紅色代表上漲,綠色代表下跌
889
- table_rows.append(html.Tr([
890
- html.Td(item['name'], style={'font-weight': 'bold', 'padding': '8px'}),
891
- html.Td(f"{item['return']:+.1f}%", style={'color': return_color, 'font-weight': 'bold', 'padding': '8px'}),
892
- html.Td(f"{item['volatility']:.1f}%", style={'padding': '8px'}),
893
- html.Td(f"${item['current_price']:.2f}", style={'padding': '8px'})
894
- ]))
895
-
896
- table = html.Table([
897
- html.Thead(html.Tr([html.Th("股票"), html.Th("報酬率"), html.Th("波動率"), html.Th("現價")])),
898
- html.Tbody(table_rows)
899
- ], style={'width': '100%', 'border-collapse': 'collapse', 'margin-top': '15px'})
900
- return fig, table
901
- else:
902
- return fig, html.Div("無可比較資料", style={'margin-top': '15px'})
903
-
904
-
905
- # ==============================================================================
906
- # ===== 【修改】市場情緒與新聞分析 (使用真實 BERT 模型) =====
907
- # ==============================================================================
908
- @app.callback(
909
- [dash.dependencies.Output('sentiment-gauge', 'children'),
910
- dash.dependencies.Output('news-summary', 'children')],
911
- [dash.dependencies.Input('url', 'pathname')] # 觸發條件:當使用者進入首頁時更新
912
- )
913
- def update_sentiment_analysis(pathname):
914
- if pathname != '/': return {}, {} # 確保只在首頁觸發
915
-
916
- # 檢查 predictor 是否成功初始化 (在程式碼開頭已處理)
917
- if predictor is None:
918
- error_fig = go.Figure().add_annotation(text="情緒指標模型載入失敗", showarrow=False)
919
- error_fig.update_layout(height=200)
920
- return dcc.Graph(figure=error_fig), html.P("新聞分析模型載入失敗,請檢查後台日誌。", style={'color': 'red'})
921
-
922
- # --- 1. 從 predictor 獲取新聞情緒平均分數 ---
923
- sentiment_score_raw = predictor.get_news_index()
924
-
925
- # --- 2. 建立情緒指標儀表板 ---
926
- gauge_content = html.Div() # 預設值
927
- if sentiment_score_raw is not None:
928
- # **重要假設**:假設您模型的輸出範圍在 [-1, 1] 之間
929
- # 我們需要將其正規化到儀表板的 [0, 100] 範圍內
930
- sentiment_score_normalized = max(0, min(100, (sentiment_score_raw + 1) * 50)) # 正規化並確保在0-100之間
931
-
932
- # 根據分數決定顏色和標籤
933
- if sentiment_score_normalized >= 65:
934
- bar_color, level_text = "#5cb85c", "樂觀" # 綠色
935
- elif sentiment_score_normalized >= 35:
936
- bar_color, level_text = "#f0ad4e", "中性" # 黃色
937
- else:
938
- bar_color, level_text = "#d9534f", "悲觀" # 紅色
939
-
940
- gauge_fig = go.Figure(go.Indicator(
941
- mode = "gauge+number",
942
- value = sentiment_score_normalized,
943
- domain = {'x': [0, 1], 'y': [0, 1]},
944
- title = {'text': f"市場情緒: {level_text}", 'font': {'size': 18}},
945
- gauge = {
946
- 'axis': {'range': [0, 100], 'tickwidth': 1, 'tickcolor': "darkblue"},
947
- 'bar': {'color': bar_color, 'thickness': 0.8},
948
- 'steps': [
949
- {'range': [0, 35], 'color': "rgba(217, 83, 79, 0.2)"}, # 悲觀區間背景
950
- {'range': [35, 65], 'color': "rgba(240, 173, 78, 0.2)"}, # 中性區間背景
951
- {'range': [65, 100], 'color': "rgba(92, 184, 92, 0.2)"} # 樂觀區間背景
952
- ],
953
- 'threshold' : { # 設定觸發線
954
- 'line': {'color': "red", 'width': 4},
955
- 'thickness': 0.75,
956
- 'value': sentiment_score_normalized # 這裡設為當前值,也可以設為固定值
957
- }
958
- }
959
- ))
960
- gauge_fig.update_layout(height=220, margin=dict(l=30, r=30, t=50, b=20))
961
- gauge_content = dcc.Graph(figure=gauge_fig)
962
- else:
963
- # 處理無法計算分數的情況
964
- error_fig = go.Figure().add_annotation(text="今日尚無情緒分數", showarrow=False)
965
- error_fig.update_layout(height=200)
966
- gauge_content = dcc.Graph(figure=error_fig)
967
-
968
- # --- 3. 從 predictor 獲取新聞摘要 ---
969
- top_news_list = predictor.get_news()
970
-
971
- # --- 4. 建立新聞摘要元件 ---
972
- if top_news_list: # 如果列表不為空
973
- news_content = html.Div([
974
- html.P(f"• {news}", style={
975
- 'margin': '8px 0',
976
- 'padding-left': '5px',
977
- 'font-size': '14px',
978
- 'line-height': '1.5',
979
- 'border-left': '3px solid #3498db', # 添加���側邊框
980
- 'background-color': '#ecf0f1', # 淺灰色背景
981
- 'border-radius': '5px',
982
- 'padding-top': '5px',
983
- 'padding-bottom': '5px'
984
- }) for news in top_news_list
985
  ])
986
- elif top_news_list == []: # 如果是空列表 (無新聞)
987
- news_content = html.P("昨日無重大相關新聞。", style={'text-align': 'center', 'padding-top': '50px', 'color': '#7f8c8d'})
988
- else: # 如果是 None (讀取檔案出錯)
989
- news_content = html.P("讀取新聞時發生錯誤。", style={'text-align': 'center', 'padding-top': '50px', 'color': 'red'})
990
-
991
- return gauge_content, news_content
992
-
993
 
994
  # 主程式執行
995
  if __name__ == '__main__':
 
1
  # HUGING_FACE_V3.1.1.py (多頁面版本)
 
2
  # 系統套件
3
  import os
4
  from datetime import datetime, timedelta
 
13
  import re
14
  from bs4 import BeautifulSoup
15
  import requests
16
+ import warnings
17
+ from sklearn.preprocessing import MinMaxScaler
18
+ import joblib
19
+ from tensorflow.keras.models import load_model
20
+
21
+ # 引用您組員的預測器程式
22
+ try:
23
+ from Bert_predict import BertPredictor
24
+ except ImportError:
25
+ print("找不到 'Bert_predict.py' 模組,新聞情緒分析功能將無法使用。")
26
+ BertPredictor = None
27
+
28
+ # 忽略所有 UserWarning
29
+ warnings.filterwarnings("ignore", category=UserWarning)
30
+
31
+ # --- 資料準備與輔助函式 ---
32
+ # 台股代號對應表
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  TAIWAN_STOCKS = {
34
  '元大台灣50': '0050.TW', # 新增
35
  '台積電': '2330.TW',
 
46
  '慧洋-KY': '2637.TW',
47
  '上銀': '2049.TW',
48
  '台泥': '1101.TW',
49
+ '中信金': '2891.TW',
50
+ '中鋼': '2002.TW',
51
+ '聯電': '2303.TW',
52
+ '國泰金': '2882.TW',
53
+ '華碩': '2357.TW',
54
+ '友達': '2409.TW',
55
+ '緯創': '3231.TW',
56
+ '廣達': '2382.TW',
57
+ '技嘉': '2376.TW',
58
+ '英業達': '2356.TW',
59
+ '光寶科': '2301.TW',
60
  }
61
 
62
+ # 產業分類 (簡化範例)
63
  INDUSTRY_MAPPING = {
64
+ '電子': ['2330.TW', '2454.TW', '2317.TW', '2308.TW', '3711.TW', '2357.TW', '2409.TW', '3231.TW', '2382.TW', '2376.TW', '2356.TW', '2301.TW'],
65
+ '金融': ['2881.TW', '2882.TW', '2891.TW'],
66
+ '塑化': ['1301.TW'],
67
+ '水泥': ['1101.TW'],
68
+ '傳產': ['2002.TW', '1216.TW', '2603.TW', '2637.TW', '2049.TW'],
69
+ '通訊': ['2412.TW'],
70
+ 'ETF': ['0050.TW']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  }
72
 
73
+ def get_stock_data(ticker, period='1y'):
74
+ """從 yfinance 獲取股票資料"""
75
  try:
76
+ stock = yf.Ticker(ticker)
77
  data = stock.history(period=period)
 
 
 
 
 
 
 
 
 
78
  return data
79
  except Exception as e:
80
+ print(f"無法獲取 {ticker} 的資料: {e}")
81
  return pd.DataFrame()
82
 
83
+ def get_economic_data(ticker, period='2y'):
84
+ """獲取總經指標資料,例如PMI"""
85
+ data = yf.download(ticker, period=period)
86
+ return data['Close']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
+ def get_business_climate_data():
89
+ """模擬獲取台灣景氣對策信號分數"""
90
+ df = pd.DataFrame({
91
+ 'Date': pd.to_datetime(['2024-01', '2024-02', '2024-03', '2024-04', '2024-05', '2024-06', '2024-07']),
92
+ 'Score': [22, 24, 25, 27, 29, 30, 31],
93
+ 'Signal': ['黃藍', '黃藍', '綠', '綠', '綠', '綠', '綠']
94
+ })
95
+ return df
96
+
97
+ def add_technical_indicators(df):
98
+ """計算並新增技術指標"""
99
+ # RSI
100
  delta = df['Close'].diff()
101
+ gain = delta.where(delta > 0, 0)
102
+ loss = -delta.where(delta < 0, 0)
103
+ avg_gain = gain.ewm(com=13, min_periods=14).mean()
104
+ avg_loss = loss.ewm(com=13, min_periods=14).mean()
105
+ rs = avg_gain / avg_loss
106
  df['RSI'] = 100 - (100 / (1 + rs))
107
+
108
+ # MACD
109
+ exp1 = df['Close'].ewm(span=12, adjust=False).mean()
110
+ exp2 = df['Close'].ewm(span=26, adjust=False).mean()
111
  df['MACD'] = exp1 - exp2
112
+ df['Signal_Line'] = df['MACD'].ewm(span=9, adjust=False).mean()
113
+ df['MACD_Hist'] = df['MACD'] - df['Signal_Line']
114
+
115
+ # Bollinger Bands
116
+ df['MA20'] = df['Close'].rolling(window=20).mean()
117
+ df['StdDev'] = df['Close'].rolling(window=20).std()
118
+ df['Upper_BB'] = df['MA20'] + (df['StdDev'] * 2)
119
+ df['Lower_BB'] = df['MA20'] - (df['StdDev'] * 2)
120
+
121
+ # KD (Stochastic Oscillator)
122
+ low_14 = df['Low'].rolling(window=14).min()
123
+ high_14 = df['High'].rolling(window=14).max()
124
+ df['%K'] = 100 * ((df['Close'] - low_14) / (high_14 - low_14))
125
+ df['%D'] = df['%K'].rolling(window=3).mean()
126
+
127
+ # %R (Williams %R)
128
+ df['%R'] = -100 * (high_14 - df['Close']) / (high_14 - low_14)
129
+
130
+ # DMI
131
+ df['DMplus'] = df['High'].diff().clip(lower=0)
132
+ df['DMminus'] = (-df['Low'].diff()).clip(lower=0)
133
+ df['TR'] = df[['High', 'Low', 'Close']].apply(lambda x: max(x['High'] - x['Low'], abs(x['High'] - x['Close']), abs(x['Low'] - x['Close'])), axis=1)
134
+ df['ADX'] = df['TR'].ewm(alpha=1/14, adjust=False).mean()
135
+ df['DIplus'] = df['DMplus'].ewm(alpha=1/14, adjust=False).mean() / df['ADX']
136
+ df['DIminus'] = df['DMminus'].ewm(alpha=1/14, adjust=False).mean() / df['ADX']
137
+ df['ADX'] = abs(df['DIplus'] - df['DIminus']) / (df['DIplus'] + df['DIminus']) * 100
138
+
 
 
 
 
139
  return df
140
 
141
+ def generate_analysis_text(df):
142
+ """生成股票分析文字"""
143
+ if df.empty:
144
+ return {
145
+ 'technical': "找不到技術資料。",
146
+ 'fundamental': "找不到基本面資料。",
147
+ 'outlook': "無法提供市場展望。"
148
+ }
149
+
150
+ latest = df.iloc[-1]
151
+
152
+ # 技術面分析
153
+ tech_text = "找不到技術分析資料。"
154
+ if 'RSI' in df.columns:
155
+ rsi = latest['RSI']
156
+ rsi_signal = "超買" if rsi > 70 else "超賣" if rsi < 30 else "中性"
157
+ tech_text = f"目前RSI為 {rsi:.2f},顯示市場處於**{rsi_signal}**。近期走勢強勁,但需留意過熱風險。"
158
+
159
+ # 基本面分析(簡化)
160
+ fundamental_text = "找不到基本面分析資料。"
161
+ fundamental_text = f"基本面表現穩健,產業前景看好。公司財務狀況良好,建議持續關注。"
162
+
163
+ # 市場展望
164
+ outlook_text = "市場展望樂觀,但仍需留意全球經濟不確定性。建議投資人審慎評估,並隨時關注最新市場動態。"
165
+
166
+ return {
167
+ 'technical': tech_text,
168
+ 'fundamental': fundamental_text,
169
+ 'outlook': outlook_text
170
+ }
171
 
172
+ # --- LSTM 預測模型 ---
173
+ def simple_lstm_predict(ticker, n_days=5):
174
+ """使用簡單 LSTM 預測未來 n 天的收盤價"""
175
+ model_path = 'lstm_model_taiex.h5'
176
+ scaler_path = 'scaler_taiex.pkl'
177
+
178
+ # 檢查模型和 scaler 是否存在
179
+ if not os.path.exists(model_path) or not os.path.exists(scaler_path):
180
+ return None, "模型或縮放器檔案不存在,無法進行預測。"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
 
 
 
182
  try:
183
+ # 載入模型和 scaler
184
+ model = load_model(model_path)
185
+ scaler = joblib.load(scaler_path)
186
+
187
+ # 獲取歷史資料
188
+ data = yf.download(ticker, period='60d', interval='1d')
189
+ if data.empty:
190
+ return None, "無法獲取歷史數據。"
191
+
192
+ # 使用最新的 60 ���收盤價作為輸入
193
+ last_60_days = data['Close'].values[-60:].reshape(-1, 1)
194
+ last_60_days_scaled = scaler.transform(last_60_days)
195
+ X_test = last_60_days_scaled.reshape(1, 60, 1)
196
+
197
+ # 進行預測
198
+ future_predictions = []
199
+ current_input = X_test
200
+ for _ in range(n_days):
201
+ predicted_scaled_price = model.predict(current_input, verbose=0)
202
+ future_predictions.append(predicted_scaled_price[0, 0])
203
+ current_input = np.append(current_input[:, 1:, :], predicted_scaled_price.reshape(1, 1, 1), axis=1)
204
+
205
+ # 反向轉換回原始價格
206
+ predicted_prices = scaler.inverse_transform(np.array(future_predictions).reshape(-1, 1)).flatten()
207
+
208
+ # 建立預測結果 DataFrame
209
+ last_date = data.index[-1]
210
+ future_dates = [last_date + timedelta(days=i) for i in range(1, n_days + 1)]
211
+ pred_df = pd.DataFrame({'Date': future_dates, 'Predicted_Price': predicted_prices})
212
 
213
+ # 建立歷史價格 DataFrame
214
+ history_df = data.reset_index()
215
+ history_df = history_df[['Date', 'Close']]
216
+ history_df.rename(columns={'Close': 'Price'}, inplace=True)
 
217
 
218
+ return history_df, pred_df
 
 
 
 
 
 
219
 
220
+ except Exception as e:
221
+ print(f"預測過程中發生錯誤: {e}")
222
+ return None, f"預測過程中發生錯誤: {e}"
223
+
224
+ # --- 主要應用程式 ---
225
+ # 建立 Dash 應用程式
226
+ app = dash.Dash(__name__, suppress_callback_exceptions=True)
227
+
228
+ # 新聞預測器初始化
229
+ predictor = None
230
+ try:
231
+ if BertPredictor:
232
+ print("正在初始化新聞情緒分析模型...")
233
+ predictor = BertPredictor(max_news_per_keyword=5)
234
+ print("新聞情緒分析模型初始化成功。")
235
+ except Exception as e:
236
+ print(f"錯誤:新聞情緒分析模型初始化失敗 - {e}")
237
+ predictor = None
238
+
239
+
240
+ # --- 頁面內容定義 ---
241
  # 首頁:預測與總經
242
  homepage_layout = html.Div([
 
243
  html.Div([
244
+ html.H2("🤖 AI深度學習預測 - 台指期指數", style={'text-align': 'center','color': '#FFCC22','margin-bottom': '25px'}),
245
  html.Div([
246
  html.Div([
247
  html.Label("預測期間:", style={'font-weight': 'bold', 'color': '#FFCC22'}),
 
272
  ], style={'margin-top': '30px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
273
 
274
  html.Div([
275
+ html.H3("景氣燈號與 PMI 分析"),
276
  html.Div([
277
  html.Div([dcc.Graph(id='business-climate-chart')], style={'width': '48%', 'display': 'inline-block'}),
278
  html.Div([dcc.Graph(id='pmi-chart')], style={'width': '48%', 'display': 'inline-block', 'margin-left': '2%'})
 
283
 
284
  # 個股分析頁面
285
  stock_page_layout = html.Div([
 
286
  html.Div([
287
  html.Div([
288
+ html.Label("選擇股票:"),
289
+ dcc.Dropdown(id='stock-dropdown', options=[{'label': name, 'value': symbol} for name, symbol in TAIWAN_STOCKS.items()], value='2330.TW', style={'margin-bottom': '10px'})
290
  ], style={'width': '30%', 'display': 'inline-block', 'vertical-align': 'top'}),
291
  html.Div([
292
+ html.Label("時間範圍:"),
293
  dcc.Dropdown(id='period-dropdown',
294
  options=[{'label': '1個月', 'value': '1mo'},{'label': '3個月', 'value': '3mo'},{'label': '6個月', 'value': '6mo'},{'label': '1年', 'value': '1y'},{'label': '2年', 'value': '2y'}],
295
+ value='6mo', style={'margin-bottom': '10px'})
296
  ], style={'width': '30%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'}),
297
  html.Div([
298
+ html.Label("圖表類型:"),
299
+ dcc.Dropdown(id='chart-type', options=[{'label': '線圖', 'value': 'line'},{'label': '蠟燭圖', 'value': 'candlestick'}], value='candlestick', style={'margin-bottom': '10px'})
300
  ], style={'width': '30%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'})
301
+ ], style={'margin-bottom': '30px'}),
 
302
  html.Div(id='stock-info-cards', style={'margin-bottom': '30px'}),
303
+ html.Div([html.Div([dcc.Graph(id='price-chart')], style={'width': '100%', 'display': 'inline-block', 'vertical-align': 'top'})]),
 
304
  html.Div([
305
  html.H3("📊 進階技術指標分析", style={'margin-bottom': '20px'}),
306
  html.Div([
 
311
  value='RSI', style={'width': '100%'})
312
  ], style={'margin-bottom': '20px'}),
313
  html.Div([dcc.Graph(id='advanced-technical-chart')])
314
+ ], style={'margin-top': '20px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
315
+ html.Div([dcc.Graph(id='volume-chart')], style={'margin-top': '20px'}),
316
+ html.Div([html.H3("產業表現分析"), dcc.Graph(id='industry-analysis')], style={'margin-top': '30px'}),
 
 
 
317
  html.Div([
318
+ html.H3("📊 分析師觀點與市場解讀", style={'color': '#2E86AB', 'margin-bottom': '20px'}),
319
  html.Div([
320
  html.Div([
321
  html.H4("🔍 技術面分析", style={'color': '#A23B72', 'margin-bottom': '15px'}),
 
331
  html.Div(id='market-outlook-text', style={'background': 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)','color': 'white','padding': '20px','border-radius': '10px','min-height': '100px','font-size': '15px','line-height': '1.7','box-shadow': '0 4px 15px rgba(0,0,0,0.1)'})
332
  ])
333
  ], style={'margin-top': '30px','padding': '25px','background': 'white','border-radius': '12px','box-shadow': '0 4px 20px rgba(0,0,0,0.08)','border': '1px solid #e9ecef'}),
 
334
  html.Div([
335
  html.H3("📊 多檔股票比較分析", style={'margin-bottom': '20px'}),
336
  html.Div([
 
353
 
354
  # --- 主要應用程式佈局 ---
355
  app.layout = html.Div([
356
+ dcc.Location(id='url', refresh=False),
357
+ html.H1("台股分析儀表板", style={'text-align': 'center', 'margin-bottom': '10px'}),
 
358
  html.Div([
359
+ dcc.Link('市場總覽', href='/', style={'margin-right': '20px', 'font-size': '18px'}),
360
+ dcc.Link('個股分析', href='/stock-analysis', style={'font-size': '18px'}),
361
+ ], style={'text-align': 'center', 'margin-bottom': '30px'}),
362
+ html.Hr(),
363
+ html.Div(id='page-content')
364
  ])
365
 
366
 
 
370
  [dash.dependencies.Input('url', 'pathname')]
371
  )
372
  def display_page(pathname):
 
373
  if pathname == '/stock-analysis':
374
  return stock_page_layout
375
+ else:
376
  return homepage_layout
377
 
378
+ # --- 回調函數 (所有原始的 callback 都放在這裡) ---
 
379
 
 
380
  @app.callback(
381
+ dash.dependencies.Output('taiex-prediction-results', 'children'),
382
+ dash.dependencies.Output('taiex-prediction-chart', 'figure'),
383
  [dash.dependencies.Input('taiex-prediction-period', 'value')]
384
  )
385
+ def update_taiex_prediction(n_days):
386
+ """更新台指期預測結果"""
387
+ history_df, pred_df = simple_lstm_predict('^TWII', n_days)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
 
389
+ if history_df is None:
390
+ return html.P(pred_df, style={'color': 'red'}), go.Figure()
391
+
392
+ current_price = history_df.iloc[-1]['Price']
393
+ predicted_price = pred_df.iloc[-1]['Predicted_Price']
394
+ change = predicted_price - current_price
395
+ change_percent = (change / current_price) * 100
396
+
397
+ direction = "📈 上漲" if change > 0 else "📉 下跌" if change < 0 else "↔ 持平"
398
+ color = "green" if change > 0 else "red" if change < 0 else "orange"
399
 
400
+ result_text = html.Div([
401
+ html.P(f"當前價格: {current_price:.2f}", style={'font-size': '1.2em', 'margin': '5px 0'}),
402
+ html.P(f"預測 {n_days} 天後價格: {predicted_price:.2f}", style={'font-size': '1.2em', 'margin': '5px 0'}),
403
+ html.P(f"預測變動: {change:.2f} ({change_percent:.2f}%) {direction}", style={'font-size': '1.5em', 'font-weight': 'bold', 'color': color, 'margin': '10px 0'})
404
+ ])
405
+
406
+ # 繪製圖表
407
+ fig = go.Figure()
408
+ # 歷史價格
409
+ fig.add_trace(go.Scatter(x=history_df['Date'], y=history_df['Price'], mode='lines', name='歷史價格', line=dict(color='#8E44AD')))
410
+ # 預測價格
411
+ fig.add_trace(go.Scatter(x=pred_df['Date'], y=pred_df['Predicted_Price'], mode='lines', name='預測價格', line=dict(color='#E74C3C', dash='dash')))
412
 
413
  fig.update_layout(
414
+ title=f'台指期指數歷史與預測 ({n_days}天)',
415
  xaxis_title='日期',
416
+ yaxis_title='價格',
417
+ legend_title='圖例',
418
+ template='plotly_white'
 
 
419
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
 
421
+ return result_text, fig
422
+
 
 
423
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  @app.callback(
425
+ dash.dependencies.Output('sentiment-gauge', 'children'),
426
+ dash.dependencies.Output('news-summary', 'children')
 
 
427
  )
428
+ def update_sentiment_analysis():
429
+ """更新新聞情緒分析"""
430
+ if not predictor:
431
+ return html.Div("新聞情緒分析模型未初始化。"), html.Div("請檢查 'Bert_predict.py' 檔案是否存在。")
432
+
433
+ try:
434
+ sentiment_score, news_list = predictor.get_sentiment_score()
435
+
436
+ except Exception as e:
437
+ sentiment_score = None
438
+ news_list = []
439
+ print(f"情緒分析獲取失敗: {e}")
440
 
441
+ # 1. 建立儀表板 (Gauge)
442
+ if sentiment_score is not None:
443
+ gauge_fig = go.Figure(go.Indicator(
444
+ mode="gauge+number",
445
+ value=sentiment_score,
446
+ title={'text': "市場情緒分數 (0-100)"},
447
+ domain={'x': [0, 1], 'y': [0, 1]},
448
+ gauge={
449
+ 'axis': {'range': [0, 100]},
450
+ 'bar': {'color': "#667eea"},
451
+ 'steps': [
452
+ {'range': [0, 35], 'color': "rgba(217, 83, 79, 0.2)"},
453
+ {'range': [35, 65], 'color': "rgba(240, 173, 78, 0.2)"},
454
+ {'range': [65, 100], 'color': "rgba(92, 184, 92, 0.2)"}
455
+ ],
456
+ }
457
+ ))
458
+ gauge_fig.update_layout(height=200, margin=dict(l=30, r=30, t=50, b=20))
459
+ gauge_content = dcc.Graph(figure=gauge_fig)
460
  else:
461
+ # 處理無法計算分數的情況
462
+ error_fig = go.Figure().add_annotation(text="今日尚無情緒分數", showarrow=False)
463
+ error_fig.update_layout(height=200)
464
+ gauge_content = dcc.Graph(figure=error_fig)
465
+
466
+
467
+ # 2. 從 predictor 獲取分數最高的3則新聞
468
+ top_news_list = predictor.get_news()
469
+
470
+ # 3. 建立新聞摘要元件
471
+ if top_news_list: # 如果列表不為空
472
+ news_content = html.Div([
473
+ html.P(f"• {news}", style={
474
+ 'margin': '8px 0',
475
+ 'padding-left': '5px',
476
+ 'font-size': '14px',
477
+ 'border-left': '3px solid #E74C3C'
478
+ }) for news in top_news_list
479
+ ])
480
+ else:
481
+ news_content = html.Div("今日尚無重大新聞摘要。")
482
+
483
+ return gauge_content, news_content
484
 
 
 
 
 
 
 
 
 
 
 
 
 
 
485
 
 
486
  @app.callback(
487
+ dash.dependencies.Output('business-climate-chart', 'figure')
 
 
 
488
  )
489
+ def update_business_climate_chart():
490
+ """更新景氣燈號圖表"""
491
+ df = get_business_climate_data()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
492
 
493
+ color_map = {'藍': '#3498DB', '黃藍': '#F39C12', '綠': '#27AE60', '黃紅': '#E67E22', '紅': '#E74C3C'}
494
+ df['Color'] = df['Signal'].map(color_map)
 
 
 
 
 
 
495
 
496
+ fig = go.Figure()
497
+ fig.add_trace(go.Scatter(x=df['Date'], y=df['Score'], mode='lines+markers', marker=dict(color=df['Color'], size=10), line=dict(color='gray')))
 
 
 
 
 
498
 
499
+ fig.update_layout(
500
+ title='台灣景氣對策信號',
501
+ xaxis_title='日期',
502
+ yaxis_title='分數',
503
+ yaxis=dict(range=[9, 45]),
504
+ template='plotly_white'
505
+ )
 
 
 
506
  return fig
507
 
 
508
  @app.callback(
509
+ dash.dependencies.Output('pmi-chart', 'figure')
 
 
510
  )
511
+ def update_pmi_chart():
512
+ """更新PMI圖表"""
513
+ # 模擬獲取PMI資料,可替換為真實API
514
+ pmi = get_economic_data('ISM-MAN_PMI')
515
 
516
+ fig = px.line(x=pmi.index, y=pmi, title='美國ISM製造業PMI', labels={'x':'日期', 'y':'PMI'})
517
+ fig.add_hline(y=50, line_dash="dash", line_color="red", annotation_text="50 榮枯線")
518
 
519
+ fig.update_layout(template='plotly_white')
 
520
  return fig
521
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
522
 
 
523
  @app.callback(
524
+ [
525
+ dash.dependencies.Output('stock-info-cards', 'children'),
526
+ dash.dependencies.Output('price-chart', 'figure'),
527
+ dash.dependencies.Output('volume-chart', 'figure'),
528
+ dash.dependencies.Output('industry-analysis', 'figure'),
529
+ dash.dependencies.Output('technical-analysis-text', 'children'),
530
+ dash.dependencies.Output('fundamental-analysis-text', 'children'),
531
+ dash.dependencies.Output('market-outlook-text', 'children')
532
+ ],
533
+ [
534
+ dash.dependencies.Input('stock-dropdown', 'value'),
535
+ dash.dependencies.Input('period-dropdown', 'value'),
536
+ dash.dependencies.Input('chart-type', 'value')
537
+ ]
538
  )
539
+ def update_all_stock_info(selected_stock, selected_period, chart_type):
540
+ """根據選取股票更新所有相關圖表與資訊"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
 
542
+ # 獲取資料
543
+ df = get_stock_data(selected_stock, period=selected_period)
544
+ df = add_technical_indicators(df)
 
 
 
 
 
 
 
 
 
 
 
545
 
546
+ if df.empty:
547
+ return [
548
+ html.Div("找不到股票資訊。"),
549
+ go.Figure(),
550
+ go.Figure(),
551
+ go.Figure(),
552
+ "找不到技術分析資料。",
553
+ "找不到基本面分析資料。",
554
+ "無法提供市場展望。"
555
+ ]
556
 
557
+ # --- 1. 股票資訊卡片 ---
558
+ latest_data = df.iloc[-1]
559
+ last_close = df['Close'].iloc[-2] if len(df) > 1 else latest_data['Close']
560
+ change = latest_data['Close'] - last_close
561
+ change_percent = (change / last_close) * 100
562
+ change_color = 'green' if change >= 0 else 'red'
 
 
 
 
 
563
 
564
+ info_cards = html.Div([
565
+ html.Div([
566
+ html.H5("收盤價", style={'color': '#8e44ad'}),
567
+ html.H3(f"{latest_data['Close']:.2f} TWD", style={'color': '#8e44ad'})
568
+ ], className="card", style={'border-left': '5px solid #8e44ad'}),
569
+ html.Div([
570
+ html.H5("漲跌幅", style={'color': '#27ae60'}),
571
+ html.H3(f"{change:.2f} ({change_percent:.2f}%)", style={'color': change_color})
572
+ ], className="card", style={'border-left': '5px solid #27ae60'}),
573
+ html.Div([
574
+ html.H5("成交量", style={'color': '#d35400'}),
575
+ html.H3(f"{latest_data['Volume']/10000:,.0f} 萬股", style={'color': '#d35400'})
576
+ ], className="card", style={'border-left': '5px solid #d35400'}),
577
+ ], style={'display': 'flex', 'justify-content': 'space-around', 'margin-bottom': '20px'})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
578
 
579
+ # --- 2. 股價圖 ---
580
+ if chart_type == 'candlestick':
581
+ price_fig = go.Figure(data=[go.Candlestick(x=df.index,
582
+ open=df['Open'],
583
+ high=df['High'],
584
+ low=df['Low'],
585
+ close=df['Close'])
586
+ ])
587
+ else: # line chart
588
+ price_fig = px.line(df, x=df.index, y='Close', title='股價走勢圖')
589
+
590
+ price_fig.update_layout(xaxis_rangeslider_visible=False, title=f'{selected_stock} 股價走勢圖', template='plotly_white')
591
 
592
+ # --- 3. 成交量圖 ---
593
+ volume_fig = px.bar(df, x=df.index, y='Volume', title='成交量', color='Volume', color_continuous_scale='bluered')
594
+ volume_fig.update_layout(template='plotly_white', coloraxis_showscale=False)
595
 
596
+ # --- 4. 產業表現分析 ---
597
+ industry_analysis_fig = go.Figure()
598
+ industry_stock = ''
599
+ for industry, stocks in INDUSTRY_MAPPING.items():
600
+ if selected_stock in stocks:
601
+ industry_stock = industry
602
+ for stock_symbol in stocks:
603
+ stock_data = get_stock_data(stock_symbol, period='1y')
604
+ if not stock_data.empty:
605
+ industry_analysis_fig.add_trace(go.Scatter(
606
+ x=stock_data.index, y=stock_data['Close'], mode='lines', name=stock_symbol,
607
+ visible='legendonly' if stock_symbol != selected_stock else True
608
+ ))
609
+ break
610
+
611
+ industry_analysis_fig.update_layout(title=f'{industry_stock} 產業表現', template='plotly_white')
612
 
613
+ # --- 5. 分析師觀點文字 ---
614
+ analysis_texts = generate_analysis_text(df)
615
+
616
+ return info_cards, price_fig, volume_fig, industry_analysis_fig, analysis_texts['technical'], analysis_texts['fundamental'], analysis_texts['outlook']
617
+
618
 
 
619
  @app.callback(
620
+ dash.dependencies.Output('advanced-technical-chart', 'figure'),
621
+ [
622
+ dash.dependencies.Input('stock-dropdown', 'value'),
623
+ dash.dependencies.Input('period-dropdown', 'value'),
624
+ dash.dependencies.Input('technical-indicator-selector', 'value')
625
+ ]
626
  )
627
+ def update_technical_indicator_chart(selected_stock, selected_period, indicator):
628
+ """更新技術指標圖表"""
629
+ df = get_stock_data(selected_stock, period=selected_period)
630
+ df = add_technical_indicators(df)
 
 
 
 
 
 
631
 
632
+ if df.empty or indicator not in df.columns:
633
+ return go.Figure()
634
+
635
  fig = go.Figure()
 
 
 
 
 
 
 
 
636
 
637
+ if indicator == 'RSI':
638
+ fig = px.line(df, x=df.index, y='RSI', title='RSI 相對強弱指標')
639
+ fig.add_hline(y=70, line_dash="dash", line_color="red", annotation_text="超買線")
640
+ fig.add_hline(y=30, line_dash="dash", line_color="green", annotation_text="超賣線")
641
+ fig.update_yaxes(range=[0, 100])
642
 
643
+ elif indicator == 'MACD':
644
+ fig = go.Figure()
645
+ fig.add_trace(go.Scatter(x=df.index, y=df['MACD'], name='MACD', mode='lines', line=dict(color='blue')))
646
+ fig.add_trace(go.Scatter(x=df.index, y=df['Signal_Line'], name='Signal', mode='lines', line=dict(color='red')))
647
+ colors = ['green' if val > 0 else 'red' for val in df['MACD_Hist']]
648
+ fig.add_trace(go.Bar(x=df.index, y=df['MACD_Hist'], name='Histogram', marker_color=colors))
649
+ fig.update_layout(title='MACD 指數平滑異同移動平均線')
650
+
651
+ elif indicator == 'BB':
652
+ fig = px.line(df, x=df.index, y=['Close', 'Upper_BB', 'Lower_BB'], title='布林通道 Bollinger Bands')
653
+
654
+ elif indicator == 'KD':
655
+ fig = px.line(df, x=df.index, y=['%K', '%D'], title='KD 隨機指標')
656
+ fig.add_hline(y=80, line_dash="dash", line_color="red", annotation_text="超買線")
657
+ fig.add_hline(y=20, line_dash="dash", line_color="green", annotation_text="超賣線")
658
+ fig.update_yaxes(range=[0, 100])
659
+
660
+ elif indicator == 'WR':
661
+ fig = px.line(df, x=df.index, y='%R', title='威廉指標 %R')
662
+ fig.add_hline(y=-20, line_dash="dash", line_color="red", annotation_text="超買線")
663
+ fig.add_hline(y=-80, line_dash="dash", line_color="green", annotation_text="超賣線")
664
+ fig.update_yaxes(range=[-100, 0])
665
+
666
+ elif indicator == 'DMI':
667
+ fig = px.line(df, x=df.index, y=['DIplus', 'DIminus', 'ADX'], title='DMI 動向指標')
668
+ fig.add_hline(y=20, line_dash="dash", line_color="orange", annotation_text="趨勢強弱參考線")
669
+
670
+ fig.update_layout(template='plotly_white')
671
  return fig
672
 
673
+
674
  @app.callback(
675
+ [
676
+ dash.dependencies.Output('comparison-chart', 'figure'),
677
+ dash.dependencies.Output('comparison-table', 'children')
678
+ ],
679
+ [
680
+ dash.dependencies.Input('comparison-stocks', 'value'),
681
+ dash.dependencies.Input('comparison-period', 'value')
682
+ ]
683
  )
684
+ def update_comparison_chart(tickers, period):
685
+ if not tickers:
686
+ return go.Figure(), html.P("請至少選擇一檔股票。")
687
+
688
+ df_dict = {}
689
+ for ticker in tickers:
690
+ df = yf.download(ticker, period=period)
691
+ if not df.empty:
692
+ df['Normalized'] = df['Close'] / df['Close'].iloc[0] * 100
693
+ df_dict[ticker] = df['Normalized']
694
+
695
+ if not df_dict:
696
+ return go.Figure(), html.P("找不到任何股票資料。")
697
+
698
+ normalized_df = pd.DataFrame(df_dict)
699
 
700
  fig = go.Figure()
701
+ for col in normalized_df.columns:
702
+ stock_name = next((name for name, symbol in TAIWAN_STOCKS.items() if symbol == col), col)
703
+ fig.add_trace(go.Scatter(x=normalized_df.index, y=normalized_df[col], name=stock_name, mode='lines'))
704
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
705
  fig.update_layout(
706
+ title='股票相對漲跌幅比較',
707
+ xaxis_title='日期',
708
+ yaxis_title='相對漲��幅 (%) (基期=100)',
709
+ template='plotly_white'
 
 
710
  )
711
 
712
+ # 建立表格
713
+ summary_data = []
714
+ for ticker, df in df_dict.items():
715
+ stock_name = next((name for name, symbol in TAIWAN_STOCKS.items() if symbol == ticker), ticker)
716
+ start_price = df.iloc[0]
717
+ end_price = df.iloc[-1]
718
+ change_percent = (end_price - start_price)
719
+ summary_data.append({
720
+ '股票': stock_name,
721
+ '漲跌幅': f'{change_percent:.2f}%'
722
+ })
723
+
724
+ summary_df = pd.DataFrame(summary_data)
725
+ table = html.Table([
726
+ html.Thead(html.Tr([html.Th(col) for col in summary_df.columns])),
727
+ html.Tbody([
728
+ html.Tr([
729
+ html.Td(summary_df.iloc[i][col]) for col in summary_df.columns
730
+ ]) for i in range(len(summary_df))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
731
  ])
732
+ ])
733
+
734
+ return fig, table
 
 
 
 
735
 
736
  # 主程式執行
737
  if __name__ == '__main__':