AlanRex commited on
Commit
34f089c
·
verified ·
1 Parent(s): 50be322

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +241 -1173
app.py CHANGED
@@ -1,3 +1,5 @@
 
 
1
  # 系統套件
2
  import os
3
  from datetime import datetime, timedelta
@@ -13,8 +15,8 @@ import re
13
  from bs4 import BeautifulSoup
14
  import requests
15
 
16
- # 引入 BERT 預測模組
17
- from Bert_predict import predict_sentiment
18
 
19
  # 台股代號對應表 (移除台指期,因為它現在是獨立區塊)
20
  TAIWAN_STOCKS = {
@@ -72,217 +74,117 @@ def get_stock_data(symbol, period='1y'):
72
  try:
73
  stock = yf.Ticker(symbol)
74
  data = stock.history(period=period)
75
-
76
- # 如果台指期資料為空,嘗試替代方案
77
  if data.empty and symbol == 'TXF=F':
78
- # 嘗試使用台灣50ETF作為替代
79
  stock = yf.Ticker('0050.TW')
80
  data = stock.history(period=period)
81
  if data.empty:
82
- # 最後嘗試使用加權指數
83
  stock = yf.Ticker('^TWII')
84
  data = stock.history(period=period)
85
-
86
  return data
87
  except:
88
  return pd.DataFrame()
89
 
90
- def create_lstm_dataset(data, time_step=60):
91
- """建立LSTM訓練資料集"""
92
- X, y = [], []
93
- for i in range(time_step, len(data)):
94
- X.append(data[i-time_step:i, 0])
95
- y.append(data[i, 0])
96
- return np.array(X), np.array(y)
97
-
98
  def simple_lstm_predict(data, predict_days=5):
99
  """簡化的LSTM預測模型 (使用統計方法模擬)"""
100
  if len(data) < 60:
101
  return None
102
-
103
- # 使用移動平均和趨勢分析來模擬深度學習預測
104
  prices = data['Close'].values
105
-
106
- # 計算短期和長期移動平均
107
  ma_short = np.mean(prices[-5:])
108
  ma_medium = np.mean(prices[-20:])
109
  ma_long = np.mean(prices[-60:])
110
-
111
- # 計算價格變化趨勢
112
  recent_trend = np.polyfit(range(20), prices[-20:], 1)[0]
113
  volatility = np.std(prices[-20:]) / np.mean(prices[-20:])
114
-
115
- # 模擬預測邏輯
116
  base_change = recent_trend * predict_days
117
  trend_factor = 1.0
118
-
119
  if ma_short > ma_medium > ma_long:
120
- trend_factor = 1.02 # 上升趨勢
121
  elif ma_short < ma_medium < ma_long:
122
- trend_factor = 0.98 # 下降趨勢
123
  else:
124
- trend_factor = 1.0 # 盤整
125
-
126
- # 加入隨機性模擬市場不確定性
127
  noise_factor = np.random.normal(1, volatility * 0.1)
128
-
129
  predicted_price = prices[-1] * trend_factor + base_change + (prices[-1] * noise_factor * 0.01)
130
  change_pct = ((predicted_price - prices[-1]) / prices[-1]) * 100
131
-
132
  return {
133
  'predicted_price': predicted_price,
134
  'change_pct': change_pct,
135
- 'confidence': max(0.6, 1 - volatility * 2) # 基於波動率的信心度
136
  }
137
 
138
  def calculate_technical_indicators(df):
139
  """計算技術指標"""
140
- if df.empty:
141
- return df
142
-
143
- # 移動平均線
144
  df['MA5'] = df['Close'].rolling(window=5).mean()
145
  df['MA20'] = df['Close'].rolling(window=20).mean()
146
-
147
- # RSI
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
  rs = gain / loss
152
  df['RSI'] = 100 - (100 / (1 + rs))
153
-
154
- # MACD (12, 26, 9)
155
  exp1 = df['Close'].ewm(span=12).mean()
156
  exp2 = df['Close'].ewm(span=26).mean()
157
  df['MACD'] = exp1 - exp2
158
  df['MACD_Signal'] = df['MACD'].ewm(span=9).mean()
159
  df['MACD_Histogram'] = df['MACD'] - df['MACD_Signal']
160
-
161
- # 布林通道 (20日, 2倍標準差)
162
  df['BB_Middle'] = df['Close'].rolling(window=20).mean()
163
  bb_std = df['Close'].rolling(window=20).std()
164
  df['BB_Upper'] = df['BB_Middle'] + (bb_std * 2)
165
  df['BB_Lower'] = df['BB_Middle'] - (bb_std * 2)
166
- df['BB_Width'] = df['BB_Upper'] - df['BB_Lower']
167
- df['BB_Position'] = (df['Close'] - df['BB_Lower']) / (df['BB_Upper'] - df['BB_Lower'])
168
-
169
- # KD指標 (9, 3, 3)
170
  low_min = df['Low'].rolling(window=9).min()
171
  high_max = df['High'].rolling(window=9).max()
172
  rsv = (df['Close'] - low_min) / (high_max - low_min) * 100
173
- df['K'] = rsv.ewm(com=2).mean() # com=2 相當於 span=3
174
  df['D'] = df['K'].ewm(com=2).mean()
175
-
176
- # 威廉指標 %R (14日)
177
  low_min_14 = df['Low'].rolling(window=14).min()
178
  high_max_14 = df['High'].rolling(window=14).max()
179
  df['Williams_R'] = -100 * (high_max_14 - df['Close']) / (high_max_14 - low_min_14)
180
-
181
- # DMI (Directional Movement Index)
182
  df['up_move'] = df['High'] - df['High'].shift(1)
183
  df['down_move'] = df['Low'].shift(1) - df['Low']
184
  df['+DM'] = np.where((df['up_move'] > df['down_move']) & (df['up_move'] > 0), df['up_move'], 0)
185
  df['-DM'] = np.where((df['down_move'] > df['up_move']) & (df['down_move'] > 0), df['down_move'], 0)
186
-
187
- # 計算真實範圍 (TR)
188
  df['TR'] = np.max([df['High'] - df['Low'], abs(df['High'] - df['Close'].shift(1)), abs(df['Low'] - df['Close'].shift(1))], axis=0)
189
-
190
- # 計算平滑後的 +DM, -DM, TR (通常使用 14 天)
191
  df['+DI'] = (df['+DM'].ewm(com=13, adjust=False).mean() / df['TR'].ewm(com=13, adjust=False).mean()) * 100
192
  df['-DI'] = (df['-DM'].ewm(com=13, adjust=False).mean() / df['TR'].ewm(com=13, adjust=False).mean()) * 100
193
-
194
- # 計算 ADX
195
  df['DX'] = abs(df['+DI'] - df['-DI']) / (df['+DI'] + df['-DI']) * 100
196
  df['ADX'] = df['DX'].ewm(com=13, adjust=False).mean()
197
-
198
  return df
199
 
200
  def calculate_volume_profile(df, num_bins=50):
201
- """
202
- 計算成交量分佈圖 (Volume Profile) 的數據。
203
- """
204
- if df.empty or 'High' not in df.columns or 'Low' not in df.columns or 'Volume' not in df.columns:
205
- return None, None, None
206
-
207
  all_prices = np.concatenate([df['High'].values, df['Low'].values])
208
- min_price = all_prices.min()
209
- max_price = all_prices.max()
210
-
211
  price_for_volume = (df['High'] + df['Low'] + df['Close']) / 3
212
  df_vol_profile = df.copy()
213
  df_vol_profile['Price_Indicator'] = price_for_volume
214
- df_vol_profile['Volume'] = df_vol_profile['Volume']
215
-
216
  hist, bin_edges = np.histogram(df_vol_profile['Price_Indicator'], bins=num_bins, range=(min_price, max_price), weights=df_vol_profile['Volume'])
217
  price_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
218
-
219
  return bin_edges, hist, price_centers
220
 
221
  def get_business_climate_data():
222
- """獲取台灣景氣燈號資料"""
223
  try:
224
- # 檢查檔案是否存在
225
- if not os.path.exists('business_climate.csv'):
226
- print("business_climate.csv 檔案不存在")
227
- return pd.DataFrame()
228
-
229
- # 讀取CSV檔案,假設列名為 Date 和 Index
230
  df = pd.read_csv('business_climate.csv')
231
-
232
- # 檢查列名並調整
233
- if 'Date' not in df.columns:
234
- # 如果第一列是日期,重新命名
235
- df.columns = ['Date', 'Index'] if len(df.columns) == 2 else df.columns
236
-
237
- # 轉換日期格式 (處理 YYYY-MM 格式)
238
  if 'Date' in df.columns:
239
- try:
240
- # 如果是 YYYY-MM 格式,轉換為日期
241
- df['Date'] = pd.to_datetime(df['Date'] + '-01', format='%Y-%m-%d', errors='coerce')
242
- except:
243
- df['Date'] = pd.to_datetime(df['Date'], errors='coerce')
244
-
245
- # 移除日期轉換失敗的行
246
  df = df.dropna(subset=['Date'])
247
-
248
- print(f"成功讀取景氣燈號資料:{len(df)} 筆記錄")
249
  return df
250
-
251
  except Exception as e:
252
  print(f"無法獲取景氣燈號資料: {str(e)}")
253
  return pd.DataFrame()
254
 
255
  def get_pmi_data():
256
- """獲取台灣 PMI 資料"""
257
  try:
258
- # 檢查檔案是否存在
259
- if not os.path.exists('taiwan_pmi.csv'):
260
- print("taiwan_pmi.csv 檔案不存在")
261
- return pd.DataFrame()
262
-
263
- # 讀取CSV檔案
264
  df = pd.read_csv('taiwan_pmi.csv')
265
-
266
- # 檢查列名並調整 (處理 DATE/INDEX 或其他可能的列名)
267
- if 'DATE' in df.columns:
268
- df = df.rename(columns={'DATE': 'Date', 'INDEX': 'Index'})
269
- elif len(df.columns) == 2:
270
- df.columns = ['Date', 'Index']
271
-
272
- # 轉換日期格式
273
  if 'Date' in df.columns:
274
- try:
275
- # 如果是 YYYY-MM 格式,轉換為日期
276
- df['Date'] = pd.to_datetime(df['Date'] + '-01', format='%Y-%m-%d', errors='coerce')
277
- except:
278
- df['Date'] = pd.to_datetime(df['Date'], errors='coerce')
279
-
280
- # 移除日期轉換失敗的行
281
  df = df.dropna(subset=['Date'])
282
-
283
- print(f"成功讀取 PMI 資料:{len(df)} 筆記錄")
284
  return df
285
-
286
  except Exception as e:
287
  print(f"無法獲取 PMI 資料: {str(e)}")
288
  return pd.DataFrame()
@@ -290,50 +192,36 @@ def get_pmi_data():
290
  # 建立 Dash 應用程式
291
  app = dash.Dash(__name__, suppress_callback_exceptions=True)
292
 
 
 
 
 
 
 
 
 
 
293
  # 應用程式佈局
294
  app.layout = html.Div([
295
  html.H1("台股分析儀表板", style={'text-align': 'center', 'margin-bottom': '30px'}),
296
-
297
- # 台指期獨立預測區塊 - 置於頂部
298
  html.Div([
299
- html.H2("🤖 AI深度學習預測 - 台指期指數", style={
300
- 'text-align': 'center',
301
- 'color': '#FFCC22',
302
- 'margin-bottom': '25px'
303
- }),
304
  html.Div([
305
  html.Div([
306
  html.Label("預測期間:", style={'font-weight': 'bold', 'color': '#FFCC22'}),
307
- dcc.Dropdown(
308
- id='taiex-prediction-period',
309
  options=[
310
- {'label': '1日後預測', 'value': 1},
311
- {'label': '5日後預測', 'value': 5},
312
- {'label': '10日後預測', 'value': 10},
313
- {'label': '20日後預測', 'value': 20},
314
- {'label': '60日後預測', 'value': 60}
315
- ],
316
- value=5,
317
- style={'margin-bottom': '10px', 'color': '#272727'}
318
- )
319
  ], style={'width': '30%', 'display': 'inline-block'}),
320
-
321
  html.Div(id='taiex-prediction-results', style={'width': '65%', 'display': 'inline-block', 'margin-left': '5%'})
322
  ]),
323
-
324
- html.Div([
325
- dcc.Graph(id='taiex-prediction-chart')
326
- ], style={'margin-top': '20px'})
327
- ], style={
328
- 'background': 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
329
- 'padding': '25px',
330
- 'border-radius': '15px',
331
- 'box-shadow': '0 8px 25px rgba(0,0,0,0.15)',
332
- 'color': 'white',
333
- 'margin-bottom': '40px'
334
- }),
335
 
336
- # 新聞情感分析區域(模擬)
337
  html.Div([
338
  html.H3("📰 市場情緒與新聞分析", style={'color': '#E74C3C', 'margin-bottom': '20px'}),
339
  html.Div([
@@ -341,359 +229,123 @@ app.layout = html.Div([
341
  html.H4("市場情緒指標", style={'color': '#8E44AD'}),
342
  html.Div(id='sentiment-gauge')
343
  ], style={'width': '48%', 'display': 'inline-block'}),
344
-
345
  html.Div([
346
  html.H4("關鍵新聞摘要", style={'color': '#27AE60'}),
347
- html.Div(id='news-summary', style={
348
- 'background': '#f8f9fa',
349
- 'padding': '15px',
350
- 'border-radius': '8px',
351
- 'max-height': '200px',
352
- 'overflow-y': 'auto'
353
- })
354
  ], style={'width': '48%', 'display': 'inline-block', 'margin-left': '4%'})
355
  ])
356
- ], style={
357
- 'margin-top': '30px',
358
- 'padding': '20px',
359
- 'background': 'white',
360
- 'border-radius': '10px',
361
- 'box-shadow': '0 2px 10px rgba(0,0,0,0.1)'
362
- }),
363
 
364
- # 景氣燈號與 PMI 分析
365
  html.Div([
366
  html.H3("景氣燈號與 PMI 分析"),
367
  html.Div([
368
- html.Div([
369
- dcc.Graph(id='business-climate-chart')
370
- ], style={'width': '48%', 'display': 'inline-block'}),
371
- html.Div([
372
- dcc.Graph(id='pmi-chart')
373
- ], style={'width': '48%', 'display': 'inline-block', 'margin-left': '2%'})
374
  ])
375
  ], style={'margin-top': '30px'}),
376
-
377
-
378
 
379
- # 控制面板 (移除台指期選項)
380
  html.Div([
381
  html.Div([
382
  html.Label("選擇股票:"),
383
- dcc.Dropdown(
384
- id='stock-dropdown',
385
- options=[{'label': name, 'value': symbol} for name, symbol in TAIWAN_STOCKS.items()],
386
- value='2330.TW', # 預設改為台積電
387
- style={'margin-bottom': '10px'}
388
- )
389
  ], style={'width': '30%', 'display': 'inline-block', 'vertical-align': 'top'}),
390
-
391
  html.Div([
392
  html.Label("時間範圍:"),
393
- dcc.Dropdown(
394
- id='period-dropdown',
395
- options=[
396
- {'label': '1個月', 'value': '1mo'},
397
- {'label': '3個月', 'value': '3mo'},
398
- {'label': '6個月', 'value': '6mo'},
399
- {'label': '1年', 'value': '1y'},
400
- {'label': '2年', 'value': '2y'}
401
- ],
402
- value='6mo',
403
- style={'margin-bottom': '10px'}
404
- )
405
  ], style={'width': '30%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'}),
406
-
407
  html.Div([
408
  html.Label("圖表類型:"),
409
- dcc.Dropdown(
410
- id='chart-type',
411
- options=[
412
- {'label': '線圖', 'value': 'line'},
413
- {'label': '蠟燭圖', 'value': 'candlestick'}
414
- ],
415
- value='candlestick',
416
- style={'margin-bottom': '10px'}
417
- )
418
  ], style={'width': '30%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'})
419
  ], style={'margin-bottom': '30px'}),
420
 
421
- # 股價資訊卡片
422
  html.Div(id='stock-info-cards', style={'margin-bottom': '30px'}),
423
-
424
- # 主要圖表區域 - 移除RSI圖表
425
- html.Div([
426
- # 左側:股價走勢圖 (現在包含成交量分佈)
427
- html.Div([
428
- html.Div([
429
- dcc.Graph(id='price-chart')
430
- ])
431
- ], style={'width': '100%', 'display': 'inline-block', 'vertical-align': 'top'}),
432
- ]),
433
-
434
- # 技術指標選擇區域
435
  html.Div([
436
  html.H3("📊 進階技術指標分析", style={'margin-bottom': '20px'}),
437
  html.Div([
438
  html.Label("選擇技術指標:", style={'font-weight': 'bold', 'margin-right': '10px'}),
439
- dcc.Dropdown(
440
- id='technical-indicator-selector',
441
- options=[
442
- {'label': 'RSI 相對強弱指標', 'value': 'RSI'},
443
- {'label': 'MACD 指數平滑異同移動平均線', 'value': 'MACD'},
444
- {'label': '布林通道 Bollinger Bands', 'value': 'BB'},
445
- {'label': 'KD 隨機指標', 'value': 'KD'},
446
- {'label': '威廉指標 %R', 'value': 'WR'},
447
- {'label': 'DMI 動向指標', 'value': 'DMI'}
448
- ],
449
- value='RSI',
450
- style={'width': '100%'}
451
- )
452
  ], style={'margin-bottom': '20px'}),
453
-
454
- html.Div([
455
- dcc.Graph(id='advanced-technical-chart')
456
- ])
457
- ], style={
458
- 'margin-top': '20px',
459
- 'padding': '20px',
460
- 'background': 'white',
461
- 'border-radius': '10px',
462
- 'box-shadow': '0 2px 10px rgba(0,0,0,0.1)'
463
- }),
464
-
465
- # 成交量圖
466
- html.Div([
467
- dcc.Graph(id='volume-chart')
468
- ], style={'margin-top': '20px'}),
469
-
470
- # 產業分析
471
- html.Div([
472
- html.H3("產業表現分析"),
473
- dcc.Graph(id='industry-analysis')
474
- ], style={'margin-top': '30px'}),
475
-
476
- # 分析師觀點區域
477
  html.Div([
478
  html.H3("📊 分析師觀點與市場解讀", style={'color': '#2E86AB', 'margin-bottom': '20px'}),
479
  html.Div([
480
- # 左側:技術分析觀點
481
  html.Div([
482
  html.H4("🔍 技術面分析", style={'color': '#A23B72', 'margin-bottom': '15px'}),
483
- html.Div(id='technical-analysis-text', style={
484
- 'background': '#f8f9fa',
485
- 'padding': '15px',
486
- 'border-radius': '8px',
487
- 'border-left': '4px solid #A23B72',
488
- 'min-height': '150px',
489
- 'font-size': '14px',
490
- 'line-height': '1.6'
491
- })
492
  ], style={'width': '48%', 'display': 'inline-block', 'vertical-align': 'top'}),
493
-
494
- # 右側:基本面分析觀點
495
  html.Div([
496
  html.H4("📈 基本面分析", style={'color': '#F18F01', 'margin-bottom': '15px'}),
497
- html.Div(id='fundamental-analysis-text', style={
498
- 'background': '#f8f9fa',
499
- 'padding': '15px',
500
- 'border-radius': '8px',
501
- 'border-left': '4px solid #F18F01',
502
- 'min-height': '150px',
503
- 'font-size': '14px',
504
- 'line-height': '1.6'
505
- })
506
  ], style={'width': '48%', 'display': 'inline-block', 'margin-left': '4%', 'vertical-align': 'top'})
507
  ]),
508
-
509
- # 底部:市場展望
510
  html.Div([
511
  html.H4("🎯 市場展望與投資建議", style={'color': '#C73E1D', 'margin-bottom': '15px', 'margin-top': '25px'}),
512
- html.Div(id='market-outlook-text', style={
513
- 'background': 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
514
- 'color': 'white',
515
- 'padding': '20px',
516
- 'border-radius': '10px',
517
- 'min-height': '100px',
518
- 'font-size': '15px',
519
- 'line-height': '1.7',
520
- 'box-shadow': '0 4px 15px rgba(0,0,0,0.1)'
521
- })
522
  ])
523
- ], style={
524
- 'margin-top': '30px',
525
- 'padding': '25px',
526
- 'background': 'white',
527
- 'border-radius': '12px',
528
- 'box-shadow': '0 4px 20px rgba(0,0,0,0.08)',
529
- 'border': '1px solid #e9ecef'
530
- }),
531
-
532
-
533
- # ==============================================================================
534
- # ===== 修改後的多檔股票比較區域 =====
535
- # ==============================================================================
536
  html.Div([
537
  html.H3("📊 多檔股票比較分析", style={'margin-bottom': '20px'}),
538
  html.Div([
539
  html.Div([
540
  html.Label("選擇比較股票(最多5檔):", style={'font-weight': 'bold'}),
541
- dcc.Dropdown(
542
- id='comparison-stocks',
543
- options=[{'label': name, 'value': symbol} for name, symbol in TAIWAN_STOCKS.items()],
544
- value=['0050.TW', '2330.TW', '2454.TW'], # 修改:預設包含0050
545
- multi=True,
546
- style={'margin-bottom': '5px'} # 調整間距
547
- ),
548
- # 新增:提示文字
549
- html.Small(
550
- '(元大台灣50 (0050.TW) 為固定比較基準,不可移除)',
551
- style={'display': 'block', 'font-style': 'italic', 'color': 'gray'}
552
- )
553
  ], style={'width': '60%', 'display': 'inline-block'}),
554
-
555
  html.Div([
556
  html.Label("比���期間:", style={'font-weight': 'bold'}),
557
- dcc.Dropdown(
558
- id='comparison-period',
559
- options=[
560
- {'label': '1個月', 'value': '1mo'},
561
- {'label': '3個月', 'value': '3mo'},
562
- {'label': '6個月', 'value': '6mo'},
563
- {'label': '1年', 'value': '1y'}
564
- ],
565
- value='3mo'
566
- )
567
  ], style={'width': '35%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'})
568
  ]),
569
-
570
  html.Div([
571
- html.Div([
572
- dcc.Graph(id='comparison-chart')
573
- ], style={'width': '65%', 'display': 'inline-block'}),
574
-
575
- html.Div([
576
- html.H4("比較結果", style={'color': '#2E86AB'}),
577
- html.Div(id='comparison-table')
578
- ], style={'width': '33%', 'display': 'inline-block', 'margin-left': '2%', 'vertical-align': 'top'})
579
  ])
580
- ], style={
581
- 'margin-top': '30px',
582
- 'padding': '20px',
583
- 'background': 'white',
584
- 'border-radius': '10px',
585
- 'box-shadow': '0 2px 10px rgba(0,0,0,0.1)'
586
- }),
587
-
588
-
589
  ])
590
 
591
- # 台指期獨立預測回調函數 (新版本)
592
  @app.callback(
593
  [dash.dependencies.Output('taiex-prediction-results', 'children'),
594
  dash.dependencies.Output('taiex-prediction-chart', 'figure')],
595
  [dash.dependencies.Input('taiex-prediction-period', 'value')]
596
  )
597
  def update_taiex_prediction(predict_days):
598
- # 獲取台指期歷史資料
599
  data = get_stock_data('^TWII', '2y')
600
- if data.empty:
601
- return html.Div("無法獲取台指期資料"), {}
602
-
603
- # 執行最終日的預測,用於顯示在結果卡片上
604
  final_prediction = simple_lstm_predict(data, predict_days)
605
- if final_prediction is None:
606
- return html.Div("資料不足,無法進行預測"), {}
607
-
608
- current_price = data['Close'].iloc[-1]
609
- last_date = data.index[-1]
610
- predicted_price = final_prediction['predicted_price']
611
- change_pct = final_prediction['change_pct']
612
- confidence = final_prediction['confidence']
613
-
614
- # --- 主要修改處:計算預測路徑 ---
615
- # 1. 定義不同預測天期所包含的中間節點
616
- prediction_paths = {
617
- 1: [1],
618
- 5: [1, 5],
619
- 10: [1, 5, 10],
620
- 20: [1, 10, 20],
621
- 60: [1, 10, 20, 60]
622
- }
623
  intervals_to_predict = prediction_paths.get(predict_days, [predict_days])
624
-
625
- # 2. 準備儲存預測路徑的座標點 (起始點為目前價格)
626
- prediction_dates = [last_date]
627
- prediction_prices = [current_price]
628
-
629
- # 3. 循環計算路徑上每個點的預測值
630
  for days in intervals_to_predict:
631
  interim_prediction = simple_lstm_predict(data, days)
632
  if interim_prediction:
633
  prediction_dates.append(last_date + timedelta(days=days))
634
  prediction_prices.append(interim_prediction['predicted_price'])
635
- # --- 修改結束 ---
636
-
637
- # 預測結果卡片 (維持不變)
638
- # 根據台股慣例修改顏色
639
- color = 'red' if change_pct >= 0 else 'green'
640
- arrow = '📈' if change_pct >= 0 else '📉'
641
-
642
  result_card = html.Div([
643
  html.H4(f"{predict_days}日後預測結果", style={'margin': '0 0 15px 0', 'color': 'white'}),
644
- html.Div([
645
- html.Span(f"{arrow} ", style={'font-size': '24px'}),
646
- html.Span(f"{change_pct:+.2f}%", style={
647
- 'font-size': '28px',
648
- 'font-weight': 'bold',
649
- 'color': color
650
- })
651
- ], style={'margin': '10px 0'}),
652
- html.P(f"目前價格: {current_price:.2f}", style={'margin': '5px 0'}),
653
- html.P(f"預測價格: {predicted_price:.2f}", style={'margin': '5px 0'}),
654
  html.P(f"信心度: {confidence:.1%}", style={'margin': '5px 0', 'font-size': '14px'})
655
- ], style={
656
- 'background': 'rgba(255,255,255,0.1)',
657
- 'padding': '20px',
658
- 'border-radius': '10px',
659
- 'border': '1px solid rgba(255,255,255,0.2)'
660
- })
661
-
662
- # 建立預測趨勢圖
663
  fig = go.Figure()
664
-
665
- # 歷史價格 (最近30天)
666
  recent_data = data.tail(30)
667
- fig.add_trace(go.Scatter(
668
- x=recent_data.index,
669
- y=recent_data['Close'],
670
- mode='lines',
671
- name='歷史價格',
672
- line=dict(color='#FFA726', width=2)
673
- ))
674
-
675
- # --- 修改處:使用新的座標點繪製預測線 ---
676
- # 4. 繪製由多個預測點連接而成的路徑
677
- fig.add_trace(go.Scatter(
678
- x=prediction_dates, # 使用包含多個日期的列表
679
- y=prediction_prices, # 使用包含多個預測價格的列表
680
- mode='lines+markers',
681
- name=f'{predict_days}日預測路徑',
682
- line=dict(color=color, width=3, dash='dash'),
683
- marker=dict(size=8)
684
- ))
685
- # --- 修改結束 ---
686
-
687
- fig.update_layout(
688
- title=f'台指期 {predict_days}日預測走勢',
689
- xaxis_title='日期',
690
- yaxis_title='指數點位',
691
- height=350,
692
- plot_bgcolor='rgba(0,0,0,0)',
693
- paper_bgcolor='rgba(0,0,0,0)',
694
- font=dict(color='white')
695
- )
696
-
697
  return result_card, fig
698
 
699
  # 更新股價資訊卡片
@@ -703,53 +355,28 @@ def update_taiex_prediction(predict_days):
703
  )
