AlanRex commited on
Commit
9a0b168
·
verified ·
1 Parent(s): 5ede8cd

Update app.py

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