AlanRex commited on
Commit
63a564e
·
verified ·
1 Parent(s): 7fd9c4d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +279 -70
app.py CHANGED
@@ -1,4 +1,4 @@
1
- # HUGING_FACE_V4.2(輕量AI版).py - 已整合 XGBoost 模型 (最終修正版)
2
 
3
  # 系統套件
4
  import os
@@ -18,28 +18,38 @@ import requests
18
  import time # 引用 time 模組以處理時間戳
19
 
20
  # ========================= 引用外部模組 START =========================
 
21
  from Bert_predict import BertPredictor
 
 
22
  from model_predictor import XGBoostModel
23
  # ========================== 引用外部模組 END ==========================
24
 
25
  # ========================= 全域設定 START =========================
 
26
  USE_ADVANCED_MODEL = True
 
 
 
27
  ANALYSIS_CACHE = {}
 
28
  CACHE_DURATION_SECONDS = 8 * 60 * 60
29
  # ========================== CACHE 設定 END ==========================
30
 
 
31
  try:
32
  print("正在初始化 XGBoost 預測模型...")
33
  xgb_model = XGBoostModel(default_model='xgboost_model')
34
  print("XGBoost 預測模型初始化成功。")
35
  except Exception as e:
36
  print(f"錯誤:XGBoost 預測模型初始化失敗 - {e}")
 
37
  USE_ADVANCED_MODEL = False
38
  xgb_model = None
39
  print("警告:已自動切換回簡易統計模型模式。")
40
  # ========================== 全域設定 END ==========================
41
 
