AlanRex commited on
Commit
55c5e80
·
verified ·
1 Parent(s): e10b453

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +507 -553
app.py CHANGED
@@ -1,4 +1,5 @@
1
- # HUGING_FACE_V3.1.1.py (多頁面版本)
 
2
  # 系統套件
3
  import os
4
  from datetime import datetime, timedelta
@@ -13,23 +14,11 @@ from plotly.subplots import make_subplots
13
  import re
14
  from bs4 import BeautifulSoup
15
  import requests
16
- import warnings
17
- from sklearn.preprocessing import MinMaxScaler
18
- import joblib
19
- from tensorflow.keras.models import load_model
20
 
21
  # 引用您組員的預測器程式
22
- try:
23
- from Bert_predict import BertPredictor
24
- except ImportError:
25
- print("找不到 'Bert_predict.py' 模組,新聞情緒分析功能將無法使用。")
26
- BertPredictor = None
27
-
28
- # 忽略所有 UserWarning
29
- warnings.filterwarnings("ignore", category=UserWarning)
30
 
31
- # --- 資料準備與輔助函式 ---
32
- # 台股代號對應表
33
  TAIWAN_STOCKS = {
34
  '元大台灣50': '0050.TW', # 新增
35
  '台積電': '2330.TW',
@@ -46,200 +35,175 @@ TAIWAN_STOCKS = {
46
  '慧洋-KY': '2637.TW',
47
  '上銀': '2049.TW',
48
  '台泥': '1101.TW',
49
- '中信金': '2891.TW',
50
- '中鋼': '2002.TW',
51
- '聯電': '2303.TW',
52
- '國泰金': '2882.TW',
53
- '華碩': '2357.TW',
54
- '友達': '2409.TW',
55
- '緯創': '3231.TW',
56
- '廣達': '2382.TW',
57
- '技嘉': '2376.TW',
58
- '英業達': '2356.TW',
59
- '光寶科': '2301.TW',
60
  }
61
 
62
- # 產業分類 (簡化範例)
63
  INDUSTRY_MAPPING = {
64
- '電子': ['2330.TW', '2454.TW', '2317.TW', '2308.TW', '3711.TW', '2357.TW', '2409.TW', '3231.TW', '2382.TW', '2376.TW', '2356.TW', '2301.TW'],
65
- '金融': ['2881.TW', '2882.TW', '2891.TW'],
66
- '塑化': ['1301.TW'],
67
- '水泥': ['1101.TW'],
68
- '傳產': ['2002.TW', '1216.TW', '2603.TW', '2637.TW', '2049.TW'],
69
- '通訊': ['2412.TW'],
70
- 'ETF': ['0050.TW']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  }
72
 
73
- def get_stock_data(ticker, period='1y'):
74
- """從 yfinance 獲取股票資料"""
75
  try:
76
- stock = yf.Ticker(ticker)
77
  data = stock.history(period=period)
 
 
 
 
 
 
78
  return data
79
- except Exception as e:
80
- print(f"無法獲取 {ticker} 的資料: {e}")
81
  return pd.DataFrame()
82
 
83
- def get_economic_data(ticker, period='2y'):
84
- """獲取總經指標資料,例如PMI"""
85
- data = yf.download(ticker, period=period)
86
- return data['Close']
87
-
88
- def get_business_climate_data():
89
- """模擬獲取台灣景氣對策信號分數"""
90
- df = pd.DataFrame({
91
- 'Date': pd.to_datetime(['2024-01', '2024-02', '2024-03', '2024-04', '2024-05', '2024-06', '2024-07']),
92
- 'Score': [22, 24, 25, 27, 29, 30, 31],
93
- 'Signal': ['黃藍', '黃藍', '綠', '綠', '綠', '綠', '綠']
94
- })
95
- return df
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
97
- def add_technical_indicators(df):
98
- """計算並新增技術指標"""
99
- # RSI
 
 
100
  delta = df['Close'].diff()
101
- gain = delta.where(delta > 0, 0)
102
- loss = -delta.where(delta < 0, 0)
103
- avg_gain = gain.ewm(com=13, min_periods=14).mean()
104
- avg_loss = loss.ewm(com=13, min_periods=14).mean()
105
- rs = avg_gain / avg_loss
106
  df['RSI'] = 100 - (100 / (1 + rs))
107
-
108
- # MACD
109
- exp1 = df['Close'].ewm(span=12, adjust=False).mean()
110
- exp2 = df['Close'].ewm(span=26, adjust=False).mean()
111
  df['MACD'] = exp1 - exp2
112
- df['Signal_Line'] = df['MACD'].ewm(span=9, adjust=False).mean()
113
- df['MACD_Hist'] = df['MACD'] - df['Signal_Line']
114
-
115
- # Bollinger Bands
116
- df['MA20'] = df['Close'].rolling(window=20).mean()
117
- df['StdDev'] = df['Close'].rolling(window=20).std()
118
- df['Upper_BB'] = df['MA20'] + (df['StdDev'] * 2)
119
- df['Lower_BB'] = df['MA20'] - (df['StdDev'] * 2)
120
-
121
- # KD (Stochastic Oscillator)
122
- low_14 = df['Low'].rolling(window=14).min()
123
- high_14 = df['High'].rolling(window=14).max()
124
- df['%K'] = 100 * ((df['Close'] - low_14) / (high_14 - low_14))
125
- df['%D'] = df['%K'].rolling(window=3).mean()
126
-
127
- # %R (Williams %R)
128
- df['%R'] = -100 * (high_14 - df['Close']) / (high_14 - low_14)
129
-
130
- # DMI
131
- df['DMplus'] = df['High'].diff().clip(lower=0)
132
- df['DMminus'] = (-df['Low'].diff()).clip(lower=0)
133
- df['TR'] = df[['High', 'Low', 'Close']].apply(lambda x: max(x['High'] - x['Low'], abs(x['High'] - x['Close']), abs(x['Low'] - x['Close'])), axis=1)
134
- df['ADX'] = df['TR'].ewm(alpha=1/14, adjust=False).mean()
135
- df['DIplus'] = df['DMplus'].ewm(alpha=1/14, adjust=False).mean() / df['ADX']
136
- df['DIminus'] = df['DMminus'].ewm(alpha=1/14, adjust=False).mean() / df['ADX']
137
- df['ADX'] = abs(df['DIplus'] - df['DIminus']) / (df['DIplus'] + df['DIminus']) * 100
138
-
139
  return df
140
 
141
- def generate_analysis_text(df):
142
- """生成股票分析文字"""
143
- if df.empty:
144
- return {
145
- 'technical': "找不到技術資料。",
146
- 'fundamental': "找不到基本面資料。",
147
- 'outlook': "無法提供市場展望。"
148
- }
149
-
150
- latest = df.iloc[-1]
151
-
152
- # 技術面分析
153
- tech_text = "找不到技術分析資料。"
154
- if 'RSI' in df.columns:
155
- rsi = latest['RSI']
156
- rsi_signal = "超買" if rsi > 70 else "超賣" if rsi < 30 else "中性"
157
- tech_text = f"目前RSI為 {rsi:.2f},顯示市場處於**{rsi_signal}**。近期走勢強勁,但需留意過熱風險。"
158
-
159
- # 基本面分析(簡化)
160
- fundamental_text = "找不到基本面分析資料。"
161
- fundamental_text = f"基本面表現穩健,產業前景看好。公司財務狀況良好,建議持續關注。"
162
-
163
- # 市場展望
164
- outlook_text = "市場展望樂觀,但仍需留意全球經濟不確定性。建議投資人審慎評估,並隨時關注最新市場動態。"
165
-
166
- return {
167
- 'technical': tech_text,
168
- 'fundamental': fundamental_text,
169
- 'outlook': outlook_text
170
- }
171
-
172
- # --- LSTM 預測模型 ---
173
- def simple_lstm_predict(ticker, n_days=5):
174
- """使用簡單 LSTM 預測未來 n 天的收盤價"""
175
- model_path = 'lstm_model_taiex.h5'
176
- scaler_path = 'scaler_taiex.pkl'
177
-
178
- # 檢查模型和 scaler 是否存在
179
- if not os.path.exists(model_path) or not os.path.exists(scaler_path):
180
- return None, "模型或縮放器檔案不存在,無法進行預測。"
181
 
 
182
  try:
183
- # 載入模型和 scaler
184
- model = load_model(model_path)
185
- scaler = joblib.load(scaler_path)
186
-
187
- # 獲取歷史資料
188
- data = yf.download(ticker, period='60d', interval='1d')
189
- if data.empty:
190
- return None, "無法獲取歷史數據。"
191
-
192
- # 使用最新的 60 天收盤價作為輸入
193
- last_60_days = data['Close'].values[-60:].reshape(-1, 1)
194
- last_60_days_scaled = scaler.transform(last_60_days)
195
- X_test = last_60_days_scaled.reshape(1, 60, 1)
196
-
197
- # 進行預測
198
- future_predictions = []
199
- current_input = X_test
200
- for _ in range(n_days):
201
- predicted_scaled_price = model.predict(current_input, verbose=0)
202
- future_predictions.append(predicted_scaled_price[0, 0])
203
- current_input = np.append(current_input[:, 1:, :], predicted_scaled_price.reshape(1, 1, 1), axis=1)
204
-
205
- # 反向轉換回原始價格
206
- predicted_prices = scaler.inverse_transform(np.array(future_predictions).reshape(-1, 1)).flatten()
207
-
208
- # 建立預測結果 DataFrame
209
- last_date = data.index[-1]
210
- future_dates = [last_date + timedelta(days=i) for i in range(1, n_days + 1)]
211
- pred_df = pd.DataFrame({'Date': future_dates, 'Predicted_Price': predicted_prices})
212
-
213
- # 建立歷史價格 DataFrame
214
- history_df = data.reset_index()
215
- history_df = history_df[['Date', 'Close']]
216
- history_df.rename(columns={'Close': 'Price'}, inplace=True)
217
-
218
- return history_df, pred_df
219
 
 
 
 
 
 
 
 
 
 
 
 
220
  except Exception as e:
221
- print(f"預測過程中發生錯誤: {e}")
222
- return None, f"預測過程中發生錯誤: {e}"
223
 
224
- # --- 主要應用程式 ---
225
  # 建立 Dash 應用程式
226
  app = dash.Dash(__name__, suppress_callback_exceptions=True)
227
 
228
- # 註解掉新聞預測器初始化
229
- # predictor = None
230
- # try:
231
- # if BertPredictor:
232
- # print("正在初始化新聞情緒分析模型...")
233
- # predictor = BertPredictor(max_news_per_keyword=5)
234
- # print("新聞情緒分析模型初始化成功。")
235
- # except Exception as e:
236
- # print(f"錯誤:新聞情緒分析模型初始化失敗 - {e}")
237
- # predictor = None
238
-
239
-
240
- # --- 頁面內容定義 ---
241
- # 首頁:預測與總經
242
- homepage_layout = html.Div([
243
  html.Div([
244
  html.H2("🤖 AI深度學習預測 - 台指期指數", style={'text-align': 'center','color': '#FFCC22','margin-bottom': '25px'}),
245
  html.Div([
@@ -257,20 +221,20 @@ homepage_layout = html.Div([
257
  html.Div([dcc.Graph(id='taiex-prediction-chart')], style={'margin-top': '20px'})
258
  ], 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'}),
259
 
260
- # 註解掉情緒分析區塊
261
- # html.Div([
262
- # html.H3("📰 市場情緒與新聞分析", style={'color': '#E74C3C', 'margin-bottom': '20px'}),
263
- # html.Div([
264
- # html.Div([
265
- # html.H4("市場情緒指標", style={'color': '#8E44AD'}),
266
- # html.Div(id='sentiment-gauge')
267
- # ], style={'width': '48%', 'display': 'inline-block'}),
268
- # html.Div([
269
- # html.H4("關鍵新聞摘要", style={'color': '#27AE60'}),
270
- # html.Div(id='news-summary', style={'background': '#f8f9fa','padding': '15px','border-radius': '8px','max-height': '200px','overflow-y': 'auto'})
271
- # ], style={'width': '48%', 'display': 'inline-block', 'margin-left': '4%'})
272
- # ])
273
- # ], style={'margin-top': '30px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
274
 
275
  html.Div([
276
  html.H3("景氣燈號與 PMI 分析"),
@@ -279,11 +243,7 @@ homepage_layout = html.Div([
279
  html.Div([dcc.Graph(id='pmi-chart')], style={'width': '48%', 'display': 'inline-block', 'margin-left': '2%'})
280
  ])
281
  ], style={'margin-top': '30px'}),
282
- ])
283
-
284
-
285
- # 個股分析頁面
286
- stock_page_layout = html.Div([
287
  html.Div([
288
  html.Div([
289
  html.Label("選擇股票:"),
@@ -300,6 +260,7 @@ stock_page_layout = html.Div([
300
  dcc.Dropdown(id='chart-type', options=[{'label': '線圖', 'value': 'line'},{'label': '蠟燭圖', 'value': 'candlestick'}], value='candlestick', style={'margin-bottom': '10px'})
301
  ], style={'width': '30%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'})
302
  ], style={'margin-bottom': '30px'}),
 
303
  html.Div(id='stock-info-cards', style={'margin-bottom': '30px'}),
304
  html.Div([html.Div([dcc.Graph(id='price-chart')], style={'width': '100%', 'display': 'inline-block', 'vertical-align': 'top'})]),
305
  html.Div([
@@ -352,386 +313,379 @@ stock_page_layout = html.Div([
352
  ], style={'margin-top': '30px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
353
  ])
354
 
355
- # --- 主要應用程式佈局 ---
356
- app.layout = html.Div([
357
- dcc.Location(id='url', refresh=False),
358
- html.H1("台股分析儀表板", style={'text-align': 'center', 'margin-bottom': '10px'}),
359
- html.Div([
360
- dcc.Link('市場總覽', href='/', style={'margin-right': '20px', 'font-size': '18px'}),
361
- dcc.Link('個股分析', href='/stock-analysis', style={'font-size': '18px'}),
362
- ], style={'text-align': 'center', 'margin-bottom': '30px'}),
363
- html.Hr(),
364
- html.Div(id='page-content')
365
- ])
366
-
367
-
368
- # --- 回調函數 (處理頁面導航) ---
369
  @app.callback(
370
- dash.dependencies.Output('page-content', 'children'),
371
- [dash.dependencies.Input('url', 'pathname')]
 
372
  )
373
- def display_page(pathname):
374
- if pathname == '/stock-analysis':
375
- return stock_page_layout
376
- else:
377
- return homepage_layout
378
-
379
- # --- 回調函數 (所有原始的 callback 都放在這裡) ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
 
 
381
  @app.callback(
382
- dash.dependencies.Output('taiex-prediction-results', 'children'),
383
- dash.dependencies.Output('taiex-prediction-chart', 'figure'),
384
- [dash.dependencies.Input('taiex-prediction-period', 'value')]
385
  )
386
- def update_taiex_prediction(n_days):
387
- """更新台指期預測結果"""
388
- history_df, pred_df = simple_lstm_predict('^TWII', n_days)
389
-
390
- if history_df is None:
391
- return html.P(pred_df, style={'color': 'red'}), go.Figure()
392
-
393
- current_price = history_df.iloc[-1]['Price']
394
- predicted_price = pred_df.iloc[-1]['Predicted_Price']
395
- change = predicted_price - current_price
396
- change_percent = (change / current_price) * 100
397
-
398
- direction = "📈 上漲" if change > 0 else "📉 下跌" if change < 0 else "↔ 持平"
399
- color = "green" if change > 0 else "red" if change < 0 else "orange"
400
-
401
- result_text = html.Div([
402
- html.P(f"當前價格: {current_price:.2f}", style={'font-size': '1.2em', 'margin': '5px 0'}),
403
- html.P(f"預測 {n_days} 天後價格: {predicted_price:.2f}", style={'font-size': '1.2em', 'margin': '5px 0'}),
404
- html.P(f"預測變動: {change:.2f} ({change_percent:.2f}%) {direction}", style={'font-size': '1.5em', 'font-weight': 'bold', 'color': color, 'margin': '10px 0'})
 
 
405
  ])
406
-
407
- # 繪製圖表
408
- fig = go.Figure()
409
- # 歷史價格
410
- fig.add_trace(go.Scatter(x=history_df['Date'], y=history_df['Price'], mode='lines', name='歷史價格', line=dict(color='#8E44AD')))
411
- # 預測價格
412
- fig.add_trace(go.Scatter(x=pred_df['Date'], y=pred_df['Predicted_Price'], mode='lines', name='預測價格', line=dict(color='#E74C3C', dash='dash')))
413
-
414
- fig.update_layout(
415
- title=f'台指期指數歷史與預測 ({n_days}天)',
416
- xaxis_title='日期',
417
- yaxis_title='價格',
418
- legend_title='圖例',
419
- template='plotly_white'
420
- )
421
-
422
- return result_text, fig
423
-
424
- # 註解掉情緒分析回調函數
425
- # @app.callback(
426
- # dash.dependencies.Output('sentiment-gauge', 'children'),
427
- # dash.dependencies.Output('news-summary', 'children')
428
- # )
429
- # def update_sentiment_analysis():
430
- # """更新新聞情緒分析"""
431
- # if not predictor:
432
- # return html.Div("新聞情緒分析模型未初始化。"), html.Div("請檢查 'Bert_predict.py' 檔案是否存在。")
433
-
434
- # try:
435
- # sentiment_score, news_list = predictor.get_sentiment_score()
436
- # except Exception as e:
437
- # sentiment_score = None
438
- # news_list = []
439
- # print(f"情緒分析獲取失敗: {e}")
440
-
441
- # # 1. 建立儀表板 (Gauge)
442
- # if sentiment_score is not None:
443
- # gauge_fig = go.Figure(go.Indicator(
444
- # mode="gauge+number",
445
- # value=sentiment_score,
446
- # title={'text': "市場情緒分數 (0-100)"},
447
- # domain={'x': [0, 1], 'y': [0, 1]},
448
- # gauge={
449
- # 'axis': {'range': [0, 100]},
450
- # 'bar': {'color': "#667eea"},
451
- # 'steps': [
452
- # {'range': [0, 35], 'color': "rgba(217, 83, 79, 0.2)"},
453
- # {'range': [35, 65], 'color': "rgba(240, 173, 78, 0.2)"},
454
- # {'range': [65, 100], 'color': "rgba(92, 184, 92, 0.2)"}
455
- # ],
456
- # }
457
- # ))
458
- # gauge_fig.update_layout(height=200, margin=dict(l=30, r=30, t=50, b=20))
459
- # gauge_content = dcc.Graph(figure=gauge_fig)
460
- # else:
461
- # # 處理無法計算分數的情況
462
- # error_fig = go.Figure().add_annotation(text="今日尚無情緒分數", showarrow=False)
463
- # error_fig.update_layout(height=200)
464
- # gauge_content = dcc.Graph(figure=error_fig)
465
-
466
-
467
- # # 2. 從 predictor 獲取分數最高的3則新聞
468
- # top_news_list = predictor.get_news()
469
-
470
- # # 3. 建立新聞摘要元件
471
- # if top_news_list: # 如果列表不為空
472
- # news_content = html.Div([
473
- # html.P(f"• {news}", style={
474
- # 'margin': '8px 0',
475
- # 'padding-left': '5px',
476
- # 'font-size': '14px',
477
- # 'border-left': '3px solid #E74C3C'
478
- # }) for news in top_news_list
479
- # ])
480
- # else:
481
- # news_content = html.Div("今日尚無重大新聞摘要。")
482
-
483
- # return gauge_content, news_content
484
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
485
 
 
486
  @app.callback(
487
- dash.dependencies.Output('business-climate-chart', 'figure')
 
 
 
488
  )
489
- def update_business_climate_chart():
490
- """更新景氣燈號圖表"""
491
- df = get_business_climate_data()
492
-
493
- color_map = {'藍': '#3498DB', '黃藍': '#F39C12', '綠': '#27AE60', '黃紅': '#E67E22', '紅': '#E74C3C'}
494
- df['Color'] = df['Signal'].map(color_map)
495
-
496
- fig = go.Figure()
497
- fig.add_trace(go.Scatter(x=df['Date'], y=df['Score'], mode='lines+markers', marker=dict(color=df['Color'], size=10), line=dict(color='gray')))
498
-
499
- fig.update_layout(
500
- title='台灣景氣對策信號',
501
- xaxis_title='日期',
502
- yaxis_title='分數',
503
- yaxis=dict(range=[9, 45]),
504
- template='plotly_white'
505
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
  return fig
507
 
 
508
  @app.callback(
509
- dash.dependencies.Output('pmi-chart', 'figure')
 
 
510
  )
511
- def update_pmi_chart():
512
- """更新PMI圖表"""
513
- # 模擬獲取PMI資料,可替換為真實API
514
- pmi = get_economic_data('ISM-MAN_PMI')
515
-
516
- fig = px.line(x=pmi.index, y=pmi, title='美國ISM製造業PMI', labels={'x':'日期', 'y':'PMI'})
517
- fig.add_hline(y=50, line_dash="dash", line_color="red", annotation_text="50 榮枯線")
518
-
519
- fig.update_layout(template='plotly_white')
520
  return fig
521
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
522
 
 
523
  @app.callback(
524
- [
525
- dash.dependencies.Output('stock-info-cards', 'children'),
526
- dash.dependencies.Output('price-chart', 'figure'),
527
- dash.dependencies.Output('volume-chart', 'figure'),
528
- dash.dependencies.Output('industry-analysis', 'figure'),
529
- dash.dependencies.Output('technical-analysis-text', 'children'),
530
- dash.dependencies.Output('fundamental-analysis-text', 'children'),
531
- dash.dependencies.Output('market-outlook-text', 'children')
532
- ],
533
- [
534
- dash.dependencies.Input('stock-dropdown', 'value'),
535
- dash.dependencies.Input('period-dropdown', 'value'),
536
- dash.dependencies.Input('chart-type', 'value')
537
- ]
538
  )
539
- def update_all_stock_info(selected_stock, selected_period, chart_type):
540
- """根據選取股票更新所有相關圖表與資訊"""
541
-
542
- # 獲取資料
543
- df = get_stock_data(selected_stock, period=selected_period)
544
- df = add_technical_indicators(df)
545
-
546
  if df.empty:
547
- return [
548
- html.Div("找不到股票資訊。"),
549
- go.Figure(),
550
- go.Figure(),
551
- go.Figure(),
552
- "找不到技術分析資料。",
553
- "找不到基本面分析資料。",
554
- "無法提供市場展望。"
555
- ]
556
-
557
- # --- 1. 股票資訊卡片 ---
558
- latest_data = df.iloc[-1]
559
- last_close = df['Close'].iloc[-2] if len(df) > 1 else latest_data['Close']
560
- change = latest_data['Close'] - last_close
561
- change_percent = (change / last_close) * 100
562
- change_color = 'green' if change >= 0 else 'red'
563
-
564
- info_cards = html.Div([
565
- html.Div([
566
- html.H5("收盤價", style={'color': '#8e44ad'}),
567
- html.H3(f"{latest_data['Close']:.2f} TWD", style={'color': '#8e44ad'})
568
- ], className="card", style={'border-left': '5px solid #8e44ad'}),
569
- html.Div([
570
- html.H5("漲跌幅", style={'color': '#27ae60'}),
571
- html.H3(f"{change:.2f} ({change_percent:.2f}%)", style={'color': change_color})
572
- ], className="card", style={'border-left': '5px solid #27ae60'}),
573
- html.Div([
574
- html.H5("成交量", style={'color': '#d35400'}),
575
- html.H3(f"{latest_data['Volume']/10000:,.0f} 萬股", style={'color': '#d35400'})
576
- ], className="card", style={'border-left': '5px solid #d35400'}),
577
- ], style={'display': 'flex', 'justify-content': 'space-around', 'margin-bottom': '20px'})
578
-
579
- # --- 2. 股價圖 ---
580
- if chart_type == 'candlestick':
581
- price_fig = go.Figure(data=[go.Candlestick(x=df.index,
582
- open=df['Open'],
583
- high=df['High'],
584
- low=df['Low'],
585
- close=df['Close'])
586
- ])
587
- else: # line chart
588
- price_fig = px.line(df, x=df.index, y='Close', title='股價走勢圖')
589
-
590
- price_fig.update_layout(xaxis_rangeslider_visible=False, title=f'{selected_stock} 股價走勢圖', template='plotly_white')
591
-
592
- # --- 3. 成交量圖 ---
593
- volume_fig = px.bar(df, x=df.index, y='Volume', title='成交量', color='Volume', color_continuous_scale='bluered')
594
- volume_fig.update_layout(template='plotly_white', coloraxis_showscale=False)
595
-
596
- # --- 4. 產業表現分析 ---
597
- industry_analysis_fig = go.Figure()
598
- industry_stock = ''
599
- for industry, stocks in INDUSTRY_MAPPING.items():
600
- if selected_stock in stocks:
601
- industry_stock = industry
602
- for stock_symbol in stocks:
603
- stock_data = get_stock_data(stock_symbol, period='1y')
604
- if not stock_data.empty:
605
- industry_analysis_fig.add_trace(go.Scatter(
606
- x=stock_data.index, y=stock_data['Close'], mode='lines', name=stock_symbol,
607
- visible='legendonly' if stock_symbol != selected_stock else True
608
- ))
609
- break
610
-
611
- industry_analysis_fig.update_layout(title=f'{industry_stock} 產業表現', template='plotly_white')
612
-
613
- # --- 5. 分析師觀點文字 ---
614
- analysis_texts = generate_analysis_text(df)
615
-
616
- return info_cards, price_fig, volume_fig, industry_analysis_fig, analysis_texts['technical'], analysis_texts['fundamental'], analysis_texts['outlook']
617
-
618
 
 
619
  @app.callback(
620
- dash.dependencies.Output('advanced-technical-chart', 'figure'),
621
- [
622
- dash.dependencies.Input('stock-dropdown', 'value'),
623
- dash.dependencies.Input('period-dropdown', 'value'),
624
- dash.dependencies.Input('technical-indicator-selector', 'value')
625
- ]
626
  )
627
- def update_technical_indicator_chart(selected_stock, selected_period, indicator):
628
- """更新技術指標圖表"""
629
- df = get_stock_data(selected_stock, period=selected_period)
630
- df = add_technical_indicators(df)
631
-
632
- if df.empty or indicator not in df.columns:
633
- return go.Figure()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
634
 
 
 
 
 
 
 
 
 
 
 
 
 
635
  fig = go.Figure()
636
-
637
- if indicator == 'RSI':
638
- fig = px.line(df, x=df.index, y='RSI', title='RSI 相對強弱指標')
639
- fig.add_hline(y=70, line_dash="dash", line_color="red", annotation_text="超買線")
640
- fig.add_hline(y=30, line_dash="dash", line_color="green", annotation_text="超賣線")
641
- fig.update_yaxes(range=[0, 100])
642
-
643
- elif indicator == 'MACD':
644
- fig = go.Figure()
645
- fig.add_trace(go.Scatter(x=df.index, y=df['MACD'], name='MACD', mode='lines', line=dict(color='blue')))
646
- fig.add_trace(go.Scatter(x=df.index, y=df['Signal_Line'], name='Signal', mode='lines', line=dict(color='red')))
647
- colors = ['green' if val > 0 else 'red' for val in df['MACD_Hist']]
648
- fig.add_trace(go.Bar(x=df.index, y=df['MACD_Hist'], name='Histogram', marker_color=colors))
649
- fig.update_layout(title='MACD 指數平滑異同移動平均線')
650
-
651
- elif indicator == 'BB':
652
- fig = px.line(df, x=df.index, y=['Close', 'Upper_BB', 'Lower_BB'], title='布林通道 Bollinger Bands')
653
-
654
- elif indicator == 'KD':
655
- fig = px.line(df, x=df.index, y=['%K', '%D'], title='KD 隨機指標')
656
- fig.add_hline(y=80, line_dash="dash", line_color="red", annotation_text="超買線")
657
- fig.add_hline(y=20, line_dash="dash", line_color="green", annotation_text="超賣線")
658
- fig.update_yaxes(range=[0, 100])
659
-
660
- elif indicator == 'WR':
661
- fig = px.line(df, x=df.index, y='%R', title='威廉指標 %R')
662
- fig.add_hline(y=-20, line_dash="dash", line_color="red", annotation_text="超買線")
663
- fig.add_hline(y=-80, line_dash="dash", line_color="green", annotation_text="超賣線")
664
- fig.update_yaxes(range=[-100, 0])
665
-
666
- elif indicator == 'DMI':
667
- fig = px.line(df, x=df.index, y=['DIplus', 'DIminus', 'ADX'], title='DMI 動向指標')
668
- fig.add_hline(y=20, line_dash="dash", line_color="orange", annotation_text="趨勢強弱參考線")
669
-
670
- fig.update_layout(template='plotly_white')
671
  return fig
672
 
673
-
674
  @app.callback(
675
- [
676
- dash.dependencies.Output('comparison-chart', 'figure'),
677
- dash.dependencies.Output('comparison-table', 'children')
678
- ],
679
- [
680
- dash.dependencies.Input('comparison-stocks', 'value'),
681
- dash.dependencies.Input('comparison-period', 'value')
682
- ]
683
  )
684
- def update_comparison_chart(tickers, period):
685
- if not tickers:
686
- return go.Figure(), html.P("請至少選擇一檔股票。")
687
-
688
- df_dict = {}
689
- for ticker in tickers:
690
- df = yf.download(ticker, period=period)
691
- if not df.empty:
692
- df['Normalized'] = df['Close'] / df['Close'].iloc[0] * 100
693
- df_dict[ticker] = df['Normalized']
694
-
695
- if not df_dict:
696
- return go.Figure(), html.P("找不到任何股票資料。")
697
-
698
- normalized_df = pd.DataFrame(df_dict)
699
-
700
  fig = go.Figure()
701
- for col in normalized_df.columns:
702
- stock_name = next((name for name, symbol in TAIWAN_STOCKS.items() if symbol == col), col)
703
- fig.add_trace(go.Scatter(x=normalized_df.index, y=normalized_df[col], name=stock_name, mode='lines'))
704
-
705
- fig.update_layout(
706
- title='股票相對漲跌幅比較',
707
- xaxis_title='日期',
708
- yaxis_title='相對漲跌幅 (%) (基期=100)',
709
- template='plotly_white'
710
- )
711
-
712
- # 建立表格
713
- summary_data = []
714
- for ticker, df in df_dict.items():
715
- stock_name = next((name for name, symbol in TAIWAN_STOCKS.items() if symbol == ticker), ticker)
716
- start_price = df.iloc[0]
717
- end_price = df.iloc[-1]
718
- change_percent = (end_price - start_price)
719
- summary_data.append({
720
- '股票': stock_name,
721
- '漲跌幅': f'{change_percent:.2f}%'
722
- })
723
-
724
- summary_df = pd.DataFrame(summary_data)
725
- table = html.Table([
726
- html.Thead(html.Tr([html.Th(col) for col in summary_df.columns])),
727
- html.Tbody([
728
- html.Tr([
729
- html.Td(summary_df.iloc[i][col]) for col in summary_df.columns
730
- ]) for i in range(len(summary_df))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
731
  ])
732
- ])
733
-
734
- return fig, table
 
 
 
 
735
 
736
  # 主程式執行
737
  if __name__ == '__main__':
 
1
+ # HUGING_FACE_V2.1.3.py (整合 Bert_predict 版本)
2
+
3
  # 系統套件
4
  import os
5
  from datetime import datetime, timedelta
 
14
  import re
15
  from bs4 import BeautifulSoup
16
  import requests
 
 
 
 
17
 
18
  # 引用您組員的預測器程式
19
+ from Bert_predict import BertPredictor
 
 
 
 
 
 
 
20
 
21
+ # 台股代號對應表 (移除台指期,因為它現在是獨立區塊)
 
22
  TAIWAN_STOCKS = {
23
  '元大台灣50': '0050.TW', # 新增
24
  '台積電': '2330.TW',
 
35
  '慧洋-KY': '2637.TW',
36
  '上銀': '2049.TW',
37
  '台泥': '1101.TW',
38
+ '南亞科': '2408.TW',
39
+ '旺宏': '2337.TW',
40
+ '譜瑞-KY': '4966.TWO',
41
+ '貿聯-KY': '3665.TW',
42
+ '騰雲': '6870.TWO',
43
+ '穩懋': '3105.TWO'
 
 
 
 
 
44
  }
45
 
46
+ # 產業分類
47
  INDUSTRY_MAPPING = {
48
+ '0050.TW': 'ETF', # 新增
49
+ '2330.TW': '半導體',
50
+ '2454.TW': '半導體',
51
+ '2317.TW': '電子組件',
52
+ '1301.TW': '塑膠',
53
+ '2412.TW': '電信',
54
+ '2881.TW': '金融',
55
+ '2882.TW': '金融',
56
+ '2308.TW': '電子',
57
+ '1216.TW': '食品',
58
+ '3711.TW': '半導體',
59
+ '2603.TW': '航運',
60
+ '2637.TW': '散裝航運',
61
+ '2049.TW': '工具機',
62
+ '1101.TW': '營建',
63
+ '2408.TW': 'DRAM',
64
+ '2337.TW': 'NFLSH',
65
+ '1101.TW': '營建',
66
+ '4966.TWO': '高速傳輸',
67
+ '3665.TW': '連接器',
68
+ '6870.TWO': '軟體整合',
69
+ '3105.TWO': 'PA功率'
70
  }
71
 
72
+ def get_stock_data(symbol, period='1y'):
73
+ """獲取股票資料"""
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()
191
 
 
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([
 
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([
228
+ 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 分析"),
 
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("選擇股票:"),
 
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([
 
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
+ # 更新股價資訊卡片
352
  @app.callback(
353
+ dash.dependencies.Output('stock-info-cards', 'children'),
354
+ [dash.dependencies.Input('stock-dropdown', 'value')]
 
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'),
383
+ dash.dependencies.Input('period-dropdown', 'value'),
384
+ dash.dependencies.Input('chart-type', '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'),
408
+ dash.dependencies.Input('stock-dropdown', 'value'),
409
+ dash.dependencies.Input('period-dropdown', '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
+ # 更新成交量圖表
465
  @app.callback(
466
+ dash.dependencies.Output('volume-chart', 'figure'),
467
+ [dash.dependencies.Input('stock-dropdown', 'value'),
468
+ dash.dependencies.Input('period-dropdown', 'value')]
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
+ # 更新產業分析圖表
480
+ @app.callback(
481
+ dash.dependencies.Output('industry-analysis', 'figure'),
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'),
527
+ dash.dependencies.Output('market-outlook-text', 'children')],
528
+ [dash.dependencies.Input('stock-dropdown', 'value'),
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')],
580
+ [dash.dependencies.Input('comparison-stocks', 'value'),
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__':