AlanRex commited on
Commit
03be5d5
·
verified ·
1 Parent(s): 46d6ad0

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +226 -643
app.py CHANGED
@@ -7,7 +7,7 @@ import google.generativeai as genai
7
  import pandas as pd
8
  import numpy as np
9
  import yfinance as yf
10
- from dash import Dash, dcc, html, callback
11
  import dash
12
  import plotly.express as px
13
  import plotly.graph_objects as go
@@ -39,6 +39,7 @@ CACHE_DURATION_SECONDS = 8 * 60 * 60
39
  # 【修改 3】: 在應用程式啟動時,預先載入 XGBoost 模型
40
  try:
41
  print("正在初始化 XGBoost 預測模型...")
 
42
  xgb_model = XGBoostModel(default_model='xgboost_model')
43
  print("XGBoost 預測模型初始化成功。")
44
  except Exception as e:
@@ -118,102 +119,6 @@ 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
- # 【新增】: 建立一個新的函式來處理 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
- try:
133
- # 1. 準備模型所需的特徵 (Features)
134
- print("正在準備 XGBoost 模型輸入特徵...")
135
-
136
- # 獲取外部市場數據
137
- external_symbols = {'DJI': '^DJI', 'NAS': '^IXIC', 'SOX': '^SOX', 'S&P_500': '^GSPC', 'TSM_ADR': 'TSM'}
138
- df_external = pd.DataFrame(index=data.index)
139
- for name, ticker in external_symbols.items():
140
- ext_data = yf.download(ticker, start=data.index.min(), end=data.index.max(), progress=False)
141
- df_external[name] = ext_data['Close']
142
-
143
- # 合併所有數據並向前填充缺失值
144
- input_df = pd.concat([data, df_external], axis=1)
145
- input_df.ffill(inplace=True)
146
-
147
- # 載入並合併景氣燈號和 PMI
148
- df_climate = get_business_climate_data()
149
- df_pmi = get_pmi_data()
150
- input_df = pd.merge(input_df.reset_index(), df_climate, on='Date', how='left', suffixes=('', '_climate')).set_index('Date')
151
- input_df = pd.merge(input_df.reset_index(), df_pmi, on='Date', how='left', suffixes=('', '_pmi')).set_index('Date')
152
- input_df.rename(columns={'Index': 'business_climate', 'Index_pmi': 'PMI'}, inplace=True)
153
- input_df[['business_climate', 'PMI']] = input_df[['business_climate', 'PMI']].ffill()
154
-
155
- # 計算技術指標
156
- input_df = calculate_technical_indicators(input_df) # 確保計算指標
157
-
158
- # 新增新聞情緒分數 (此處為範例,假設從 predictor 取得)
159
- # 注意: predictor.get_news_index() 僅返回一個值,需要對齊到時間序列
160
- news_score = predictor.get_news_index() if predictor else 0
161
- input_df['NEWS'] = news_score if news_score is not None else 0
162
-
163
- # 新增 'rate' 欄位 (注意:此處為佔位符,您需要提供真實數據源)
164
- input_df['rate'] = 1.75 # 範例:假設為固定利率,請替換為真實數據
165
-
166
- # 欄位重新命名以符合模型訓練時的名稱
167
- input_df.rename(columns={
168
- 'Close': 'close', 'Volume': 'volume', 'MACD_Signal': 'MACDsign',
169
- 'MACD_Histogram': 'MACDvol'
170
- }, inplace=True)
171
-
172
- # 確保所有模型需要的欄位都存在
173
- columns_to_keep = [
174
- 'close', 'volume', 'rate', 'DJI', 'NAS', 'SOX', 'S&P_500', 'TSM_ADR', 'NEWS',
175
- 'RSI', 'MACD', 'MACDsign', 'MACDvol', 'K', 'D', '+DI', '-DI', 'ADX',
176
- 'business_climate', 'PMI'
177
- ]
178
-
179
- final_input = input_df[columns_to_keep].tail(1)
180
-
181
- if final_input.isnull().values.any():
182
- print("警告: 準備好的輸入數據中存在缺失值,無法進行預測。")
183
- return None
184
-
185
- # 2. 呼叫模型預測
186
- print("呼叫 XGBoost 模型進行預測...")
187
- predictions = xgb_model.predict('xgboost_model', final_input)
188
-
189
- # 3. 根據 predict_days 解析輸出
190
- day_to_key_map = {
191
- 1: 'Close_t0_pred', 5: 'Close_t5_pred', 10: 'Close_t10_pred', 20: 'Close_t20_pred'
192
- }
193
-
194
- prediction_key = day_to_key_map.get(predict_days)
195
-
196
- if prediction_key is None or prediction_key not in predictions:
197
- print(f"警告: XGBoost 模型沒有提供 {predict_days} 天的預測結果。")
198
- return None
199
-
200
- predicted_price = predictions[prediction_key]
201
- current_price = data['Close'].iloc[-1]
202
- change_pct = ((predicted_price - current_price) / current_price) * 100
203
-
204
- # 4. 包裝成主程式所需的格式
205
- return {
206
- 'predicted_price': predicted_price,
207
- 'change_pct': change_pct,
208
- 'confidence': 0.95 # XGBoost模型通常不直接提供信心度,可給定一個較高的固定值
209
- }
210
- except Exception as e:
211
- print(f"執行 XGBoost 預測時發生錯誤: {e}")
212
- return None
213
-
214
-
215
- # 【修改 4】: 建立一個新的函式來處理 XGBoost 模型的輸入和輸出
216
- # 修正後的 advanced_xgboost_predict 函數
217
  def advanced_xgboost_predict(data, predict_days):
218
  """
219
  【進階模型橋接函式】
@@ -228,45 +133,35 @@ def advanced_xgboost_predict(data, predict_days):
228
  print("正在準備 XGBoost 模型所需的20個輸入特徵...")
229
 
230
  # 1. 獲取外部市場數據
231
- # 建立一個與主股票數據相同索引的 DataFrame 以便對齊
232
  start_date = data.index.min() - pd.Timedelta(days=5) # 提前幾天以確保數據填充
233
  end_date = data.index.max()
234
 
235
- df_aligned = pd.DataFrame(index=pd.date_range(start=start_date, end=end_date, freq='D'))
236
-
237
  external_symbols = {'DJI': '^DJI', 'NAS': '^IXIC', 'SOX': '^SOX', 'S&P_500': '^GSPC', 'TSM_ADR': 'TSM'}
238
 
239
  # 使用 yf.download 一次性獲取所有外部數據
240
  ext_data = yf.download(list(external_symbols.values()), start=start_date, end=end_date, progress=False)['Close']
241
  ext_data.rename(columns={v: k for k, v in external_symbols.items()}, inplace=True)
242
 
243
- # 2. 合併所有數據源
244
- # 將主數據與外部數據合併
245
  input_df = data.join(ext_data, how='left')
246
 
247
- # 載入並合併景氣燈號和 PMI
248
- df_climate = get_business_climate_data() #
249
- df_pmi = get_pmi_data() #
250
 
251
- # 'Date' 轉為 datetime 物件以進行合併
252
- input_df.index = pd.to_datetime(input_df.index)
253
- df_climate['Date'] = pd.to_datetime(df_climate['Date']) #
254
- df_pmi['Date'] = pd.to_datetime(df_pmi['Date']) #
255
-
256
- input_df = pd.merge(input_df, df_climate.rename(columns={'Index': 'business_climate'}), on='Date', how='left')
257
- input_df = pd.merge(input_df, df_pmi.rename(columns={'Index': 'PMI'}), on='Date', how='left')
258
 
 
 
 
 
 
259
  # 向前填充所有缺失值 (例如假日)
260
  input_df.ffill(inplace=True)
261
  input_df.bfill(inplace=True) # 向後填充開頭可能存在的缺失值
262
 
263
- # 3. 計算技術指標與新增其他特徵
264
- input_df = calculate_technical_indicators(input_df) #
265
- news_score = predictor.get_news_index() if predictor else 0
266
- input_df['NEWS'] = news_score if news_score is not None else 0
267
- input_df['rate'] = 1.75 # 注意:此為利率佔位符,請替換為真實數據源
268
-
269
- # 4. 格式化最終輸入
270
  input_df.rename(columns={
271
  'Close': 'close', 'Volume': 'volume', 'MACD_Signal': 'MACDsign',
272
  'MACD_Histogram': 'MACDvol'
@@ -290,11 +185,21 @@ def advanced_xgboost_predict(data, predict_days):
290
  print(f"警告: 最終輸入數據中存在缺失值,無法預測。\n{final_input.isnull().sum()}")
291
  return None
292
 
293
- # 5. 呼叫模型預測
294
  print("特徵準備完成,呼叫 XGBoost 模型...")
295
  predictions = xgb_model.predict('xgboost_model', final_input)
296
 
297
- predicted_price = predictions['Close_t5_pred'] # 預設獲取5日預測
 
 
 
 
 
 
 
 
 
 
298
  current_price = data['Close'].iloc[-1]
299
  change_pct = ((predicted_price - current_price) / current_price) * 100
300
 
@@ -316,13 +221,11 @@ def get_prediction(data, predict_days=5):
316
  """