704
  def update_stock_info(selected_stock):
705
  data = get_stock_data(selected_stock, '5d')
706
- if data.empty:
707
- return html.Div("無法獲取股票資料")
708
-
709
  current_price = data['Close'].iloc[-1]
710
  prev_price = data['Close'].iloc[-2] if len(data) > 1 else current_price
711
  change = current_price - prev_price
712
  change_pct = (change / prev_price) * 100
713
-
714
- # 找出股票中文名稱
715
  stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
716
-
717
- # 根據台股慣例修改顏色
718
- color = 'red' if change >= 0 else 'green'
719
- arrow = '▲' if change >= 0 else '▼'
720
-
721
  return html.Div([
722
  html.Div([
723
  html.H3(f"{stock_name} ({selected_stock})", style={'margin': '0'}),
724
  html.H2(f"${current_price:.2f}", style={'margin': '5px 0', 'color': color}),
725
- html.P(f"{arrow} {change:+.2f} ({change_pct:+.2f}%)",
726
- style={'margin': '0', 'color': color, 'font-weight': 'bold'})
727
- ], style={
728
- 'background': 'white',
729
- 'padding': '20px',
730
- 'border-radius': '10px',
731
- 'box-shadow': '0 2px 10px rgba(0,0,0,0.1)',
732
- 'display': 'inline-block',
733
- 'margin-right': '20px'
734
- }),
735
-
736
  html.Div([
737
  html.H4("今日統計", style={'margin': '0 0 10px 0'}),
738
  html.P(f"最高: ${data['High'].iloc[-1]:.2f}", style={'margin': '5px 0'}),
739
  html.P(f"最低: ${data['Low'].iloc[-1]:.2f}", style={'margin': '5px 0'}),
740
  html.P(f"成交量: {data['Volume'].iloc[-1]:,.0f}", style={'margin': '5px 0'})
741
- ], style={
742
- 'background': 'white',
743
- 'padding': '20px',
744
- 'border-radius': '10px',
745
- 'box-shadow': '0 2px 10px rgba(0,0,0,0.1)',
746
- 'display': 'inline-block'
747
- })
748
  ])
