AlanRex commited on
Commit
ea2ba86
·
verified ·
1 Parent(s): 40923c7

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +103 -77
app.py CHANGED
@@ -3,7 +3,7 @@
3
  # 系統套件
4
  import os
5
  from datetime import datetime, timedelta
6
- import google.generativeai as genai # <--- 【新增】引用 Gemini
7
  import pandas as pd
8
  import numpy as np
9
  import yfinance as yf
@@ -21,7 +21,7 @@ from Bert_predict import BertPredictor
21
 
22
  # 台股代號對應表 (移除台指期,因為它現在是獨立區塊)
23
  TAIWAN_STOCKS = {
24
- '元大台灣50': '0050.TW', # 新增
25
  '台積電': '2330.TW',
26
  '聯發科': '2454.TW',
27
  '鴻海': '2317.TW',
@@ -46,7 +46,7 @@ TAIWAN_STOCKS = {
46
 
47
  # 產業分類
48
  INDUSTRY_MAPPING = {
49
- '0050.TW': 'ETF', # 新增
50
  '2330.TW': '半導體',
51
  '2454.TW': '半導體',
52
  '2317.TW': '電子組件',
@@ -63,7 +63,6 @@ INDUSTRY_MAPPING = {
63
  '1101.TW': '營建',
64
  '2408.TW': 'DRAM',
65
  '2337.TW': 'NFLSH',
66
- '1101.TW': '營建',
67
  '4966.TWO': '高速傳輸',
68
  '3665.TW': '連接器',
69
  '6870.TWO': '軟體整合',
@@ -190,10 +189,72 @@ def get_pmi_data():
190
  print(f"無法獲取 PMI 資料: {str(e)}")
191
  return pd.DataFrame()
192
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  # 建立 Dash 應用程式
194
  app = dash.Dash(__name__, suppress_callback_exceptions=True)
195
 
196
- # --- 【新增】在程式啟動時,初始化 BERT 新聞預測器 ---
197
  try:
198
  print("正在初始化新聞情緒分析模型...")
199
  predictor = BertPredictor(max_news_per_keyword=5)
@@ -222,7 +283,6 @@ app.layout = html.Div([
222
  html.Div([dcc.Graph(id='taiex-prediction-chart')], style={'margin-top': '20px'})
223
  ], 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'}),
224
 
225
- # 新聞情感分析區域
226
  html.Div([
227
  html.H3("📰 市場情緒與新聞分析", style={'color': '#E74C3C', 'margin-bottom': '20px'}),
228
  html.Div([
@@ -254,7 +314,7 @@ app.layout = html.Div([
254
  html.Label("時間範圍:"),
255
  dcc.Dropdown(id='period-dropdown',
256
  options=[{'label': '1個月', 'value': '1mo'},{'label': '3個月', 'value': '3mo'},{'label': '6個月', 'value': '6mo'},{'label': '1年', 'value': '1y'},{'label': '2年', 'value': '2y'}],
257
- value='6mo', style={'margin-bottom': '10px'})
258
  ], style={'width': '30%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'}),
259
  html.Div([
260
  html.Label("圖表類型:"),
@@ -285,12 +345,12 @@ app.layout = html.Div([
285
  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'})
286
  ], style={'width': '48%', 'display': 'inline-block', 'vertical-align': 'top'}),
287
  html.Div([
288
- html.H4("📈 基本面分析", style={'color': '#F18F01', 'margin-bottom': '15px'}),
289
  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'})
290
  ], style={'width': '48%', 'display': 'inline-block', 'margin-left': '4%', 'vertical-align': 'top'})
291
  ]),
292
  html.Div([
293
- html.H4("🎯 市場展望與投資建議", style={'color': '#C73E1D', 'margin-bottom': '15px', 'margin-top': '25px'}),
294
  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)'})
295
  ])
296
  ], 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'}),
@@ -314,7 +374,6 @@ app.layout = html.Div([
314
  ], style={'margin-top': '30px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
315
  ])
316
 
317
- # 台指期獨立預測回調函數
318
  @app.callback(
319
  [dash.dependencies.Output('taiex-prediction-results', 'children'),
320
  dash.dependencies.Output('taiex-prediction-chart', 'figure')],
@@ -349,7 +408,6 @@ def update_taiex_prediction(predict_days):
349
  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'))
350
  return result_card, fig
351
 
352
- # 更新股價資訊卡片
353
  @app.callback(
354
  dash.dependencies.Output('stock-info-cards', 'children'),
355
  [dash.dependencies.Input('stock-dropdown', 'value')]
@@ -377,7 +435,6 @@ def update_stock_info(selected_stock):
377
  ], style={'background': 'white','padding': '20px','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)','display': 'inline-block'})
378
  ])
379
 
380
- # 更新主要圖表 (股價與成交量分佈)
381
  @app.callback(
382
  dash.dependencies.Output('price-chart', 'figure'),
383
  [dash.dependencies.Input('stock-dropdown', 'value'),
@@ -402,7 +459,6 @@ def update_price_chart(selected_stock, period, chart_type):
402
  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)
403
  return fig
404
 
405
- # 更新進階技術指標圖表
406
  @app.callback(
407
  dash.dependencies.Output('advanced-technical-chart', 'figure'),
408
  [dash.dependencies.Input('technical-indicator-selector', 'value'),
@@ -414,7 +470,7 @@ def update_advanced_technical_chart(indicator, selected_stock, period):
414
  if data.empty: return {}
415
  data = calculate_technical_indicators(data)
416
  stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
417
- fig = go.Figure() # Fallback
418
  if indicator == 'RSI':
419
  fig = go.Figure()
420
  fig.add_trace(go.Scatter(x=data.index, y=data['RSI'], mode='lines', name='RSI', line=dict(color='purple', width=2)))
@@ -462,7 +518,6 @@ def update_advanced_technical_chart(indicator, selected_stock, period):
462
  fig.update_layout(title=f'{stock_name} - DMI 動向指標 (14日)', height=500, showlegend=True, yaxis2_range=[0, 100])
463
  return fig
464
 
465
- # 更��成交量圖表
466
  @app.callback(
467
  dash.dependencies.Output('volume-chart', 'figure'),
468
  [dash.dependencies.Input('stock-dropdown', 'value'),
@@ -477,44 +532,34 @@ def update_volume_chart(selected_stock, period):
477
  fig.update_layout(title=f'{stock_name} 成交量', xaxis_title='日期', yaxis_title='成交量', height=300)
478
  return fig
479
 
480
- # ========================= MODIFIED SECTION START =========================
481
  @app.callback(
482
  dash.dependencies.Output('industry-analysis', 'figure'),
483
- [dash.dependencies.Input('stock-dropdown', 'value')] # Callback can be triggered by any change, e.g., the main stock selection
484
  )
485
  def update_industry_analysis(selected_stock):
486
  performance_data = []
487
- # 1. Iterate through ALL stocks to calculate their performance
488
  for name, symbol in TAIWAN_STOCKS.items():
489
  data = get_stock_data(symbol, '1mo')
490
  if not data.empty and len(data) > 1:
491
- # Calculate 1-month return percentage
492
  return_pct = ((data['Close'].iloc[-1] - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
493
  performance_data.append({
494
  '股票': name,
495
  '代碼': symbol,
496
  '月報酬率(%)': return_pct,
497
- '絕對波動': abs(return_pct) # Use absolute value for sorting
498
  })
499
-
500
  if not performance_data:
501
  fig = go.Figure().add_annotation(text="無法計算產業資料", showarrow=False)
502
  fig.update_layout(title="近一月市場波動最大標的", height=400)
503
  return fig
504
-
505
- # 2. Sort by the absolute fluctuation and take the top 10
506
  df_performance = pd.DataFrame(performance_data)
507
  df_top_movers = df_performance.sort_values(by='絕對波動', ascending=False).head(10)
508
-
509
- # 3. Create the pie chart with the top 10 movers
510
- # We use the absolute value for the pie chart size to represent volatility,
511
- # but the hover data will show the actual (positive/negative) return.
512
  fig = px.pie(
513
  df_top_movers,
514
  values='絕對波動',
515
  names='股票',
516
  title='近一月市場波動最大 Top 10 標的',
517
- hover_data={'月報酬率(%)': ':.2f'} # Show the actual return on hover
518
  )
519
  fig.update_traces(
520
  textposition='inside',
@@ -523,9 +568,7 @@ def update_industry_analysis(selected_stock):
523
  )
524
  fig.update_layout(height=400, showlegend=False)
525
  return fig
526
- # ========================== MODIFIED SECTION END ==========================
527
 
528
- # 更新景氣燈號圖表
529
  @app.callback(
530
  dash.dependencies.Output('business-climate-chart', 'figure'),
531
  [dash.dependencies.Input('stock-dropdown', 'value')]
@@ -550,7 +593,7 @@ def update_business_climate_chart(selected_stock):
550
  fig.update_layout(title="台灣景氣燈號走勢", xaxis_title='日期', yaxis_title='燈號分數', height=300, yaxis=dict(range=[0, 40]))
551
  return fig
552
 
553
- # 更新分析師觀點
554
  @app.callback(
555
  [dash.dependencies.Output('technical-analysis-text', 'children'),
556
  dash.dependencies.Output('fundamental-analysis-text', 'children'),
@@ -561,31 +604,38 @@ def update_business_climate_chart(selected_stock):
561
  def update_analysis_text(selected_stock, period):
562
  data = get_stock_data(selected_stock, period)
563
  stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
564
- if data.empty: return "無法獲取資料", "無法獲取資料", "無法獲取資料"
 
 
565
  data = calculate_technical_indicators(data)
566
- current_price = data['Close'].iloc[-1]
567
- price_change = ((current_price - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
 
568
  rsi_current = data['RSI'].iloc[-1] if not pd.isna(data['RSI'].iloc[-1]) else 50
569
  macd_current = data['MACD'].iloc[-1] if not pd.isna(data['MACD'].iloc[-1]) else 0
570
  macd_signal_current = data['MACD_Signal'].iloc[-1] if not pd.isna(data['MACD_Signal'].iloc[-1]) else 0
 
571
  technical_text = html.Div([
572
- 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}%。"]),
573
- html.P([html.Strong("RSI指標:"), f"目前為{rsi_current:.1f},", html.Span("處於超買區間" if rsi_current > 70 else "處於超賣區間" if rsi_current < 30 else "在正常範圍內", style={'color': 'green' if rsi_current > 70 else 'red' if rsi_current < 30 else 'blue', 'font-weight': 'bold'}), "。"]),
574
- html.P([html.Strong("MACD指標:"), f"MACD({macd_current:.3f})", html.Span("高於" if macd_current > macd_signal_current else "低於", style={'color': 'red' if macd_current > macd_signal_current else 'green', 'font-weight': 'bold'}), f"信號線({macd_signal_current:.3f}),", f"顯示{'多頭' if macd_current > macd_signal_current else '空頭'}格局。"]),
575
- ])
576
- industry = INDUSTRY_MAPPING.get(selected_stock, '綜合')
577
- fundamental_text = html.Div([
578
- html.P([html.Strong("產業地位:"), f"{stock_name}屬於{industry}產業,在產業鏈中具有", html.Span("重要地位" if selected_stock in ['2330.TW', '2454.TW', '2317.TW'] else "一定影響力", style={'font-weight': 'bold'}), "。"]),
579
- html.P([html.Strong("營運展望:"), f"建議持續關注季報表現及未來指引。"]),
580
  ])
581
- outlook_tone = "謹慎樂觀" if price_change > 10 else "保守觀望" if price_change < -10 else "中性持平"
582
- market_outlook = html.Div([
583
- html.P([html.Strong("整體評估:"), f"基於技術面及基本面分析,對{stock_name}採取", html.Span(f"{outlook_tone}", style={'font-weight': 'bold'}), "態度。"]),
584
- html.P([html.Strong("投資建議:"), "短線操作注意技術指標,長線投資關注基本面變化。"]),
 
585
  ])
586
- return technical_text, fundamental_text, market_outlook
 
 
 
 
 
 
 
 
587
 
588
- # 更新PMI圖表
589
  @app.callback(
590
  dash.dependencies.Output('pmi-chart', 'figure'),
591
  [dash.dependencies.Input('stock-dropdown', 'value')]
@@ -606,26 +656,17 @@ def update_pmi_chart(selected_stock):
606
  def summarize_news_with_gemini(news_list: list) -> str:
607
  """
608
  使用 Gemini API 將英文新聞標題列表摘要成一段繁體中文。
609
-
610
- Args:
611
- news_list (list): 包含英文新聞標題字串的列表。
612
-
613
- Returns:
614
- str: Gemini 生成的繁體中文摘要,或在發生錯誤時回傳錯誤訊息。
615
  """
616
- # 從 Hugging Face Secrets 安全地讀取 API 金鑰
617
  api_key = os.getenv("GEMINI_API_KEY")
618
  if not api_key:
619
  return "錯誤:找不到 GEMINI_API_KEY。請在 Hugging Face Secrets 中設定。"
620
 
621
  try:
622
  genai.configure(api_key=api_key)
623
- model = genai.GenerativeModel('gemini-1.5-flash') # 使用速度較快的 Flash 模型
624
 
625
- # 將新聞列表格式化,方便 AI 閱讀
626
  formatted_news = "\n".join([f"- {news}" for news in news_list])
627
 
628
- # 這就是您對 AI 下的指令 (Prompt)
629
  prompt = f"""
630
  請扮演一位專業的金融市場分析師。
631
  以下是幾則最新的英文財經新聞標題,請將它們整合成一段簡潔、流暢、約 200 字的繁體中文市場動態摘要,與利多哪些產業,利空哪些產業。
@@ -643,9 +684,6 @@ def summarize_news_with_gemini(news_list: list) -> str:
643
  print(f"呼叫 Gemini API 時發生錯誤: {e}")
644
  return f"無法生成新聞摘要,請稍後再試。錯誤訊息:{e}"
645
 
646
-
647
-
648
- # 更新多檔股票比較
649
  @app.callback(
650
  [dash.dependencies.Output('comparison-chart', 'figure'),
651
  dash.dependencies.Output('comparison-table', 'children')],
@@ -678,26 +716,19 @@ def update_comparison_analysis(selected_stocks, period):
678
  return fig, table
679
  return fig, html.Div("無可比較資料")
680
 
681
-
682
- # ==============================================================================
683
- # ===== 【修改】市場情緒與新聞分析 (使用真實 BERT 模型) =====
684
- # ==============================================================================
685
  @app.callback(
686
  [dash.dependencies.Output('sentiment-gauge', 'children'),
687
  dash.dependencies.Output('news-summary', 'children')],
688
  [dash.dependencies.Input('stock-dropdown', 'value')]
689
  )
690
  def update_sentiment_analysis(selected_stock):
691
- # 檢查 predictor 是否成功初始化 (這部分邏輯不變)
692
  if predictor is None:
693
  error_fig = go.Figure().add_annotation(text="情緒指標模型載入失敗", showarrow=False)
694
  error_fig.update_layout(height=200)
695
  return dcc.Graph(figure=error_fig), html.P("新聞分析模型載入失敗,請檢查後台日誌。")
696
 
697
- # --- 1. 從 predictor 獲取新聞情緒平均分數 (不變) ---
698
  sentiment_score_raw = predictor.get_news_index()
699
 
700
- # --- 2. 建立情緒指標儀表板 (不變) ---
701
  if sentiment_score_raw is not None:
702
  sentiment_score_normalized = (sentiment_score_raw + 1) * 50
703
  sentiment_score_normalized = max(0, min(100, sentiment_score_normalized))
@@ -723,27 +754,22 @@ def update_sentiment_analysis(selected_stock):
723
  error_fig.update_layout(height=200)
724
  gauge_content = dcc.Graph(figure=error_fig)
725
 
726
-
727
- # --- 3. 【核心修改】獲取新聞並使用 Gemini 進行摘要 ---
728
  top_news_list = predictor.get_news()
729
- news_content = None # 先初始化
730
 
731
- if top_news_list and isinstance(top_news_list, list): # 確保列表不為空且格式正確
732
- # *** 呼叫我們的新函式來生成中文摘要 ***
733
  summary_text = summarize_news_with_gemini(top_news_list)
734
- # 使用 dcc.Markdown 來顯示,這樣如果摘要包含換行等格式會更好看
735
  news_content = dcc.Markdown(summary_text, style={
736
  'margin': '8px 0', 'padding-left': '5px',
737
  'font-size': '15px', 'line-height': '1.7'
738
  })
739
- elif top_news_list == []: # 如果是空列表
740
  news_content = html.P("昨日無重大相關新聞。", style={'text-align': 'center', 'padding-top': '50px'})
741
- else: # 如果是 None 或其他錯誤
742
  news_content = html.P("讀取新聞時發生錯誤。", style={'text-align': 'center', 'padding-top': '50px'})
743
 
744
  return gauge_content, news_content
745
 
746
  # 主程式執行
747
  if __name__ == '__main__':
748
- # 在 Hugging Face Spaces 中執行
749
  app.run(host="0.0.0.0", port=7860, debug=False)
 
3
  # 系統套件
4
  import os
5
  from datetime import datetime, timedelta
6
+ import google.generativeai as genai
7
  import pandas as pd
8
  import numpy as np
9
  import yfinance as yf
 
21
 
22
  # 台股代號對應表 (移除台指期,因為它現在是獨立區塊)
23
  TAIWAN_STOCKS = {
24
+ '元大台灣50': '0050.TW',
25
  '台積電': '2330.TW',
26
  '聯發科': '2454.TW',
27
  '鴻海': '2317.TW',
 
46
 
47
  # 產業分類
48
  INDUSTRY_MAPPING = {
49
+ '0050.TW': 'ETF',
50
  '2330.TW': '半導體',
51
  '2454.TW': '半導體',
52
  '2317.TW': '電子組件',
 
63
  '1101.TW': '營建',
64
  '2408.TW': 'DRAM',
65
  '2337.TW': 'NFLSH',
 
66
  '4966.TWO': '高速傳輸',
67
  '3665.TW': '連接器',
68
  '6870.TWO': '軟體整合',
 
189
  print(f"無法獲取 PMI 資料: {str(e)}")
190
  return pd.DataFrame()
191
 
192
+ # ========================= GEMINI 整合 START =========================
193
+ def generate_gemini_analysis(stock_name, stock_symbol, period, data):
194
+ """
195
+ 使用 Gemini API 生成基本面和市場展望分析。
196
+ """
197
+ api_key = os.getenv("GEMINI_API_KEY")
198
+ if not api_key:
199
+ return "無法讀取 GEMINI API 金鑰", "請在系統環境變數中設定您的金鑰"
200
+
201
+ try:
202
+ genai.configure(api_key=api_key)
203
+ model = genai.GenerativeModel('gemini-1.5-flash')
204
+
205
+ # 準備傳送給模型的數據
206
+ price_change = ((data['Close'].iloc[-1] - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
207
+ rsi_current = data['RSI'].iloc[-1]
208
+ macd_current = data['MACD'].iloc[-1]
209
+ macd_signal_current = data['MACD_Signal'].iloc[-1]
210
+ industry = INDUSTRY_MAPPING.get(stock_symbol, '綜合')
211
+
212
+ prompt = f"""
213
+ 請扮演一位專業、資深的台灣股市金融分析師。
214
+ 我將提供一檔台股的即時技術指標數據,請你基於這些數據,結合你對這家公司、其所在產業以及當前市場趨勢的理解,為我生成一段專業的「基本面分析」和一段「市場展望與投資建議」。
215
+
216
+ **股票資訊:**
217
+ - **公司名稱:** {stock_name} ({stock_symbol})
218
+ - **分析期間:** 最近 {period}
219
+ - **所屬產業:** {industry}
220
+ - **期間價格變動:** {price_change:+.2f}%
221
+ - **目前 RSI 指標:** {rsi_current:.2f}
222
+ - **目前 MACD 指標:** MACD線為 {macd_current:.3f}, 信號線為 {macd_signal_current:.3f}
223
+
224
+ **你的任務:**
225
+ 1. **基本面分析 (約 150 字):**
226
+ - 評論這家公司的產業地位、近期營運亮點或挑戰。
227
+ - 提及任何可能影響其基本面的關鍵因素 (例如:財報、法說會、政策、供應鏈變化等)。
228
+ - 請用專業、客觀的語氣撰寫。
229
+
230
+ 2. **市場展望與投資建議 (約 150 字):**
231
+ - 基於上述所有資訊,提供對該股票的短期和中期市場展望。
232
+ - 提出具體的投資建議,例如:適合何種類型的投資人、潛在的風險點、以及建議的觀察價位區間或進出場策略。
233
+ - 請直接提供分析內容,不要包含任何問候語。
234
+
235
+ **輸出格式:**
236
+ 請嚴格按照以下格式回傳,使用"$$"作為兩個段落之間的分隔符:
237
+ [基本面分析內容]$$[市場展望與投資建議內容]
238
+ """
239
+
240
+ response = model.generate_content(prompt)
241
+ parts = response.text.split('$$')
242
+ if len(parts) == 2:
243
+ fundamental_analysis = parts[0].strip()
244
+ market_outlook = parts[1].strip()
245
+ return dcc.Markdown(fundamental_analysis), dcc.Markdown(market_outlook)
246
+ else:
247
+ return "無法解析 Gemini 回應", response.text
248
+
249
+ except Exception as e:
250
+ error_message = f"呼叫 Gemini API 時發生錯誤: {str(e)}"
251
+ print(error_message)
252
+ return error_message, "請檢查後台日誌或 API 金鑰設定"
253
+ # ========================== GEMINI 整合 END ==========================
254
+
255
  # 建立 Dash 應用程式
256
  app = dash.Dash(__name__, suppress_callback_exceptions=True)
257
 
 
258
  try:
259
  print("正在初始化新聞情緒分析模型...")
260
  predictor = BertPredictor(max_news_per_keyword=5)
 
283
  html.Div([dcc.Graph(id='taiex-prediction-chart')], style={'margin-top': '20px'})
284
  ], 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'}),
285
 
 
286
  html.Div([
287
  html.H3("📰 市場情緒與新聞分析", style={'color': '#E74C3C', 'margin-bottom': '20px'}),
288
  html.Div([
 
314
  html.Label("時間範圍:"),
315
  dcc.Dropdown(id='period-dropdown',
316
  options=[{'label': '1個月', 'value': '1mo'},{'label': '3個月', 'value': '3mo'},{'label': '6個月', 'value': '6mo'},{'label': '1年', 'value': '1y'},{'label': '2年', 'value': '2y'}],
317
+ value='1mo', style={'margin-bottom': '10px'}) # 預設改為 1mo
318
  ], style={'width': '30%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'}),
319
  html.Div([
320
  html.Label("圖表類型:"),
 
345
  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'})
346
  ], style={'width': '48%', 'display': 'inline-block', 'vertical-align': 'top'}),
347
  html.Div([
348
+ html.H4("📈 基本面分析 (AI 生成)", style={'color': '#F18F01', 'margin-bottom': '15px'}),
349
  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'})
350
  ], style={'width': '48%', 'display': 'inline-block', 'margin-left': '4%', 'vertical-align': 'top'})
351
  ]),
352
  html.Div([
353
+ html.H4("🎯 市場展望與投資建議 (AI 生成)", style={'color': '#C73E1D', 'margin-bottom': '15px', 'margin-top': '25px'}),
354
  html.Div(id='market-outlook-text', style={'background': 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)','color': 'white','padding': '20px','border-radius': '10px','min-height': '100px','font-size': '15px','line-height': '1.7','box-shadow': '0 4px 15px rgba(0,0,0,0.1)'})
355
  ])
356
  ], style={'margin-top': '30px','padding': '25px','background': 'white','border-radius': '12px','box-shadow': '0 4px 20px rgba(0,0,0,0.08)','border': '1px solid #e9ecef'}),
 
374
  ], style={'margin-top': '30px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
375
  ])
376
 
 
377
  @app.callback(
378
  [dash.dependencies.Output('taiex-prediction-results', 'children'),
379
  dash.dependencies.Output('taiex-prediction-chart', 'figure')],
 
408
  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'))
409
  return result_card, fig
410
 
 
411
  @app.callback(
412
  dash.dependencies.Output('stock-info-cards', 'children'),
413
  [dash.dependencies.Input('stock-dropdown', 'value')]
 
435
  ], style={'background': 'white','padding': '20px','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)','display': 'inline-block'})
436
  ])
437
 
 
438
  @app.callback(
439
  dash.dependencies.Output('price-chart', 'figure'),
440
  [dash.dependencies.Input('stock-dropdown', 'value'),
 
459
  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)
460
  return fig
461
 
 
462
  @app.callback(
463
  dash.dependencies.Output('advanced-technical-chart', 'figure'),
464
  [dash.dependencies.Input('technical-indicator-selector', 'value'),
 
470
  if data.empty: return {}
471
  data = calculate_technical_indicators(data)
472
  stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
473
+ fig = go.Figure()
474
  if indicator == 'RSI':
475
  fig = go.Figure()
476
  fig.add_trace(go.Scatter(x=data.index, y=data['RSI'], mode='lines', name='RSI', line=dict(color='purple', width=2)))
 
518
  fig.update_layout(title=f'{stock_name} - DMI 動向指標 (14日)', height=500, showlegend=True, yaxis2_range=[0, 100])
519
  return fig
520
 
 
521
  @app.callback(
522
  dash.dependencies.Output('volume-chart', 'figure'),
523
  [dash.dependencies.Input('stock-dropdown', 'value'),
 
532
  fig.update_layout(title=f'{stock_name} 成交量', xaxis_title='日期', yaxis_title='成交量', height=300)
533
  return fig
534
 
 
535
  @app.callback(
536
  dash.dependencies.Output('industry-analysis', 'figure'),
537
+ [dash.dependencies.Input('stock-dropdown', 'value')]
538
  )
539
  def update_industry_analysis(selected_stock):
540
  performance_data = []
 
541
  for name, symbol in TAIWAN_STOCKS.items():
542
  data = get_stock_data(symbol, '1mo')
543
  if not data.empty and len(data) > 1:
 
544
  return_pct = ((data['Close'].iloc[-1] - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
545
  performance_data.append({
546
  '股票': name,
547
  '代碼': symbol,
548
  '月報酬率(%)': return_pct,
549
+ '絕對波動': abs(return_pct)
550
  })
 
551
  if not performance_data:
552
  fig = go.Figure().add_annotation(text="無法計算產業資料", showarrow=False)
553
  fig.update_layout(title="近一月市場波動最大標的", height=400)
554
  return fig
 
 
555
  df_performance = pd.DataFrame(performance_data)
556
  df_top_movers = df_performance.sort_values(by='絕對波動', ascending=False).head(10)
 
 
 
 
557
  fig = px.pie(
558
  df_top_movers,
559
  values='絕對波動',
560
  names='股票',
561
  title='近一月市場波動最大 Top 10 標的',
562
+ hover_data={'月報酬率(%)': ':.2f'}
563
  )
564
  fig.update_traces(
565
  textposition='inside',
 
568
  )
569
  fig.update_layout(height=400, showlegend=False)
570
  return fig
 
571
 
 
572
  @app.callback(
573
  dash.dependencies.Output('business-climate-chart', 'figure'),
574
  [dash.dependencies.Input('stock-dropdown', 'value')]
 
593
  fig.update_layout(title="台灣景氣燈號走勢", xaxis_title='日期', yaxis_title='燈號分數', height=300, yaxis=dict(range=[0, 40]))
594
  return fig
595
 
596
+ # ========================= MODIFIED SECTION START =========================
597
  @app.callback(
598
  [dash.dependencies.Output('technical-analysis-text', 'children'),
599
  dash.dependencies.Output('fundamental-analysis-text', 'children'),
 
604
  def update_analysis_text(selected_stock, period):
605
  data = get_stock_data(selected_stock, period)
606
  stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
607
+ if data.empty or len(data) < 20: # 確保有足夠資料計算指標
608
+ return "資料不足,無法分析", "資料不足,無法分析", "資料不足,無法分析"
609
+
610
  data = calculate_technical_indicators(data)
611
+
612
+ # 1. 技術面分析 (保留客觀數據呈現)
613
+ price_change = ((data['Close'].iloc[-1] - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
614
  rsi_current = data['RSI'].iloc[-1] if not pd.isna(data['RSI'].iloc[-1]) else 50
615
  macd_current = data['MACD'].iloc[-1] if not pd.isna(data['MACD'].iloc[-1]) else 0
616
  macd_signal_current = data['MACD_Signal'].iloc[-1] if not pd.isna(data['MACD_Signal'].iloc[-1]) else 0
617
+
618
  technical_text = html.Div([
619
+ 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}%。"]),
620
+ 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'}), "。"]),
621
+ 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 '空頭'}"]),
 
 
 
 
 
622
  ])
623
+
624
+ # 2. 基本面與展望分析 (呼叫 Gemini)
625
+ # 顯示“正在生成…”提示,改善使用者體驗
626
+ loading_text = html.Div([
627
+ dcc.Loading(id="loading-analysis", type="dots", children=[html.Div(id="loading-output")])
628
  ])
629
+
630
+ try:
631
+ fundamental_text, market_outlook_text = generate_gemini_analysis(stock_name, selected_stock, period, data)
632
+ except Exception as e:
633
+ fundamental_text = f"生成分析時發生錯誤: {e}"
634
+ market_outlook_text = "請檢查 API 金鑰或網路連線。"
635
+
636
+ return technical_text, fundamental_text, market_outlook_text
637
+ # ========================== MODIFIED SECTION END ==========================
638
 
 
639
  @app.callback(
640
  dash.dependencies.Output('pmi-chart', 'figure'),
641
  [dash.dependencies.Input('stock-dropdown', 'value')]
 
656
  def summarize_news_with_gemini(news_list: list) -> str:
657
  """
658
  使用 Gemini API 將英文新聞標題列表摘要成一段繁體中文。
 
 
 
 
 
 
659
  """
 
660
  api_key = os.getenv("GEMINI_API_KEY")
661
  if not api_key:
662
  return "錯誤:找不到 GEMINI_API_KEY。請在 Hugging Face Secrets 中設定。"
663
 
664
  try:
665
  genai.configure(api_key=api_key)
666
+ model = genai.GenerativeModel('gemini-1.5-flash')
667
 
 
668
  formatted_news = "\n".join([f"- {news}" for news in news_list])
669
 
 
670
  prompt = f"""
671
  請扮演一位專業的金融市場分析師。
672
  以下是幾則最新的英文財經新聞標題,請將它們整合成一段簡潔、流暢、約 200 字的繁體中文市場動態摘要,與利多哪些產業,利空哪些產業。
 
684
  print(f"呼叫 Gemini API 時發生錯誤: {e}")
685
  return f"無法生成新聞摘要,請稍後再試。錯誤訊息:{e}"
686
 
 
 
 
687
  @app.callback(
688
  [dash.dependencies.Output('comparison-chart', 'figure'),
689
  dash.dependencies.Output('comparison-table', 'children')],
 
716
  return fig, table
717
  return fig, html.Div("無可比較資料")
718
 
 
 
 
 
719
  @app.callback(
720
  [dash.dependencies.Output('sentiment-gauge', 'children'),
721
  dash.dependencies.Output('news-summary', 'children')],
722
  [dash.dependencies.Input('stock-dropdown', 'value')]
723
  )
724
  def update_sentiment_analysis(selected_stock):
 
725
  if predictor is None:
726
  error_fig = go.Figure().add_annotation(text="情緒指標模型載入失敗", showarrow=False)
727
  error_fig.update_layout(height=200)
728
  return dcc.Graph(figure=error_fig), html.P("新聞分析模型載入失敗,請檢查後台日誌。")
729
 
 
730
  sentiment_score_raw = predictor.get_news_index()
731
 
 
732
  if sentiment_score_raw is not None:
733
  sentiment_score_normalized = (sentiment_score_raw + 1) * 50
734
  sentiment_score_normalized = max(0, min(100, sentiment_score_normalized))
 
754
  error_fig.update_layout(height=200)
755
  gauge_content = dcc.Graph(figure=error_fig)
756
 
 
 
757
  top_news_list = predictor.get_news()
758
+ news_content = None
759
 
760
+ if top_news_list and isinstance(top_news_list, list):
 
761
  summary_text = summarize_news_with_gemini(top_news_list)
 
762
  news_content = dcc.Markdown(summary_text, style={
763
  'margin': '8px 0', 'padding-left': '5px',
764
  'font-size': '15px', 'line-height': '1.7'
765
  })
766
+ elif top_news_list == []:
767
  news_content = html.P("昨日無重大相關新聞。", style={'text-align': 'center', 'padding-top': '50px'})
768
+ else:
769
  news_content = html.P("讀取新聞時發生錯誤。", style={'text-align': 'center', 'padding-top': '50px'})
770
 
771
  return gauge_content, news_content
772
 
773
  # 主程式執行
774
  if __name__ == '__main__':
 
775
  app.run(host="0.0.0.0", port=7860, debug=False)