317
  if USE_ADVANCED_MODEL:
318
  print(f"模式: 進階XGBoost模型 | 預測天期: {predict_days}天")
319
- # 【修改】: 呼叫新的 XGBoost 橋接函式
320
  prediction = advanced_xgboost_predict(data, predict_days)
321
- # 如果進階模型預測失敗,則自動降級使用簡易模型
322
  if prediction is not None:
323
  return prediction
324
  else:
325
- print("進階模型預測失敗或無對應天期,自動降級為簡易統計模型。")
326
 
327
  # 預設或降級時執行簡易模型
328
  print(f"模式: 簡易統計模型 | 預測天期: {predict_days}天")
@@ -370,7 +273,6 @@ def calculate_volume_profile(df, num_bins=50):
370
  if df.empty or 'High' not in df.columns or 'Low' not in df.columns or 'Volume' not in df.columns:
371
  return None, None, None
372
 
373
- # 確保沒有 NaN 值
374
  df_clean = df.dropna(subset=['High', 'Low', 'Close', 'Volume'])
375
  if df_clean.empty:
376
  return None, None, None
@@ -381,7 +283,6 @@ def calculate_volume_profile(df, num_bins=50):
381
  if min_price == max_price:
382
  return None, None, None
383
 
384
- # 使用典型價格作為價格指標
385
  price_for_volume = (df_clean['High'] + df_clean['Low'] + df_clean['Close']) / 3
386
 
387
  try:
@@ -397,34 +298,28 @@ def calculate_volume_profile(df, num_bins=50):
397
  print(f"Volume profile 計算錯誤: {e}")
398
  return None, None, None
399
 
400
- def get_business_climate_data():
401
  try:
402
- if not os.path.exists('business_climate.csv'): return pd.DataFrame()
403
- df = pd.read_csv('business_climate.csv')
404
- if 'Date' not in df.columns: df.columns = ['Date', 'Index'] if len(df.columns) == 2 else df.columns
405
- if 'Date' in df.columns:
406
- try: df['Date'] = pd.to_datetime(df['Date'] + '-01', format='%Y-%m-%d', errors='coerce')
407
- except: df['Date'] = pd.to_datetime(df['Date'], errors='coerce')
408
- df = df.dropna(subset=['Date'])
409
  return df
410
  except Exception as e:
411
  print(f"無法獲取景氣燈號資料: {str(e)}")
412
- return pd.DataFrame()
413
 
414
- def get_pmi_data():
415
  try:
416
- if not os.path.exists('taiwan_pmi.csv'): return pd.DataFrame()
417
- df = pd.read_csv('taiwan_pmi.csv')
418
- if 'DATE' in df.columns: df = df.rename(columns={'DATE': 'Date', 'INDEX': 'Index'})
419
- elif len(df.columns) == 2: df.columns = ['Date', 'Index']
420
- if 'Date' in df.columns:
421
- try: df['Date'] = pd.to_datetime(df['Date'] + '-01', format='%Y-%m-%d', errors='coerce')
422
- except: df['Date'] = pd.to_datetime(df['Date'], errors='coerce')
423
- df = df.dropna(subset=['Date'])
424
  return df
425
  except Exception as e:
426
  print(f"無法獲取 PMI 資料: {str(e)}")
427
- return pd.DataFrame()
 
428
 
429
  def generate_gemini_analysis(stock_name, stock_symbol, period, data):