749
 
750
- # ==============================================================================
751
- # ===== 修改後的主要圖表回呼函式 (合併股價與成交量分佈) =====
752
- # ==============================================================================
753
  @app.callback(
754
  dash.dependencies.Output('price-chart', 'figure'),
755
  [dash.dependencies.Input('stock-dropdown', 'value'),
@@ -758,98 +385,23 @@ def update_stock_info(selected_stock):
758
  )
759
  def update_price_chart(selected_stock, period, chart_type):
760
  data = get_stock_data(selected_stock, period)
761
- if data.empty:
762
- return {}
763
-
764
  data = calculate_technical_indicators(data)
765
  stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
766
-
767
- # --- 1. 建立共享 Y 軸的子圖 ---
768
- # 建立一個 1x2 的網格,設定欄位寬度比例,並共享 Y 軸
769
- fig = make_subplots(
770
- rows=1, cols=2,
771
- shared_yaxes=True,
772
- column_widths=[0.8, 0.2], # 左側圖佔80%,右側圖佔20%
773
- horizontal_spacing=0.01 # 子圖間的水平間距
774
- )
775
-
776
- # --- 2. 在左側子圖 (col=1) 繪製股價圖 ---
777
  if chart_type == 'candlestick':
778
- # 根據台股慣例修改顏色
779
- fig.add_trace(go.Candlestick(
780
- x=data.index,
781
- open=data['Open'],
782
- high=data['High'],
783
- low=data['Low'],
784
- close=data['Close'],
785
- name=stock_name,
786
- increasing_line_color='red', # 上漲為紅色
787
- decreasing_line_color='green' # 下跌為綠色
788
- ), row=1, col=1)
789
  else:
790
  fig.add_trace(px.line(data, y='Close').data[0], row=1, col=1)
791
-
792
- # 添加移動平均線到左側子圖
793
- fig.add_trace(go.Scatter(
794
- x=data.index, y=data['MA5'], mode='lines',
795
- name='MA5', line=dict(color='orange')
796
- ), row=1, col=1)
797
- fig.add_trace(go.Scatter(
798
- x=data.index, y=data['MA20'], mode='lines',
799
- name='MA20', line=dict(color='blue')
800
- ), row=1, col=1)
801
-
802
- # --- 3. 在右側子圖 (col=2) 繪製成交量分佈圖 ---
803
- # 計算 Volume Profile 數據
804
  bin_edges, volume_per_bin, price_centers = calculate_volume_profile(data, num_bins=50)
805
-
806
  if volume_per_bin is not None:
807
- # 繪製水平長條圖
808
- fig.add_trace(go.Bar(
809
- orientation='h',
810
- y=price_centers,
811
- x=volume_per_bin,
812
- name='Volume Profile',
813
- text=[f'{vol/1000:.0f}k' for vol in volume_per_bin], # 顯示成交量
814
- textposition='auto',
815
- marker=dict(
816
- color='rgba(173, 216, 230, 0.6)',
817
- line=dict(color='rgba(30, 144, 255, 0.8)', width=1)
818
- )
819
- ), row=1, col=2)
820
-
821
- # --- 4. 更新整體圖表佈局 ---
822
- fig.update_layout(
823
- title_text=f'{stock_name} 股價走勢與成交量分佈',
824
- height=500,
825
- showlegend=True,
826
-
827
- # 左側子圖的座標軸設定
828
- xaxis1=dict(
829
- title='日期',
830
- type='date',
831
- rangeslider_visible=False # 隱藏範圍滑桿,避免干擾佈局
832
- ),
833
- yaxis1=dict(
834
- title='價格 (TWD)'
835
- ),
836
-
837
- # 右側子圖的座標軸設定
838
- xaxis2=dict(
839
- title='成交量',
840
- showticklabels=True # 顯示刻度
841
- ),
842
- yaxis2=dict(
843
- showticklabels=False # 因為共享Y軸,所以隱藏右側的Y軸標籤
844
- ),
845
-
846
- bargap=0.05 # 長條圖間的間隙
847
- )
848
-
849
  return fig
850
 
851
-
852
- # 新增:進階技術指標圖表
853
  @app.callback(
854
  dash.dependencies.Output('advanced-technical-chart', 'figure'),
855
  [dash.dependencies.Input('technical-indicator-selector', 'value'),
@@ -858,150 +410,55 @@ def update_price_chart(selected_stock, period, chart_type):
858
  )
859
  def update_advanced_technical_chart(indicator, selected_stock, period):
860
  data = get_stock_data(selected_stock, period)
861
- if data.empty:
862
- return {}
863
-
864
  data = calculate_technical_indicators(data)
865
  stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
866
-
867
  if indicator == 'RSI':
868
  fig = go.Figure()
869
  fig.add_trace(go.Scatter(x=data.index, y=data['RSI'], mode='lines', name='RSI', line=dict(color='purple', width=2)))
870
  fig.add_hline(y=70, line_dash="dash", line_color="green", annotation_text="超買線(70)")
871
  fig.add_hline(y=30, line_dash="dash", line_color="red", annotation_text="超賣線(30)")
872
  fig.add_hline(y=50, line_dash="dot", line_color="gray", annotation_text="中線(50)")
873
- fig.add_hrect(y0=70, y1=100, fillcolor="green", opacity=0.1)
874
- fig.add_hrect(y0=0, y1=30, fillcolor="red", opacity=0.1)
875
- fig.update_layout(
876
- title=f'{stock_name} - RSI 相對強弱指標',
877
- xaxis_title='日期',
878
- yaxis_title='RSI',
879
- height=450,
880
- yaxis=dict(range=[0, 100])
881
- )
882
-
883
  elif indicator == 'MACD':
884
- fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
885
- vertical_spacing=0.1,
886
- row_heights=[0.7, 0.3],
887
- subplot_titles=('價格走勢', 'MACD 指標'))
888
- fig.add_trace(go.Scatter(
889
- x=data.index, y=data['Close'], mode='lines', name='收盤價', line=dict(color='black', width=1.5)
890
- ), row=1, col=1)
891
- fig.add_trace(go.Scatter(
892
- x=data.index, y=data['MACD'], mode='lines', name='MACD (快線)', line=dict(color='blue', width=2)
893
- ), row=2, col=1)
894
- fig.add_trace(go.Scatter(
895
- x=data.index, y=data['MACD_Signal'], mode='lines', name='Signal (慢線)', line=dict(color='red', width=2)
896
- ), row=2, col=1)
897
  colors = ['red' if x >= 0 else 'green' for x in data['MACD_Histogram']]
898
- fig.add_trace(go.Bar(
899
- x=data.index, y=data['MACD_Histogram'], name='MACD柱狀圖', marker_color=colors
900
- ), row=2, col=1)
901
- fig.add_hline(y=0, line_dash="dash", line_color="gray", row=2, col=1)
902
- fig.update_layout(
903
- title_text=f'{stock_name} - MACD 指數平滑異同移動平均線',
904
- height=550,
905
- legend_title_text='圖例',
906
- showlegend=True
907
- )
908
- fig.update_traces(showlegend=False, selector=dict(type='bar'))
909
-
910
  elif indicator == 'BB':
911
  fig = go.Figure()
912
- fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價',
913
- line=dict(color='black', width=2)))
914
- fig.add_trace(go.Scatter(x=data.index, y=data['BB_Upper'], mode='lines', name='上軌',
915
- line=dict(color='red', width=1, dash='dash')))
916
- fig.add_trace(go.Scatter(x=data.index, y=data['BB_Middle'], mode='lines', name='中軌(MA20)',
917
- line=dict(color='blue', width=1)))
918
- fig.add_trace(go.Scatter(x=data.index, y=data['BB_Lower'], mode='lines', name='下軌',
919
- line=dict(color='green', width=1, dash='dash')))
920
- fig.add_trace(go.Scatter(x=data.index, y=data['BB_Upper'], mode='lines',
921
- line=dict(color='rgba(0,0,0,0)'), showlegend=False))
922
- fig.add_trace(go.Scatter(x=data.index, y=data['BB_Lower'], mode='lines',
923
- fill='tonexty', fillcolor='rgba(173,216,230,0.2)',
924
- line=dict(color='rgba(0,0,0,0)'), name='布林通道', showlegend=False))
925
- fig.update_layout(
926
- title=f'{stock_name} - 布林通道 (20日, 2σ)',
927
- xaxis_title='日期',
928
- yaxis_title='價格 (TWD)',
929
- height=450
930
- )
931
-
932
  elif indicator == 'KD':
933
- fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
934
- vertical_spacing=0.1,
935
- row_heights=[0.6, 0.4],
936
- subplot_titles=('價格走勢', 'KD指標'))
937
- fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價',
938
- line=dict(color='black', width=1)), row=1, col=1)
939
- fig.add_trace(go.Scatter(x=data.index, y=data['K'], mode='lines', name='K線',
940
- line=dict(color='blue', width=2)), row=2, col=1)
941
- fig.add_trace(go.Scatter(x=data.index, y=data['D'], mode='lines', name='D線',
942
- line=dict(color='red', width=2)), row=2, col=1)
943
  fig.add_hline(y=80, line_dash="dash", line_color="green", annotation_text="超買線(80)", row=2, col=1)
944
  fig.add_hline(y=20, line_dash="dash", line_color="red", annotation_text="超賣線(20)", row=2, col=1)
945
- fig.add_hline(y=50, line_dash="dot", line_color="gray", annotation_text="中線(50)", row=2, col=1)
946
- fig.add_hrect(y0=80, y1=100, fillcolor="green", opacity=0.1, row=2, col=1)
947
- fig.add_hrect(y0=0, y1=20, fillcolor="red", opacity=0.1, row=2, col=1)
948
- fig.update_layout(
949
- title=f'{stock_name} - KD 隨機指標 (9,3,3)',
950
- height=500
951
- )
952
- fig.update_yaxes(range=[0, 100], row=2, col=1)
953
-
954
  elif indicator == 'WR':
955
- fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
956
- vertical_spacing=0.1,
957
- row_heights=[0.6, 0.4],
958
- subplot_titles=('價格走勢', '威廉指標 %R'))
959
- fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價',
960
- line=dict(color='black', width=1)), row=1, col=1)
961
- fig.add_trace(go.Scatter(x=data.index, y=data['Williams_R'], mode='lines', name='威廉%R',
962
- line=dict(color='purple', width=2)), row=2, col=1)
963
  fig.add_hline(y=-20, line_dash="dash", line_color="green", annotation_text="超買線(-20)", row=2, col=1)
964
  fig.add_hline(y=-80, line_dash="dash", line_color="red", annotation_text="超賣線(-80)", row=2, col=1)
965
- fig.add_hline(y=-50, line_dash="dot", line_color="gray", annotation_text="中線(-50)", row=2, col=1)
966
- fig.add_hrect(y0=-20, y1=0, fillcolor="green", opacity=0.1, row=2, col=1)
967
- fig.add_hrect(y0=-100, y1=-80, fillcolor="red", opacity=0.1, row=2, col=1)
968
- fig.update_layout(
969
- title=f'{stock_name} - 威廉指標 %R (14日)',
970
- height=500
971
- )
972
- fig.update_yaxes(range=[-100, 0], row=2, col=1)
973
-
974
  elif indicator == 'DMI':
975
- fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
976
- vertical_spacing=0.1,
977
- row_heights=[0.6, 0.4],
978
- subplot_titles=('價格走勢', 'DMI 指標'))
979
-
980
- # 過濾掉不穩定的初始數據(通常為14天)
981
  data_filtered = data.iloc[14:]
982
-
983
- # 上方:價格線
984
- fig.add_trace(go.Scatter(x=data_filtered.index, y=data_filtered['Close'], mode='lines', name='收盤價',
985
- line=dict(color='black', width=1)), row=1, col=1)
986
-
987
- # 下方:DMI 線
988
- fig.add_trace(go.Scatter(x=data_filtered.index, y=data_filtered['+DI'], mode='lines', name='+DI',
989
- line=dict(color='red', width=2)), row=2, col=1)
990
- fig.add_trace(go.Scatter(x=data_filtered.index, y=data_filtered['-DI'], mode='lines', name='-DI',
991
- line=dict(color='green', width=2)), row=2, col=1)
992
- fig.add_trace(go.Scatter(x=data_filtered.index, y=data_filtered['ADX'], mode='lines', name='ADX',
993
- line=dict(color='blue', width=2, dash='dot')), row=2, col=1)
994
-
995
- # DMI 參考線
996
- fig.add_hline(y=20, line_dash="dash", line_color="gray", annotation_text="ADX強弱線(20)", row=2, col=1)
997
-
998
- fig.update_layout(
999
- title=f'{stock_name} - DMI 動向指標 (14日)',
1000
- height=500,
1001
- showlegend=True
1002
- )
1003
- fig.update_yaxes(range=[0, 100], row=2, col=1)
1004
-
1005
  return fig
1006
 
1007
  # 更新成交量圖表
@@ -1012,29 +469,11 @@ def update_advanced_technical_chart(indicator, selected_stock, period):
1012
  )
1013
  def update_volume_chart(selected_stock, period):
1014
  data = get_stock_data(selected_stock, period)
1015
- if data.empty:
1016
- return {}
1017
-
1018
  stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
1019
-
1020
- # 根據漲跌決定顏色 (台股慣例)
1021
  colors = ['red' if data['Close'].iloc[i] > data['Open'].iloc[i] else 'green' for i in range(len(data))]
1022
-
1023
- fig = go.Figure()
1024
- fig.add_trace(go.Bar(
1025
- x=data.index,
1026
- y=data['Volume'],
1027
- marker_color=colors,
1028
- name='成交量'
1029
- ))
1030
-
1031
- fig.update_layout(
1032
- title=f'{stock_name} 成交量',
1033
- xaxis_title='日期',
1034
- yaxis_title='成交量',
1035
- height=300
1036
- )
1037
-
1038
  return fig
