AlanRex commited on
Commit
f5573c5
·
verified ·
1 Parent(s): 53bb8fb

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +41 -144
app.py CHANGED
@@ -1,4 +1,4 @@
1
- # HUGING_FACE_V4.2(輕量AI版).py - 已整合 XGBoost 模型 (錯誤修正版)
2
 
3
  # 系統套件
4
  import os
@@ -18,38 +18,28 @@ import requests
18
  import time # 引用 time 模組以處理時間戳
19
 
20
  # ========================= 引用外部模組 START =========================
21
- # 引用您組員的預測器程式
22
  from Bert_predict import BertPredictor
23
-
24
- # 匯入 XGBoostModel 類別
25
  from model_predictor import XGBoostModel
26
  # ========================== 引用外部模組 END ==========================
27
 
28
  # ========================= 全域設定 START =========================
29
- # 將開關設為 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
- # 在應用程式啟動時,預先載入 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,7 +58,7 @@ TAIWAN_STOCKS = {
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,7 +78,6 @@ INDUSTRY_MAPPING = {
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,7 +92,6 @@ def get_stock_data(symbol, period='1y'):
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,70 +106,31 @@ def simple_statistical_predict(data, predict_days=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
- # 【修正 1】: 修正 XGBoost 模型的輸入資料
122
  def advanced_xgboost_predict(data, predict_days):
123
- """
124
- 【進階模型橋接函式】
125
- - 準備 XGBoost 模型所需的輸入 DataFrame。
126
- - 呼叫模型進行預測。
127
- - 將模型的輸出格式轉換為主程式所需的格式。
128
- """
129
  if xgb_model is None or data.empty:
130
  return None
131
-
132
- # 1. 準備輸入資料
133
- # 根據錯誤日誌,模型需要的特徵是 ['Open', 'High', 'Low', 'Volume']
134
  feature_columns = ['Open', 'High', 'Low', 'Volume']
135
-
136
- # 我們使用最新的資料點來進行未來預測
137
  input_df = data.tail(1)
138
-
139
- # 確保輸入的 DataFrame 只包含模型需要的欄位
140
  if not all(col in input_df.columns for col in feature_columns):
141
  print(f"錯誤: 輸入資料缺少必要欄位。需要 {feature_columns}")
142
  return None
143
-
144
- # 篩選出模型需要的特徵欄位
145
  input_df_filtered = input_df[feature_columns]
146
-
147
  try:
148
- # 2. 呼叫模型預測
149
  predictions = xgb_model.predict('xgboost_model', input_df_filtered)
150
-
151
- # 3. 根據 predict_days 解析輸出
152
- day_to_key_map = {
153
- 1: 'Close_t0_pred',
154
- 5: 'Close_t5_pred',
155
- 10: 'Close_t10_pred',
156
- 20: 'Close_t20_pred',
157
- 60: None
158
- }
159
-
160
  prediction_key = day_to_key_map.get(predict_days)
161
-
162
  if prediction_key is None or prediction_key not in predictions:
163
  print(f"警告: XGBoost 模型沒有提供 {predict_days} 天的預測結果。")
164
  return None
165
-
166
  predicted_price = predictions[prediction_key]
167
  current_price = data['Close'].iloc[-1]
168
  change_pct = ((predicted_price - current_price) / current_price) * 100
169
-
170
- # 4. 包裝成主程式所需的格式
171
- return {
172
- 'predicted_price': predicted_price,
173
- 'change_pct': change_pct,
174
- 'confidence': 0.95
175
- }
176
  except Exception as e:
177
  print(f"執行 XGBoost 預測時發生錯誤: {e}")
178
  return None
179
 
180
  def get_prediction(data, predict_days=5):
181
- """
182
- 【模型預測控制器】
183
- 根據 USE_ADVANCED_MODEL 的設定,呼叫對應的預測模型。
184
- """
185
  if USE_ADVANCED_MODEL:
186
  print(f"模式: 進階XGBoost模型 | 預測天期: {predict_days}天")
187
  prediction = advanced_xgboost_predict(data, predict_days)
@@ -189,12 +138,10 @@ def get_prediction(data, predict_days=5):
189
  return prediction
190
  else:
191
  print("進階模型預測失敗或無對應天期,自動降級為簡易統計模型。")
192
-
193
  print(f"模式: 簡易統計模型 | 預測天期: {predict_days}天")
194
  return simple_statistical_predict(data, predict_days)
195
 
196
  def calculate_technical_indicators(df):
197
- """計算技術指標"""
198
  if df.empty: return df
199
  df['MA5'] = df['Close'].rolling(window=5).mean()
200
  df['MA20'] = df['Close'].rolling(window=20).mean()
@@ -231,32 +178,41 @@ def calculate_technical_indicators(df):
231
  df['ADX'] = df['DX'].ewm(com=13, adjust=False).mean()
232
  return df
233
 
234
- # 【修正 2】: 修正 Volume Profile 計算中的 NaN 錯誤
235
  def calculate_volume_profile(df, num_bins=50):
236
- if df.empty or 'High' not in df.columns or 'Low' not in df.columns or 'Volume' not in df.columns: return None, None, None
237
-
238
- # 結合高低價並移除可能存在的 NaN 值
239
- all_prices = np.concatenate([df['High'].values, df['Low'].values])
240
- all_prices = all_prices[~np.isnan(all_prices)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
 
242
- # 如果移除 NaN 後沒有數據,則直接返回
243
  if all_prices.size == 0:
244
  return None, None, None
245
 
246
  min_price, max_price = all_prices.min(), all_prices.max()
247
 
248
- # 如果價格範圍無效,也返回
249
  if min_price >= max_price:
250
  return None, None, None
251
 
252
- price_for_volume = (df['High'] + df['Low'] + df['Close']) / 3
253
- df_vol_profile = df.copy()
254
- df_vol_profile['Price_Indicator'] = price_for_volume
255
-
256
- # 確保用於計算權重的 Volume 也沒有 NaN
257
- weights = df_vol_profile['Volume'].fillna(0).values
258
 
259
- hist, bin_edges = np.histogram(df_vol_profile['Price_Indicator'].dropna(), bins=num_bins, range=(min_price, max_price), weights=weights)
 
260
  price_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
261
  return bin_edges, hist, price_centers
262
 
@@ -293,7 +249,6 @@ def generate_gemini_analysis(stock_name, stock_symbol, period, data):
293
  api_key = os.getenv("GEMINI_API_KEY")
294
  if not api_key:
295
  return "無法讀取 GEMINI API 金鑰", "請在系統環境變數中設定您的金鑰"
296
-
297
  try:
298
  genai.configure(api_key=api_key)
299
  model = genai.GenerativeModel('gemini-1.5-flash')
@@ -302,10 +257,8 @@ def generate_gemini_analysis(stock_name, stock_symbol, period, data):
302
  macd_current = data['MACD'].iloc[-1]
303
  macd_signal_current = data['MACD_Signal'].iloc[-1]
304
  industry = INDUSTRY_MAPPING.get(stock_symbol, '綜合')
305
-
306
  prompt = f"""
307
- 請扮演一位專業、資深的台灣股市金融分析師。
308
- 我將提供一檔台股的即時技術指標數據,請你基於這些數據,結合你對這家公司、其所在產業以及當前市場趨勢的理解,為我生成一段專業的「基本面分析」和一段「市場展望與投資建議」。
309
  **股票資訊:**
310
  - **公司名稱:** {stock_name} ({stock_symbol})
311
  - **分析期間:** 最近 {period}
@@ -314,17 +267,10 @@ def generate_gemini_analysis(stock_name, stock_symbol, period, data):
314
  - **目前 RSI 指標:** {rsi_current:.2f}
315
  - **目前 MACD 指標:** MACD線為 {macd_current:.3f}, 信號線為 {macd_signal_current:.3f}
316
  **你的任務:**
317
- 1. **基本面分析 (約 150 字):**
318
- - 評論這家公司的產業地位、近期營運亮點或挑戰。
319
- - 提及任何可能影響其基本面的關鍵因素 (例如:財報、法說會、政策、供應鏈變化等)。
320
- - 請用專業、客觀的語氣撰寫。
321
- 2. **市場展望與投資建議 (約 150 字):**
322
- - 基於上述所有資訊,提供對該股票的短期和中期市場展望。
323
- - 提出具體的投資建議,例如:適合何種類型的投資人、潛在的風險點。
324
- - 請直接提供分析內容,不要包含任何問候語。
325
  **輸出格式:**
326
- 請嚴格按照以下格式回傳,使用"$$"作為兩個段落之間的分隔符:
327
- [基本面分析內容]$$[市場展望與投資建議內容]
328
  """
329
  response = model.generate_content(prompt)
330
  parts = response.text.split('$$')
@@ -351,7 +297,7 @@ except Exception as e:
351
  print(f"錯誤:新聞情緒分析模型初始化失敗 - {e}")
352
  predictor = None
353
 
354
- # 應用程式佈局
355
  app.layout = html.Div([
356
  html.H1("台股分析儀表板", style={'text-align': 'center', 'margin-bottom': '30px'}),
357
  html.Div([
@@ -370,7 +316,6 @@ app.layout = html.Div([
370
  ]),
371
  html.Div([dcc.Graph(id='taiex-prediction-chart')], style={'margin-top': '20px'})
372
  ], 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'}),
373
-
374
  html.Div([
375
  html.H3("📰 市場情緒與新聞分析", style={'color': '#E74C3C', 'margin-bottom': '20px'}),
376
  html.Div([
@@ -384,7 +329,6 @@ app.layout = html.Div([
384
  ], style={'width': '48%', 'display': 'inline-block', 'margin-left': '4%'})
385
  ])
386
  ], style={'margin-top': '30px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
387
-
388
  html.Div([
389
  html.H3("景氣燈號與 PMI 分析"),
390
  html.Div([
@@ -392,7 +336,6 @@ app.layout = html.Div([
392
  html.Div([dcc.Graph(id='pmi-chart')], style={'width': '48%', 'display': 'inline-block', 'margin-left': '2%'})
393
  ])
394
  ], style={'margin-top': '30px'}),
395
-
396
  html.Div([
397
  html.Div([
398
  html.Label("選擇股票:"),
@@ -409,7 +352,6 @@ app.layout = html.Div([
409
  dcc.Dropdown(id='chart-type', options=[{'label': '線圖', 'value': 'line'},{'label': '蠟燭圖', 'value': 'candlestick'}], value='candlestick', style={'margin-bottom': '10px'})
410
  ], style={'width': '30%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'})
411
  ], style={'margin-bottom': '30px'}),
412
-
413
  html.Div(id='stock-info-cards', style={'margin-bottom': '30px'}),
414
  html.Div([html.Div([dcc.Graph(id='price-chart')], style={'width': '100%', 'display': 'inline-block', 'vertical-align': 'top'})]),
415
  html.Div([
@@ -462,6 +404,7 @@ app.layout = html.Div([
462
  ], style={'margin-top': '30px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
463
  ])
464
 
 
465
  @app.callback(
466
  [dash.dependencies.Output('taiex-prediction-results', 'children'),
467
  dash.dependencies.Output('taiex-prediction-chart', 'figure')],
@@ -470,23 +413,18 @@ app.layout = html.Div([
470
  def update_taiex_prediction(predict_days):
471
  data = get_stock_data('^TWII', '2y')
472
  if data.empty: return html.Div("無法獲取台指期資料"), {}
473
-
474
  final_prediction = get_prediction(data, predict_days)
475
-
476
  if final_prediction is None: return html.Div("資料不足或模型無法預測此天期"), {}
477
  current_price, last_date = data['Close'].iloc[-1], data.index[-1]
478
  predicted_price, change_pct, confidence = final_prediction['predicted_price'], final_prediction['change_pct'], final_prediction['confidence']
479
-
480
  prediction_paths = {1: [1], 5: [1, 5], 10: [1, 5, 10], 20: [1, 10, 20]}
481
  intervals_to_predict = prediction_paths.get(predict_days, [predict_days])
482
  prediction_dates, prediction_prices = [last_date], [current_price]
483
-
484
  for days in intervals_to_predict:
485
  interim_prediction = get_prediction(data, days)
486
  if interim_prediction:
487
  prediction_dates.append(last_date + timedelta(days=days))
488
  prediction_prices.append(interim_prediction['predicted_price'])
489
-
490
  color, arrow = ('red', '📈') if change_pct >= 0 else ('green', '📉')
491
  result_card = html.Div([
492
  html.H4(f"{predict_days}日後預測結果", style={'margin': '0 0 15px 0', 'color': 'white'}),
@@ -635,30 +573,15 @@ def update_industry_analysis(selected_stock):
635
  data = get_stock_data(symbol, '1mo')
636
  if not data.empty and len(data) > 1:
637
  return_pct = ((data['Close'].iloc[-1] - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
638
- performance_data.append({
639
- '股票': name,
640
- '代碼': symbol,
641
- '月報酬率(%)': return_pct,
642
- '絕對波動': abs(return_pct)
643
- })
644
  if not performance_data:
645
  fig = go.Figure().add_annotation(text="無法計算產業資料", showarrow=False)
646
  fig.update_layout(title="近一月市場波動最大標的", height=400)
647
  return fig
648
  df_performance = pd.DataFrame(performance_data)
649
  df_top_movers = df_performance.sort_values(by='絕對波動', ascending=False).head(10)
650
- fig = px.pie(
651
- df_top_movers,
652
- values='絕對波動',
653
- names='股票',
654
- title='近一月市場波動最大 Top 10 標的',
655
- hover_data={'月報酬率(%)': ':.2f'}
656
- )
657
- fig.update_traces(
658
- textposition='inside',
659
- textinfo='percent+label',
660
- hovertemplate="<b>%{label}</b><br>月報酬率: %{customdata[0]:.2f}%<extra></extra>"
661
- )
662
  fig.update_layout(height=400, showlegend=False)
663
  return fig
664
 
@@ -696,41 +619,28 @@ def update_business_climate_chart(selected_stock):
696
  def update_analysis_text(selected_stock, period):
697
  cache_key = f"{selected_stock}-{period}"
698
  current_time = time.time()
699
-
700
  if cache_key in ANALYSIS_CACHE:
701
  cached_data = ANALYSIS_CACHE[cache_key]
702
  if current_time - cached_data['timestamp'] < CACHE_DURATION_SECONDS:
703
  print(f"從快取載入分析: {cache_key}")
704
  return cached_data['technical'], cached_data['fundamental'], cached_data['outlook']
705
-
706
  print(f"重新生成分析: {selected_stock}-{period}")
707
  data = get_stock_data(selected_stock, period)
708
  stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
709
  if data.empty or len(data) < 20:
710
  return "資料不足,無法分析", "資料不足,無法分析", "資料不足,無法分析"
711
-
712
  data = calculate_technical_indicators(data)
713
-
714
  price_change = ((data['Close'].iloc[-1] - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
715
  rsi_current = data['RSI'].iloc[-1] if not pd.isna(data['RSI'].iloc[-1]) else 50
716
  macd_current = data['MACD'].iloc[-1] if not pd.isna(data['MACD'].iloc[-1]) else 0
717
  macd_signal_current = data['MACD_Signal'].iloc[-1] if not pd.isna(data['MACD_Signal'].iloc[-1]) else 0
718
-
719
  technical_text = html.Div([
720
  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}%。"]),
721
  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'}), "。"]),
722
  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 '空頭'}。"]),
723
  ])
724
-
725
  fundamental_text, market_outlook_text = generate_gemini_analysis(stock_name, selected_stock, period, data)
726
-
727
- ANALYSIS_CACHE[cache_key] = {
728
- 'technical': technical_text,
729
- 'fundamental': fundamental_text,
730
- 'outlook': market_outlook_text,
731
- 'timestamp': current_time
732
- }
733
-
734
  return technical_text, fundamental_text, market_outlook_text
735
 
736
  @app.callback(
@@ -754,16 +664,12 @@ def summarize_news_with_gemini(news_list: list) -> str:
754
  api_key = os.getenv("GEMINI_API_KEY")
755
  if not api_key:
756
  return "錯誤:找不到 GEMINI_API_KEY。請在 Hugging Face Secrets 中設定。"
757
-
758
  try:
759
  genai.configure(api_key=api_key)
760
  model = genai.GenerativeModel('gemini-1.5-flash')
761
  formatted_news = "\n".join([f"- {news}" for news in news_list])
762
  prompt = f"""
763
- 請扮演一位專業的金融市場分析師。
764
- 以下是幾則最新的英文財經新聞標題,請將它們整合成一段簡潔、流暢、約 200 字的繁體中文市場動態摘要,與利多哪些產業,利空哪些產業。
765
- 提供3段重點,
766
- 請專注於可能影響市場情緒和股價的關鍵資訊,並直接提供摘要內容,不要包含任何額外的問候語或說明。
767
  英文新聞標題如下:
768
  {formatted_news}
769
  """
@@ -793,7 +699,6 @@ def update_comparison_analysis(selected_stocks, period):
793
  normalized_prices = (data['Close'] / data['Close'].iloc[0]) * 100
794
  fig.add_trace(go.Scatter(x=data.index, y=normalized_prices, mode='lines', name=stock_name, line=dict(width=2)))
795
  total_return = ((data['Close'].iloc[-1] / data['Close'].iloc[0]) - 1) * 100
796
- # 【修正 3】: 修正 FutureWarning 警告
797
  volatility = data['Close'].pct_change(fill_method=None).std() * np.sqrt(252) * 100
798
  comparison_data.append({'name': stock_name, 'return': total_return, 'volatility': volatility, 'current_price': data['Close'].iloc[-1]})
799
  fig.update_layout(title=f'股票績效比較 - {period}', xaxis_title='日期', yaxis_title='相對績效 (基期=100)', height=400, hovermode='x unified')
@@ -816,9 +721,7 @@ def update_sentiment_analysis(selected_stock):
816
  error_fig = go.Figure().add_annotation(text="情緒指標模型載入失敗", showarrow=False)
817
  error_fig.update_layout(height=200)
818
  return dcc.Graph(figure=error_fig), html.P("新聞分析模型載入失敗,請檢查後台日誌。")
819
-
820
  sentiment_score_raw = predictor.get_news_index()
821
-
822
  if sentiment_score_raw is not None:
823
  sentiment_score_normalized = (sentiment_score_raw + 1) * 50
824
  sentiment_score_normalized = max(0, min(100, sentiment_score_normalized))
@@ -843,21 +746,15 @@ def update_sentiment_analysis(selected_stock):
843
  error_fig = go.Figure().add_annotation(text="今日尚無情緒分數", showarrow=False)
844
  error_fig.update_layout(height=200)
845
  gauge_content = dcc.Graph(figure=error_fig)
846
-
847
  top_news_list = predictor.get_news()
848
  news_content = None
849
-
850
  if top_news_list and isinstance(top_news_list, list):
851
  summary_text = summarize_news_with_gemini(top_news_list)
852
- news_content = dcc.Markdown(summary_text, style={
853
- 'margin': '8px 0', 'padding-left': '5px',
854
- 'font-size': '15px', 'line-height': '1.7'
855
- })
856
  elif top_news_list == []:
857
  news_content = html.P("昨日無重大相關新聞。", style={'text-align': 'center', 'padding-top': '50px'})
858
  else:
859
  news_content = html.P("讀取新聞時發生錯誤。", style={'text-align': 'center', 'padding-top': '50px'})
860
-
861
  return gauge_content, news_content
862
 
863
  # 主程式執行
 
1
+ # HUGING_FACE_V4.2(輕量AI版).py - 已整合 XGBoost 模型 (最終修正版)
2
 
3
  # 系統套件
4
  import os
 
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
  '譜瑞-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
  }
79
 
80
  def get_stock_data(symbol, period='1y'):
 
81
  try:
82
  stock = yf.Ticker(symbol)
83
  data = stock.history(period=period)
 
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
  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)
 
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
  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
 
 
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')
 
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}
 
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('$$')
 
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
  ]),
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
  ], 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
  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("選擇股票:"),
 
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
  ], 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
  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'}),
 
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
  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(
 
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
  """
 
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')
 
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
  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
  # 主程式執行