430
  """
@@ -479,7 +374,6 @@ def generate_gemini_analysis(stock_name, stock_symbol, period, data):
479
  market_outlook = parts[1].strip()
480
  return dcc.Markdown(fundamental_analysis), dcc.Markdown(market_outlook)
481
  else:
482
- # Fallback for unexpected response format
483
  return dcc.Markdown("無法解析 Gemini 回應,請稍後再試。"), dcc.Markdown(response.text)
484
 
485
  except Exception as e:
@@ -489,6 +383,7 @@ def generate_gemini_analysis(stock_name, stock_symbol, period, data):
489
 
490
  # 建立 Dash 應用程式
491
  app = dash.Dash(__name__, suppress_callback_exceptions=True)
 
492
 
493
  try:
494
  print("正在初始化新聞情緒分析模型...")
@@ -516,10 +411,18 @@ app.layout = html.Div([
516
  html.Div(id='taiex-prediction-results', style={'width': '65%', 'display': 'inline-block', 'margin-left': '5%'})
517
  ]),
518
  html.Div([dcc.Graph(id='taiex-prediction-chart')], style={'margin-top': '20px'})
519
- ], 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'}),
520
-
 
 
 
 
 
 
 
521
  html.Div([
522
  html.H3("📰 市場情緒與新聞分析", style={'color': '#E74C3C', 'margin-bottom': '20px'}),
 
523
  html.Div([
524
  html.Div([
525
  html.H4("市場情緒指標", style={'color': '#8E44AD'}),
@@ -539,544 +442,224 @@ app.layout = html.Div([
539
  html.Div([dcc.Graph(id='pmi-chart')], style={'width': '48%', 'display': 'inline-block', 'margin-left': '2%'})
540
  ])
541
  ], style={'margin-top': '30px'}),
542
-
543
  html.Div([
544
  html.Div([
545
  html.Label("選擇股票:"),
546
- dcc.Dropdown(id='stock-dropdown', options=[{'label': name, 'value': symbol} for name, symbol in TAIWAN_STOCKS.items()], value='0050.TW', style={'margin-bottom': '10px'})
 
 
 
547
  ], style={'width': '30%', 'display': 'inline-block', 'vertical-align': 'top'}),
548
  html.Div([
549
  html.Label("時間範圍:"),
550
  dcc.Dropdown(id='period-dropdown',
551
- options=[{'label': '1個月', 'value': '1mo'},{'label': '3個月', 'value': '3mo'},{'label': '6個月', 'value': '6mo'},{'label': '1年', 'value': '1y'},{'label': '2年', 'value': '2y'}],
552
- value='1mo', style={'margin-bottom': '10px'})
 
 
 
 
553
  ], style={'width': '30%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'}),
554
  html.Div([
555
  html.Label("圖表類型:"),
556
- dcc.Dropdown(id='chart-type', options=[{'label': '線圖', 'value': 'line'},{'label': '蠟燭圖', 'value': 'candlestick'}], value='candlestick', style={'margin-bottom': '10px'})
557
- ], style={'width': '30%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'})
558
- ], style={'margin-bottom': '30px'}),
559
-
560
- html.Div(id='stock-info-cards', style={'margin-bottom': '30px'}),
561
- html.Div([html.Div([dcc.Graph(id='price-chart')], style={'width': '100%', 'display': 'inline-block', 'vertical-align': 'top'})]),
562
- html.Div([
563
- html.H3("📊 進階技術指標分析", style={'margin-bottom': '20px'}),
564
- html.Div([
565
- html.Label("選擇技術指標:", style={'font-weight': 'bold', 'margin-right': '10px'}),
566
- dcc.Dropdown(id='technical-indicator-selector',
567
- options=[{'label': 'RSI 相對強弱指標', 'value': 'RSI'},{'label': 'MACD 指數平滑異同移動平均線', 'value': 'MACD'},{'label': '布林通道 Bollinger Bands', 'value': 'BB'},
568
- {'label': 'KD 隨機指標', 'value': 'KD'},{'label': '威廉指標 %R', 'value': 'WR'},{'label': 'DMI 動向指標', 'value': 'DMI'}],
569
- value='RSI', style={'width': '100%'})
570
- ], style={'margin-bottom': '20px'}),
571
- html.Div([dcc.Graph(id='advanced-technical-chart')])
572
- ], style={'margin-top': '20px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
573
- html.Div([dcc.Graph(id='volume-chart')], style={'margin-top': '20px'}),
574
- html.Div([html.H3("產業表現分析"), dcc.Graph(id='industry-analysis')], style={'margin-top': '30px'}),
575
- html.Div([
576
- html.H3("📊 分析師觀點與市場解讀", style={'color': '#2E86AB', 'margin-bottom': '20px'}),
577
- html.Div([
578
- html.Div([
579
- html.H4("🔍 技術面分析", style={'color': '#A23B72', 'margin-bottom': '15px'}),
580
- html.Div(id='technical-analysis-text', style={'background': '#f8f9fa','padding': '15px','border-radius': '8px','border-left': '4px solid #A23B72','min-height': '150px','font-size': '14px','line-height': '1.6'})
581
- ], style={'width': '48%', 'display': 'inline-block', 'vertical-align': 'top'}),
582
- html.Div([
583
- html.H4("📈 基本面分析 (AI 生成)", style={'color': '#F18F01', 'margin-bottom': '15px'}),
584
- html.Div(id='fundamental-analysis-text', style={'background': '#f8f9fa','padding': '15px','border-radius': '8px','border-left': '4px solid #F18F01','min-height': '150px','font-size': '14px','line-height': '1.6'})
585
- ], style={'width': '48%', 'display': 'inline-block', 'margin-left': '4%', 'vertical-align': 'top'})
586
- ]),
587
- html.Div([
588
- html.H4("🎯 市場展望與投資建議 (AI 生成)", style={'color': '#C73E1D', 'margin-bottom': '15px', 'margin-top': '25px'}),
589
- 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)'})
590
- ])
591
- ], 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'}),
592
  html.Div([
593
- html.H3("📊 多檔股票比較分析", style={'margin-bottom': '20px'}),
594
  html.Div([
595
- html.Div([
596
- html.Label("選擇比較股票(最多5檔):", style={'font-weight': 'bold'}),
597
- dcc.Dropdown(id='comparison-stocks', options=[{'label': name, 'value': symbol} for name, symbol in TAIWAN_STOCKS.items()], value=['0050.TW', '2330.TW', '2454.TW'], multi=True, style={'margin-bottom': '5px'}),
598
- html.Small('(元大台灣50 (0050.TW) 為固定比較基準,不可移除)', style={'display': 'block', 'font-style': 'italic', 'color': 'gray'})
599
- ], style={'width': '60%', 'display': 'inline-block'}),
600
- html.Div([
601
- html.Label("比較期間:", style={'font-weight': 'bold'}),
602
- dcc.Dropdown(id='comparison-period', options=[{'label': '1個月', 'value': '1mo'},{'label': '3個月', 'value': '3mo'},{'label': '6個月', 'value': '6mo'},{'label': '1年', 'value': '1y'}], value='3mo')
603
- ], style={'width': '35%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'})
604
- ]),
605
- html.Div([
606
- html.Div([dcc.Graph(id='comparison-chart')], style={'width': '65%', 'display': 'inline-block'}),
607
- html.Div([html.H4("比較結果", style={'color': '#2E86AB'}), html.Div(id='comparison-table')], style={'width': '33%', 'display': 'inline-block', 'margin-left': '2%', 'vertical-align': 'top'})
608
- ])
609
- ], style={'margin-top': '30px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
610
  ])
611
 
 
612
  @app.callback(
613
- [dash.dependencies.Output('taiex-prediction-results', 'children'),
614
- dash.dependencies.Output('taiex-prediction-chart', 'figure')],
615
- [dash.dependencies.Input('taiex-prediction-period', 'value')]
616
  )
617
  def update_taiex_prediction(predict_days):
618
- data = get_stock_data('^TWII', '2y')
619
- if data.empty: return html.Div("無法獲取台指期資料"), {}
620
-
621
- # === 呼叫 get_prediction 控制器,它會自動選擇模型 ===
622
- final_prediction = get_prediction(data, predict_days)
623
-
624
- if final_prediction is None: return html.Div("資料不足,無法進行預測"), {}
625
- current_price, last_date = data['Close'].iloc[-1], data.index[-1]
626
- predicted_price, change_pct, confidence = final_prediction['predicted_price'], final_prediction['change_pct'], final_prediction['confidence']
627
-
628
- prediction_paths = {1: [1], 5: [1, 5], 10: [1, 5, 10], 20: [1, 10, 20], 60: [1, 10, 20, 60]}
629
- intervals_to_predict = prediction_paths.get(predict_days, [predict_days])
630
- prediction_dates, prediction_prices = [last_date], [current_price]
631
-
632
- for days in intervals_to_predict:
633
- # === 迴圈內也使用統一的預測控制器 ===
634
- interim_prediction = get_prediction(data, days)
635
- if interim_prediction:
636
- prediction_dates.append(last_date + timedelta(days=days))
637
- prediction_prices.append(interim_prediction['predicted_price'])
638
-
639
- # (後續繪圖邏輯不變)
640
- color, arrow = ('red', '📈') if change_pct >= 0 else ('green', '📉')
641
- result_card = html.Div([
642
- html.H4(f"{predict_days}日後預測結果", style={'margin': '0 0 15px 0', 'color': 'white'}),
643
- 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'}),
644
- html.P(f"目前價格: {current_price:.2f}", style={'margin': '5px 0'}), html.P(f"預測價格: {predicted_price:.2f}", style={'margin': '5px 0'}),
645
- html.P(f"信心度: {confidence:.1%}", style={'margin': '5px 0', 'font-size': '14px'})
646
- ], style={'background': 'rgba(255,255,255,0.1)','padding': '20px','border-radius': '10px','border': '1px solid rgba(255,255,255,0.2)'})
647
- fig = go.Figure()
648
- recent_data = data.tail(30)
649
- fig.add_trace(go.Scatter(x=recent_data.index, y=recent_data['Close'], mode='lines', name='歷史價格', line=dict(color='#FFA726', width=2)))
650
- 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)))
651
- 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'))
652
- return result_card, fig
653
 
654
- @app.callback(
655
- dash.dependencies.Output('stock-info-cards', 'children'),
656
- [dash.dependencies.Input('stock-dropdown', 'value')]
657
- )
658
- def update_stock_info(selected_stock):
659
- data = get_stock_data(selected_stock, '5d')
660
- if data.empty: return html.Div("無法獲取股票資料")
661
- current_price = data['Close'].iloc[-1]
662
- prev_price = data['Close'].iloc[-2] if len(data) > 1 else current_price
663
- change = current_price - prev_price
664
- change_pct = (change / prev_price) * 100
665
- stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
666
- color, arrow = ('red', '▲') if change >= 0 else ('green', '▼')
667
- return html.Div([
668
- html.Div([
669
- html.H3(f"{stock_name} ({selected_stock})", style={'margin': '0'}),
670
- html.H2(f"${current_price:.2f}", style={'margin': '5px 0', 'color': color}),
671
- html.P(f"{arrow} {change:+.2f} ({change_pct:+.2f}%)", style={'margin': '0', 'color': color, 'font-weight': 'bold'})
672
- ], 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'}),
673
- html.Div([
674
- html.H4("今日統計", style={'margin': '0 0 10px 0'}),
675
- html.P(f"最高: ${data['High'].iloc[-1]:.2f}", style={'margin': '5px 0'}),
676
- html.P(f"最低: ${data['Low'].iloc[-1]:.2f}", style={'margin': '5px 0'}),
677
- html.P(f"成交量: {data['Volume'].iloc[-1]:,.0f}", style={'margin': '5px 0'})
678
- ], style={'background': 'white','padding': '20px','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)','display': 'inline-block'})
679
- ])
680
-
681
- @app.callback(
682
- dash.dependencies.Output('price-chart', 'figure'),
683
- [dash.dependencies.Input('stock-dropdown', 'value'),
684
- dash.dependencies.Input('period-dropdown', 'value'),
685
- dash.dependencies.Input('chart-type', 'value')]
686
- )
687
- # 修正後的 update_price_chart callback 函數的相關部分
688
- def update_price_chart_fixed(selected_stock, period, chart_type):
689
- data = get_stock_data(selected_stock, period)
690
- if data.empty:
691
- return {}
692
-
693
  data = calculate_technical_indicators(data)
694
- stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
695
-
696
- fig = make_subplots(rows=1, cols=2, shared_yaxes=True,
697
- column_widths=[0.8, 0.2], horizontal_spacing=0.01)
698
-
699
- if chart_type == 'candlestick':
700
- fig.add_trace(go.Candlestick(
701
- x=data.index,
702
- open=data['Open'],
703
- high=data['High'],
704
- low=data['Low'],
705
- close=data['Close'],
706
- name=stock_name,
707
- increasing_line_color='red',
708
- decreasing_line_color='green'
709
- ), row=1, col=1)
710
- else:
711
- fig.add_trace(go.Scatter(
712
- x=data.index,
713
- y=data['Close'],
714
- mode='lines',
715
- name=stock_name
716
- ), row=1, col=1)
717
-
718
- # 添加移動平均線
719
- fig.add_trace(go.Scatter(
720
- x=data.index,
721
- y=data['MA5'],
722
- mode='lines',
723
- name='MA5',
724
- line=dict(color='orange')
725
- ), row=1, col=1)
726
-
727
- fig.add_trace(go.Scatter(
728
- x=data.index,
729
- y=data['MA20'],
730
- mode='lines',
731
- name='MA20',
732
- line=dict(color='blue')
733
- ), row=1, col=1)
734
-
735
- # 修正後的 Volume Profile 計算
736
- bin_edges, volume_per_bin, price_centers = calculate_volume_profile(data, num_bins=50)
737
-
738
- if volume_per_bin is not None and price_centers is not None:
739
- fig.add_trace(go.Bar(
740
- orientation='h',
741
- y=price_centers,
742
- x=volume_per_bin,
743
- name='Volume Profile',
744
- text=[f'{vol/1000:.0f}k' for vol in volume_per_bin],
745
- textposition='auto',
746
- marker=dict(
747
- color='rgba(173, 216, 230, 0.6)',
748
- line=dict(color='rgba(30, 144, 255, 0.8)', width=1)
749
- )
750
- ), row=1, col=2)
751
-
752
- fig.update_layout(
753
- title_text=f'{stock_name} 股價走勢與成交量分佈',
754
- height=500,
755
- showlegend=True,
756
- xaxis1=dict(title='日期', type='date', rangeslider_visible=False),
757
- yaxis1=dict(title='價格 (TWD)'),
758
- xaxis2=dict(title='成交量', showticklabels=True),
759
- yaxis2=dict(showticklabels=False),
760
- bargap=0.05
761
- )
762
-
763
- return fig
764
 
765
- @app.callback(
766
- dash.dependencies.Output('advanced-technical-chart', 'figure'),
767
- [dash.dependencies.Input('technical-indicator-selector', 'value'),
768
- dash.dependencies.Input('stock-dropdown', 'value'),
769
- dash.dependencies.Input('period-dropdown', 'value')]
770
- )
771
- def update_advanced_technical_chart(indicator, selected_stock, period):
772
- data = get_stock_data(selected_stock, period)
773
- if data.empty: return {}
774
- data = calculate_technical_indicators(data)
775
- stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
776
- fig = go.Figure()
777
- if indicator == 'RSI':
778
- fig = go.Figure()
779
- fig.add_trace(go.Scatter(x=data.index, y=data['RSI'], mode='lines', name='RSI', line=dict(color='purple', width=2)))
780
- fig.add_hline(y=70, line_dash="dash", line_color="green", annotation_text="超買線(70)")
781
- fig.add_hline(y=30, line_dash="dash", line_color="red", annotation_text="超賣線(30)")
782
- fig.add_hline(y=50, line_dash="dot", line_color="gray", annotation_text="中線(50)")
783
- fig.update_layout(title=f'{stock_name} - RSI 相對強弱指標', xaxis_title='日期', yaxis_title='RSI', height=450, yaxis=dict(range=[0, 100]))
784
- elif indicator == 'MACD':
785
- fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1, row_heights=[0.7, 0.3], subplot_titles=('價格走勢', 'MACD 指標'))
786
- 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)
787
- 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)
788
- 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)
789
- colors = ['red' if x >= 0 else 'green' for x in data['MACD_Histogram']]
790
- fig.add_trace(go.Bar(x=data.index, y=data['MACD_Histogram'], name='MACD柱狀圖', marker_color=colors), row=2, col=1)
791
- fig.update_layout(title_text=f'{stock_name} - MACD 指數平滑異同移動平均線', height=550)
792
- elif indicator == 'BB':
793
  fig = go.Figure()
794
- fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價', line=dict(color='black', width=2)))
795
- fig.add_trace(go.Scatter(x=data.index, y=data['BB_Upper'], mode='lines', name='上軌', line=dict(color='red', width=1, dash='dash')))
796
- fig.add_trace(go.Scatter(x=data.index, y=data['BB_Middle'], mode='lines', name='中軌(MA20)', line=dict(color='blue', width=1)))
797
- fig.add_trace(go.Scatter(x=data.index, y=data['BB_Lower'], mode='lines', name='下軌', line=dict(color='green', width=1, dash='dash')))
798
- fig.update_layout(title=f'{stock_name} - 布林通道 (20日, 2σ)', xaxis_title='日期', yaxis_title='價格 (TWD)', height=450)
799
- elif indicator == 'KD':
800
- fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1, row_heights=[0.6, 0.4], subplot_titles=('價格走勢', 'KD指標'))
801
- fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價', line=dict(color='black', width=1)), row=1, col=1)
802
- 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)
803
- 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)
804
- fig.add_hline(y=80, line_dash="dash", line_color="green", annotation_text="超買線(80)", row=2, col=1)
805
- fig.add_hline(y=20, line_dash="dash", line_color="red", annotation_text="超賣線(20)", row=2, col=1)
806
- fig.update_layout(title=f'{stock_name} - KD 隨機指標 (9,3,3)', height=500, yaxis2_range=[0, 100])
807
- elif indicator == 'WR':
808
- fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1, row_heights=[0.6, 0.4], subplot_titles=('價格走勢', '威廉指標 %R'))
809
- fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價', line=dict(color='black', width=1)), row=1, col=1)
810
- 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)
811
- fig.add_hline(y=-20, line_dash="dash", line_color="green", annotation_text="超買線(-20)", row=2, col=1)
812
- fig.add_hline(y=-80, line_dash="dash", line_color="red", annotation_text="超賣線(-80)", row=2, col=1)
813
- fig.update_layout(title=f'{stock_name} - 威廉指標 %R (14日)', height=500, yaxis2_range=[-100, 0])
814
- elif indicator == 'DMI':
815
- fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1, row_heights=[0.6, 0.4], subplot_titles=('價格走勢', 'DMI 指標'))
816
- data_filtered = data.iloc[14:]
817
- 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)
818
- 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)
819
- 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)
820
- 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)
821
- fig.update_layout(title=f'{stock_name} - DMI 動向指標 (14日)', height=500, showlegend=True, yaxis2_range=[0, 100])
822
- return fig
823
 
 
824
  @app.callback(
825
- dash.dependencies.Output('volume-chart', 'figure'),
826
- [dash.dependencies.Input('stock-dropdown', 'value'),
827
- dash.dependencies.Input('period-dropdown', 'value')]
828
  )
829
- def update_volume_chart(selected_stock, period):
830
- data = get_stock_data(selected_stock, period)
831
- if data.empty: return {}
832
- stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
833
- colors = ['red' if data['Close'].iloc[i] > data['Open'].iloc[i] else 'green' for i in range(len(data))]
834
- fig = go.Figure(go.Bar(x=data.index, y=data['Volume'], marker_color=colors, name='成交量'))
835
- fig.update_layout(title=f'{stock_name} 成交量', xaxis_title='日期', yaxis_title='成交量', height=300)
836
- return fig
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
837
 
838
- @app.callback(
839
- dash.dependencies.Output('industry-analysis', 'figure'),
840
- [dash.dependencies.Input('stock-dropdown', 'value')]
841
- )
842
- def update_industry_analysis(selected_stock):
843
- performance_data = []
844
- for name, symbol in TAIWAN_STOCKS.items():
845
- data = get_stock_data(symbol, '1mo')
846
- if not data.empty and len(data) > 1:
847
- return_pct = ((data['Close'].iloc[-1] - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
848
- performance_data.append({
849
- '股票': name,
850
- '代碼': symbol,
851
- '月報酬率(%)': return_pct,
852
- '絕對波動': abs(return_pct)
853
- })
854
- if not performance_data:
855
- fig = go.Figure().add_annotation(text="無法計算產業資料", showarrow=False)
856
- fig.update_layout(title="近一月市場波動最大標的", height=400)
857
- return fig
858
- df_performance = pd.DataFrame(performance_data)
859
- df_top_movers = df_performance.sort_values(by='絕對波動', ascending=False).head(10)
860
- fig = px.pie(
861
- df_top_movers,
862
- values='絕對波動',
863
- names='股票',
864
- title='近一月市場波動最大 Top 10 標的',
865
- hover_data={'月報酬率(%)': ':.2f'}
866
- )
867
- fig.update_traces(
868
- textposition='inside',
869
- textinfo='percent+label',
870
- hovertemplate="<b>%{label}</b><br>月報酬率: %{customdata[0]:.2f}%<extra></extra>"
871
- )
872
- fig.update_layout(height=400, showlegend=False)
873
- return fig
874
 
 
 
875
  @app.callback(
876
- dash.dependencies.Output('business-climate-chart', 'figure'),
877
- [dash.dependencies.Input('stock-dropdown', 'value')]
878
  )
879
- def update_business_climate_chart(selected_stock):
880
  df = get_business_climate_data()
881
- if df.empty:
882
- fig = go.Figure().add_annotation(text="無法載入景氣燈號資料", showarrow=False)
883
- fig.update_layout(title="台灣景氣燈號", height=300)
884
  return fig
885
- def get_light_color(score):
886
- if score >= 32: return 'red'
887
- elif score >= 24: return 'orange'
888
- elif score >= 17: return 'yellow'
889
- elif score >= 10: return 'lightgreen'
890
- else: return 'blue'
891
- colors = [get_light_color(score) for score in df['Index']]
892
- fig = go.Figure()
893
- 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'))))
894
- fig.add_hline(y=32, line_dash="dash", line_color="red", annotation_text="紅燈(32)")
895
- fig.add_hline(y=17, line_dash="dash", line_color="yellow", annotation_text="黃燈(17)")
896
- fig.update_layout(title="台灣景氣燈號走勢", xaxis_title='日期', yaxis_title='燈號分數', height=300, yaxis=dict(range=[0, 40]))
897
- return fig
898
 
 
899
  @app.callback(
900
- [dash.dependencies.Output('technical-analysis-text', 'children'),
901
- dash.dependencies.Output('fundamental-analysis-text', 'children'),
902
- dash.dependencies.Output('market-outlook-text', 'children')],
903
- [dash.dependencies.Input('stock-dropdown', 'value'),
904
- dash.dependencies.Input('period-dropdown', 'value')]
905
  )
906
- def update_analysis_text(selected_stock, period):
907
- cache_key = f"{selected_stock}-{period}"
908
- current_time = time.time()
909
-
910
- if cache_key in ANALYSIS_CACHE:
911
- cached_data = ANALYSIS_CACHE[cache_key]
912
- if current_time - cached_data['timestamp'] < CACHE_DURATION_SECONDS:
913
- print(f"從快取載入分析: {cache_key}")
914
- return cached_data['technical'], cached_data['fundamental'], cached_data['outlook']
915
-
916
- print(f"重新生成分析: {cache_key}")
917
- data = get_stock_data(selected_stock, period)
918
- stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
919
- if data.empty or len(data) < 20:
920
- return "資料不足,無法分析", "資料不足,無法分析", "資料不足,無法分析"
921
-
922
- data = calculate_technical_indicators(data)
923
-
924
- price_change = ((data['Close'].iloc[-1] - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
925
- rsi_current = data['RSI'].iloc[-1] if not pd.isna(data['RSI'].iloc[-1]) else 50
926
- macd_current = data['MACD'].iloc[-1] if not pd.isna(data['MACD'].iloc[-1]) else 0
927
- macd_signal_current = data['MACD_Signal'].iloc[-1] if not pd.isna(data['MACD_Signal'].iloc[-1]) else 0
928
-
929
- technical_text = html.Div([
930
- 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}%。"]),
931
- 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'}), "。"]),
932
- 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 '空頭'}。"]),
933
- ])
934
-
935
- fundamental_text, market_outlook_text = generate_gemini_analysis(stock_name, selected_stock, period, data)
936
-
937
- ANALYSIS_CACHE[cache_key] = {
938
- 'technical': technical_text,
939
- 'fundamental': fundamental_text,
940
- 'outlook': market_outlook_text,
941
- 'timestamp': current_time
942
- }
943
-
944
- return technical_text, fundamental_text, market_outlook_text
945
-
946
- @app.callback(
947
- dash.dependencies.Output('pmi-chart', 'figure'),
948
- [dash.dependencies.Input('stock-dropdown', 'value')]
949
- )
950
- def update_pmi_chart(selected_stock):
951
  df = get_pmi_data()
952
- if df.empty:
953
- fig = go.Figure().add_annotation(text="無法載入PMI資料", showarrow=False)
954
- fig.update_layout(title="台灣PMI指數", height=300)
955
  return fig
956
- colors = ['red' if value >= 50 else 'green' for value in df['Index']]
957
- fig = go.Figure()
958
- 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'))))
959
- fig.add_hline(y=50, line_dash="dash", line_color="black", annotation_text="榮枯線(50)")
960
- fig.update_layout(title="台灣PMI指數走勢", xaxis_title='日期', yaxis_title='PMI指數', height=300, yaxis=dict(range=[35, 60]))
961
- return fig
962
-
963
- def summarize_news_with_gemini(news_list: list) -> str:
964
- """
965
- 使用 Gemini API 將英文新聞標題列表摘要成一段繁體中文。
966
- """
967
- api_key = os.getenv("GEMINI_API_KEY")
968
- if not api_key:
969
- return "錯誤:找不到 GEMINI_API_KEY。請在 Hugging Face Secrets 中設定。"
970
-
971
- try:
972
- genai.configure(api_key=api_key)
973
- model = genai.GenerativeModel('gemini-1.5-flash')
974
-
975
- formatted_news = "\n".join([f"- {news}" for news in news_list])
976
-
977
- prompt = f"""
978
- 請扮演一位專業的金融市場分析師。
979
- 以下是幾則最新的英文財經新聞標題,請將它們整合成一段簡潔、流暢、約 200 字的繁體中文市場動態摘要,與利多哪些產業,利空哪些產業。
980
- 提供3段重點,
981
- 請專注於可能影響市場情緒和股價的關鍵資訊,並直接提供摘要內容,不要包含任何額外的問候語或說明。
982
-
983
- 英文新聞標題如下:
984
- {formatted_news}
985
- """
986
-
987
- response = model.generate_content(prompt)
988
- return response.text
989
-
990
- except Exception as e:
991
- print(f"呼叫 Gemini API 時發生錯誤: {e}")
992
- return f"無法生成新聞摘要,請稍後再試。錯誤訊息:{e}"
993
 
 
994
  @app.callback(
995
- [dash.dependencies.Output('comparison-chart', 'figure'),
996
- dash.dependencies.Output('comparison-table', 'children')],
997
- [dash.dependencies.Input('comparison-stocks', 'value'),
998
- dash.dependencies.Input('comparison-period', 'value')]
 
 
999
  )
1000
- def update_comparison_analysis(selected_stocks, period):
1001
- fixed_stock = '0050.TW'
1002
- if not selected_stocks: selected_stocks = [fixed_stock]
1003
- elif fixed_stock not in selected_stocks: selected_stocks.insert(0, fixed_stock)
1004
- selected_stocks = selected_stocks[:5]
1005
- fig = go.Figure()
1006
- comparison_data = []
1007
- for stock in selected_stocks:
1008
- data = get_stock_data(stock, period)
1009
- if not data.empty:
1010
- stock_name = next((name for name, symbol in TAIWAN_STOCKS.items() if symbol == stock), stock)
1011
- normalized_prices = (data['Close'] / data['Close'].iloc[0]) * 100
1012
- fig.add_trace(go.Scatter(x=data.index, y=normalized_prices, mode='lines', name=stock_name, line=dict(width=2)))
1013
- total_return = ((data['Close'].iloc[-1] / data['Close'].iloc[0]) - 1) * 100
1014
- volatility = data['Close'].pct_change().std() * np.sqrt(252) * 100
1015
- comparison_data.append({'name': stock_name, 'return': total_return, 'volatility': volatility, 'current_price': data['Close'].iloc[-1]})
1016
- fig.update_layout(title=f'股票績效比較 - {period}', xaxis_title='日期', yaxis_title='相對績效 (基期=100)', height=400, hovermode='x unified')
1017
- if comparison_data:
1018
- table_rows = []
1019
- for item in sorted(comparison_data, key=lambda x: x['return'], reverse=True):
1020
- color = 'red' if item['return'] > 0 else 'green'
1021
- 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}")]))
1022
- table = html.Table([html.Thead(html.Tr([html.Th("股票"), html.Th("報酬率"), html.Th("波動率"), html.Th("現價")])), html.Tbody(table_rows)], style={'width': '100%'})
1023
- return fig, table
1024
- return fig, html.Div("無可比較資料")
1025
 
1026
- @app.callback(
1027
- [dash.dependencies.Output('sentiment-gauge', 'children'),
1028
- dash.dependencies.Output('news-summary', 'children')],
1029
- [dash.dependencies.Input('stock-dropdown', 'value')]
1030
- )
1031
- def update_sentiment_analysis(selected_stock):
1032
- if predictor is None:
1033
- error_fig = go.Figure().add_annotation(text="情緒指標模型載入失敗", showarrow=False)
1034
- error_fig.update_layout(height=200)
1035
- return dcc.Graph(figure=error_fig), html.P("新聞分析模型載入失敗,請檢查後台日誌。")
1036
 
1037
- sentiment_score_raw = predictor.get_news_index()
 
 
 
 
1038
 
1039
- if sentiment_score_raw is not None:
1040
- sentiment_score_normalized = (sentiment_score_raw + 1) * 50
1041
- sentiment_score_normalized = max(0, min(100, sentiment_score_normalized))
1042
- if sentiment_score_normalized >= 65:
1043
- bar_color, level_text = "#5cb85c", "樂觀"
1044
- elif sentiment_score_normalized >= 35:
1045
- bar_color, level_text = "#f0ad4e", "中性"
1046
- else:
1047
- bar_color, level_text = "#d9534f", "悲觀"
1048
- gauge_fig = go.Figure(go.Indicator(
1049
- mode = "gauge+number", value = sentiment_score_normalized,
1050
- domain = {'x': [0, 1], 'y': [0, 1]},
1051
- title = {'text': f"昨日市場情緒: {level_text}", 'font': {'size': 18}},
1052
- gauge = {'axis': {'range': [0, 100]}, 'bar': {'color': bar_color},
1053
- 'steps': [{'range': [0, 35], 'color': "rgba(217, 83, 79, 0.2)"},
1054
- {'range': [35, 65], 'color': "rgba(240, 173, 78, 0.2)"},
1055
- {'range': [65, 100], 'color': "rgba(92, 184, 92, 0.2)"}]}
1056
- ))
1057
- gauge_fig.update_layout(height=200, margin=dict(l=30, r=30, t=50, b=20))
1058
- gauge_content = dcc.Graph(figure=gauge_fig)
1059
  else:
1060
- error_fig = go.Figure().add_annotation(text="今日尚無情緒分數", showarrow=False)
1061
- error_fig.update_layout(height=200)
1062
- gauge_content = dcc.Graph(figure=error_fig)
1063
 
1064
- top_news_list = predictor.get_news()
1065
- news_content = None
1066
-
1067
- if top_news_list and isinstance(top_news_list, list):
1068
- summary_text = summarize_news_with_gemini(top_news_list)
1069
- news_content = dcc.Markdown(summary_text, style={
1070
- 'margin': '8px 0', 'padding-left': '5px',
1071
- 'font-size': '15px', 'line-height': '1.7'
1072
- })
1073
- elif top_news_list == []:
1074
- news_content = html.P("昨日無重大相關新聞。", style={'text-align': 'center', 'padding-top': '50px'})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1075
  else:
1076
- news_content = html.P("讀取新聞時發生錯誤。", style={'text-align': 'center', 'padding-top': '50px'})
1077
-
1078
- return gauge_content, news_content
 
 
 
 
 
 
1079
 
1080
- # 主程式執行
1081
  if __name__ == '__main__':
1082
- app.run(host="0.0.0.0", port=7860, debug=False)
 
7
  import pandas as pd
8
  import numpy as np
9
  import yfinance as yf
10
+ from dash import Dash, dcc, html, callback, Input, Output, State
11
  import dash
12
  import plotly.express as px
13
  import plotly.graph_objects as go
 
39
  # 【修改 3】: 在應用程式啟動時,預先載入 XGBoost 模型
40
  try:
41
  print("正在初始化 XGBoost 預測模型...")
42
+ # 使用 model_predictor.py 中的 XGBoostModel 類別
43
  xgb_model = XGBoostModel(default_model='xgboost_model')
44
  print("XGBoost 預測模型初始化成功。")
45
  except Exception as e:
 
119
  change_pct = ((predicted_price - prices[-1]) / prices[-1]) * 100
120
  return {'predicted_price': predicted_price, 'change_pct': change_pct, 'confidence': max(0.6, 1 - volatility * 2)}
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  def advanced_xgboost_predict(data, predict_days):
123
  """