1039
 
1040
  # 更新產業分析圖表
@@ -1043,110 +482,45 @@ def update_volume_chart(selected_stock, period):
1043
  [dash.dependencies.Input('stock-dropdown', 'value')]
1044
  )
1045
  def update_industry_analysis(selected_stock):
1046
- # 獲取多檔股票資料進行產業比較
1047
  industry_data = []
1048
-
1049
- for symbol in list(TAIWAN_STOCKS.values())[:10]: # 取前10檔做示範
1050
  data = get_stock_data(symbol, '1mo')
1051
  if not data.empty:
1052
  stock_name = [name for name, symbol_code in TAIWAN_STOCKS.items() if symbol_code == symbol][0]
1053
- latest_price = data['Close'].iloc[-1]
1054
- first_price = data['Close'].iloc[0]
1055
- return_pct = ((latest_price - first_price) / first_price) * 100
1056
-
1057
- industry_data.append({
1058
- '股票': stock_name,
1059
- '代碼': symbol,
1060
- '月報酬率(%)': return_pct,
1061
- '產業': INDUSTRY_MAPPING.get(symbol, '其他')
1062
- })
1063
-
1064
- if not industry_data:
1065
- return {}
1066
-
1067
  df_industry = pd.DataFrame(industry_data)
1068
-
1069
- # 建立產業表現圓餅圖
1070
- fig = px.pie(df_industry, values='月報酬率(%)', names='股票',
1071
- title='各股票月報酬率比較',
1072
- color_discrete_sequence=px.colors.qualitative.Set3)
1073
-
1074
  fig.update_layout(height=400)
1075
  return fig
1076
 
1077
- # 新增:更新景氣燈號圖表
1078
  @app.callback(
1079
  dash.dependencies.Output('business-climate-chart', 'figure'),
1080
- [dash.dependencies.Input('stock-dropdown', 'value')] # 雖然不會影響圖表,但需要觸發
1081
  )
1082
  def update_business_climate_chart(selected_stock):
1083
  df = get_business_climate_data()
1084
-
1085
  if df.empty:
1086
- # 如果沒有資料,顯示提示圖表
1087
- fig = go.Figure()
1088
- fig.add_annotation(
1089
- x=0.5, y=0.5,
1090
- text="無法載入景氣燈號資料<br>請確認網路連線或國發會網站是否可存取",
1091
- xref="paper", yref="paper",
1092
- showarrow=False,
1093
- font=dict(size=14)
1094
- )
1095
- fig.update_layout(
1096
- title="台灣景氣燈號",
1097
- height=300,
1098
- showlegend=False
1099
- )
1100
  return fig
1101
-
1102
- # 定義燈號顏色
1103
  def get_light_color(score):
1104
- if score >= 32:
1105
- return 'red' # 紅燈
1106
- elif score >= 24:
1107
- return 'orange' # 黃紅燈
1108
- elif score >= 17:
1109
- return 'yellow' # 黃燈
1110
- elif score >= 10:
1111
- return 'lightgreen' # 黃藍燈
1112
- else:
1113
- return 'blue' # 藍燈
1114
-
1115
- # 為每個點設定顏色
1116
  colors = [get_light_color(score) for score in df['Index']]
1117
-
1118
  fig = go.Figure()
1119
-
1120
- fig.add_trace(go.Scatter(
1121
- x=df['Date'],
1122
- y=df['Index'],
1123
- mode='lines+markers',
1124
- name='景氣燈號',
1125
- line=dict(color='darkblue', width=2),
1126
- marker=dict(
1127
- size=8,
1128
- color=colors,
1129
- line=dict(width=2, color='darkblue')
1130
- )
1131
- ))
1132
-
1133
- # 添加燈號區間線
1134
  fig.add_hline(y=32, line_dash="dash", line_color="red", annotation_text="紅燈(32)")
1135
- fig.add_hline(y=24, line_dash="dash", line_color="orange", annotation_text="黃紅燈(24)")
1136
  fig.add_hline(y=17, line_dash="dash", line_color="yellow", annotation_text="黃燈(17)")
1137
- fig.add_hline(y=10, line_dash="dash", line_color="lightgreen", annotation_text="黃藍燈(10)")
1138
-
1139
- fig.update_layout(
1140
- title="台灣景氣燈號走勢",
1141
- xaxis_title='日期',
1142
- yaxis_title='燈號分數',
1143
- height=300,
1144
- yaxis=dict(range=[0, 40])
1145
- )
1146
-
1147
  return fig
1148
 
1149
- # 新增:更新分析師觀點
1150
  @app.callback(
1151
  [dash.dependencies.Output('technical-analysis-text', 'children'),
1152
  dash.dependencies.Output('fundamental-analysis-text', 'children'),
@@ -1155,227 +529,51 @@ def update_business_climate_chart(selected_stock):
1155
  dash.dependencies.Input('period-dropdown', 'value')]
1156
  )
1157
  def update_analysis_text(selected_stock, period):
1158
- # 獲取股票資料進行分析
1159
  data = get_stock_data(selected_stock, period)
1160
  stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
1161
-
1162
- if data.empty:
1163
- return "無法獲取資料進行分析", "無法獲取資料進行分析", "無法獲取資料進行分析"
1164
-
1165
- # 計算技術指標
1166
  data = calculate_technical_indicators(data)
1167
-
1168
- # 基本數據
1169
  current_price = data['Close'].iloc[-1]