42
- # 台股代號對應表 (省略)
43
  TAIWAN_STOCKS = {
44
  '元大台灣50': '0050.TW', '台積電': '2330.TW', '聯發科': '2454.TW',
45
  '鴻海': '2317.TW', '台達電': '2308.TW', '廣達': '2382.TW', '富邦金': '2881.TW',
@@ -58,7 +68,7 @@ TAIWAN_STOCKS = {
58
  '譜瑞-KY': '4966.TWO', '貿聯-KY': '3665.TW', '騰雲': '6870.TWO', '穩懋': '3105.TWO'
59
  }
60
 
61
- # 產業分類 (省略)
62
  INDUSTRY_MAPPING = {
63
  '0050.TW': 'ETF', '2330.TW': '半導體', '2454.TW': '半導體', '2317.TW': '電子組件',
64
  '2308.TW': '電子', '2382.TW': '電子', '2881.TW': '金融', '2891.TW': '金融',
@@ -78,6 +88,7 @@ INDUSTRY_MAPPING = {
78
  }
79
 
80
  def get_stock_data(symbol, period='1y'):
 
81
  try:
82
  stock = yf.Ticker(symbol)
83
  data = stock.history(period=period)
@@ -92,6 +103,7 @@ def get_stock_data(symbol, period='1y'):
92
  return pd.DataFrame()
93
 
94
  def simple_statistical_predict(data, predict_days=5):
 
95
  if len(data) < 60: return None
96
  prices = data['Close'].values
97
  ma_short = np.mean(prices[-5:])
@@ -106,42 +118,101 @@ def simple_statistical_predict(data, predict_days=5):
106
  change_pct = ((predicted_price - prices[-1]) / prices[-1]) * 100
107
  return {'predicted_price': predicted_price, 'change_pct': change_pct, 'confidence': max(0.6, 1 - volatility * 2)}
108
 
 
 
109
  def advanced_xgboost_predict(data, predict_days):
 
 
 
 
 
 
110
  if xgb_model is None or data.empty:
 
111
  return None
112
- feature_columns = ['Open', 'High', 'Low', 'Volume']
113
- input_df = data.tail(1)
114
- if not all(col in input_df.columns for col in feature_columns):
115
- print(f"錯誤: 輸入資料缺少必要欄位。需要 {feature_columns}")
 
 
 
 
 
 
 
 
 
 
 
116
  return None
117
- input_df_filtered = input_df[feature_columns]
118
  try:
119
- predictions = xgb_model.predict('xgboost_model', input_df_filtered)
120
- day_to_key_map = {1: 'Close_t0_pred', 5: 'Close_t5_pred', 10: 'Close_t10_pred', 20: 'Close_t20_pred', 60: None}
 
 
 
 
 
 
 
 
 
 
 
 
121
  prediction_key = day_to_key_map.get(predict_days)
 
122
  if prediction_key is None or prediction_key not in predictions:
123
- print(f"警告: XGBoost 模型沒有提供 {predict_days} 天的預測結果。")
124
- return None
 
 
 
 
 
125
  predicted_price = predictions[prediction_key]
126
  current_price = data['Close'].iloc[-1]
127
  change_pct = ((predicted_price - current_price) / current_price) * 100
128
- return {'predicted_price': predicted_price, 'change_pct': change_pct, 'confidence': 0.95}
 
 
 
 
 
 
 
 
 
 
129
  except Exception as e:
130
  print(f"執行 XGBoost 預測時發生錯誤: {e}")
 
 
131
  return None
132
 
133
  def get_prediction(data, predict_days=5):
 
 
 
 
134
  if USE_ADVANCED_MODEL:
135
  print(f"模式: 進階XGBoost模型 | 預測天期: {predict_days}天")
 
136
  prediction = advanced_xgboost_predict(data, predict_days)
 
137
  if prediction is not None:
138
  return prediction
139
  else:
140
  print("進階模型預測失敗或無對應天期,自動降級為簡易統計模型。")
 
 
141
  print(f"模式: 簡易統計模型 | 預測天期: {predict_days}天")
142
  return simple_statistical_predict(data, predict_days)
143
 
144
  def calculate_technical_indicators(df):
 
145
  if df.empty: return df
146
  df['MA5'] = df['Close'].rolling(window=5).mean()
147
  df['MA20'] = df['Close'].rolling(window=20).mean()
@@ -178,43 +249,42 @@ def calculate_technical_indicators(df):
178
  df['ADX'] = df['DX'].ewm(com=13, adjust=False).mean()
179
  return df
180
 
181
- # 【【【主要修正點】】】: 修正 calculate_volume_profile 函式以處理 NaN 和 shape 不匹配問題
182
  def calculate_volume_profile(df, num_bins=50):
183
- if df.empty or 'High' not in df.columns or 'Low' not in df.columns or 'Volume' not in df.columns:
184
- return None, None, None
185
-
186
- # 建立一個包含所需欄位的臨時 DataFrame
187
- df_temp = pd.DataFrame({
188
- 'High': df['High'],
189
- 'Low': df['Low'],
190
- 'Close': df['Close'],
191
- 'Volume': df['Volume']
192
- })
193
-
194
- # 一次性移除任何欄位包含 NaN 的整行資料
195
- df_temp.dropna(inplace=True)
196
-
197
- if df_temp.empty:
198
  return None, None, None
199
-
200
- # 從清理過的 DataFrame 中獲取資料
201
- all_prices = np.concatenate([df_temp['High'].values, df_temp['Low'].values])
202
 
203
- if all_prices.size == 0:
 
 
204
  return None, None, None
205
-
 
206
  min_price, max_price = all_prices.min(), all_prices.max()
207
 
208
- if min_price >= max_price:
 
 
 
 
 
 
 
 
209
  return None, None, None
210
-
211
- price_for_volume = (df_temp['High'] + df_temp['Low'] + df_temp['Close']) / 3
212
- weights = df_temp['Volume'].values
213
 
214
- # 現在 price_for_volume 和 weights 的長度保證一致
215
- hist, bin_edges = np.histogram(price_for_volume, bins=num_bins, range=(min_price, max_price), weights=weights)
216
- price_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
217
- return bin_edges, hist, price_centers
 
 
 
 
 
 
 
 
218
 
219
  def get_business_climate_data():
220
  try:
@@ -246,19 +316,27 @@ def get_pmi_data():
246
  return pd.DataFrame()
247
 
248
  def generate_gemini_analysis(stock_name, stock_symbol, period, data):
 
 
 
249
  api_key = os.getenv("GEMINI_API_KEY")
250
  if not api_key:
251
  return "無法讀取 GEMINI API 金鑰", "請在系統環境變數中設定您的金鑰"
 
252
  try:
253
  genai.configure(api_key=api_key)
254
  model = genai.GenerativeModel('gemini-1.5-flash')
 
255
  price_change = ((data['Close'].iloc[-1] - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
256
  rsi_current = data['RSI'].iloc[-1]
257
  macd_current = data['MACD'].iloc[-1]
258
  macd_signal_current = data['MACD_Signal'].iloc[-1]
259
  industry = INDUSTRY_MAPPING.get(stock_symbol, '綜合')
 
260
  prompt = f"""
261
- 請扮演一位專業、資深的台灣股市金融分析師。我將提供一檔台股的即時技術指標數據,請你基於這些數據,結合你對這家公司、其所在產業以及當前市場趨勢的理解,為我生成一段專業的「基本面分析」和一段「市場展望與投資建議」。
 
 
262
  **股票資訊:**
263
  - **公司名稱:** {stock_name} ({stock_symbol})
264
  - **分析期間:** 最近 {period}
@@ -266,12 +344,23 @@ def generate_gemini_analysis(stock_name, stock_symbol, period, data):
266
  - **期間價格變動:** {price_change:+.2f}%
267
  - **目前 RSI 指標:** {rsi_current:.2f}
268
  - **目前 MACD 指標:** MACD線為 {macd_current:.3f}, 信號線為 {macd_signal_current:.3f}
 
269
  **你的任務:**
270
- 1. **基本面分析 (約 150 字):** 評論這家公司的產業地位、近期營運亮點或挑戰。提及任何可能影響其基本面的關鍵因素 (例如:財報、法說會、政策、供應鏈變化等)。請用專業、客觀的語氣撰寫。
271
- 2. **市場展望與投資建議 (約 150 字):** 基於上述所有資訊,提供對該股票的短期和中期市場展望。提出具體的投資建議,例如:適合何種類型的投資人、潛在的風險點。請直接提供分析內容,不要包含任何問候語。
 
 
 
 
 
 
 
 
272
  **輸出格式:**
273
- 請嚴格按照以下格式回傳,使用"$$"作為兩個段落之間的分隔符:[基本面分析內容]$$[市場展望與投資建議內容]
 
274
  """
 
275
  response = model.generate_content(prompt)
276
  parts = response.text.split('$$')
277
  if len(parts) == 2:
@@ -279,7 +368,9 @@ def generate_gemini_analysis(stock_name, stock_symbol, period, data):
279
  market_outlook = parts[1].strip()
280
  return dcc.Markdown(fundamental_analysis), dcc.Markdown(market_outlook)
281
  else:
 
282
  return dcc.Markdown("無法解析 Gemini 回應,請稍後再試。"), dcc.Markdown(response.text)
 
283
  except Exception as e:
284
  error_message = f"呼叫 Gemini API 時發生錯誤: {str(e)}"
285
  print(error_message)
@@ -287,7 +378,6 @@ def generate_gemini_analysis(stock_name, stock_symbol, period, data):
287
 
288
  # 建立 Dash 應用程式
289
  app = dash.Dash(__name__, suppress_callback_exceptions=True)
290
- server = app.server
291
 
292
  try:
293
  print("正在初始化新聞情緒��析模型...")
@@ -297,7 +387,7 @@ except Exception as e:
297
  print(f"錯誤:新聞情緒分析模型初始化失敗 - {e}")
298
  predictor = None
299
 
300
- # 應用程式佈局 (省略)
301
  app.layout = html.Div([
302
  html.H1("台股分析儀表板", style={'text-align': 'center', 'margin-bottom': '30px'}),
303
  html.Div([
@@ -316,6 +406,7 @@ app.layout = html.Div([
316
  ]),
317
  html.Div([dcc.Graph(id='taiex-prediction-chart')], style={'margin-top': '20px'})
318
  ], 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'}),
 
319
  html.Div([
320
  html.H3("📰 市場情緒與新聞分析", style={'color': '#E74C3C', 'margin-bottom': '20px'}),
321
  html.Div([
@@ -329,6 +420,7 @@ app.layout = html.Div([
329
  ], style={'width': '48%', 'display': 'inline-block', 'margin-left': '4%'})
330
  ])
331
  ], style={'margin-top': '30px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
 
332
  html.Div([
333
  html.H3("景氣燈號與 PMI 分析"),
334
  html.Div([
@@ -336,10 +428,11 @@ app.layout = html.Div([
336
  html.Div([dcc.Graph(id='pmi-chart')], style={'width': '48%', 'display': 'inline-block', 'margin-left': '2%'})
337
  ])
338
  ], style={'margin-top': '30px'}),
 
339
  html.Div([
340
  html.Div([
341
  html.Label("選擇股票:"),
342
- dcc.Dropdown(id='stock-dropdown', options=[{'label': name, 'value': symbol} for name, symbol in TAIWAN_STOCKS.items()], value='2330.TW', style={'margin-bottom': '10px'})
343
  ], style={'width': '30%', 'display': 'inline-block', 'vertical-align': 'top'}),
344
  html.Div([
345
  html.Label("時間範圍:"),
@@ -352,6 +445,7 @@ app.layout = html.Div([
352
  dcc.Dropdown(id='chart-type', options=[{'label': '線圖', 'value': 'line'},{'label': '蠟燭圖', 'value': 'candlestick'}], value='candlestick', style={'margin-bottom': '10px'})
353
  ], style={'width': '30%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'})
354
  ], style={'margin-bottom': '30px'}),
 
355
  html.Div(id='stock-info-cards', style={'margin-bottom': '30px'}),
356
  html.Div([html.Div([dcc.Graph(id='price-chart')], style={'width': '100%', 'display': 'inline-block', 'vertical-align': 'top'})]),
357
  html.Div([
@@ -404,7 +498,6 @@ app.layout = html.Div([
404
  ], style={'margin-top': '30px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
405
  ])
406
 
407
- # 所有 Callback 函式 (省略)
408
  @app.callback(
409
  [dash.dependencies.Output('taiex-prediction-results', 'children'),
410
  dash.dependencies.Output('taiex-prediction-chart', 'figure')],
@@ -413,18 +506,26 @@ app.layout = html.Div([
413
  def update_taiex_prediction(predict_days):
414
  data = get_stock_data('^TWII', '2y')
415
  if data.empty: return html.Div("無法獲取台指期資料"), {}
 
 
416
  final_prediction = get_prediction(data, predict_days)
417
- if final_prediction is None: return html.Div("資料不足或模型無法預測此天期"), {}
 
418
  current_price, last_date = data['Close'].iloc[-1], data.index[-1]
419
  predicted_price, change_pct, confidence = final_prediction['predicted_price'], final_prediction['change_pct'], final_prediction['confidence']
420
- prediction_paths = {1: [1], 5: [1, 5], 10: [1, 5, 10], 20: [1, 10, 20]}
 
421
  intervals_to_predict = prediction_paths.get(predict_days, [predict_days])
422
  prediction_dates, prediction_prices = [last_date], [current_price]
 
423
  for days in intervals_to_predict:
 
424
  interim_prediction = get_prediction(data, days)
425
  if interim_prediction:
426
  prediction_dates.append(last_date + timedelta(days=days))
427
  prediction_prices.append(interim_prediction['predicted_price'])
 
 
428
  color, arrow = ('red', '📈') if change_pct >= 0 else ('green', '📉')
429
  result_card = html.Div([
430
  html.H4(f"{predict_days}日後預測結果", style={'margin': '0 0 15px 0', 'color': 'white'}),
@@ -472,22 +573,82 @@ def update_stock_info(selected_stock):
472
  dash.dependencies.Input('period-dropdown', 'value'),
473
  dash.dependencies.Input('chart-type', 'value')]
474
  )
475
- def update_price_chart(selected_stock, period, chart_type):
 
476
  data = get_stock_data(selected_stock, period)
477
- if data.empty: return {}
 
 
478
  data = calculate_technical_indicators(data)
479
  stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
480
- fig = make_subplots(rows=1, cols=2, shared_yaxes=True, column_widths=[0.8, 0.2], horizontal_spacing=0.01)
 
 
 
481
  if chart_type == 'candlestick':
482
- 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)
 
 
 
 
 
 
 
 
 
483
  else:
484
- fig.add_trace(px.line(data, y='Close').data[0], row=1, col=1)
485
- fig.add_trace(go.Scatter(x=data.index, y=data['MA5'], mode='lines', name='MA5', line=dict(color='orange')), row=1, col=1)
486
- fig.add_trace(go.Scatter(x=data.index, y=data['MA20'], mode='lines', name='MA20', line=dict(color='blue')), row=1, col=1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
487
  bin_edges, volume_per_bin, price_centers = calculate_volume_profile(data, num_bins=50)
488
- if volume_per_bin is not None:
489
- 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)
490
- 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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
491
  return fig
492
 
493
  @app.callback(
@@ -573,15 +734,30 @@ def update_industry_analysis(selected_stock):
573
  data = get_stock_data(symbol, '1mo')
574
  if not data.empty and len(data) > 1:
575
  return_pct = ((data['Close'].iloc[-1] - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
576
- performance_data.append({'股票': name, '代碼': symbol, '月報酬率(%)': return_pct, '絕對波動': abs(return_pct)})
 
 
 
 
 
577
  if not performance_data:
578
  fig = go.Figure().add_annotation(text="無法計算產業資料", showarrow=False)
579
  fig.update_layout(title="近一月市場波動最大標的", height=400)
580
  return fig
581
  df_performance = pd.DataFrame(performance_data)
582
  df_top_movers = df_performance.sort_values(by='絕對波動', ascending=False).head(10)
583
- fig = px.pie(df_top_movers, values='絕對波動', names='股票', title='近一月市場波動最大 Top 10 標的', hover_data={'月報酬率(%)': ':.2f'})
584
- fig.update_traces(textposition='inside', textinfo='percent+label', hovertemplate="<b>%{label}</b><br>月報酬率: %{customdata[0]:.2f}%<extra></extra>")
 
 
 
 
 
 
 
 
 
 
585
  fig.update_layout(height=400, showlegend=False)
586
  return fig
587
 
@@ -619,28 +795,41 @@ def update_business_climate_chart(selected_stock):
619
  def update_analysis_text(selected_stock, period):
620
  cache_key = f"{selected_stock}-{period}"
621
  current_time = time.time()
 
622
  if cache_key in ANALYSIS_CACHE:
623
  cached_data = ANALYSIS_CACHE[cache_key]
624
  if current_time - cached_data['timestamp'] < CACHE_DURATION_SECONDS:
625
  print(f"從快取載入分析: {cache_key}")
626
  return cached_data['technical'], cached_data['fundamental'], cached_data['outlook']
627
- print(f"重新生成分析: {selected_stock}-{period}")
 
628
  data = get_stock_data(selected_stock, period)
629
  stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
630
  if data.empty or len(data) < 20:
631
  return "資料不足,無法分析", "資料不足,無法分析", "資料不足,無法分析"
 
632
  data = calculate_technical_indicators(data)
 
633
  price_change = ((data['Close'].iloc[-1] - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
634
  rsi_current = data['RSI'].iloc[-1] if not pd.isna(data['RSI'].iloc[-1]) else 50
635
  macd_current = data['MACD'].iloc[-1] if not pd.isna(data['MACD'].iloc[-1]) else 0
636
  macd_signal_current = data['MACD_Signal'].iloc[-1] if not pd.isna(data['MACD_Signal'].iloc[-1]) else 0
 
637
  technical_text = html.Div([
638
  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}%。"]),
639
  html.P([html.Strong("RSI 指標:"), f"目前的 RSI 值為 {rsi_current:.1f},", html.Span("處於超買區(>70)" if rsi_current > 70 else "處於超賣區(<30)" if rsi_current < 30 else "在正常範圍內", style={'color': 'green' if rsi_current > 70 else 'red' if rsi_current < 30 else 'blue', 'font-weight': 'bold'}), "。"]),
640
  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" Signal 慢線 ({macd_signal_current:.3f}),", f"顯示市場動能偏向{'多頭' if macd_current > macd_signal_current else '空頭'}。"]),
641
  ])
 
642
  fundamental_text, market_outlook_text = generate_gemini_analysis(stock_name, selected_stock, period, data)
643
- ANALYSIS_CACHE[cache_key] = {'technical': technical_text, 'fundamental': fundamental_text, 'outlook': market_outlook_text, 'timestamp': current_time}
 
 
 
 
 
 
 
644
  return technical_text, fundamental_text, market_outlook_text
645
 
646
  @app.callback(
@@ -661,20 +850,32 @@ def update_pmi_chart(selected_stock):
661
  return fig
662
 
663
  def summarize_news_with_gemini(news_list: list) -> str:
 
 
 
664
  api_key = os.getenv("GEMINI_API_KEY")
665
  if not api_key:
666
  return "錯誤:找不到 GEMINI_API_KEY。請在 Hugging Face Secrets 中設定。"
 
667
  try:
668
  genai.configure(api_key=api_key)
669
  model = genai.GenerativeModel('gemini-1.5-flash')
 
670
  formatted_news = "\n".join([f"- {news}" for news in news_list])
 
671
  prompt = f"""
672
- 請扮演一位專業的金融市場分析師。以下是幾則最新的英文財經新聞標題,請將它們整合成一段簡潔、流暢、約 200 字的繁體中文市場動態摘要,與利多哪些產業,利空哪些產業。提供3段重點,請專注於可能影響市場情緒和股價的關鍵資訊,並直接提供摘要內容,不要包含任何額外的問候語或說明。
 
 
 
 
673
  英文新聞標題如下:
674
  {formatted_news}
675
  """
 
676
  response = model.generate_content(prompt)
677
  return response.text
 
678
  except Exception as e:
679
  print(f"呼叫 Gemini API 時發生錯誤: {e}")
680
  return f"無法生成新聞摘要,請稍後再試。錯誤訊息:{e}"
@@ -699,7 +900,7 @@ def update_comparison_analysis(selected_stocks, period):
699
  normalized_prices = (data['Close'] / data['Close'].iloc[0]) * 100
700
  fig.add_trace(go.Scatter(x=data.index, y=normalized_prices, mode='lines', name=stock_name, line=dict(width=2)))
701
  total_return = ((data['Close'].iloc[-1] / data['Close'].iloc[0]) - 1) * 100
702
- volatility = data['Close'].pct_change(fill_method=None).std() * np.sqrt(252) * 100
703
  comparison_data.append({'name': stock_name, 'return': total_return, 'volatility': volatility, 'current_price': data['Close'].iloc[-1]})
704
  fig.update_layout(title=f'股票績效比較 - {period}', xaxis_title='日期', yaxis_title='相對績效 (基期=100)', height=400, hovermode='x unified')
705
  if comparison_data:
@@ -721,7 +922,9 @@ def update_sentiment_analysis(selected_stock):
721
  error_fig = go.Figure().add_annotation(text="情緒指標模型載入失敗", showarrow=False)
722
  error_fig.update_layout(height=200)
723
  return dcc.Graph(figure=error_fig), html.P("新聞分析模型載入失敗,請檢查後台日誌。")
 
724
  sentiment_score_raw = predictor.get_news_index()
 
725
  if sentiment_score_raw is not None:
726
  sentiment_score_normalized = (sentiment_score_raw + 1) * 50
727
  sentiment_score_normalized = max(0, min(100, sentiment_score_normalized))
@@ -746,15 +949,21 @@ def update_sentiment_analysis(selected_stock):
746
  error_fig = go.Figure().add_annotation(text="今日尚無情緒分數", showarrow=False)
747
  error_fig.update_layout(height=200)
748
  gauge_content = dcc.Graph(figure=error_fig)
 
749
  top_news_list = predictor.get_news()
750
  news_content = None
 
751
  if top_news_list and isinstance(top_news_list, list):
752
  summary_text = summarize_news_with_gemini(top_news_list)
753
- news_content = dcc.Markdown(summary_text, style={'margin': '8px 0', 'padding-left': '5px', 'font-size': '15px', 'line-height': '1.7'})
 
 
 
754
  elif top_news_list == []:
755
  news_content = html.P("昨日無重大相關新聞。", style={'text-align': 'center', 'padding-top': '50px'})
756
  else:
757
  news_content = html.P("讀取新聞時發生錯誤。", style={'text-align': 'center', 'padding-top': '50px'})
 
758
  return gauge_content, news_content
759
 
760
  # 主程式執行
 
1
+ # HUGING_FACE_V4.2(輕量AI版).py - 已整合 XGBoost 模型
2
 
3
  # 系統套件
4
  import os
 
18
  import time # 引用 time 模組以處理時間戳
19
 
20
  # ========================= 引用外部模組 START =========================
21
+ # 引用您組員的預測器程式
22
  from Bert_predict import BertPredictor
23
+
24
+ # 【修改 1】: 匯入 XGBoostModel 類別
25
  from model_predictor import XGBoostModel
26
  # ========================== 引用外部模組 END ==========================
27
 
28
  # ========================= 全域設定 START =========================
29
+ # 【修改 2】: 將開關設為 True 來啟用您的 XGBoost 模型
30
  USE_ADVANCED_MODEL = True
31
+
32
+ # ========================= CACHE 設定 START =========================
33
+ # 分析結果的快取字典
34
  ANALYSIS_CACHE = {}
35
+ # 快取有效時間(秒),例如:8 小時 = 8 * 60 * 60 = 28800 秒
36
  CACHE_DURATION_SECONDS = 8 * 60 * 60
37
  # ========================== CACHE 設定 END ==========================
38
 
39
+ # 【修改 3】: 在應用程式啟動時,預先載入 XGBoost 模型
40
  try:
41
  print("正在初始化 XGBoost 預測模型...")
42
  xgb_model = XGBoostModel(default_model='xgboost_model')
43
  print("XGBoost 預測模型初始化成功。")
44
  except Exception as e:
45
  print(f"錯誤:XGBoost 預測模型初始化失敗 - {e}")
46
+ # 如果模型載入失敗,則強制關閉進階模型開關,退回簡易模式
47
  USE_ADVANCED_MODEL = False
48
  xgb_model = None
49
  print("警告:已自動切換回簡易統計模型模式。")
50
  # ========================== 全域設定 END ==========================
51
 
52
+ # 台股代號對應表
53
  TAIWAN_STOCKS = {
54
  '元大台灣50': '0050.TW', '台積電': '2330.TW', '聯發科': '2454.TW',
55
  '鴻海': '2317.TW', '台達電': '2308.TW', '廣達': '2382.TW', '富邦金': '2881.TW',
 
68
  '譜瑞-KY': '4966.TWO', '貿聯-KY': '3665.TW', '騰雲': '6870.TWO', '穩懋': '3105.TWO'
69
  }
70
 
71
+ # 產業分類
72
  INDUSTRY_MAPPING = {
73
  '0050.TW': 'ETF', '2330.TW': '半導體', '2454.TW': '半導體', '2317.TW': '電子組件',
74
  '2308.TW': '電子', '2382.TW': '電子', '2881.TW': '金融', '2891.TW': '金融',
 
88
  }
89
 
90
  def get_stock_data(symbol, period='1y'):
91
+ """獲取股票資料"""
92
  try:
93
  stock = yf.Ticker(symbol)
94
  data = stock.history(period=period)
 
103
  return pd.DataFrame()
104
 
105
  def simple_statistical_predict(data, predict_days=5):
106
+ """【備用模型】簡化的統計預測模型。"""
107
  if len(data) < 60: return None
108
  prices = data['Close'].values
109
  ma_short = np.mean(prices[-5:])
 
118
  change_pct = ((predicted_price - prices[-1]) / prices[-1]) * 100
119
  return {'predicted_price': predicted_price, 'change_pct': change_pct, 'confidence': max(0.6, 1 - volatility * 2)}
120
 
121
+ # 【修改 4】: 建立一個新的函式來處理 XGBoost 模型的輸入和輸出
122
+ # 修正後的 advanced_xgboost_predict 函數
123
  def advanced_xgboost_predict(data, predict_days):
124
+ """
125
+ 【進階模型橋接函式】
126
+ - 準備 XGBoost 模型所需的輸入 DataFrame。
127
+ - 呼叫模型進行預測。
128
+ - 將模型的輸出格式轉換為主程式所需的格式。
129
+ """
130
  if xgb_model is None or data.empty:
131
+ print("XGBoost 模型未載入或數據為空")
132
  return None
133
+
134
+ # 1. 準備輸入資料
135
+ # 確保數據有足夠的歷史記錄
136
+ if len(data) < 20:
137
+ print("歷史數據不足,無法使用 XGBoost 模型")
138
+ return None
139
+
140
+ # 使用最新的資料點來進行未來預測
141
+ input_df = data.tail(1).copy()
142
+
143
+ # 檢查必要欄位是否存在
144
+ required_columns = ['Open', 'High', 'Low', 'Close', 'Volume']
145
+ missing_columns = [col for col in required_columns if col not in input_df.columns]
146
+ if missing_columns:
147
+ print(f"缺少必要欄位: {missing_columns}")
148
  return None
149
+
150
  try:
151
+ # 2. 呼叫模型預測
152
+ print(f"呼叫 XGBoost 模型進行 {predict_days} 天預測...")
153
+ predictions = xgb_model.predict('xgboost_model', input_df)
154
+
155
+ # 3. 根據 predict_days 解析輸出
156
+ # 建立預測天數到模型輸出鍵的映射
157
+ day_to_key_map = {
158
+ 1: 'Close_t0_pred', # 假��� t0 代表 1 天後
159
+ 5: 'Close_t5_pred',
160
+ 10: 'Close_t10_pred',
161
+ 20: 'Close_t20_pred'
162
+ }
163
+
164
+ # 找到對應的預測鍵
165
  prediction_key = day_to_key_map.get(predict_days)
166
+
167
  if prediction_key is None or prediction_key not in predictions:
168
+ print(f"警告: XGBoost 模型沒有提供 {predict_days} 天的預測結果。可用鍵值: {list(predictions.keys())}")
169
+ # 如果沒有對應的預測期間,嘗試使用最接近的
170
+ available_days = [1, 5, 10, 20]
171
+ closest_day = min(available_days, key=lambda x: abs(x - predict_days))
172
+ prediction_key = day_to_key_map[closest_day]
173
+ print(f"使用最接近的預測期間: {closest_day} 天")
174
+
175
  predicted_price = predictions[prediction_key]
176
  current_price = data['Close'].iloc[-1]
177
  change_pct = ((predicted_price - current_price) / current_price) * 100
178
+
179
+ # 4. 包裝成主程式所需的格式
180
+ result = {
181
+ 'predicted_price': float(predicted_price),
182
+ 'change_pct': float(change_pct),
183
+ 'confidence': 0.85 # XGBoost 模型通常有較高的信心度
184
+ }
185
+
186
+ print(f"XGBoost 預測成功: 當前價格={current_price:.2f}, 預測價格={predicted_price:.2f}, 變化={change_pct:.2f}%")
187
+ return result
188
+
189
  except Exception as e:
190
  print(f"執行 XGBoost 預測時發生錯誤: {e}")
191
+ import traceback
192
+ traceback.print_exc()
193
  return None
194
 
195
  def get_prediction(data, predict_days=5):
196
+ """
197
+ 【【模型預測控制器】】
198
+ 根據 USE_ADVANCED_MODEL 的設定,呼叫對應的預測模型。
199
+ """
200
  if USE_ADVANCED_MODEL:
201
  print(f"模式: 進階XGBoost模型 | 預測天期: {predict_days}天")
202
+ # 【修改 5】: 呼叫新的 XGBoost 橋接函式
203
  prediction = advanced_xgboost_predict(data, predict_days)
204
+ # 如果進階模型預測失敗,則自動降級使用簡易模型
205
  if prediction is not None:
206
  return prediction
207
  else:
208
  print("進階模型預測失敗或無對應天期,自動降級為簡易統計模型。")
209
+
210
+ # 預設或降級時執行簡易模型
211
  print(f"模式: 簡易統計模型 | 預測天期: {predict_days}天")
212
  return simple_statistical_predict(data, predict_days)
213
 
214
  def calculate_technical_indicators(df):
215
+ """計算技術指標"""
216
  if df.empty: return df
217
  df['MA5'] = df['Close'].rolling(window=5).mean()
218
  df['MA20'] = df['Close'].rolling(window=20).mean()
 
249
  df['ADX'] = df['DX'].ewm(com=13, adjust=False).mean()
250
  return df
251
 
252
+ # 修正後的 calculate_volume_profile 函數
253
  def calculate_volume_profile(df, num_bins=50):
254
+ if df.empty or 'High' not in df.columns or 'Low' not in df.columns or 'Volume' not in df.columns:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  return None, None, None
 
 
 
256
 
257
+ # 確保沒有 NaN
258
+ df_clean = df.dropna(subset=['High', 'Low', 'Close', 'Volume'])
259
+ if df_clean.empty:
260
  return None, None, None
261
+
262
+ all_prices = np.concatenate([df_clean['High'].values, df_clean['Low'].values])
263
  min_price, max_price = all_prices.min(), all_prices.max()
264
 
265
+ # 使用典型價格 (High + Low + Close) / 3 作為價格指標
266
+ price_for_volume = (df_clean['High'] + df_clean['Low'] + df_clean['Close']) / 3
267
+
268
+ # 移除 NaN 值並確保對應的權重也被移除
269
+ price_indicator = price_for_volume.dropna()
270
+ corresponding_volume = df_clean['Volume'].loc[price_indicator.index]
271
+
272
+ # 再次檢查是否有空數據
273
+ if len(price_indicator) == 0 or len(corresponding_volume) == 0:
274
  return None, None, None
 
 
 
275
 
276
+ try:
277
+ hist, bin_edges = np.histogram(
278
+ price_indicator.values,
279
+ bins=num_bins,
280
+ range=(min_price, max_price),
281
+ weights=corresponding_volume.values
282
+ )
283
+ price_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
284
+ return bin_edges, hist, price_centers
285
+ except Exception as e:
286
+ print(f"Volume profile 計算錯誤: {e}")
287
+ return None, None, None
288
 
289
  def get_business_climate_data():
290
  try:
 
316
  return pd.DataFrame()
317
 
318
  def generate_gemini_analysis(stock_name, stock_symbol, period, data):
319
+ """
320
+ 使用 Gemini API 生成基本面和市場展望分析。
321
+ """
322
  api_key = os.getenv("GEMINI_API_KEY")
323
  if not api_key:
324
  return "無法讀取 GEMINI API 金鑰", "請在系統環境變數中設定您的金鑰"
325
+
326
  try:
327
  genai.configure(api_key=api_key)
328
  model = genai.GenerativeModel('gemini-1.5-flash')
329
+
330
  price_change = ((data['Close'].iloc[-1] - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
331
  rsi_current = data['RSI'].iloc[-1]
332
  macd_current = data['MACD'].iloc[-1]
333
  macd_signal_current = data['MACD_Signal'].iloc[-1]
334
  industry = INDUSTRY_MAPPING.get(stock_symbol, '綜合')
335
+
336
  prompt = f"""
337
+ 請扮演一位專業、資深的台灣股市金融分析師。
338
+ 我將提供一檔台股的即時技術指標數據,請你基於這些數據,結合你對這家公司、其所在產業以及當前市場趨勢的理解,為我生成一段專業的「基本面分析」和一段「市場展望與投資建議」。
339
+
340
  **股票資訊:**
341
  - **公司名稱:** {stock_name} ({stock_symbol})
342
  - **分析期間:** 最近 {period}
 
344
  - **期間價格變動:** {price_change:+.2f}%
345
  - **目前 RSI 指標:** {rsi_current:.2f}
346
  - **目前 MACD 指標:** MACD線為 {macd_current:.3f}, 信號線為 {macd_signal_current:.3f}
347
+
348
  **你的任務:**
349
+ 1. **基本面分析 (約 150 字):**
350
+ - 評論這家公司的產業地位、近期營運亮點或挑戰。
351
+ - 提及任何可能影響其基本面的關鍵因素 (例如:財報、法說會、政策、供應鏈變化等)。
352
+ - 請用專業、客觀的語氣撰寫。
353
+
354
+ 2. **市場展望與投資建議 (約 150 字):**
355
+ - 基於上述所有資訊,提供對該股票的短期和中期市場展望。
356
+ - 提出具體的投資建議,例如:適合何種類型的投資人、潛在的風險點。
357
+ - 請直接提供分析內容,不要包含任何問候語。
358
+
359
  **輸出格式:**
360
+ 請嚴格按照以下格式回傳,使用"$$"作為兩個段落之間的分隔符:
361
+ [基本面分析內容]$$[市場展望與投資建議內容]
362
  """
363
+
364
  response = model.generate_content(prompt)
365
  parts = response.text.split('$$')
366
  if len(parts) == 2:
 
368
  market_outlook = parts[1].strip()
369
  return dcc.Markdown(fundamental_analysis), dcc.Markdown(market_outlook)
370
  else:
371
+ # Fallback for unexpected response format
372
  return dcc.Markdown("無法解析 Gemini 回應,請稍後再試。"), dcc.Markdown(response.text)
373
+
374
  except Exception as e:
375
  error_message = f"呼叫 Gemini API 時發生錯誤: {str(e)}"
376
  print(error_message)
 
378
 
379
  # 建立 Dash 應用程式
380
  app = dash.Dash(__name__, suppress_callback_exceptions=True)
 
381
 
382
  try:
383
  print("正在初始化新聞情緒��析模型...")
 
387
  print(f"錯誤:新聞情緒分析模型初始化失敗 - {e}")
388
  predictor = None
389
 
390
+ # 應用程式佈局
391
  app.layout = html.Div([
392
  html.H1("台股分析儀表板", style={'text-align': 'center', 'margin-bottom': '30px'}),
393
  html.Div([
 
406
  ]),
407
  html.Div([dcc.Graph(id='taiex-prediction-chart')], style={'margin-top': '20px'})
408
  ], 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'}),
409
+
410
  html.Div([
411
  html.H3("📰 市場情緒與新聞分析", style={'color': '#E74C3C', 'margin-bottom': '20px'}),
412
  html.Div([
 
420
  ], style={'width': '48%', 'display': 'inline-block', 'margin-left': '4%'})
421
  ])
422
  ], style={'margin-top': '30px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
423
+
424
  html.Div([
425
  html.H3("景氣燈號與 PMI 分析"),
426
  html.Div([
 
428
  html.Div([dcc.Graph(id='pmi-chart')], style={'width': '48%', 'display': 'inline-block', 'margin-left': '2%'})
429
  ])
430
  ], style={'margin-top': '30px'}),
431
+
432
  html.Div([
433
  html.Div([
434
  html.Label("選擇股票:"),
435
+ dcc.Dropdown(id='stock-dropdown', options=[{'label': name, 'value': symbol} for name, symbol in TAIWAN_STOCKS.items()], value='0050.TW', style={'margin-bottom': '10px'})
436
  ], style={'width': '30%', 'display': 'inline-block', 'vertical-align': 'top'}),
437
  html.Div([
438
  html.Label("時間範圍:"),
 
445
  dcc.Dropdown(id='chart-type', options=[{'label': '線圖', 'value': 'line'},{'label': '蠟燭圖', 'value': 'candlestick'}], value='candlestick', style={'margin-bottom': '10px'})
446
  ], style={'width': '30%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'})
447
  ], style={'margin-bottom': '30px'}),
448
+
449
  html.Div(id='stock-info-cards', style={'margin-bottom': '30px'}),
450
  html.Div([html.Div([dcc.Graph(id='price-chart')], style={'width': '100%', 'display': 'inline-block', 'vertical-align': 'top'})]),
451
  html.Div([
 
498
  ], style={'margin-top': '30px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
499
  ])
500
 
 
501
  @app.callback(
502
  [dash.dependencies.Output('taiex-prediction-results', 'children'),
503
  dash.dependencies.Output('taiex-prediction-chart', 'figure')],
 
506
  def update_taiex_prediction(predict_days):
507
  data = get_stock_data('^TWII', '2y')
508
  if data.empty: return html.Div("無法獲取台指期資料"), {}
509
+
510
+ # === 呼叫 get_prediction 控制器,它會自動選擇模型 ===
511
  final_prediction = get_prediction(data, predict_days)
512
+
513
+ if final_prediction is None: return html.Div("資料不足,無法進行預測"), {}
514
  current_price, last_date = data['Close'].iloc[-1], data.index[-1]
515
  predicted_price, change_pct, confidence = final_prediction['predicted_price'], final_prediction['change_pct'], final_prediction['confidence']
516
+
517
+ prediction_paths = {1: [1], 5: [1, 5], 10: [1, 5, 10], 20: [1, 10, 20], 60: [1, 10, 20, 60]}
518
  intervals_to_predict = prediction_paths.get(predict_days, [predict_days])
519
  prediction_dates, prediction_prices = [last_date], [current_price]
520
+
521
  for days in intervals_to_predict:
522
+ # === 迴圈內也使用統一的預測控制器 ===
523
  interim_prediction = get_prediction(data, days)
524
  if interim_prediction:
525
  prediction_dates.append(last_date + timedelta(days=days))
526
  prediction_prices.append(interim_prediction['predicted_price'])
527
+
528
+ # (後續繪圖邏輯不變)
529
  color, arrow = ('red', '📈') if change_pct >= 0 else ('green', '📉')
530
  result_card = html.Div([
531
  html.H4(f"{predict_days}日後預測結果", style={'margin': '0 0 15px 0', 'color': 'white'}),
 
573
  dash.dependencies.Input('period-dropdown', 'value'),
574
  dash.dependencies.Input('chart-type', 'value')]
575
  )
576
+ # 修正後的 update_price_chart callback 函數的相關部分
577
+ def update_price_chart_fixed(selected_stock, period, chart_type):
578
  data = get_stock_data(selected_stock, period)
579
+ if data.empty:
580
+ return {}
581
+
582
  data = calculate_technical_indicators(data)
583
  stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
584
+
585
+ fig = make_subplots(rows=1, cols=2, shared_yaxes=True,
586
+ column_widths=[0.8, 0.2], horizontal_spacing=0.01)
587
+
588
  if chart_type == 'candlestick':
589
+ fig.add_trace(go.Candlestick(
590
+ x=data.index,
591
+ open=data['Open'],
592
+ high=data['High'],
593
+ low=data['Low'],
594
+ close=data['Close'],
595
+ name=stock_name,
596
+ increasing_line_color='red',
597
+ decreasing_line_color='green'
598
+ ), row=1, col=1)
599
  else:
600
+ fig.add_trace(go.Scatter(
601
+ x=data.index,
602
+ y=data['Close'],
603
+ mode='lines',
604
+ name=stock_name
605
+ ), row=1, col=1)
606
+
607
+ # 添加移動平均線
608
+ fig.add_trace(go.Scatter(
609
+ x=data.index,
610
+ y=data['MA5'],
611
+ mode='lines',
612
+ name='MA5',
613
+ line=dict(color='orange')
614
+ ), row=1, col=1)
615
+
616
+ fig.add_trace(go.Scatter(
617
+ x=data.index,
618
+ y=data['MA20'],
619
+ mode='lines',
620
+ name='MA20',
621
+ line=dict(color='blue')
622
+ ), row=1, col=1)
623
+
624
+ # 修正後的 Volume Profile 計算
625
  bin_edges, volume_per_bin, price_centers = calculate_volume_profile(data, num_bins=50)
626
+
627
+ if volume_per_bin is not None and price_centers is not None:
628
+ fig.add_trace(go.Bar(
629
+ orientation='h',
630
+ y=price_centers,
631
+ x=volume_per_bin,
632
+ name='Volume Profile',
633
+ text=[f'{vol/1000:.0f}k' for vol in volume_per_bin],
634
+ textposition='auto',
635
+ marker=dict(
636
+ color='rgba(173, 216, 230, 0.6)',
637
+ line=dict(color='rgba(30, 144, 255, 0.8)', width=1)
638
+ )
639
+ ), row=1, col=2)
640
+
641
+ fig.update_layout(
642
+ title_text=f'{stock_name} 股價走勢與成交量分佈',
643
+ height=500,
644
+ showlegend=True,
645
+ xaxis1=dict(title='日期', type='date', rangeslider_visible=False),
646
+ yaxis1=dict(title='價格 (TWD)'),
647
+ xaxis2=dict(title='成交量', showticklabels=True),
648
+ yaxis2=dict(showticklabels=False),
649
+ bargap=0.05
650
+ )
651
+
652
  return fig
653
 
654
  @app.callback(
 
734
  data = get_stock_data(symbol, '1mo')
735
  if not data.empty and len(data) > 1:
736
  return_pct = ((data['Close'].iloc[-1] - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
737
+ performance_data.append({
738
+ '股票': name,
739
+ '代碼': symbol,
740
+ '月報酬率(%)': return_pct,
741
+ '絕對波動': abs(return_pct)
742
+ })
743
  if not performance_data:
744
  fig = go.Figure().add_annotation(text="無法計算產業資料", showarrow=False)
745
  fig.update_layout(title="近一月市場波動最大標的", height=400)
746
  return fig
747
  df_performance = pd.DataFrame(performance_data)
748
  df_top_movers = df_performance.sort_values(by='絕對波動', ascending=False).head(10)
749
+ fig = px.pie(
750
+ df_top_movers,
751
+ values='絕對波動',
752
+ names='股票',
753
+ title='近一月市場波動最大 Top 10 標的',
754
+ hover_data={'月報酬率(%)': ':.2f'}
755
+ )
756
+ fig.update_traces(
757
+ textposition='inside',
758
+ textinfo='percent+label',
759
+ hovertemplate="<b>%{label}</b><br>月報酬率: %{customdata[0]:.2f}%<extra></extra>"
760
+ )
761
  fig.update_layout(height=400, showlegend=False)
762
  return fig
763
 
 
795
  def update_analysis_text(selected_stock, period):
796
  cache_key = f"{selected_stock}-{period}"
797
  current_time = time.time()
798
+
799
  if cache_key in ANALYSIS_CACHE:
800
  cached_data = ANALYSIS_CACHE[cache_key]
801
  if current_time - cached_data['timestamp'] < CACHE_DURATION_SECONDS:
802
  print(f"從快取載入分析: {cache_key}")
803
  return cached_data['technical'], cached_data['fundamental'], cached_data['outlook']
804
+
805
+ print(f"重新生成分析: {cache_key}")
806
  data = get_stock_data(selected_stock, period)
807
  stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
808
  if data.empty or len(data) < 20:
809
  return "資料不足,無法分析", "資料不足,無法分析", "資料不足,無法分析"
810
+
811
  data = calculate_technical_indicators(data)
812
+
813
  price_change = ((data['Close'].iloc[-1] - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
814
  rsi_current = data['RSI'].iloc[-1] if not pd.isna(data['RSI'].iloc[-1]) else 50
815
  macd_current = data['MACD'].iloc[-1] if not pd.isna(data['MACD'].iloc[-1]) else 0
816
  macd_signal_current = data['MACD_Signal'].iloc[-1] if not pd.isna(data['MACD_Signal'].iloc[-1]) else 0
817
+
818
  technical_text = html.Div([
819
  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}%。"]),
820
  html.P([html.Strong("RSI 指標:"), f"目前的 RSI 值為 {rsi_current:.1f},", html.Span("處於超買區(>70)" if rsi_current > 70 else "處於超賣區(<30)" if rsi_current < 30 else "在正常範圍內", style={'color': 'green' if rsi_current > 70 else 'red' if rsi_current < 30 else 'blue', 'font-weight': 'bold'}), "。"]),
821
  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" Signal 慢線 ({macd_signal_current:.3f}),", f"顯示市場動能偏向{'多頭' if macd_current > macd_signal_current else '空頭'}。"]),
822
  ])
823
+
824
  fundamental_text, market_outlook_text = generate_gemini_analysis(stock_name, selected_stock, period, data)
825
+
826
+ ANALYSIS_CACHE[cache_key] = {
827
+ 'technical': technical_text,
828
+ 'fundamental': fundamental_text,
829
+ 'outlook': market_outlook_text,
830
+ 'timestamp': current_time
831
+ }
832
+
833
  return technical_text, fundamental_text, market_outlook_text
834
 
835
  @app.callback(
 
850
  return fig
851
 
852
  def summarize_news_with_gemini(news_list: list) -> str:
853
+ """
854
+ 使用 Gemini API 將英文新聞標題列表摘要成一段繁體中文。
855
+ """
856
  api_key = os.getenv("GEMINI_API_KEY")
857
  if not api_key:
858
  return "錯誤:找不到 GEMINI_API_KEY。請在 Hugging Face Secrets 中設定。"
859
+
860
  try:
861
  genai.configure(api_key=api_key)
862
  model = genai.GenerativeModel('gemini-1.5-flash')
863
+
864
  formatted_news = "\n".join([f"- {news}" for news in news_list])
865
+
866
  prompt = f"""
867
+ 請扮演一位專業的金融市場分析師。
868
+ 以下是幾則最新的英文財經新聞標題,請將它們整合成一段簡潔、流暢、約 200 字的繁體中文市場動態摘要,與利多哪些產業,利空哪些產業。
869
+ 提供3段重點,
870
+ 請專注於可能影響市場情緒和股價的關鍵資訊,並直接提供摘要內容,不要包含任何額外的問候語或說明。
871
+
872
  英文新聞標題如下:
873
  {formatted_news}
874
  """
875
+
876
  response = model.generate_content(prompt)
877
  return response.text
878
+
879
  except Exception as e:
880
  print(f"呼叫 Gemini API 時發生錯誤: {e}")
881
  return f"無法生成新聞摘要,請稍後再試。錯誤訊息:{e}"
 
900
  normalized_prices = (data['Close'] / data['Close'].iloc[0]) * 100
901
  fig.add_trace(go.Scatter(x=data.index, y=normalized_prices, mode='lines', name=stock_name, line=dict(width=2)))
902
  total_return = ((data['Close'].iloc[-1] / data['Close'].iloc[0]) - 1) * 100
903
+ volatility = data['Close'].pct_change().std() * np.sqrt(252) * 100
904
  comparison_data.append({'name': stock_name, 'return': total_return, 'volatility': volatility, 'current_price': data['Close'].iloc[-1]})
905
  fig.update_layout(title=f'股票績效比較 - {period}', xaxis_title='日期', yaxis_title='相對績效 (基期=100)', height=400, hovermode='x unified')
906
  if comparison_data:
 
922
  error_fig = go.Figure().add_annotation(text="情緒指標模型載入失敗", showarrow=False)
923
  error_fig.update_layout(height=200)
924
  return dcc.Graph(figure=error_fig), html.P("新聞分析模型載入失敗,請檢查後台日誌。")
925
+
926
  sentiment_score_raw = predictor.get_news_index()
927
+
928
  if sentiment_score_raw is not None:
929
  sentiment_score_normalized = (sentiment_score_raw + 1) * 50
930
  sentiment_score_normalized = max(0, min(100, sentiment_score_normalized))
 
949
  error_fig = go.Figure().add_annotation(text="今日尚無情緒分數", showarrow=False)
950
  error_fig.update_layout(height=200)
951
  gauge_content = dcc.Graph(figure=error_fig)
952
+
953
  top_news_list = predictor.get_news()
954
  news_content = None
955
+
956
  if top_news_list and isinstance(top_news_list, list):
957
  summary_text = summarize_news_with_gemini(top_news_list)
958
+ news_content = dcc.Markdown(summary_text, style={
959
+ 'margin': '8px 0', 'padding-left': '5px',
960
+ 'font-size': '15px', 'line-height': '1.7'
961
+ })
962
  elif top_news_list == []:
963
  news_content = html.P("昨日無重大相關新聞。", style={'text-align': 'center', 'padding-top': '50px'})
964
  else:
965
  news_content = html.P("讀取新聞時發生錯誤。", style={'text-align': 'center', 'padding-top': '50px'})
966
+
967
  return gauge_content, news_content
968
 
969
  # 主程式執行