124
  【進階模型橋接函式】
 
133
  print("正在準備 XGBoost 模型所需的20個輸入特徵...")
134
 
135
  # 1. 獲取外部市場數據
 
136
  start_date = data.index.min() - pd.Timedelta(days=5) # 提前幾天以確保數據填充
137
  end_date = data.index.max()
138
 
 
 
139
  external_symbols = {'DJI': '^DJI', 'NAS': '^IXIC', 'SOX': '^SOX', 'S&P_500': '^GSPC', 'TSM_ADR': 'TSM'}
140
 
141
  # 使用 yf.download 一次性獲取所有外部數據
142
  ext_data = yf.download(list(external_symbols.values()), start=start_date, end=end_date, progress=False)['Close']
143
  ext_data.rename(columns={v: k for k, v in external_symbols.items()}, inplace=True)
144
 
145
+ # 2. 合併主數據與外部數據
 
146
  input_df = data.join(ext_data, how='left')
147
 
148
+ # 3. 計算技術指標
149
+ input_df = calculate_technical_indicators(input_df)
 
150
 
151
+ # 4. 新增其他特徵 (利率, 新聞情緒, PMI, 景氣燈號)
152
+ input_df['rate'] = 1.75 # 注意:此為利率佔位符,請替換為真實數據源
153
+ news_score = predictor.get_news_index() if predictor else 0
154
+ input_df['NEWS'] = news_score if news_score is not None else 0
 
 
 