1170
  price_change = ((current_price - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
1171
- volume_avg = data['Volume'].mean()
1172
- recent_volume = data['Volume'].iloc[-5:].mean()
1173
  rsi_current = data['RSI'].iloc[-1] if not pd.isna(data['RSI'].iloc[-1]) else 50
1174
-
1175
- # 新增技術指標數據
1176
  macd_current = data['MACD'].iloc[-1] if not pd.isna(data['MACD'].iloc[-1]) else 0
1177
  macd_signal_current = data['MACD_Signal'].iloc[-1] if not pd.isna(data['MACD_Signal'].iloc[-1]) else 0
1178
- bb_position = data['BB_Position'].iloc[-1] if not pd.isna(data['BB_Position'].iloc[-1]) else 0.5
1179
- k_current = data['K'].iloc[-1] if not pd.isna(data['K'].iloc[-1]) else 50
1180
- d_current = data['D'].iloc[-1] if not pd.isna(data['D'].iloc[-1]) else 50
1181
- pdi_current = data['+DI'].iloc[-1] if not pd.isna(data['+DI'].iloc[-1]) else 0
1182
- ndi_current = data['-DI'].iloc[-1] if not pd.isna(data['-DI'].iloc[-1]) else 0
1183
- adx_current = data['ADX'].iloc[-1] if not pd.isna(data['ADX'].iloc[-1]) else 0
1184
-
1185
-
1186
- # 技術面分析
1187
  technical_text = html.Div([
1188
- html.P([
1189
- html.Strong("價格趨勢:"),
1190
- f"近期{period}期間內,{stock_name}呈現",
1191
- html.Span(f"{'上漲' if price_change > 5 else '下跌' if price_change < -5 else '盤整'}",
1192
- style={'color': 'red' if price_change > 5 else 'green' if price_change < -5 else 'orange', 'font-weight': 'bold'}),
1193
- f"走勢,累計變動{price_change:+.1f}%。"
1194
- ]),
1195
- html.P([
1196
- html.Strong("RSI指標:"),
1197
- f"目前為{rsi_current:.1f},",
1198
- html.Span(
1199
- "處於超買區間" if rsi_current > 70 else "處於超賣區間" if rsi_current < 30 else "在正常範圍內",
1200
- style={'color': 'green' if rsi_current > 70 else 'red' if rsi_current < 30 else 'blue', 'font-weight': 'bold'}
1201
- ),
1202
- "。"
1203
- ]),
1204
- html.P([
1205
- html.Strong("MACD指標:"),
1206
- f"MACD線({macd_current:.3f})",
1207
- html.Span(
1208
- "高於" if macd_current > macd_signal_current else "低於",
1209
- style={'color': 'red' if macd_current > macd_signal_current else 'green', 'font-weight': 'bold'}
1210
- ),
1211
- f"信號線({macd_signal_current:.3f}),",
1212
- f"顯示{'多頭' if macd_current > macd_signal_current else '空頭'}格局。"
1213
- ]),
1214
- html.P([
1215
- html.Strong("布林通道:"),
1216
- f"股價位於通道",
1217
- html.Span(
1218
- "上半部" if bb_position > 0.8 else "下半部" if bb_position < 0.2 else "中段",
1219
- style={'color': 'green' if bb_position > 0.8 else 'red' if bb_position < 0.2 else 'blue', 'font-weight': 'bold'}
1220
- ),
1221
- f"({bb_position*100:.0f}%),",
1222
- f"{'壓力較大' if bb_position > 0.8 else '支撐較強' if bb_position < 0.2 else '整理格局'}。"
1223
- ]),
1224
- html.P([
1225
- html.Strong("KD指標:"),
1226
- f"K值({k_current:.1f})",
1227
- html.Span(
1228
- "高於" if k_current > d_current else "低於",
1229
- style={'color': 'red' if k_current > d_current else 'green', 'font-weight': 'bold'}
1230
- ),
1231
- f"D值({d_current:.1f}),",
1232
- html.Span(
1233
- "超買警戒" if k_current > 80 else "超賣關注" if k_current < 20 else "正常區間",
1234
- style={'color': 'green' if k_current > 80 else 'red' if k_current < 20 else 'blue', 'font-weight': 'bold'}
1235
- ),
1236
- "。"
1237
- ]),
1238
- html.P([
1239
- html.Strong("DMI指標:"),
1240
- f"目前+DI ({pdi_current:.1f}) 與 -DI ({ndi_current:.1f}),",
1241
- html.Span(
1242
- "呈現多頭趨勢" if pdi_current > ndi_current else "呈現空頭趨勢",
1243
- style={'color': 'red' if pdi_current > ndi_current else 'green', 'font-weight': 'bold'}
1244
- ),
1245
- f"。ADX值為 {adx_current:.1f},顯示市場趨勢{'強勁' if adx_current > 25 else '不明顯' if adx_current < 20 else '有趨勢'}"
1246
- ]),
1247
- html.P([
1248
- html.Strong("成交量分析:"),
1249
- f"近期成交量{'放大' if recent_volume > volume_avg * 1.2 else '萎縮' if recent_volume < volume_avg * 0.8 else '平穩'},",
1250
- f"顯示市場{'關注度提升' if recent_volume > volume_avg * 1.2 else '觀望氣氛濃厚' if recent_volume < volume_avg * 0.8 else '交投正常'}。"
1251
- ])
1252
  ])
1253
-
1254
- # 基本面分析
1255
  industry = INDUSTRY_MAPPING.get(selected_stock, '綜合')
1256
  fundamental_text = html.Div([
1257
- html.P([
1258
- html.Strong("產業地位:"),
1259
- f"{stock_name}屬於{industry}產業,在產業鏈中具有",
1260
- html.Span("重要地位" if selected_stock in ['2330.TW', '2454.TW', '2317.TW'] else "一定影響力",
1261
- style={'font-weight': 'bold'}),
1262
- "。"
1263
- ]),
1264
- html.P([
1265
- html.Strong("營運展望:"),
1266
- f"考量{industry}產業前景及公司基本面,建議持續關注季報表現及未來指引。"
1267
- ]),
1268
- html.P([
1269
- html.Strong("風險評估:"),
1270
- "注意產業週期性變化、國際競爭及法規環境變化等風險因子。"
1271
- ])
1272
  ])
1273
-
1274
- # 市場展望
1275
- if price_change > 10:
1276
- outlook_tone = "謹慎樂觀"
1277
- outlook_color = "#dc3545"
1278
- elif price_change < -10:
1279
- outlook_tone = "保守觀望"
1280
- outlook_color = "#28a745"
1281
- else:
1282
- outlook_tone = "中性持平"
1283
- outlook_color = "#ffc107"
1284
-
1285
  market_outlook = html.Div([
1286
- html.P([
1287
- html.Strong("整體評估:", style={'font-size': '16px'}),
1288
- f"基於技術面及基本面分析,對{stock_name}採取",
1289
- html.Span(f"{outlook_tone}", style={'color': outlook_color, 'font-weight': 'bold', 'font-size': '16px'}),
1290
- "態度。"
1291
- ]),
1292
- html.P([
1293
- html.Strong("投資建議:"),
1294
- "建議投資人根據自身風險承受能力,採取適當的資產配置策略。短線操作注意技術指標,長線投資關注基本面變化。"
1295
- ]),
1296
- html.P([
1297
- html.Strong("風險提醒:"),
1298
- "股票投資具有風險,過去績效不代表未來表現,投資前請詳閱公開說明書並審慎評估。"
1299
- ], style={'font-style': 'italic', 'font-size': '13px'})
1300
  ])
1301
-
1302
  return technical_text, fundamental_text, market_outlook
1303
 
1304
- # 新增:更新PMI圖表
1305
  @app.callback(
1306
  dash.dependencies.Output('pmi-chart', 'figure'),
1307
- [dash.dependencies.Input('stock-dropdown', 'value')] # 雖然不會影響圖表,但需要觸發
1308
  )
1309
  def update_pmi_chart(selected_stock):
1310
  df = get_pmi_data()
1311
-
1312
  if df.empty:
1313
- # 如果沒有資料,顯示提示圖表
1314
- fig = go.Figure()
1315
- fig.add_annotation(
1316
- x=0.5, y=0.5,
1317
- text="無法載入PMI資料<br>請確認 taiwan_pmi.csv 檔案是否存在",
1318
- xref="paper", yref="paper",
1319
- showarrow=False,
1320
- font=dict(size=14)
1321
- )
1322
- fig.update_layout(
1323
- title="台灣PMI指數",
1324
- height=300,
1325
- showlegend=False
1326
- )
1327
  return fig
1328
-
1329
- # 定義PMI顏色 (50以上擴張,以下緊縮)
1330
- def get_pmi_color(value):
1331
- return 'red' if value >= 50 else 'green'
1332
-
1333
- colors = [get_pmi_color(value) for value in df['Index']]
1334
-
1335
  fig = go.Figure()
1336
-
1337
- fig.add_trace(go.Scatter(
1338
- x=df['Date'],
1339
- y=df['Index'],
1340
- mode='lines+markers',
1341
- name='PMI指數',
1342
- line=dict(color='darkblue', width=2),
1343
- marker=dict(
1344
- size=8,
1345
- color=colors,
1346
- line=dict(width=2, color='darkblue')
1347
- )
1348
- ))
1349
-
1350
- # 添加榮枯線
1351
  fig.add_hline(y=50, line_dash="dash", line_color="black", annotation_text="榮枯線(50)")
1352
-
1353
- # 添加背景色區域
1354
- fig.add_hrect(
1355
- y0=50, y1=60,
1356
- fillcolor="lightcoral", opacity=0.2,
1357
- annotation_text="擴張區間", annotation_position="top left"
1358
- )
1359
- fig.add_hrect(
1360
- y0=40, y1=50,
1361
- fillcolor="lightgreen", opacity=0.2,
1362
- annotation_text="緊縮區間", annotation_position="bottom left"
1363
- )
1364
-
1365
- fig.update_layout(
1366
- title="台灣PMI指數走勢",
1367
- xaxis_title='日期',
1368
- yaxis_title='PMI指數',
1369
- height=300,
1370
- yaxis=dict(range=[35, 60])
1371
- )
1372
-
1373
  return fig
1374
 
1375
-
1376
- # ==============================================================================
1377
- # ===== 修改後的多檔股票比較回呼函式 =====
1378
- # ==============================================================================
1379
  @app.callback(
1380
  [dash.dependencies.Output('comparison-chart', 'figure'),
1381
  dash.dependencies.Output('comparison-table', 'children')],
@@ -1383,243 +581,113 @@ def update_pmi_chart(selected_stock):
1383
  dash.dependencies.Input('comparison-period', 'value')]
1384
  )
1385
  def update_comparison_analysis(selected_stocks, period):
1386
- # --- 新增:確保 0050.TW 始終存在 ---
1387
  fixed_stock = '0050.TW'
1388
- # 如果列表為空或 None,則只顯示 0050
1389
- if not selected_stocks:
1390
- selected_stocks = [fixed_stock]
1391
- # 如果 0050 不在列表中,則將其插入到最前面
1392
- elif fixed_stock not in selected_stocks:
1393
- selected_stocks.insert(0, fixed_stock)
1394
- # --- 修改結束 ---
1395
-
1396
- if not selected_stocks:
1397
- return {}, html.Div("請選擇要比較的股票")
1398
-
1399
- # 限制最多5檔
1400
  selected_stocks = selected_stocks[:5]
1401
-
1402
  fig = go.Figure()
1403
  comparison_data = []
1404
-
1405
  for stock in selected_stocks:
1406
  data = get_stock_data(stock, period)
1407
  if not data.empty:
1408
- # 安全地獲取股票名稱,如果找不到則使用代碼本身
1409
  stock_name = next((name for name, symbol in TAIWAN_STOCKS.items() if symbol == stock), stock)
1410
-
1411
- # 正規化價格(以期初為基準100)
1412
  normalized_prices = (data['Close'] / data['Close'].iloc[0]) * 100
1413
-
1414
- fig.add_trace(go.Scatter(
1415
- x=data.index,
1416
- y=normalized_prices,
1417
- mode='lines',
1418
- name=stock_name,
1419
- line=dict(width=2)
1420
- ))
1421
-
1422
- # 計算績效數據
1423
  total_return = ((data['Close'].iloc[-1] / data['Close'].iloc[0]) - 1) * 100
1424
- volatility = data['Close'].pct_change().std() * np.sqrt(252) * 100 # 年化波動率
1425
-
1426
- comparison_data.append({
1427
- 'name': stock_name,
1428
- 'return': total_return,
1429
- 'volatility': volatility,
1430
- 'current_price': data['Close'].iloc[-1]
1431
- })
1432
-
1433
- fig.update_layout(
1434
- title=f'股票績效比較 - {period}',
1435
- xaxis_title='日期',
1436
- yaxis_title='相對績效 (基期=100)',
1437
- height=400,
1438
- hovermode='x unified'
1439
- )
1440
-
1441
- # 建立比較表格
1442
  if comparison_data:
1443
  table_rows = []
1444
  for item in sorted(comparison_data, key=lambda x: x['return'], reverse=True):
1445
  color = 'red' if item['return'] > 0 else 'green'
1446
- table_rows.append(
1447
- html.Tr([
1448
- html.Td(item['name'], style={'font-weight': 'bold'}),
1449
- html.Td(f"{item['return']:+.1f}%", style={'color': color, 'font-weight': 'bold'}),
1450
- html.Td(f"{item['volatility']:.1f}%"),
1451
- html.Td(f"${item['current_price']:.2f}")
1452
- ])
1453
- )
1454
-
1455
- table = html.Table([
1456
- html.Thead([
1457
- html.Tr([
1458
- html.Th("股票", style={'text-align': 'center'}),
1459
- html.Th("報酬率", style={'text-align': 'center'}),
1460
- html.Th("波動率", style={'text-align': 'center'}),
1461
- html.Th("現價", style={'text-align': 'center'})
1462
- ])
1463
- ]),
1464
- html.Tbody(table_rows)
1465
- ], style={
1466
- 'width': '100%',
1467
- 'border-collapse': 'collapse',
1468
- 'font-size': '12px'
1469
- })
1470
-
1471
  return fig, table
1472
-
1473
  return fig, html.Div("無可比較資料")
1474
 
1475
- # 新增:市場情緒分析
 
 
 
1476
  @app.callback(
1477
  [dash.dependencies.Output('sentiment-gauge', 'children'),
1478
  dash.dependencies.Output('news-summary', 'children')],
1479
- [dash.dependencies.Input('stock-dropdown', 'value')]
1480
  )
1481
  def update_sentiment_analysis(selected_stock):
1482
- stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
1483
-
1484
- # --- 修改部分:從 CSV 檔案讀取新聞 ---
1485
- # 假設 CSV 檔案名稱是 'news_YYYY-MM-DD.csv' 並且與主程式在同一目錄
1486
- # 您需要確保新聞檔案的命名規則與此一致,或者修改檔案名稱的獲取方式
1487
- today_str = datetime.now().strftime('%Y-%m-%d') # 獲取今天的日期
1488
- news_csv_filename = f'news_{today_str}.csv' # 例如: news_2025-09-12.csv
1489
-
1490
- news_items_content = []
1491
- try:
1492
- # 檢查檔案是否存在
1493
- if not os.path.exists(news_csv_filename):
1494
- print(f"警告:新聞檔案 '{news_csv_filename}' 不存在。將使用預設新聞。")
1495
- # 如果檔案不存在,可以選擇顯示錯誤訊息或使用預設模擬新聞
1496
- news_items_content = [
1497
- f"⚠️ 無法載入新聞檔案 '{news_csv_filename}',顯示預設內容。",
1498
- f"📈 {stock_name} 股價受到市場關注。",
1499
- f"📉 投資者情緒趨於謹慎。",
1500
- ]
 
 
 
 
1501
  else:
1502
- news_df = pd.read_csv(news_csv_filename)
1503
- # 確保 CSV 檔案中存在 '內容' 欄位
1504
- if '內容' in news_df.columns:
1505
- news_items_content = news_df['內容'].tolist()
1506
- else:
1507
- print(f"警告:CSV 檔案 '{news_csv_filename}' 中未找到 '內容' 欄位。")
1508
- news_items_content = [
1509
- f"⚠️ CSV 檔案 '{news_csv_filename}' 格式錯誤,未找到 '內容' 欄位。",
1510
- f"📈 {stock_name} 股價動態。",
1511
- f"📉 市場消息更新。",
1512
- ]
1513
- except FileNotFoundError:
1514
- print(f"警告:新聞檔案 '{news_csv_filename}' 讀取時發生 FileNotFoundError。")
1515
- news_items_content = [
1516
- f"⚠️ 讀取新聞檔案 '{news_csv_filename}' 時發生錯誤。",
1517
- f"📈 {stock_name} 股價分析。",
1518
- f"📉 投資者情緒參考。",
1519
- ]
1520
- except Exception as e:
1521
- print(f"讀取 CSV 檔案 '{news_csv_filename}' 時發生未預期錯誤:{e}")
1522
- news_items_content = [
1523
- f"⚠️ 讀取新聞檔案 '{news_csv_filename}' 時發生錯誤:{e}",
1524
- f"📈 {stock_name} 股價資訊。",
1525
- f"📉 市場趨勢分析。",
1526
- ]
1527
-
1528
- # --- 接著對讀取的新聞內容進行情緒分析 ---
1529
- total_sentiment_score = 0
1530
- analyzed_news_html = []
1531
- sentiment_mapping = {'negative': 0, 'neutral': 50, 'positive': 100} # BERT 輸出的映射
1532
-
1533
- if not news_items_content: # 如果 news_items_content 為空
1534
- analyzed_news_html.append(html.P("目前沒有新聞可供分析。", style={'font-style': 'italic'}))
1535
- avg_sentiment_score = 50 # 預設為中性
1536
- else:
1537
- for news in news_items_content:
1538
- # 使用 BERT 模型進行預測
1539
- # predict_sentiment 應返回 (sentiment_label, probability_score)
1540
- # 例如:('positive', 0.95)
1541
- sentiment_label, probability_score = predict_sentiment(news)
1542
-
1543
- # 將情緒標籤轉換為數值分數
1544
- numeric_score = sentiment_mapping.get(sentiment_label, 50) # 預設為中性
1545
-
1546
- total_sentiment_score += numeric_score
1547
-
1548
- # 設置顯示顏���和表情符號
1549
- sentiment_emoji = '⚪'
1550
- sentiment_color = 'gray'
1551
- if sentiment_label == 'positive':
1552
- sentiment_emoji = '🟢'
1553
- sentiment_color = 'green'
1554
- elif sentiment_label == 'neutral':
1555
- sentiment_emoji = '🟡'
1556
- sentiment_color = 'orange'
1557
- elif sentiment_label == 'negative':
1558
- sentiment_emoji = '🔴'
1559
- sentiment_color = 'red'
1560
 
1561
- # 建立新聞摘要的 HTML 元素
1562
- analyzed_news_html.append(
1563
- html.P([
1564
- html.Span(f"{sentiment_emoji} ", style={'font-size': '1.2em'}),
1565
- html.A(news, href="#", style={
1566
- 'color': sentiment_color,
1567
- 'text-decoration': 'none',
1568
- 'font-weight': 'bold'
1569
- }),
1570
- html.Br(),
1571
- html.Small(f"情緒: {sentiment_label}, 信賴度: {probability_score:.2%}", style={'margin-left': '20px', 'color': 'gray'})
1572
- ], style={
1573
- 'margin': '8px 0',
1574
- 'padding': '8px',
1575
- 'background': '#f8f9fa',
1576
- 'border-radius': '5px',
1577
- 'border-left': f'3px solid {sentiment_color}',
1578
- 'font-size': '13px'
1579
- })
1580
- )
1581
-
1582
- # 計算平均情緒分數
1583
- avg_sentiment_score = total_sentiment_score / len(news_items_content)
1584
-
1585
- # --- 建立情緒指標圓形圖 (Gauge Chart) ---
1586
- gauge_fig = go.Figure(go.Indicator(
1587
- mode = "gauge+number",
1588
- value = avg_sentiment_score,
1589
- domain = {'x': [0, 1], 'y': [0, 1]},
1590
- title = {'text': "市場情緒指數"},
1591
- gauge = {
1592
- 'axis': {'range': [0, 100], 'tickvals': [0, 25, 50, 75, 100], 'ticktext': ['極度負面', '', '中性', '', '極度正面']},
1593
- 'bar': {'color': "#667eea"}, # 預設的 bar 顏色,可選
1594
- 'steps': [
1595
- {'range': [0, 30], 'color': "#e74c3c"}, # 紅色 (負面)
1596
- {'range': [30, 70], 'color': "#f1c40f"}, # 黃色 (中性)
1597
- {'range': [70, 100], 'color': "#2ecc71"} # 綠色 (正面)
1598
- ],
1599
- # 您可以在這裡調整 threshold 的設定,如果需要的話
1600
- # 'threshold': {
1601
- # 'line': {'color': "red", 'width': 4},
1602
- # 'thickness': 0.75,
1603
- # 'value': 75 # 例如,設定一個門檻值
1604
- # }
1605
- }
1606
- ))
1607
 
1608
- gauge_fig.update_layout(height=200, margin=dict(l=20, r=20, t=40, b=20))
1609
 
1610
- return dcc.Graph(figure=gauge_fig), html.Div(analyzed_news_html)
1611
 
1612
- # 在 Colab 中執行的設定
1613
  if __name__ == '__main__':
1614
- # 在執行前先測試檔案讀取
1615
- print("測試檔案讀取...")
1616
- business_data = get_business_climate_data()
1617
- pmi_data = get_pmi_data()
1618
-
1619
- if not business_data.empty:
1620
- print(f"景氣燈號資料預覽:\n{business_data.head()}")
1621
- if not pmi_data.empty:
1622
- print(f"PMI資料預覽:\n{pmi_data.head()}")
1623
-
1624
  # 在 Hugging Face Spaces 中執行
1625
  app.run(host="0.0.0.0", port=7860, debug=False)
 
1
+ # HUGING_FACE_V2.1.3.py (整合 Bert_predict 版本)
2
+
3
  # 系統套件
4
  import os
5
  from datetime import datetime, timedelta
 
15
  from bs4 import BeautifulSoup
16
  import requests
17
 
18
+ # 引用您組員的預測器程式
19
+ from Bert_predict import BertPredictor
20
 
21
  # 台股代號對應表 (移除台指期,因為它現在是獨立區塊)
22
  TAIWAN_STOCKS = {
 
74
  try:
75
  stock = yf.Ticker(symbol)
76
  data = stock.history(period=period)
 
 
77
  if data.empty and symbol == 'TXF=F':
 
78
  stock = yf.Ticker('0050.TW')
79
  data = stock.history(period=period)
80
  if data.empty:
 
81
  stock = yf.Ticker('^TWII')
82
  data = stock.history(period=period)
 
83
  return data
84
  except:
85
  return pd.DataFrame()
86
 
 
 
 
 
 
 
 
 
87
  def simple_lstm_predict(data, predict_days=5):
88
  """簡化的LSTM預測模型 (使用統計方法模擬)"""
89
  if len(data) < 60:
90
  return None
 
 
91
  prices = data['Close'].values
 
 
92
  ma_short = np.mean(prices[-5:])
93
  ma_medium = np.mean(prices[-20:])
94
  ma_long = np.mean(prices[-60:])
 
 
95
  recent_trend = np.polyfit(range(20), prices[-20:], 1)[0]
96
  volatility = np.std(prices[-20:]) / np.mean(prices[-20:])
 
 
97
  base_change = recent_trend * predict_days
98
  trend_factor = 1.0
 
99
  if ma_short > ma_medium > ma_long:
100
+ trend_factor = 1.02
101
  elif ma_short < ma_medium < ma_long:
102
+ trend_factor = 0.98
103
  else:
104
+ trend_factor = 1.0
 
 
105
  noise_factor = np.random.normal(1, volatility * 0.1)
 
106
  predicted_price = prices[-1] * trend_factor + base_change + (prices[-1] * noise_factor * 0.01)
107
  change_pct = ((predicted_price - prices[-1]) / prices[-1]) * 100
 
108
  return {
109
  'predicted_price': predicted_price,
110
  'change_pct': change_pct,
111
+ 'confidence': max(0.6, 1 - volatility * 2)
112
  }
113
 
114
  def calculate_technical_indicators(df):
115
  """計算技術指標"""
116
+ if df.empty: return df
 
 
 
117
  df['MA5'] = df['Close'].rolling(window=5).mean()
118
  df['MA20'] = df['Close'].rolling(window=20).mean()
 
 
119
  delta = df['Close'].diff()
120
  gain = (delta.where(delta > 0, 0)).rolling(window=14).mean()
121
  loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
122
  rs = gain / loss
123
  df['RSI'] = 100 - (100 / (1 + rs))
 
 
124
  exp1 = df['Close'].ewm(span=12).mean()
125
  exp2 = df['Close'].ewm(span=26).mean()
126
  df['MACD'] = exp1 - exp2
127
  df['MACD_Signal'] = df['MACD'].ewm(span=9).mean()
128
  df['MACD_Histogram'] = df['MACD'] - df['MACD_Signal']
 
 
129
  df['BB_Middle'] = df['Close'].rolling(window=20).mean()
130
  bb_std = df['Close'].rolling(window=20).std()
131
  df['BB_Upper'] = df['BB_Middle'] + (bb_std * 2)
132
  df['BB_Lower'] = df['BB_Middle'] - (bb_std * 2)
 
 
 
 
133
  low_min = df['Low'].rolling(window=9).min()
134
  high_max = df['High'].rolling(window=9).max()
135
  rsv = (df['Close'] - low_min) / (high_max - low_min) * 100
136
+ df['K'] = rsv.ewm(com=2).mean()
137
  df['D'] = df['K'].ewm(com=2).mean()
 
 
138
  low_min_14 = df['Low'].rolling(window=14).min()
139
  high_max_14 = df['High'].rolling(window=14).max()
140
  df['Williams_R'] = -100 * (high_max_14 - df['Close']) / (high_max_14 - low_min_14)
 
 
141
  df['up_move'] = df['High'] - df['High'].shift(1)
142
  df['down_move'] = df['Low'].shift(1) - df['Low']
143
  df['+DM'] = np.where((df['up_move'] > df['down_move']) & (df['up_move'] > 0), df['up_move'], 0)
144
  df['-DM'] = np.where((df['down_move'] > df['up_move']) & (df['down_move'] > 0), df['down_move'], 0)
 
 
145
  df['TR'] = np.max([df['High'] - df['Low'], abs(df['High'] - df['Close'].shift(1)), abs(df['Low'] - df['Close'].shift(1))], axis=0)
 
 
146
  df['+DI'] = (df['+DM'].ewm(com=13, adjust=False).mean() / df['TR'].ewm(com=13, adjust=False).mean()) * 100
147
  df['-DI'] = (df['-DM'].ewm(com=13, adjust=False).mean() / df['TR'].ewm(com=13, adjust=False).mean()) * 100
 
 
148
  df['DX'] = abs(df['+DI'] - df['-DI']) / (df['+DI'] + df['-DI']) * 100
149
  df['ADX'] = df['DX'].ewm(com=13, adjust=False).mean()
 
150
  return df
151
 
152
  def calculate_volume_profile(df, num_bins=50):
153
+ 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
 
 
 
 
 
154
  all_prices = np.concatenate([df['High'].values, df['Low'].values])
155
+ min_price, max_price = all_prices.min(), all_prices.max()
 
 
156
  price_for_volume = (df['High'] + df['Low'] + df['Close']) / 3
157
  df_vol_profile = df.copy()
158
  df_vol_profile['Price_Indicator'] = price_for_volume
 
 
159
  hist, bin_edges = np.histogram(df_vol_profile['Price_Indicator'], bins=num_bins, range=(min_price, max_price), weights=df_vol_profile['Volume'])
160
  price_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
 
161
  return bin_edges, hist, price_centers
162
 
163
  def get_business_climate_data():
 
164
  try:
165
+ if not os.path.exists('business_climate.csv'): return pd.DataFrame()
 
 
 
 
 
166
  df = pd.read_csv('business_climate.csv')
167
+ if 'Date' not in df.columns: df.columns = ['Date', 'Index'] if len(df.columns) == 2 else df.columns
 
 
 
 
 
 
168
  if 'Date' in df.columns:
169
+ try: df['Date'] = pd.to_datetime(df['Date'] + '-01', format='%Y-%m-%d', errors='coerce')
170
+ except: df['Date'] = pd.to_datetime(df['Date'], errors='coerce')
 
 
 
 
 
171
  df = df.dropna(subset=['Date'])
 
 
172
  return df
 
173
  except Exception as e:
174
  print(f"無法獲取景氣燈號資料: {str(e)}")
175
  return pd.DataFrame()
176
 
177
  def get_pmi_data():
 
178
  try:
179
+ if not os.path.exists('taiwan_pmi.csv'): return pd.DataFrame()
 
 
 
 
 
180
  df = pd.read_csv('taiwan_pmi.csv')
181
+ if 'DATE' in df.columns: df = df.rename(columns={'DATE': 'Date', 'INDEX': 'Index'})
182
+ elif len(df.columns) == 2: df.columns = ['Date', 'Index']
 
 
 
 
 
 
183
  if 'Date' in df.columns:
184
+ try: df['Date'] = pd.to_datetime(df['Date'] + '-01', format='%Y-%m-%d', errors='coerce')
185
+ except: df['Date'] = pd.to_datetime(df['Date'], errors='coerce')
 
 
 
 
 
186
  df = df.dropna(subset=['Date'])
 
 
187
  return df
 
188
  except Exception as e:
189
  print(f"無法獲取 PMI 資料: {str(e)}")
190
  return pd.DataFrame()
 
192
  # 建立 Dash 應用程式
193
  app = dash.Dash(__name__, suppress_callback_exceptions=True)
194
 
195
+ # --- 【新增】在程式啟動時,初始化 BERT 新聞預測器 ---
196
+ try:
197
+ print("正在初始化新聞情緒分析模型...")
198
+ predictor = BertPredictor(max_news_per_keyword=5)
199
+ print("新聞情緒分析模型初始化成功。")
200
+ except Exception as e:
201
+ print(f"錯誤:新聞情緒分析模型初始化失敗 - {e}")
202
+ predictor = None
203
+
204
  # 應用程式佈局
205
  app.layout = html.Div([
206
  html.H1("台股分析儀表板", style={'text-align': 'center', 'margin-bottom': '30px'}),
 
 
207
  html.Div([
208
+ html.H2("🤖 AI深度學習預測 - 台指期指數", style={'text-align': 'center','color': '#FFCC22','margin-bottom': '25px'}),
 
 
 
 
209
  html.Div([
210
  html.Div([
211
  html.Label("預測期間:", style={'font-weight': 'bold', 'color': '#FFCC22'}),
212
+ dcc.Dropdown(id='taiex-prediction-period',
 
213
  options=[
214
+ {'label': '1日後預測', 'value': 1},{'label': '5日後預測', 'value': 5},
215
+ {'label': '10日後預測', 'value': 10},{'label': '20日後預測', 'value': 20},
216
+ {'label': '60日後預測', 'value': 60}], value=5,
217
+ style={'margin-bottom': '10px', 'color': '#272727'})
 
 
 
 
 
218
  ], style={'width': '30%', 'display': 'inline-block'}),
 
219
  html.Div(id='taiex-prediction-results', style={'width': '65%', 'display': 'inline-block', 'margin-left': '5%'})
220
  ]),
221
+ html.Div([dcc.Graph(id='taiex-prediction-chart')], style={'margin-top': '20px'})
222
+ ], style={'background': 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)','padding': '25px','border-radius': '15px','box-shadow': '0 8px 25px rgba(0,0,0,0.15)','color': 'white','margin-bottom': '40px'}),
 
 
 
 
 
 
 
 
 
 
223
 
224
+ # 新聞情感分析區域
225
  html.Div([
226
  html.H3("📰 市場情緒與新聞分析", style={'color': '#E74C3C', 'margin-bottom': '20px'}),
227
  html.Div([
 
229
  html.H4("市場情緒指標", style={'color': '#8E44AD'}),
230
  html.Div(id='sentiment-gauge')
231
  ], style={'width': '48%', 'display': 'inline-block'}),
 
232
  html.Div([
233
  html.H4("關鍵新聞摘要", style={'color': '#27AE60'}),
234
+ html.Div(id='news-summary', style={'background': '#f8f9fa','padding': '15px','border-radius': '8px','max-height': '200px','overflow-y': 'auto'})
 
 
 
 
 
 
235
  ], style={'width': '48%', 'display': 'inline-block', 'margin-left': '4%'})
236
  ])
237
+ ], style={'margin-top': '30px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
 
 
 
 
 
 
238
 
 
239
  html.Div([
240
  html.H3("景氣燈號與 PMI 分析"),
241
  html.Div([
242
+ html.Div([dcc.Graph(id='business-climate-chart')], style={'width': '48%', 'display': 'inline-block'}),
243
+ html.Div([dcc.Graph(id='pmi-chart')], style={'width': '48%', 'display': 'inline-block', 'margin-left': '2%'})
 
 
 
 
244
  ])
245
  ], style={'margin-top': '30px'}),
 
 
246
 
 
247
  html.Div([
248
  html.Div([
249
  html.Label("選擇股票:"),
250
+ dcc.Dropdown(id='stock-dropdown', options=[{'label': name, 'value': symbol} for name, symbol in TAIWAN_STOCKS.items()], value='2330.TW', style={'margin-bottom': '10px'})
 
 
 
 
 
251
  ], style={'width': '30%', 'display': 'inline-block', 'vertical-align': 'top'}),
 
252
  html.Div([
253
  html.Label("時間範圍:"),
254
+ dcc.Dropdown(id='period-dropdown',
255
+ options=[{'label': '1個月', 'value': '1mo'},{'label': '3個月', 'value': '3mo'},{'label': '6個月', 'value': '6mo'},{'label': '1年', 'value': '1y'},{'label': '2年', 'value': '2y'}],
256
+ value='6mo', style={'margin-bottom': '10px'})
 
 
 
 
 
 
 
 
 
257
  ], style={'width': '30%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'}),
 
258
  html.Div([
259
  html.Label("圖表類型:"),
260
+ dcc.Dropdown(id='chart-type', options=[{'label': '線圖', 'value': 'line'},{'label': '蠟燭圖', 'value': 'candlestick'}], value='candlestick', style={'margin-bottom': '10px'})
 
 
 
 
 
 
 
 
261
  ], style={'width': '30%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'})
262
  ], style={'margin-bottom': '30px'}),
263
 
 
264
  html.Div(id='stock-info-cards', style={'margin-bottom': '30px'}),
265
+ html.Div([html.Div([dcc.Graph(id='price-chart')], style={'width': '100%', 'display': 'inline-block', 'vertical-align': 'top'})]),
 
 
 
 
 
 
 
 
 
 
 
266
  html.Div([
267
  html.H3("📊 進階技術指標分析", style={'margin-bottom': '20px'}),
268
  html.Div([
269
  html.Label("選擇技術指標:", style={'font-weight': 'bold', 'margin-right': '10px'}),
270
+ dcc.Dropdown(id='technical-indicator-selector',
271
+ options=[{'label': 'RSI 相對強弱指標', 'value': 'RSI'},{'label': 'MACD 指數平滑異同移動平均線', 'value': 'MACD'},{'label': '布林通道 Bollinger Bands', 'value': 'BB'},
272
+ {'label': 'KD 隨機指標', 'value': 'KD'},{'label': '威廉指標 %R', 'value': 'WR'},{'label': 'DMI 動向指標', 'value': 'DMI'}],
273
+ value='RSI', style={'width': '100%'})
 
 
 
 
 
 
 
 
 
274
  ], style={'margin-bottom': '20px'}),
275
+ html.Div([dcc.Graph(id='advanced-technical-chart')])
276
+ ], style={'margin-top': '20px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
277
+ html.Div([dcc.Graph(id='volume-chart')], style={'margin-top': '20px'}),
278
+ html.Div([html.H3("產業表現分析"), dcc.Graph(id='industry-analysis')], style={'margin-top': '30px'}),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  html.Div([
280
  html.H3("📊 分析師觀點與市場解讀", style={'color': '#2E86AB', 'margin-bottom': '20px'}),
281
  html.Div([
 
282
  html.Div([
283
  html.H4("🔍 技術面分析", style={'color': '#A23B72', 'margin-bottom': '15px'}),
284
+ html.Div(id='technical-analysis-text', style={'background': '#f8f9fa','padding': '15px','border-radius': '8px','border-left': '4px solid #A23B72','min-height': '150px','font-size': '14px','line-height': '1.6'})
 
 
 
 
 
 
 
 
285
  ], style={'width': '48%', 'display': 'inline-block', 'vertical-align': 'top'}),
 
 
286
  html.Div([
287
  html.H4("📈 基本面分析", style={'color': '#F18F01', 'margin-bottom': '15px'}),
288
+ html.Div(id='fundamental-analysis-text', style={'background': '#f8f9fa','padding': '15px','border-radius': '8px','border-left': '4px solid #F18F01','min-height': '150px','font-size': '14px','line-height': '1.6'})
 
 
 
 
 
 
 
 
289
  ], style={'width': '48%', 'display': 'inline-block', 'margin-left': '4%', 'vertical-align': 'top'})
290
  ]),
 
 
291
  html.Div([
292
  html.H4("🎯 市場展望與投資建議", style={'color': '#C73E1D', 'margin-bottom': '15px', 'margin-top': '25px'}),
293
+ 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)'})
 
 
 
 
 
 
 
 
 
294
  ])
295
+ ], 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'}),
 
 
 
 
 
 
 
 
 
 
 
 
296
  html.Div([
297
  html.H3("📊 多檔股票比較分析", style={'margin-bottom': '20px'}),
298
  html.Div([
299
  html.Div([
300
  html.Label("選擇比較股票(最多5檔):", style={'font-weight': 'bold'}),
301
+ dcc.Dropdown(id='comparison-stocks', options=[{'label': name, 'value': symbol} for name, symbol in TAIWAN_STOCKS.items()], value=['0050.TW', '2330.TW', '2454.TW'], multi=True, style={'margin-bottom': '5px'}),
302
+ html.Small('(元大台灣50 (0050.TW) 為固定比較基準,不可移除)', style={'display': 'block', 'font-style': 'italic', 'color': 'gray'})
 
 
 
 
 
 
 
 
 
 
303
  ], style={'width': '60%', 'display': 'inline-block'}),
 
304
  html.Div([
305
  html.Label("比���期間:", style={'font-weight': 'bold'}),
306
+ dcc.Dropdown(id='comparison-period', options=[{'label': '1個月', 'value': '1mo'},{'label': '3個月', 'value': '3mo'},{'label': '6個月', 'value': '6mo'},{'label': '1年', 'value': '1y'}], value='3mo')
 
 
 
 
 
 
 
 
 
307
  ], style={'width': '35%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'})
308
  ]),
 
309
  html.Div([
310
+ html.Div([dcc.Graph(id='comparison-chart')], style={'width': '65%', 'display': 'inline-block'}),
311
+ html.Div([html.H4("比較結果", style={'color': '#2E86AB'}), html.Div(id='comparison-table')], style={'width': '33%', 'display': 'inline-block', 'margin-left': '2%', 'vertical-align': 'top'})
 
 
 
 
 
 
312
  ])
313
+ ], style={'margin-top': '30px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
 
 
 
 
 
 
 
 
314
  ])
315
 
316
+ # 台指期獨立預測回調函數
317
  @app.callback(
318
  [dash.dependencies.Output('taiex-prediction-results', 'children'),
319
  dash.dependencies.Output('taiex-prediction-chart', 'figure')],
320
  [dash.dependencies.Input('taiex-prediction-period', 'value')]
321
  )
322
  def update_taiex_prediction(predict_days):
 
323
  data = get_stock_data('^TWII', '2y')
324
+ if data.empty: return html.Div("無法獲取台指期資料"), {}
 
 
 
325
  final_prediction = simple_lstm_predict(data, predict_days)
326
+ if final_prediction is None: return html.Div("資料不足,無法進行預測"), {}
327
+ current_price, last_date = data['Close'].iloc[-1], data.index[-1]
328
+ predicted_price, change_pct, confidence = final_prediction['predicted_price'], final_prediction['change_pct'], final_prediction['confidence']
329
+ prediction_paths = {1: [1], 5: [1, 5], 10: [1, 5, 10], 20: [1, 10, 20], 60: [1, 10, 20, 60]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  intervals_to_predict = prediction_paths.get(predict_days, [predict_days])
331
+ prediction_dates, prediction_prices = [last_date], [current_price]
 
 
 
 
 
332
  for days in intervals_to_predict:
333
  interim_prediction = simple_lstm_predict(data, days)
334
  if interim_prediction:
335
  prediction_dates.append(last_date + timedelta(days=days))
336
  prediction_prices.append(interim_prediction['predicted_price'])
337
+ color, arrow = ('red', '📈') if change_pct >= 0 else ('green', '📉')
 
 
 
 
 
 
338
  result_card = html.Div([
339
  html.H4(f"{predict_days}日後預測結果", style={'margin': '0 0 15px 0', 'color': 'white'}),
340
+ 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'}),
341
+ html.P(f"目前價格: {current_price:.2f}", style={'margin': '5px 0'}), html.P(f"預測價格: {predicted_price:.2f}", style={'margin': '5px 0'}),
 
 
 
 
 
 
 
 
342
  html.P(f"信心度: {confidence:.1%}", style={'margin': '5px 0', 'font-size': '14px'})
343
+ ], style={'background': 'rgba(255,255,255,0.1)','padding': '20px','border-radius': '10px','border': '1px solid rgba(255,255,255,0.2)'})
 
 
 
 
 
 
 
344
  fig = go.Figure()
 
 
345
  recent_data = data.tail(30)
346
+ fig.add_trace(go.Scatter(x=recent_data.index, y=recent_data['Close'], mode='lines', name='歷史價格', line=dict(color='#FFA726', width=2)))
347
+ 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)))
348
+ fig.update_layout(title=f'台指期 {predict_days}日預測走勢', xaxis_title='日期', yaxis_title='指數點位', height=350, plot_bgcolor='rgba(0,0,0,0)', paper_bgcolor='rgba(0,0,0,0)', font=dict(color='white'))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  return result_card, fig
350
 
351
  # 更新股價資訊卡片
 
355
  )
356
  def update_stock_info(selected_stock):
357
  data = get_stock_data(selected_stock, '5d')
358
+ if data.empty: return html.Div("無法獲取股票資料")
 
 
359
  current_price = data['Close'].iloc[-1]
360
  prev_price = data['Close'].iloc[-2] if len(data) > 1 else current_price
361
  change = current_price - prev_price
362
  change_pct = (change / prev_price) * 100
 
 
363
  stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
364
+ color, arrow = ('red', '▲') if change >= 0 else ('green', '▼')
 
 
 
 
365
  return html.Div([
366
  html.Div([
367
  html.H3(f"{stock_name} ({selected_stock})", style={'margin': '0'}),
368
  html.H2(f"${current_price:.2f}", style={'margin': '5px 0', 'color': color}),
369
+ html.P(f"{arrow} {change:+.2f} ({change_pct:+.2f}%)", style={'margin': '0', 'color': color, 'font-weight': 'bold'})
370
+ ], 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'}),
 
 
 
 
 
 
 
 
 
371
  html.Div([
372
  html.H4("今日統計", style={'margin': '0 0 10px 0'}),
373
  html.P(f"最高: ${data['High'].iloc[-1]:.2f}", style={'margin': '5px 0'}),
374
  html.P(f"最低: ${data['Low'].iloc[-1]:.2f}", style={'margin': '5px 0'}),
375
  html.P(f"成交量: {data['Volume'].iloc[-1]:,.0f}", style={'margin': '5px 0'})
376
+ ], style={'background': 'white','padding': '20px','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)','display': 'inline-block'})
 
 
 
 
 
 
377
  ])
378
 
379
+ # 更新主要圖表 (股價與成交量分佈)
 
 
380
  @app.callback(
381
  dash.dependencies.Output('price-chart', 'figure'),
382
  [dash.dependencies.Input('stock-dropdown', 'value'),
 
385
  )
386
  def update_price_chart(selected_stock, period, chart_type):
387
  data = get_stock_data(selected_stock, period)
388
+ if data.empty: return {}
 
 
389
  data = calculate_technical_indicators(data)
390
  stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
391
+ fig = make_subplots(rows=1, cols=2, shared_yaxes=True, column_widths=[0.8, 0.2], horizontal_spacing=0.01)
 
 
 
 
 
 
 
 
 
 
392
  if chart_type == 'candlestick':
393
+ 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)
 
 
 
 
 
 
 
 
 
 
394
  else:
395
  fig.add_trace(px.line(data, y='Close').data[0], row=1, col=1)
396
+ fig.add_trace(go.Scatter(x=data.index, y=data['MA5'], mode='lines', name='MA5', line=dict(color='orange')), row=1, col=1)
397
+ fig.add_trace(go.Scatter(x=data.index, y=data['MA20'], mode='lines', name='MA20', line=dict(color='blue')), row=1, col=1)
 
 
 
 
 
 
 
 
 
 
 
398
  bin_edges, volume_per_bin, price_centers = calculate_volume_profile(data, num_bins=50)
 
399
  if volume_per_bin is not None:
400
+ fig.add_trace(go.Bar(orientation='h', y=price_centers, x=volume_per_bin, name='Volume Profile', text=[f'{vol/1000:.0f}k' for vol in volume_per_bin], textposition='auto', marker=dict(color='rgba(173, 216, 230, 0.6)', line=dict(color='rgba(30, 144, 255, 0.8)', width=1))), row=1, col=2)
401
+ fig.update_layout(title_text=f'{stock_name} 股價走勢與成交量分佈', height=500, showlegend=True, xaxis1=dict(title='日期', type='date', rangeslider_visible=False), yaxis1=dict(title='價格 (TWD)'), xaxis2=dict(title='成交量', showticklabels=True), yaxis2=dict(showticklabels=False), bargap=0.05)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
  return fig
403
 
404
+ # 更新進階技術指標圖表
 
405
  @app.callback(
406
  dash.dependencies.Output('advanced-technical-chart', 'figure'),
407
  [dash.dependencies.Input('technical-indicator-selector', 'value'),
 
410
  )
411
  def update_advanced_technical_chart(indicator, selected_stock, period):
412
  data = get_stock_data(selected_stock, period)
413
+ if data.empty: return {}
 
 
414
  data = calculate_technical_indicators(data)
415
  stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
416
+ fig = go.Figure() # Fallback
417
  if indicator == 'RSI':
418
  fig = go.Figure()
419
  fig.add_trace(go.Scatter(x=data.index, y=data['RSI'], mode='lines', name='RSI', line=dict(color='purple', width=2)))
420
  fig.add_hline(y=70, line_dash="dash", line_color="green", annotation_text="超買線(70)")
421
  fig.add_hline(y=30, line_dash="dash", line_color="red", annotation_text="超賣線(30)")
422
  fig.add_hline(y=50, line_dash="dot", line_color="gray", annotation_text="中線(50)")
423
+ fig.update_layout(title=f'{stock_name} - RSI 相對強弱指標', xaxis_title='日期', yaxis_title='RSI', height=450, yaxis=dict(range=[0, 100]))
 
 
 
 
 
 
 
 
 
424
  elif indicator == 'MACD':
425
+ fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1, row_heights=[0.7, 0.3], subplot_titles=('價格走勢', 'MACD 指標'))
426
+ 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)
427
+ 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)
428
+ 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)
 
 
 
 
 
 
 
 
 
429
  colors = ['red' if x >= 0 else 'green' for x in data['MACD_Histogram']]
430
+ fig.add_trace(go.Bar(x=data.index, y=data['MACD_Histogram'], name='MACD柱狀圖', marker_color=colors), row=2, col=1)
431
+ fig.update_layout(title_text=f'{stock_name} - MACD 指數平滑異同移動平均線', height=550)
 
 
 
 
 
 
 
 
 
 
432
  elif indicator == 'BB':
433
  fig = go.Figure()
434
+ fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價', line=dict(color='black', width=2)))
435
+ fig.add_trace(go.Scatter(x=data.index, y=data['BB_Upper'], mode='lines', name='上軌', line=dict(color='red', width=1, dash='dash')))
436
+ fig.add_trace(go.Scatter(x=data.index, y=data['BB_Middle'], mode='lines', name='中軌(MA20)', line=dict(color='blue', width=1)))
437
+ fig.add_trace(go.Scatter(x=data.index, y=data['BB_Lower'], mode='lines', name='下軌', line=dict(color='green', width=1, dash='dash')))
438
+ fig.update_layout(title=f'{stock_name} - 布林通道 (20日, 2σ)', xaxis_title='日期', yaxis_title='價格 (TWD)', height=450)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
  elif indicator == 'KD':
440
+ fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1, row_heights=[0.6, 0.4], subplot_titles=('價格走勢', 'KD指標'))
441
+ fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價', line=dict(color='black', width=1)), row=1, col=1)
442
+ 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)
443
+ 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)
 
 
 
 
 
 
444
  fig.add_hline(y=80, line_dash="dash", line_color="green", annotation_text="超買線(80)", row=2, col=1)