155
 
156
+ # 【修改】: 獲取最新月份的單一數值
157
+ input_df['business_climate'] = get_business_climate_data()
158
+ input_df['PMI'] = get_pmi_data()
159
+
160
+ # 5. 數據清洗與格式化
161
  # 向前填充所有缺失值 (例如假日)
162
  input_df.ffill(inplace=True)
163
  input_df.bfill(inplace=True) # 向後填充開頭可能存在的缺失值
164
 
 
 
 
 
 
 
 
165
  input_df.rename(columns={
166
  'Close': 'close', 'Volume': 'volume', 'MACD_Signal': 'MACDsign',
167
  'MACD_Histogram': 'MACDvol'
 
185
  print(f"警告: 最終輸入數據中存在缺失值,無法預測。\n{final_input.isnull().sum()}")
186
  return None
187
 
188
+ # 6. 呼叫模型預測
189
  print("特徵準備完成,呼叫 XGBoost 模型...")
190
  predictions = xgb_model.predict('xgboost_model', final_input)
191
 
192
+ # 根據預測天期選擇對應的輸出欄位
193
+ if predict_days <= 1:
194
+ pred_col = 'Close_t1_pred'
195
+ elif predict_days <= 5:
196
+ pred_col = 'Close_t5_pred'
197
+ elif predict_days <= 10:
198
+ pred_col = 'Close_t10_pred'
199
+ else: # predict_days >= 20
200
+ pred_col = 'Close_t20_pred'
201
+
202
+ predicted_price = predictions[pred_col]
203
  current_price = data['Close'].iloc[-1]
204
  change_pct = ((predicted_price - current_price) / current_price) * 100
205
 
 
221
  """
222
  if USE_ADVANCED_MODEL:
223
  print(f"模式: 進階XGBoost模型 | 預測天期: {predict_days}天")
 
224
  prediction = advanced_xgboost_predict(data, predict_days)
 
225
  if prediction is not None:
226
  return prediction
227
  else:
228
+ print("進階模型預測失敗,自動降級為簡易統計模型。")
229
 
230
  # 預設或降級時執行簡易模型
231
  print(f"模式: 簡易統計模型 | 預測天期: {predict_days}天")
 
273
  if df.empty or 'High' not in df.columns or 'Low' not in df.columns or 'Volume' not in df.columns:
274
  return None, None, None
275
 
 
276
  df_clean = df.dropna(subset=['High', 'Low', 'Close', 'Volume'])
277
  if df_clean.empty:
278
  return None, None, None
 
283
  if min_price == max_price:
284
  return None, None, None
285
 
 
286
  price_for_volume = (df_clean['High'] + df_clean['Low'] + df_clean['Close']) / 3
287
 
288
  try:
 
298
  print(f"Volume profile 計算錯誤: {e}")
299
  return None, None, None
300
 
301
+ def get_business_climate_data(get_latest_value=False):
302
  try:
303
+ if not os.path.exists('business_climate.csv'): return None
304
+ df = pd.read_csv('business_climate.csv', index_col='Date', parse_dates=True)
305
+ if get_latest_value:
306
+ return df['Index'].iloc[-1]
 
 
 
307
  return df
308
  except Exception as e:
309
  print(f"無法獲取景氣燈號資料: {str(e)}")
310
+ return None if get_latest_value else pd.DataFrame()
311
 
312
+ def get_pmi_data(get_latest_value=False):
313
  try:
314
+ if not os.path.exists('taiwan_pmi.csv'): return None
315
+ df = pd.read_csv('taiwan_pmi.csv', index_col='Date', parse_dates=True)
316
+ if get_latest_value:
317
+ return df['Index'].iloc[-1]
 
 
 
 
318
  return df
319
  except Exception as e:
320
  print(f"無法獲取 PMI 資料: {str(e)}")
321
+ return None if get_latest_value else pd.DataFrame()
322
+
323
 
324
  def generate_gemini_analysis(stock_name, stock_symbol, period, data):
325
  """
 
374
  market_outlook = parts[1].strip()
375
  return dcc.Markdown(fundamental_analysis), dcc.Markdown(market_outlook)
376
  else:
 
377
  return dcc.Markdown("無法解析 Gemini 回應,請稍後再試。"), dcc.Markdown(response.text)
378
 
379
  except Exception as e:
 
383
 
384
  # 建立 Dash 應用程式
385
  app = dash.Dash(__name__, suppress_callback_exceptions=True)
386
+ server = app.server
387
 
388
  try:
389
  print("正在初始化新聞情緒分析模型...")
 
411
  html.Div(id='taiex-prediction-results', style={'width': '65%', 'display': 'inline-block', 'margin-left': '5%'})
412
  ]),