445
  fig.add_hline(y=20, line_dash="dash", line_color="red", annotation_text="超賣線(20)", row=2, col=1)
446
+ fig.update_layout(title=f'{stock_name} - KD 隨機指標 (9,3,3)', height=500, yaxis2_range=[0, 100])
 
 
 
 
 
 
 
 
447
  elif indicator == 'WR':
448
+ fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1, row_heights=[0.6, 0.4], subplot_titles=('價格走勢', '威廉指標 %R'))
449
+ fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價', line=dict(color='black', width=1)), row=1, col=1)
450
+ 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)
 
 
 
 
 
451
  fig.add_hline(y=-20, line_dash="dash", line_color="green", annotation_text="超買線(-20)", row=2, col=1)
452
  fig.add_hline(y=-80, line_dash="dash", line_color="red", annotation_text="超賣線(-80)", row=2, col=1)
453
+ fig.update_layout(title=f'{stock_name} - 威廉指標 %R (14日)', height=500, yaxis2_range=[-100, 0])
 
 
 
 
 
 
 
 
454
  elif indicator == 'DMI':
455
+ fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1, row_heights=[0.6, 0.4], subplot_titles=('價格走勢', 'DMI 指標'))
 
 
 
 
 
456
  data_filtered = data.iloc[14:]