413
  html.Div([dcc.Graph(id='taiex-prediction-chart')], style={'margin-top': '20px'})
414
+ ], style={
415
+ 'background': 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
416
+ 'padding': '25px',
417
+ 'border-radius': '15px',
418
+ 'box-shadow': '0 8px 25px rgba(0,0,0,0.15)',
419
+ 'color': 'white',
420
+ 'margin-bottom': '40px'
421
+ }),
422
+
423
  html.Div([
424
  html.H3("📰 市場情緒與新聞分析", style={'color': '#E74C3C', 'margin-bottom': '20px'}),
425
+ dcc.Interval(id='news-interval-component', interval=30*60*1000, n_intervals=0), # 30分鐘更新一次
426
  html.Div([
427
  html.Div([
428
  html.H4("市場情緒指標", style={'color': '#8E44AD'}),
 
442
  html.Div([dcc.Graph(id='pmi-chart')], style={'width': '48%', 'display': 'inline-block', 'margin-left': '2%'})
443
  ])
444
  ], style={'margin-top': '30px'}),
445
+
446
  html.Div([
447
  html.Div([
448
  html.Label("選擇股票:"),
449
+ dcc.Dropdown(id='stock-dropdown',
450
+ options=[{'label': name, 'value': symbol} for name, symbol in TAIWAN_STOCKS.items()],
451
+ value='0050.TW',
452
+ style={'margin-bottom': '10px'})
453
  ], style={'width': '30%', 'display': 'inline-block', 'vertical-align': 'top'}),
454
  html.Div([
455
  html.Label("時間範圍:"),
456
  dcc.Dropdown(id='period-dropdown',
457
+ options=[
458
+ {'label': '1個月', 'value': '1mo'},{'label': '3個月', 'value': '3mo'},
459
+ {'label': '6個月', 'value': '6mo'},{'label': '1年', 'value': '1y'},
460
+ {'label': '2年', 'value': '2y'}],
461
+ value='1mo',
462
+ style={'margin-bottom': '10px'})
463
  ], style={'width': '30%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'}),
464
  html.Div([
465
  html.Label("圖表類型:"),
466
+ dcc.Dropdown(id='chart-type-dropdown',
467
+ options=[
468
+ {'label': 'K線圖', 'value': 'candlestick'},
469
+ {'label': '折線圖', 'value': 'line'}],
470
+ value='candlestick',
471
+ style={'margin-bottom': '10px'})
472
+ ], style={'width': '30%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'}),
473
+ ]),
474
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
475
  html.Div([
476
+ dcc.Graph(id='stock-chart'),
477
  html.Div([
478
+ html.H4("AI 智慧分析", style={'text-align': 'center', 'margin-top': '20px', 'margin-bottom': '10px'}),
479
+ html.Div(id='gemini-fundamental-analysis', style={'width': '48%', 'display': 'inline-block', 'background': '#f8f9fa', 'padding': '15px', 'border-radius': '8px'}),
480
+ html.Div(id='gemini-market-outlook', style={'width': '48%', 'display': 'inline-block', 'margin-left': '4%', 'background': '#f8f9fa', 'padding': '15px', 'border-radius': '8px'})
481
+ ], style={'margin-top': '20px'})
482
+ ]),
 
 
 
 
 
 
 
 
 
 
483
  ])
484
 
485
+ # 更新台指期預測
486
  @app.callback(
487
+ [Output('taiex-prediction-results', 'children'),
488
+ Output('taiex-prediction-chart', 'figure')],
489
+ [Input('taiex-prediction-period', 'value')]
490
  )
491
  def update_taiex_prediction(predict_days):
492
+ data = get_stock_data('TXF=F', '2y')
493
+ if data.empty:
494
+ return "無法獲取台指期數據", go.Figure()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
495
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
496
  data = calculate_technical_indicators(data)
497
+ prediction = get_prediction(data, predict_days)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498
 
499
+ if prediction:
500
+ current_price = data['Close'].iloc[-1]
501
+ predicted_price = prediction['predicted_price']
502
+ change_pct = prediction['change_pct']
503
+ confidence = prediction['confidence']
504
+
505
+ results_text = f"""
506
+ 預測 {predict_days} 日後指數: **{predicted_price:.2f}**
507
+ (相較於目前 {current_price:.2f},預計變動 **{change_pct:+.2f}%**,信心指數: {confidence:.0%})
508
+ """
509
+
510
+ future_date = data.index[-1] + timedelta(days=predict_days)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
511
  fig = go.Figure()
512
+ fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='歷史指數'))
513
+ fig.add_trace(go.Scatter(x=[data.index[-1], future_date],
514
+ y=[current_price, predicted_price],
515
+ mode='lines+markers', name='預測趨勢',
516
+ line=dict(dash='dot', color='orange')))
517
+ fig.update_layout(title_text=f"台指期未來 {predict_days} 日趨勢預測",
518
+ xaxis_title="日期", yaxis_title="指數",
519
+ legend=dict(x=0.01, y=0.99))
520
+ return dcc.Markdown(results_text), fig
521
+ else:
522
+ return "模型預測失敗", go.Figure()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
523
 