457
+ 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)
458
+ 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)
459
+ 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)
460
+ 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)
461
+ fig.update_layout(title=f'{stock_name} - DMI 動向指標 (14日)', height=500, showlegend=True, yaxis2_range=[0, 100])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
462
  return fig
463
 
464
  # 更新成交量圖表
 
469
  )
470
  def update_volume_chart(selected_stock, period):
471
  data = get_stock_data(selected_stock, period)
472
+ if data.empty: return {}
 
 
473
  stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
 
 
474
  colors = ['red' if data['Close'].iloc[i] > data['Open'].iloc[i] else 'green' for i in range(len(data))]
475
+ fig = go.Figure(go.Bar(x=data.index, y=data['Volume'], marker_color=colors, name='成交量'))
476
+ fig.update_layout(title=f'{stock_name} 成交量', xaxis_title='日期', yaxis_title='成交量', height=300)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  return fig
478
 
479
  # 更新產業分析圖表
 
482
  [dash.dependencies.Input('stock-dropdown', 'value')]
483
  )
484
  def update_industry_analysis(selected_stock):
 
485
  industry_data = []
486
+ for symbol in list(TAIWAN_STOCKS.values())[:10]:
 
487
  data = get_stock_data(symbol, '1mo')
488
  if not data.empty:
489
  stock_name = [name for name, symbol_code in TAIWAN_STOCKS.items() if symbol_code == symbol][0]