524
+ # 更新新聞情緒和摘要
525
  @app.callback(
526
+ [Output('sentiment-gauge', 'children'),
527
+ Output('news-summary', 'children')],
528
+ [Input('news-interval-component', 'n_intervals')]
529
  )
530
+ def update_news_sentiment(n):
531
+ if not predictor:
532
+ return "情緒模型未載入", "新聞摘要無法使用"
533
+
534
+ sentiment_score = predictor.get_news_index()
535
+ if sentiment_score is not None:
536
+ level_text = "極度恐慌" if sentiment_score < 20 else "恐慌" if sentiment_score < 40 else "中性" if sentiment_score < 60 else "樂觀" if sentiment_score < 80 else "極度樂觀"
537
+ bar_color = "red" if sentiment_score < 40 else "orange" if sentiment_score < 60 else "green"
538
+
539
+ gauge_fig = go.Figure(go.Indicator(
540
+ mode = "gauge+number",
541
+ value = sentiment_score,
542
+ domain = {'x': [0, 1], 'y': [0, 1]},
543
+ title = {'text': f"昨日市場情緒: {level_text}", 'font': {'size': 18}},
544
+ gauge = {'axis': {'range': [0, 100]}, 'bar': {'color': bar_color},
545
+ 'steps': [{'range': [0, 35], 'color': "rgba(217, 83, 79, 0.2)"},
546
+ {'range': [35, 65], 'color': "rgba(240, 173, 78, 0.2)"},
547
+ {'range': [65, 100], 'color': "rgba(92, 184, 92, 0.2)"}]}))
548
+ gauge_fig.update_layout(height=200, margin=dict(l=30, r=30, t=50, b=20))
549
+ gauge_content = dcc.Graph(figure=gauge_fig)
550
+ else:
551
+ error_fig = go.Figure().add_annotation(text="今日尚無情緒分數", showarrow=False)
552
+ error_fig.update_layout(height=200)
553
+ gauge_content = dcc.Graph(figure=error_fig)
554
 
555
+ top_news_list = predictor.get_news()
556
+ news_content = None
557
+
558
+ if top_news_list and isinstance(top_news_list, list):
559
+ summary_text = "\n\n".join([f"- [{news['title']}]({news['link']})" for news in top_news_list])
560
+ news_content = dcc.Markdown(summary_text)
561
+ else:
562
+ news_content = html.P("無法獲取新聞摘要。")
563
+
564
+ return gauge_content, news_content
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
565
 
566
+
567
+ # 更新景氣燈號圖表
568
  @app.callback(
569
+ Output('business-climate-chart', 'figure'),
570
+ [Input('stock-dropdown', 'value')] # 隨便觸發即可
571
  )
572
+ def update_business_climate_chart(_):
573
  df = get_business_climate_data()
574
+ if not df.empty:
575
+ fig = px.line(df, x=df.index, y='Index', title='台灣景氣對策信號')
 
576
  return fig
577
+ return go.Figure()
 
 
 
 
 
 
 
 
 
 
 
 
578
 
579
+ # 更新PMI圖表
580
  @app.callback(
581
+ Output('pmi-chart', 'figure'),
582
+ [Input('stock-dropdown', 'value')] # 隨便觸發即可
 
 
 
583
  )
584
+ def update_pmi_chart(_):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
585
  df = get_pmi_data()
586
+ if not df.empty:
587
+ fig = px.line(df, x=df.index, y='Index', title='台灣PMI指數')
588
+ fig.add_hline(y=50, line_dash="dot", annotation_text="榮枯線 (50)", annotation_position="bottom right")
589
  return fig
590
+ return go.Figure()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
591
 
592
+ # 更新個股圖表和Gemini分析
593
  @app.callback(
594
+ [Output('stock-chart', 'figure'),
595
+ Output('gemini-fundamental-analysis', 'children'),
596
+ Output('gemini-market-outlook', 'children')],
597
+ [Input('stock-dropdown', 'value'),
598
+ Input('period-dropdown', 'value'),
599
+ Input('chart-type-dropdown', 'value')]
600
  )
601
+ def update_main_chart_and_analysis(stock_symbol, period, chart_type):
602
+ stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == stock_symbol][0]
603
+ data = get_stock_data(stock_symbol, period)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
604
 
605
+ if data.empty:
606
+ return go.Figure().update_layout(title=f"無法獲取 {stock_name} 的資料"), "無法生成分析", "無法生成分析"
 
 
 
 
 
 
 
 
607
 
608
+ data = calculate_technical_indicators(data)
609
+
610
+ # 圖表製作
611
+ fig = make_subplots(rows=4, cols=1, shared_xaxes=True, vertical_spacing=0.03,
612
+ row_heights=[0.5, 0.15, 0.15, 0.2])
613
 
614
+ if chart_type == 'candlestick':
615
+ fig.add_trace(go.Candlestick(x=data.index, open=data['Open'], high=data['High'],
616
+ low=data['Low'], close=data['Close'], name='K線'), row=1, col=1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
617
  else:
618
+ fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價'), row=1, col=1)
 
 
619
 
620
+ fig.add_trace(go.Scatter(x=data.index, y=data['MA5'], mode='lines', name='5日均線', line=dict(width=1)), row=1, col=1)
621
+ fig.add_trace(go.Scatter(x=data.index, y=data['MA20'], mode='lines', name='20日均線', line=dict(width=1)), row=1, col=1)
622
+
623
+ # 交易量
624
+ fig.add_trace(go.Bar(x=data.index, y=data['Volume'], name='交易量'), row=2, col=1)
625
+
626
+ # RSI
627
+ fig.add_trace(go.Scatter(x=data.index, y=data['RSI'], mode='lines', name='RSI'), row=3, col=1)
628
+ fig.add_hline(y=70, line_dash="dot", row=3, col=1, line_color="red")
629
+ fig.add_hline(y=30, line_dash="dot", row=3, col=1, line_color="green")
630
+
631
+ # MACD
632
+ fig.add_trace(go.Bar(x=data.index, y=data['MACD_Histogram'], name='MACD柱'), row=4, col=1)
633
+ fig.add_trace(go.Scatter(x=data.index, y=data['MACD'], mode='lines', name='MACD線'), row=4, col=1)
634
+ fig.add_trace(go.Scatter(x=data.index, y=data['MACD_Signal'], mode='lines', name='信號線'), row=4, col=1)
635
+
636
+ fig.update_layout(title_text=f"{stock_name} ({stock_symbol}) - 技術分析",
637
+ xaxis_rangeslider_visible=False,
638
+ height=700)
639
+ fig.update_yaxes(title_text="價格", row=1, col=1)
640
+ fig.update_yaxes(title_text="交易量", row=2, col=1)
641
+ fig.update_yaxes(title_text="RSI", row=3, col=1)
642
+ fig.update_yaxes(title_text="MACD", row=4, col=1)
643
+
644
+ # Gemini 分析
645
+ cache_key = f"{stock_symbol}-{period}"
646
+ current_time = time.time()
647
+
648
+ if cache_key in ANALYSIS_CACHE and (current_time - ANALYSIS_CACHE[cache_key]['timestamp']) < CACHE_DURATION_SECONDS:
649
+ print(f"從快取載入 {stock_name} 的分析...")
650
+ fundamental_analysis = ANALYSIS_CACHE[cache_key]['fundamental']
651
+ market_outlook = ANALYSIS_CACHE[cache_key]['outlook']
652
  else:
653
+ print(f" {stock_name} 生成新的 Gemini 分析...")
654
+ fundamental_analysis, market_outlook = generate_gemini_analysis(stock_name, stock_symbol, period, data)
655
+ ANALYSIS_CACHE[cache_key] = {
656
+ 'fundamental': fundamental_analysis,
657
+ 'outlook': market_outlook,
658
+ 'timestamp': current_time
659
+ }
660
+
661
+ return fig, fundamental_analysis, market_outlook
662
 
663
+ # 啟動伺服器
664
  if __name__ == '__main__':
665
+ app.run_server(debug=True)