490
+ return_pct = ((data['Close'].iloc[-1] - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
491
+ industry_data.append({'股票': stock_name, '代碼': symbol, '月報酬率(%)': return_pct, '產業': INDUSTRY_MAPPING.get(symbol, '其他')})
492
+ if not industry_data: return {}
 
 
 
 
 
 
 
 
 
 
 
493
  df_industry = pd.DataFrame(industry_data)
494
+ fig = px.pie(df_industry, values='月報酬率(%)', names='股票', title='各股票月報酬率比較', color_discrete_sequence=px.colors.qualitative.Set3)
 
 
 
 
 
495
  fig.update_layout(height=400)
496
  return fig
497
 
498
+ # 更新景氣燈號圖表
499
  @app.callback(
500
  dash.dependencies.Output('business-climate-chart', 'figure'),
501
+ [dash.dependencies.Input('stock-dropdown', 'value')]
502
  )
503
  def update_business_climate_chart(selected_stock):
504
  df = get_business_climate_data()
 
505
  if df.empty:
506
+ fig = go.Figure().add_annotation(text="無法載入景氣燈號資料", showarrow=False)
507
+ fig.update_layout(title="台灣景氣燈號", height=300)
 
 
 
 
 
 
 
 
 
 
 
 
508
  return fig
 
 
509
  def get_light_color(score):
510
+ if score >= 32: return 'red'
511
+ elif score >= 24: return 'orange'
512
+ elif score >= 17: return 'yellow'
513
+ elif score >= 10: return 'lightgreen'
514
+ else: return 'blue'
 
 
 
 
 
 
 
515
  colors = [get_light_color(score) for score in df['Index']]
 
516
  fig = go.Figure()
517
+ fig.add_trace(go.Scatter(x=df['Date'], y=df['Index'], mode='lines+markers', name='景氣燈號', line=dict(color='darkblue', width=2), marker=dict(size=8, color=colors, line=dict(width=2, color='darkblue'))))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
518
  fig.add_hline(y=32, line_dash="dash", line_color="red", annotation_text="紅燈(32)")
 
519
  fig.add_hline(y=17, line_dash="dash", line_color="yellow", annotation_text="黃燈(17)")
520
+ fig.update_layout(title="台灣景氣燈號走勢", xaxis_title='日期', yaxis_title='燈號分數', height=300, yaxis=dict(range=[0, 40]))
 
 
 
 
 
 
 
 
 
521
  return fig
522
 
523
+ # 更新分析師觀點
524
  @app.callback(
525
  [dash.dependencies.Output('technical-analysis-text', 'children'),
526
  dash.dependencies.Output('fundamental-analysis-text', 'children'),
 
529
  dash.dependencies.Input('period-dropdown', 'value')]
530
  )
531
  def update_analysis_text(selected_stock, period):
 
532
  data = get_stock_data(selected_stock, period)
533
  stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
534
+ if data.empty: return "無法獲取資料", "無法獲取資料", "無法獲取資料"
 
 
 
 
535
  data = calculate_technical_indicators(data)
 
 
536
  current_price = data['Close'].iloc[-1]
537
  price_change = ((current_price - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
 
 
538
  rsi_current = data['RSI'].iloc[-1] if not pd.isna(data['RSI'].iloc[-1]) else 50
 
 
539
  macd_current = data['MACD'].iloc[-1] if not pd.isna(data['MACD'].iloc[-1]) else 0
540
  macd_signal_current = data['MACD_Signal'].iloc[-1] if not pd.isna(data['MACD_Signal'].iloc[-1]) else 0
 
 
 
 
 
 
 
 
 
541
  technical_text = html.Div([
542
+ html.P([html.Strong("價格趨勢:"), f"近期{period}期間內,{stock_name}呈現", html.Span(f"{'上漲' if price_change > 5 else '下跌' if price_change < -5 else '盤整'}", style={'color': 'red' if price_change > 5 else 'green' if price_change < -5 else 'orange', 'font-weight': 'bold'}), f"走勢,累計變動{price_change:+.1f}%。"]),
543
+ html.P([html.Strong("RSI指標:"), f"目前為{rsi_current:.1f},", html.Span("處於超買區間" if rsi_current > 70 else "處於超賣區間" if rsi_current < 30 else "在正常範圍內", style={'color': 'green' if rsi_current > 70 else 'red' if rsi_current < 30 else 'blue', 'font-weight': 'bold'}), "。"]),
544
+ html.P([html.Strong("MACD指標:"), f"MACD線({macd_current:.3f})", html.Span("高於" if macd_current > macd_signal_current else "低於", style={'color': 'red' if macd_current > macd_signal_current else 'green', 'font-weight': 'bold'}), f"信號線({macd_signal_current:.3f}),", f"顯示{'多頭' if macd_current > macd_signal_current else '空頭'}格局。"]),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
545
  ])
 
 
546
  industry = INDUSTRY_MAPPING.get(selected_stock, '綜合')
547
  fundamental_text = html.Div([
548
+ html.P([html.Strong("產業地位:"), f"{stock_name}屬於{industry}產業,在產業鏈中具有", html.Span("重要地位" if selected_stock in ['2330.TW', '2454.TW', '2317.TW'] else "一定影響力", style={'font-weight': 'bold'}), "。"]),
549
+ html.P([html.Strong("營運展望:"), f"建議持續關注季報表現及未來指引。"]),
 
 
 
 
 
 
 
 
 
 
 
 
 
550
  ])
551
+ outlook_tone = "謹慎樂觀" if price_change > 10 else "保守觀望" if price_change < -10 else "中性持平"
 
 
 
 
 
 
 
 
 
 
 
552
  market_outlook = html.Div([
553
+ html.P([html.Strong("整體評估:"), f"基於技術面及基本面分析,對{stock_name}採取", html.Span(f"{outlook_tone}", style={'font-weight': 'bold'}), "態度。"]),
554
+ html.P([html.Strong("投資建議:"), "短線操作注意技術指標,長線投資關注基本面變化。"]),
 
 
 
 
 
 
 
 
 
 
 
 
555
  ])
 
556
  return technical_text, fundamental_text, market_outlook
557
 
558
+ # 更新PMI圖表
559
  @app.callback(
560
  dash.dependencies.Output('pmi-chart', 'figure'),
561
+ [dash.dependencies.Input('stock-dropdown', 'value')]
562
  )
563
  def update_pmi_chart(selected_stock):
564
  df = get_pmi_data()
 
565
  if df.empty:
566
+ fig = go.Figure().add_annotation(text="無法載入PMI資料", showarrow=False)
567
+ fig.update_layout(title="台灣PMI指數", height=300)
 
 
 
 
 
 
 
 
 
 
 
 
568
  return fig
569
+ colors = ['red' if value >= 50 else 'green' for value in df['Index']]
 
 
 
 
 
 
570
  fig = go.Figure()
571
+ fig.add_trace(go.Scatter(x=df['Date'], y=df['Index'], mode='lines+markers', name='PMI指數', line=dict(color='darkblue', width=2), marker=dict(size=8, color=colors, line=dict(width=2, color='darkblue'))))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
572
  fig.add_hline(y=50, line_dash="dash", line_color="black", annotation_text="榮枯線(50)")
573
+ fig.update_layout(title="台灣PMI指數走勢", xaxis_title='日期', yaxis_title='PMI指數', height=300, yaxis=dict(range=[35, 60]))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
574
  return fig
575
 
576
+ # 更新多檔股票比較
 
 
 
577
  @app.callback(
578
  [dash.dependencies.Output('comparison-chart', 'figure'),
579
  dash.dependencies.Output('comparison-table', 'children')],
 
581
  dash.dependencies.Input('comparison-period', 'value')]
582
  )
583
  def update_comparison_analysis(selected_stocks, period):
 
584
  fixed_stock = '0050.TW'
585
+ if not selected_stocks: selected_stocks = [fixed_stock]
586
+ elif fixed_stock not in selected_stocks: selected_stocks.insert(0, fixed_stock)
 
 
 
 
 
 
 
 
 
 
587
  selected_stocks = selected_stocks[:5]
 
588
  fig = go.Figure()
589
  comparison_data = []
 
590
  for stock in selected_stocks:
591
  data = get_stock_data(stock, period)
592
  if not data.empty:
 
593
  stock_name = next((name for name, symbol in TAIWAN_STOCKS.items() if symbol == stock), stock)
 
 
594
  normalized_prices = (data['Close'] / data['Close'].iloc[0]) * 100
595
+ fig.add_trace(go.Scatter(x=data.index, y=normalized_prices, mode='lines', name=stock_name, line=dict(width=2)))
 
 
 
 
 
 
 
 
 
596
  total_return = ((data['Close'].iloc[-1] / data['Close'].iloc[0]) - 1) * 100
597
+ volatility = data['Close'].pct_change().std() * np.sqrt(252) * 100
598
+ comparison_data.append({'name': stock_name, 'return': total_return, 'volatility': volatility, 'current_price': data['Close'].iloc[-1]})
599
+ fig.update_layout(title=f'股票績效比較 - {period}', xaxis_title='日期', yaxis_title='相對績效 (基期=100)', height=400, hovermode='x unified')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
600
  if comparison_data:
601
  table_rows = []
602
  for item in sorted(comparison_data, key=lambda x: x['return'], reverse=True):
603
  color = 'red' if item['return'] > 0 else 'green'
604
+ table_rows.append(html.Tr([html.Td(item['name'], style={'font-weight': 'bold'}), html.Td(f"{item['return']:+.1f}%", style={'color': color, 'font-weight': 'bold'}), html.Td(f"{item['volatility']:.1f}%"), html.Td(f"${item['current_price']:.2f}")]))
605
+ table = html.Table([html.Thead(html.Tr([html.Th("股票"), html.Th("報酬率"), html.Th("波動率"), html.Th("現價")])), html.Tbody(table_rows)], style={'width': '100%'})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
606
  return fig, table
 
607
  return fig, html.Div("無可比較資料")
608
 
609
+
610
+ # ==============================================================================
611
+ # ===== 【修改】市場情緒與新聞分析 (使用真實 BERT 模型) =====
612
+ # ==============================================================================
613
  @app.callback(
614
  [dash.dependencies.Output('sentiment-gauge', 'children'),
615
  dash.dependencies.Output('news-summary', 'children')],
616
+ [dash.dependencies.Input('stock-dropdown', 'value')] # 觸發條件不變
617
  )
618
  def update_sentiment_analysis(selected_stock):
619
+ # 檢查 predictor 是否成功初始化
620
+ if predictor is None:
621
+ error_fig = go.Figure().add_annotation(text="情緒指標模型載入失敗", showarrow=False)
622
+ error_fig.update_layout(height=200)
623
+ return dcc.Graph(figure=error_fig), html.P("新聞分析模型載入失敗,請檢查後台日誌。")
624
+
625
+ # --- 1. predictor 獲取新聞情緒平均分數 ---
626
+ sentiment_score_raw = predictor.get_news_index()
627
+
628
+ # --- 2. 建立情緒指標儀表板 ---
629
+ if sentiment_score_raw is not None:
630
+ # **重要假設**:假設您模型的輸出範圍在 [-1, 1] 之間 (負相關映到-1, 正相關映到1)
631
+ # 我們需要將其正規化到儀表板的 [0, 100] 範圍內
632
+ # 公式: normalized_score = (raw_score + 1) * 50
633
+ sentiment_score_normalized = (sentiment_score_raw + 1) * 50
634
+ # 確保分數不會超出 0-100 的範圍
635
+ sentiment_score_normalized = max(0, min(100, sentiment_score_normalized))
636
+
637
+ # 根據分數決定顏色和標籤
638
+ if sentiment_score_normalized >= 65:
639
+ bar_color, level_text = "#5cb85c", "樂觀" # 綠色
640
+ elif sentiment_score_normalized >= 35:
641
+ bar_color, level_text = "#f0ad4e", "中性" # 黃色
642
  else:
643
+ bar_color, level_text = "#d9534f", "悲觀" # 紅色
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
644
 
645
+ gauge_fig = go.Figure(go.Indicator(
646
+ mode = "gauge+number",
647
+ value = sentiment_score_normalized,
648
+ domain = {'x': [0, 1], 'y': [0, 1]},
649
+ title = {'text': f"昨日市場情緒: {level_text}", 'font': {'size': 18}},
650
+ gauge = {
651
+ 'axis': {'range': [0, 100], 'tickwidth': 1, 'tickcolor': "darkblue"},
652
+ 'bar': {'color': bar_color, 'thickness': 0.8},
653
+ 'steps': [
654
+ {'range': [0, 35], 'color': "rgba(217, 83, 79, 0.2)"},
655
+ {'range': [35, 65], 'color': "rgba(240, 173, 78, 0.2)"},
656
+ {'range': [65, 100], 'color': "rgba(92, 184, 92, 0.2)"}
657
+ ],
658
+ }
659
+ ))
660
+ gauge_fig.update_layout(height=200, margin=dict(l=30, r=30, t=50, b=20))
661
+ gauge_content = dcc.Graph(figure=gauge_fig)
662
+ else:
663
+ # 處理無法計算分數的情況 (例如 API 失敗或沒有新聞)
664
+ error_fig = go.Figure().add_annotation(text="今日尚無情緒分數", showarrow=False)
665
+ error_fig.update_layout(height=200)
666
+ gauge_content = dcc.Graph(figure=error_fig)
667
+
668
+
669
+ # --- 3. predictor 獲取分數最高的3則新聞 ---
670
+ top_news_list = predictor.get_news()
671
+
672
+ # --- 4. 建立新聞摘要元件 ---
673
+ if top_news_list: # 如果列表不為空
674
+ news_content = html.Div([
675
+ html.P(f"• {news}", style={
676
+ 'margin': '8px 0',
677
+ 'padding-left': '5px',
678
+ 'font-size': '14px',
679
+ 'line-height': '1.5'
680
+ }) for news in top_news_list
681
+ ])
682
+ elif top_news_list == []: # 如果是空列表
683
+ news_content = html.P("昨日無重大相關新聞。", style={'text-align': 'center', 'padding-top': '50px'})
684
+ else: # 如果是 None (代表讀取檔案出錯)
685
+ news_content = html.P("讀取新聞時發生錯誤。", style={'text-align': 'center', 'padding-top': '50px'})
 
 
 
 
 
686
 
687
+ return gauge_content, news_content
688
 
 
689
 
690
+ # 主程式執行
691
  if __name__ == '__main__':
 
 
 
 
 
 
 
 
 
 
692
  # 在 Hugging Face Spaces 中執行
693
  app.run(host="0.0.0.0", port=7860, debug=False)