AlanRex commited on
Commit
a86c47d
·
verified ·
1 Parent(s): c09b1ab

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +632 -312
app.py CHANGED
@@ -1,120 +1,131 @@
1
- # HUGING_FACE_V3.1.3_FIXED.py
2
 
3
- # 系統與資料處理套件
4
  import os
5
  from datetime import datetime, timedelta
 
6
  import pandas as pd
7
  import numpy as np
8
  import yfinance as yf
9
- import requests
10
- import uvicorn
11
- from contextlib import asynccontextmanager # 【新增】用於 FastAPI lifespan
12
-
13
- # Gemini AI 套件
14
- import google.generativeai as genai
15
-
16
- # Dash (Web UI) 套件
17
- from dash import Dash, dcc, html, callback, dependencies
18
- import dash_bootstrap_components as dbc # 【新增】用於美化介面
19
  import plotly.express as px
20
  import plotly.graph_objects as go
21
  from plotly.subplots import make_subplots
22
-
23
- # FastAPI (API & Webhook) 套件
24
- from fastapi import FastAPI, Request, Header
25
- from fastapi.middleware.wsgi import WSGIMiddleware
26
- from fastapi.exceptions import HTTPException
27
-
28
- # LINE Bot 套件
29
- from linebot import LineBotApi, WebhookHandler
30
- from linebot.exceptions import InvalidSignatureError
31
- from linebot.models import MessageEvent, TextMessage, TextSendMessage
32
 
33
  # 引用您組員的預測器程式
34
- # 假設 Bert_predict 模組存在並可以正常匯入
35
- # from Bert_predict import BertPredictor
36
-
37
- # ==============================================================================
38
- # 1. 初始化應用程式與 Lifespan 管理
39
- # ==============================================================================
40
-
41
- # 【優化】使用新的 lifespan 寫法來管理啟動事件
42
- @asynccontextmanager
43
- async def lifespan(app: FastAPI):
44
- # 服務啟動時執行的程式碼
45
- print("服務啟動中...")
46
- initialize_line_services()
47
- setup_line_message_handler()
48
- print("服務初始化完成。")
49
- yield
50
- # 服務關閉時執行的程式碼 (如果需要的話)
51
- print("服務已關閉。")
52
-
53
- # 建立 FastAPI 應用程式實例,並掛載 lifespan
54
- fastapi_app = FastAPI(lifespan=lifespan)
55
-
56
- # 建立 Dash 應用程式實例
57
- # 【修正】使用 dash_bootstrap_components 樣式表
58
- dash_app = Dash(__name__, suppress_callback_exceptions=True, server=False, url_base_pathname='/', external_stylesheets=[dbc.themes.BOOTSTRAP])
59
-
60
- # ==============================================================================
61
- # 2. 全域變數與共用函式
62
- # ==============================================================================
63
-
64
- # --- LINE Bot 全域變數 ---
65
- line_bot_api = None
66
- line_handler = None
67
- # 【重要!】請務必將此處的 URL 替換成您自己的 Hugging Face Space URL
68
- TARGET_URL = "https://Your-Space-Name.hf.space"
69
 
70
- # --- 台股代號與產業對應表 ---
71
  TAIWAN_STOCKS = {
72
- '元大台灣50': '0050.TW', '台積電': '2330.TW', '聯發科': '2454.TW',
73
- '鴻海': '2317.TW', '台塑': '1301.TW', '中華電': '2412.TW',
74
- '富邦金': '2881.TW', '國泰金': '2882.TW', '台達電': '2308.TW',
75
- '統一': '1216.TW', '日月光': '3711.TW', '長榮': '2603.TW',
76
- '慧洋-KY': '2637.TW', '上銀': '2049.TW', '台泥': '1101.TW',
77
- '南亞科': '2408.TW', '旺宏': '2337.TW', '譜瑞-KY': '4966.TWO',
78
- '貿聯-KY': '3665.TW', '騰雲': '6870.TWO', '穩懋': '3105.TWO'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  }
80
 
 
81
  INDUSTRY_MAPPING = {
82
- '0050.TW': 'ETF', '2330.TW': '半導體', '2454.TW': '半導體',
83
- '2317.TW': '電子組件', '1301.TW': '塑膠', '2412.TW': '電信',
84
- '2881.TW': '金融', '2882.TW': '金融', '2308.TW': '電子',
85
- '1216.TW': '食品', '3711.TW': '半導體', '2603.TW': '航運',
86
- '2637.TW': '散裝航運', '2049.TW': '工具機', '1101.TW': '營建',
87
- '2408.TW': 'DRAM', '2337.TW': 'NFLSH', '4966.TWO': '高速傳輸',
88
- '3665.TW': '連接器', '6870.TWO': '軟體整合', '3105.TWO': 'PA功率'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  }
90
 
91
- # --- 共用資料獲取與分析函式 ---
92
  def get_stock_data(symbol, period='1y'):
 
93
  try:
94
  stock = yf.Ticker(symbol)
95
  data = stock.history(period=period)
96
- if data.empty:
97
- stock = yf.Ticker('^TWII')
98
  data = stock.history(period=period)
 
 
 
99
  return data
100
- except Exception as e:
101
- print(f"獲取 {symbol} 資料時出錯: {e}")
102
  return pd.DataFrame()
103
 
104
- def calculate_technical_indicators_full(df):
105
- """計算所有技術指標 (供 Dash 使用)"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  if df.empty: return df
107
  df['MA5'] = df['Close'].rolling(window=5).mean()
108
  df['MA20'] = df['Close'].rolling(window=20).mean()
109
  delta = df['Close'].diff()
110
  gain = (delta.where(delta > 0, 0)).rolling(window=14).mean()
111
  loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
112
- rs = gain / loss if loss.any() != 0 else pd.Series(0, index=loss.index)
113
  df['RSI'] = 100 - (100 / (1 + rs))
114
- exp1 = df['Close'].ewm(span=12, adjust=False).mean()
115
- exp2 = df['Close'].ewm(span=26, adjust=False).mean()
116
  df['MACD'] = exp1 - exp2
117
- df['MACD_Signal'] = df['MACD'].ewm(span=9, adjust=False).mean()
118
  df['MACD_Histogram'] = df['MACD'] - df['MACD_Signal']
119
  df['BB_Middle'] = df['Close'].rolling(window=20).mean()
120
  bb_std = df['Close'].rolling(window=20).std()
@@ -123,187 +134,558 @@ def calculate_technical_indicators_full(df):
123
  low_min = df['Low'].rolling(window=9).min()
124
  high_max = df['High'].rolling(window=9).max()
125
  rsv = (df['Close'] - low_min) / (high_max - low_min) * 100
126
- df['K'] = rsv.ewm(com=2, adjust=False).mean()
127
- df['D'] = df['K'].ewm(com=2, adjust=False).mean()
 
 
 
 
 
 
 
 
 
 
 
 
128
  return df
129
 
130
- def calculate_technical_indicators_simple(df):
131
- """計算簡易技術指標 (供 LINE Bot 使用)"""
132
- if df.empty: return df
133
- df['MA5'] = df['Close'].rolling(window=5).mean()
134
- df['MA20'] = df['Close'].rolling(window=20).mean()
135
- delta = df['Close'].diff()
136
- gain = delta.clip(lower=0).rolling(window=14).mean()
137
- loss = -delta.clip(upper=0).rolling(window=14).mean()
138
- rs = gain / loss if loss.any() != 0 else pd.Series(0, index=loss.index)
139
- df['RSI'] = 100 - (100 / (1 + rs))
140
- return df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
 
142
  def summarize_news_with_gemini(news_list: list) -> str:
 
 
 
 
 
 
 
 
 
 
143
  api_key = os.getenv("GEMINI_API_KEY")
144
  if not api_key:
145
  return "錯誤:找不到 GEMINI_API_KEY。請在 Hugging Face Secrets 中設定。"
 
146
  try:
147
  genai.configure(api_key=api_key)
148
- model = genai.GenerativeModel('gemini-1.5-flash')
 
 
149
  formatted_news = "\n".join([f"- {news}" for news in news_list])
 
 
150
  prompt = f"""
151
  請扮演一位專業的金融市場分析師。
152
- 以下是幾則最新的英文財經新聞標題,請將它們整合成一段簡潔、流暢、約 200 字的繁體中文市場動態摘要,並點出可能利多與利空的產業。
153
- 請提供3段重點,並專注於可能影響市場情緒和股價的關鍵資訊,直接提供摘要內容,不要包含任何額外的問候語或說明。
 
154
 
155
  英文新聞標題如下:
156
  {formatted_news}
157
  """
 
158
  response = model.generate_content(prompt)
159
  return response.text
 
160
  except Exception as e:
161
  print(f"呼叫 Gemini API 時發生錯誤: {e}")
162
  return f"無法生成新聞摘要,請稍後再試。錯誤訊息:{e}"
163
 
164
- # ==============================================================================
165
- # 3. LINE Bot 專用函式
166
- # ==============================================================================
167
- def initialize_line_services():
168
- global line_bot_api, line_handler
169
- # 【修正】在 getenv 後面加上 .strip(),移除潛在的頭尾空白或換行符
170
- channel_access_token = os.getenv("CHANNEL_ACCESS_TOKEN", "").strip()
171
- channel_secret = os.getenv("CHANNEL_SECRET", "").strip()
172
- if channel_access_token and channel_secret:
173
- try:
174
- from linebot.v3.messaging import LineBotApi
175
- from linebot.v3.webhooks import WebhookHandler
176
- line_bot_api = LineBotApi(channel_access_token)
177
- line_handler = WebhookHandler(channel_secret)
178
- print("Line Bot API (v3) 初始化成功。")
179
- except ImportError:
180
- # 兼容舊版 linebot 程式庫
181
- print("警告:找不到 linebot.v3 模組。將嘗試使用舊版 API。")
182
- from linebot import LineBotApi, WebhookHandler
183
- line_bot_api = LineBotApi(channel_access_token)
184
- line_handler = WebhookHandler(channel_secret)
185
- print("Line Bot API (舊版) 初始化成功。")
186
- else:
187
- print("警告: 找不到 LINE Bot 的 CHANNEL_ACCESS_TOKEN 或 CHANNEL_SECRET 環境變數。")
188
-
189
- def generate_analysis_report(data, stock_name):
190
- if data.empty or data.iloc[-1].isnull().any():
191
- return f"抱歉,{stock_name} 的資料不足,無法產生報告。"
192
- last_row = data.iloc[-1]
193
- rsi_val = last_row['RSI']
194
- if pd.isna(rsi_val):
195
- rsi_comment = "RSI 指標數據不足,無法評估。"
196
- elif rsi_val > 70:
197
- rsi_comment = "RSI指標顯示處於超買區,可能有回檔風險。"
198
- elif rsi_val < 30:
199
- rsi_comment = "RSI指標顯示處於超賣區,可能有反彈機會。"
200
- else:
201
- rsi_comment = "RSI指標處於中性區間。"
202
- report = f"""📈 {stock_name} 分析報告
203
- 🗓️ 最新收盤價: ${last_row['Close']:.2f}
204
- 🚀 RSI: {rsi_val:.2f}
205
- 💬 趨勢評估:
206
- {rsi_comment}"""
207
- return report
208
-
209
- def generate_market_outlook(data, stock_name):
210
- if data.empty or data.iloc[-1].isnull().any(): return ""
211
- last_row = data.iloc[-1]
212
- outlook_text = f"\n---\n📊 **市場展望與投資建議**"
213
- rsi_val = last_row['RSI']
214
- if pd.isna(rsi_val):
215
- outlook_text += "\n🔴 技術面觀察:數據不足,無法生成 RSI 建議。"
216
- elif rsi_val > 70:
217
- outlook_text += "\n🔴 技術面觀察:RSI 指標顯示股價處於超買區,短期內可能有回檔壓力,建議密切關注。"
218
- elif rsi_val < 30:
219
- outlook_text += "\n🟢 技術面觀察:RSI 指標顯示股價處於超賣區,短期可能出現反彈,但仍需留意下方支撐力道。"
220
- else:
221
- outlook_text += "\n🟡 技術面觀察:RSI 指標處於中性區間,顯示目前市場趨勢不明顯,可能進入盤整階段。"
222
- if pd.isna(last_row['MA5']) or pd.isna(last_row['MA20']):
223
- outlook_text += "\n📈 趨勢分析:均線數據不足,無法生成趨勢建議。"
224
- elif last_row['MA5'] > last_row['MA20']:
225
- outlook_text += "\n📈 趨勢分析:短期均線(MA5)位於中期均線(MA20)上方,顯示短期趨勢偏多。"
226
- else:
227
- outlook_text += "\n📉 趨勢分析:短期均線(MA5)位於中期均線(MA20)下方,顯示短期趨勢偏空。"
228
- outlook_text += "\n\n💡 **綜合建議**:投資決策應搭配其他分析工具與個人風險承受能力。以上僅供參考,不構成任何投資建議。"
229
- return outlook_text
230
-
231
- def setup_line_message_handler():
232
- if not line_handler: return
233
- @line_handler.add(MessageEvent, message=TextMessage)
234
- def handle_message(event):
235
- if event.message.type != "text": return
236
- user_message = event.message.text.strip()
237
- response_text = f"感謝您的訊息!\n若想查看完整的互動式圖表,請點擊以下連結:\n{TARGET_URL}"
238
- stock_symbol, stock_name = None, None
239
- for name, symbol in TAIWAN_STOCKS.items():
240
- if name in user_message or symbol.split('.')[0] in user_message:
241
- stock_symbol, stock_name = symbol, name
242
- break
243
- if stock_symbol:
244
- stock_data = get_stock_data(stock_symbol, period='6mo')
245
- data_with_indicators = calculate_technical_indicators_simple(stock_data)
246
- report_part = generate_analysis_report(data_with_indicators, stock_name)
247
- outlook_part = generate_market_outlook(data_with_indicators, stock_name)
248
- response_text = report_part + outlook_part
249
- try:
250
- line_bot_api.reply_message(event.reply_token, TextSendMessage(text=response_text))
251
- except Exception as e:
252
- print(f"LINE Bot 回覆訊息時出錯: {e}")
253
 
254
- # ==============================================================================
255
- # 4. FastAPI 端點 (Endpoints)
256
- # ==============================================================================
257
- @fastapi_app.post("/webhook")
258
- async def webhook(request: Request, x_line_signature: str = Header(None)):
259
- if not line_handler:
260
- raise HTTPException(status_code=500, detail="Line Bot 服務未設定")
261
- body = await request.body()
262
- try:
263
- line_handler.handle(body.decode("utf-8"), x_line_signature)
264
- except InvalidSignatureError:
265
- raise HTTPException(status_code=400, detail="簽名錯誤")
266
- return "ok"
267
 
268
- # ==============================================================================
269
- # 5. Dash 儀表板的佈局 (Layout) 與回呼 (Callbacks)
270
- # ==============================================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
 
272
- # --- BERT 新聞預測器初始化 ---
273
- try:
274
- print("正在初始化新聞情緒分析模型...")
275
- # 假設 BertPredictor 類別已正確定義
276
- # predictor = BertPredictor(max_news_per_keyword=5)
277
- # 為了讓程式碼可執行,這裡使用一個 Mock 物件
278
- class MockPredictor:
279
- def get_news_index(self): return 0.5
280
- def get_news(self): return ["Tech stocks rally on strong earnings reports.", "Global economic outlook improves.", "Supply chain disruptions ease for key industries."]
281
- predictor = MockPredictor()
282
- print("新聞情緒分析模型初始化成功。")
283
- except Exception as e:
284
- print(f"錯誤:新聞情緒分析模型初始化失敗 - {e}")
285
- predictor = None
286
 
287
- # --- Dash Callbacks ---
288
- @dash_app.callback(
289
- [dependencies.Output('sentiment-gauge', 'children'),
290
- dependencies.Output('news-summary', 'children')],
291
- [dependencies.Input('stock-dropdown', 'value')]
 
 
292
  )
293
  def update_sentiment_analysis(selected_stock):
 
294
  if predictor is None:
295
  error_fig = go.Figure().add_annotation(text="情緒指標模型載入失敗", showarrow=False)
296
  error_fig.update_layout(height=200)
297
- return dcc.Graph(figure=error_fig), html.P("新聞分析模型載入失敗。")
 
 
298
  sentiment_score_raw = predictor.get_news_index()
 
 
299
  if sentiment_score_raw is not None:
300
- sentiment_score_normalized = max(0, min(100, (sentiment_score_raw + 1) * 50))
301
- level_text = "樂觀" if sentiment_score_normalized >= 65 else "悲觀" if sentiment_score_normalized < 35 else "中性"
302
- bar_color = "#5cb85c" if level_text == "樂觀" else "#d9534f" if level_text == "悲觀" else "#f0ad4e"
 
 
 
 
 
303
  gauge_fig = go.Figure(go.Indicator(
304
- mode="gauge+number", value=sentiment_score_normalized,
305
- title={'text': f"昨日市場情緒: {level_text}"},
306
- gauge={'axis': {'range': [0, 100]}, 'bar': {'color': bar_color}}
 
 
 
 
307
  ))
308
  gauge_fig.update_layout(height=200, margin=dict(l=30, r=30, t=50, b=20))
309
  gauge_content = dcc.Graph(figure=gauge_fig)
@@ -311,90 +693,28 @@ def update_sentiment_analysis(selected_stock):
311
  error_fig = go.Figure().add_annotation(text="今日尚無情緒分數", showarrow=False)
312
  error_fig.update_layout(height=200)
313
  gauge_content = dcc.Graph(figure=error_fig)
314
- top_news_list = predictor.get_news()
315
- if top_news_list and isinstance(top_news_list, list):
316
- summary_text = summarize_news_with_gemini(top_news_list)
317
- news_content = dcc.Markdown(summary_text, style={'font-size': '15px', 'line-height': '1.7'})
318
- else:
319
- news_content = html.P("昨日無重大相關新聞或讀取時發生錯誤。", style={'text-align': 'center', 'padding-top': '50px'})
320
- return gauge_content, news_content
321
-
322
- @dash_app.callback(
323
- dependencies.Output('price-chart', 'figure'),
324
- [dependencies.Input('stock-dropdown', 'value'),
325
- dependencies.Input('period-dropdown', 'value'),
326
- dependencies.Input('chart-type', 'value')]
327
- )
328
- def update_price_chart(selected_stock, period, chart_type):
329
- data = get_stock_data(selected_stock, period)
330
- if data.empty: return {}
331
- data = calculate_technical_indicators_full(data)
332
- stock_name = next((name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock), selected_stock)
333
-
334
- fig = make_subplots(rows=1, cols=1)
335
-
336
- if chart_type == 'candlestick':
337
- fig.add_trace(go.Candlestick(x=data.index, open=data['Open'], high=data['High'], low=data['Low'], close=data['Close'], name=stock_name, increasing_line_color='red', decreasing_line_color='green'))
338
- else:
339
- fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價'))
340
-
341
- fig.add_trace(go.Scatter(x=data.index, y=data['MA5'], mode='lines', name='MA5', line=dict(color='orange')))
342
- fig.add_trace(go.Scatter(x=data.index, y=data['MA20'], mode='lines', name='MA20', line=dict(color='blue')))
343
-
344
- fig.update_layout(title_text=f'{stock_name} 股價走勢', height=500, xaxis_rangeslider_visible=False)
345
- return fig
346
 
347
- # (此處省略了其他所有 callback 函式,它們應放在這裡,內容與您原檔案相同)
348
- # 由於原程式碼沒有 taiex-prediction-period 和 taiex-prediction-results 的 callback,
349
- # 為了避免錯誤,先將其移除。您可以依需求自行補上。
350
- # @dash_app.callback(...)
351
- # def update_taiex_prediction(...):
352
- # ...
353
 
354
- # ==============================================================================
355
- # 6. 【修正關鍵】先定義 Layout,再進行掛載
356
- # ==============================================================================
357
-
358
- # --- Dash 應用程式佈局 ---
359
- dash_app.layout = html.Div([
360
- html.H1("台股分析儀表板 (已整合 LINE Bot)", style={'text-align': 'center', 'margin-bottom': '30px'}),
361
- html.Div([
362
- html.H2("🤖 AI深度學習預測 - 台指期指數", style={'text-align': 'center','color': '#FFCC22','margin-bottom': '25px'}),
363
- html.Div([
364
- html.Div([
365
- html.Label("預測期間:", style={'font-weight': 'bold', 'color': '#FFCC22'}),
366
- dcc.Dropdown(id='taiex-prediction-period',
367
- options=[{'label': f'{d}日後預測', 'value': d} for d in [1, 5, 10, 20, 60]], value=5,
368
- style={'margin-bottom': '10px', 'color': '#272727'})
369
- ], style={'width': '30%', 'display': 'inline-block'}),
370
- html.Div(id='taiex-prediction-results', style={'width': '65%', 'display': 'inline-block', 'margin-left': '5%'})
371
- ]),
372
- html.Div([dcc.Graph(id='taiex-prediction-chart')], style={'margin-top': '20px'})
373
- ], 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'}),
374
- html.Div([
375
- html.H3("📰 市場情緒與新聞分析", style={'color': '#E74C3C', 'margin-bottom': '20px'}),
376
- html.Div([
377
- html.Div([html.H4("市場情緒指標", style={'color': '#8E44AD'}), html.Div(id='sentiment-gauge')], style={'width': '48%', 'display': 'inline-block'}),
378
- html.Div([html.H4("關鍵新聞摘要", style={'color': '#27AE60'}), html.Div(id='news-summary', style={'background': '#f8f9fa','padding': '15px','border-radius': '8px','max-height': '200px','overflow-y': 'auto'})], style={'width': '48%', 'display': 'inline-block', 'margin-left': '4%'})
379
- ])
380
- ], style={'margin-top': '30px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
381
- html.Div([
382
- html.Label("選擇股票:"),
383
- dcc.Dropdown(id='stock-dropdown', options=[{'label': name, 'value': symbol} for name, symbol in TAIWAN_STOCKS.items()], value='2330.TW'),
384
- html.Label("時間範圍:"),
385
- dcc.Dropdown(id='period-dropdown', options=[{'label': '1個月', 'value': '1mo'}, {'label': '6個月', 'value': '6mo'}, {'label': '1年', 'value': '1y'}], value='6mo'),
386
- html.Label("圖表類型:"),
387
- dcc.Dropdown(id='chart-type', options=[{'label': '線圖', 'value': 'line'}, {'label': '蠟燭圖', 'value': 'candlestick'}], value='candlestick'),
388
- ]),
389
- dcc.Graph(id='price-chart'),
390
- ])
391
 
392
- # Dash App 中獲取底層的 Flask server
393
- dash_server = dash_app.server
 
 
 
 
 
 
 
 
 
 
394
 
395
- # 【修正】使用 WSGIMiddleware 將 Flask server 掛載到 FastAPI 的根路徑 "/"
396
- fastapi_app.mount("/", WSGIMiddleware(dash_server))
397
 
398
- # 主程式執行點 (用於本機測試)
399
- if __name__ == "__main__":
400
- uvicorn.run(fastapi_app, host="0.0.0.0", port=7860)
 
 
1
+ # HUGING_FACE_V2.1.3.py (整合 Bert_predict 版本)
2
 
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
10
+ from dash import Dash, dcc, html, callback
11
+ import dash
 
 
 
 
 
 
 
 
12
  import plotly.express as px
13
  import plotly.graph_objects as go
14
  from plotly.subplots import make_subplots
15
+ import re
16
+ from bs4 import BeautifulSoup
17
+ import requests
 
 
 
 
 
 
 
18
 
19
  # 引用您組員的預測器程式
20
+ from Bert_predict import BertPredictor
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
+ # 台股代號對應表 (移除台指期,因為它現在是獨立區塊)
23
  TAIWAN_STOCKS = {
24
+ '元大台灣50': '0050.TW', # 新增
25
+ '台積電': '2330.TW',
26
+ '聯發科': '2454.TW',
27
+ '鴻海': '2317.TW',
28
+ '台塑': '1301.TW',
29
+ '中華電': '2412.TW',
30
+ '富邦金': '2881.TW',
31
+ '國泰金': '2882.TW',
32
+ '台達電': '2308.TW',
33
+ '統一': '1216.TW',
34
+ '日月光': '3711.TW',
35
+ '長榮': '2603.TW',
36
+ '慧洋-KY': '2637.TW',
37
+ '上銀': '2049.TW',
38
+ '台泥': '1101.TW',
39
+ '南亞科': '2408.TW',
40
+ '旺宏': '2337.TW',
41
+ '譜瑞-KY': '4966.TWO',
42
+ '貿聯-KY': '3665.TW',
43
+ '騰雲': '6870.TWO',
44
+ '穩懋': '3105.TWO'
45
  }
46
 
47
+ # 產業分類
48
  INDUSTRY_MAPPING = {
49
+ '0050.TW': 'ETF', # 新增
50
+ '2330.TW': '半導體',
51
+ '2454.TW': '半導體',
52
+ '2317.TW': '電子組件',
53
+ '1301.TW': '塑膠',
54
+ '2412.TW': '電信',
55
+ '2881.TW': '金融',
56
+ '2882.TW': '金融',
57
+ '2308.TW': '電子',
58
+ '1216.TW': '食品',
59
+ '3711.TW': '半導體',
60
+ '2603.TW': '航運',
61
+ '2637.TW': '散裝航運',
62
+ '2049.TW': '工具機',
63
+ '1101.TW': '營建',
64
+ '2408.TW': 'DRAM',
65
+ '2337.TW': 'NFLSH',
66
+ '1101.TW': '營建',
67
+ '4966.TWO': '高速傳輸',
68
+ '3665.TW': '連接器',
69
+ '6870.TWO': '軟體整合',
70
+ '3105.TWO': 'PA功率'
71
  }
72
 
 
73
  def get_stock_data(symbol, period='1y'):
74
+ """獲取股票資料"""
75
  try:
76
  stock = yf.Ticker(symbol)
77
  data = stock.history(period=period)
78
+ if data.empty and symbol == 'TXF=F':
79
+ stock = yf.Ticker('0050.TW')
80
  data = stock.history(period=period)
81
+ if data.empty:
82
+ stock = yf.Ticker('^TWII')
83
+ data = stock.history(period=period)
84
  return data
85
+ except:
 
86
  return pd.DataFrame()
87
 
88
+ def simple_lstm_predict(data, predict_days=5):
89
+ """簡化的LSTM預測模型 (使用統計方法模擬)"""
90
+ if len(data) < 60:
91
+ return None
92
+ prices = data['Close'].values
93
+ ma_short = np.mean(prices[-5:])
94
+ ma_medium = np.mean(prices[-20:])
95
+ ma_long = np.mean(prices[-60:])
96
+ recent_trend = np.polyfit(range(20), prices[-20:], 1)[0]
97
+ volatility = np.std(prices[-20:]) / np.mean(prices[-20:])
98
+ base_change = recent_trend * predict_days
99
+ trend_factor = 1.0
100
+ if ma_short > ma_medium > ma_long:
101
+ trend_factor = 1.02
102
+ elif ma_short < ma_medium < ma_long:
103
+ trend_factor = 0.98
104
+ else:
105
+ trend_factor = 1.0
106
+ noise_factor = np.random.normal(1, volatility * 0.1)
107
+ predicted_price = prices[-1] * trend_factor + base_change + (prices[-1] * noise_factor * 0.01)
108
+ change_pct = ((predicted_price - prices[-1]) / prices[-1]) * 100
109
+ return {
110
+ 'predicted_price': predicted_price,
111
+ 'change_pct': change_pct,
112
+ 'confidence': max(0.6, 1 - volatility * 2)
113
+ }
114
+
115
+ def calculate_technical_indicators(df):
116
+ """計算技術指標"""
117
  if df.empty: return df
118
  df['MA5'] = df['Close'].rolling(window=5).mean()
119
  df['MA20'] = df['Close'].rolling(window=20).mean()
120
  delta = df['Close'].diff()
121
  gain = (delta.where(delta > 0, 0)).rolling(window=14).mean()
122
  loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
123
+ rs = gain / loss
124
  df['RSI'] = 100 - (100 / (1 + rs))
125
+ exp1 = df['Close'].ewm(span=12).mean()
126
+ exp2 = df['Close'].ewm(span=26).mean()
127
  df['MACD'] = exp1 - exp2
128
+ df['MACD_Signal'] = df['MACD'].ewm(span=9).mean()
129
  df['MACD_Histogram'] = df['MACD'] - df['MACD_Signal']
130
  df['BB_Middle'] = df['Close'].rolling(window=20).mean()
131
  bb_std = df['Close'].rolling(window=20).std()
 
134
  low_min = df['Low'].rolling(window=9).min()
135
  high_max = df['High'].rolling(window=9).max()
136
  rsv = (df['Close'] - low_min) / (high_max - low_min) * 100
137
+ df['K'] = rsv.ewm(com=2).mean()
138
+ df['D'] = df['K'].ewm(com=2).mean()
139
+ low_min_14 = df['Low'].rolling(window=14).min()
140
+ high_max_14 = df['High'].rolling(window=14).max()
141
+ df['Williams_R'] = -100 * (high_max_14 - df['Close']) / (high_max_14 - low_min_14)
142
+ df['up_move'] = df['High'] - df['High'].shift(1)
143
+ df['down_move'] = df['Low'].shift(1) - df['Low']
144
+ df['+DM'] = np.where((df['up_move'] > df['down_move']) & (df['up_move'] > 0), df['up_move'], 0)
145
+ df['-DM'] = np.where((df['down_move'] > df['up_move']) & (df['down_move'] > 0), df['down_move'], 0)
146
+ df['TR'] = np.max([df['High'] - df['Low'], abs(df['High'] - df['Close'].shift(1)), abs(df['Low'] - df['Close'].shift(1))], axis=0)
147
+ df['+DI'] = (df['+DM'].ewm(com=13, adjust=False).mean() / df['TR'].ewm(com=13, adjust=False).mean()) * 100
148
+ df['-DI'] = (df['-DM'].ewm(com=13, adjust=False).mean() / df['TR'].ewm(com=13, adjust=False).mean()) * 100
149
+ df['DX'] = abs(df['+DI'] - df['-DI']) / (df['+DI'] + df['-DI']) * 100
150
+ df['ADX'] = df['DX'].ewm(com=13, adjust=False).mean()
151
  return df
152
 
153
+ def calculate_volume_profile(df, num_bins=50):
154
+ 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
155
+ all_prices = np.concatenate([df['High'].values, df['Low'].values])
156
+ min_price, max_price = all_prices.min(), all_prices.max()
157
+ price_for_volume = (df['High'] + df['Low'] + df['Close']) / 3
158
+ df_vol_profile = df.copy()
159
+ df_vol_profile['Price_Indicator'] = price_for_volume
160
+ hist, bin_edges = np.histogram(df_vol_profile['Price_Indicator'], bins=num_bins, range=(min_price, max_price), weights=df_vol_profile['Volume'])
161
+ price_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
162
+ return bin_edges, hist, price_centers
163
+
164
+ def get_business_climate_data():
165
+ try:
166
+ if not os.path.exists('business_climate.csv'): return pd.DataFrame()
167
+ df = pd.read_csv('business_climate.csv')
168
+ if 'Date' not in df.columns: df.columns = ['Date', 'Index'] if len(df.columns) == 2 else df.columns
169
+ if 'Date' in df.columns:
170
+ try: df['Date'] = pd.to_datetime(df['Date'] + '-01', format='%Y-%m-%d', errors='coerce')
171
+ except: df['Date'] = pd.to_datetime(df['Date'], errors='coerce')
172
+ df = df.dropna(subset=['Date'])
173
+ return df
174
+ except Exception as e:
175
+ print(f"無法獲取景氣燈號資料: {str(e)}")
176
+ return pd.DataFrame()
177
+
178
+ def get_pmi_data():
179
+ try:
180
+ if not os.path.exists('taiwan_pmi.csv'): return pd.DataFrame()
181
+ df = pd.read_csv('taiwan_pmi.csv')
182
+ if 'DATE' in df.columns: df = df.rename(columns={'DATE': 'Date', 'INDEX': 'Index'})
183
+ elif len(df.columns) == 2: df.columns = ['Date', 'Index']
184
+ if 'Date' in df.columns:
185
+ try: df['Date'] = pd.to_datetime(df['Date'] + '-01', format='%Y-%m-%d', errors='coerce')
186
+ except: df['Date'] = pd.to_datetime(df['Date'], errors='coerce')
187
+ df = df.dropna(subset=['Date'])
188
+ return df
189
+ except Exception as e:
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)
200
+ print("新聞情緒分析模型初始化成功。")
201
+ except Exception as e:
202
+ print(f"錯誤:新聞情緒分析模型初始化失敗 - {e}")
203
+ predictor = None
204
+
205
+ # 應用程式佈局
206
+ app.layout = html.Div([
207
+ html.H1("台股分析儀表板", style={'text-align': 'center', 'margin-bottom': '30px'}),
208
+ html.Div([
209
+ html.H2("🤖 AI深度學習預測 - 台指期指數", style={'text-align': 'center','color': '#FFCC22','margin-bottom': '25px'}),
210
+ html.Div([
211
+ html.Div([
212
+ html.Label("預測期間:", style={'font-weight': 'bold', 'color': '#FFCC22'}),
213
+ dcc.Dropdown(id='taiex-prediction-period',
214
+ options=[
215
+ {'label': '1日後預測', 'value': 1},{'label': '5日後預測', 'value': 5},
216
+ {'label': '10日後預測', 'value': 10},{'label': '20日後預測', 'value': 20},
217
+ {'label': '60日後預測', 'value': 60}], value=5,
218
+ style={'margin-bottom': '10px', 'color': '#272727'})
219
+ ], style={'width': '30%', 'display': 'inline-block'}),
220
+ html.Div(id='taiex-prediction-results', style={'width': '65%', 'display': 'inline-block', 'margin-left': '5%'})
221
+ ]),
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([
229
+ html.Div([
230
+ html.H4("市場情緒指標", style={'color': '#8E44AD'}),
231
+ html.Div(id='sentiment-gauge')
232
+ ], style={'width': '48%', 'display': 'inline-block'}),
233
+ html.Div([
234
+ html.H4("關鍵新聞摘要", style={'color': '#27AE60'}),
235
+ html.Div(id='news-summary', style={'background': '#f8f9fa','padding': '15px','border-radius': '8px','max-height': '200px','overflow-y': 'auto'})
236
+ ], style={'width': '48%', 'display': 'inline-block', 'margin-left': '4%'})
237
+ ])
238
+ ], style={'margin-top': '30px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
239
+
240
+ html.Div([
241
+ html.H3("景氣燈號與 PMI 分析"),
242
+ html.Div([
243
+ html.Div([dcc.Graph(id='business-climate-chart')], style={'width': '48%', 'display': 'inline-block'}),
244
+ html.Div([dcc.Graph(id='pmi-chart')], style={'width': '48%', 'display': 'inline-block', 'margin-left': '2%'})
245
+ ])
246
+ ], style={'margin-top': '30px'}),
247
+
248
+ html.Div([
249
+ html.Div([
250
+ html.Label("選擇股票:"),
251
+ dcc.Dropdown(id='stock-dropdown', options=[{'label': name, 'value': symbol} for name, symbol in TAIWAN_STOCKS.items()], value='2330.TW', style={'margin-bottom': '10px'})
252
+ ], style={'width': '30%', 'display': 'inline-block', 'vertical-align': 'top'}),
253
+ 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("圖表類型:"),
261
+ dcc.Dropdown(id='chart-type', options=[{'label': '線圖', 'value': 'line'},{'label': '蠟燭圖', 'value': 'candlestick'}], value='candlestick', style={'margin-bottom': '10px'})
262
+ ], style={'width': '30%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'})
263
+ ], style={'margin-bottom': '30px'}),
264
+
265
+ html.Div(id='stock-info-cards', style={'margin-bottom': '30px'}),
266
+ html.Div([html.Div([dcc.Graph(id='price-chart')], style={'width': '100%', 'display': 'inline-block', 'vertical-align': 'top'})]),
267
+ html.Div([
268
+ html.H3("📊 進階技術指標分析", style={'margin-bottom': '20px'}),
269
+ html.Div([
270
+ html.Label("選擇技術指標:", style={'font-weight': 'bold', 'margin-right': '10px'}),
271
+ dcc.Dropdown(id='technical-indicator-selector',
272
+ options=[{'label': 'RSI 相對強弱指標', 'value': 'RSI'},{'label': 'MACD 指數平滑異同移動平均線', 'value': 'MACD'},{'label': '布林通道 Bollinger Bands', 'value': 'BB'},
273
+ {'label': 'KD 隨機指標', 'value': 'KD'},{'label': '威廉指標 %R', 'value': 'WR'},{'label': 'DMI 動向指標', 'value': 'DMI'}],
274
+ value='RSI', style={'width': '100%'})
275
+ ], style={'margin-bottom': '20px'}),
276
+ html.Div([dcc.Graph(id='advanced-technical-chart')])
277
+ ], style={'margin-top': '20px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
278
+ html.Div([dcc.Graph(id='volume-chart')], style={'margin-top': '20px'}),
279
+ html.Div([html.H3("產業表現分析"), dcc.Graph(id='industry-analysis')], style={'margin-top': '30px'}),
280
+ html.Div([
281
+ html.H3("📊 分析師觀點與市場解讀", style={'color': '#2E86AB', 'margin-bottom': '20px'}),
282
+ html.Div([
283
+ html.Div([
284
+ html.H4("🔍 技術面分析", style={'color': '#A23B72', 'margin-bottom': '15px'}),
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'}),
297
+ html.Div([
298
+ html.H3("📊 多檔股票比較分析", style={'margin-bottom': '20px'}),
299
+ html.Div([
300
+ html.Div([
301
+ html.Label("選擇比較股票(最多5檔):", style={'font-weight': 'bold'}),
302
+ 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'}),
303
+ html.Small('(元大台灣50 (0050.TW) 為固定比較基準,不可移除)', style={'display': 'block', 'font-style': 'italic', 'color': 'gray'})
304
+ ], style={'width': '60%', 'display': 'inline-block'}),
305
+ html.Div([
306
+ html.Label("比較期間:", style={'font-weight': 'bold'}),
307
+ dcc.Dropdown(id='comparison-period', options=[{'label': '1個月', 'value': '1mo'},{'label': '3個月', 'value': '3mo'},{'label': '6個月', 'value': '6mo'},{'label': '1年', 'value': '1y'}], value='3mo')
308
+ ], style={'width': '35%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'})
309
+ ]),
310
+ html.Div([
311
+ html.Div([dcc.Graph(id='comparison-chart')], style={'width': '65%', 'display': 'inline-block'}),
312
+ html.Div([html.H4("比較結果", style={'color': '#2E86AB'}), html.Div(id='comparison-table')], style={'width': '33%', 'display': 'inline-block', 'margin-left': '2%', 'vertical-align': 'top'})
313
+ ])
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')],
321
+ [dash.dependencies.Input('taiex-prediction-period', 'value')]
322
+ )
323
+ def update_taiex_prediction(predict_days):
324
+ data = get_stock_data('^TWII', '2y')
325
+ if data.empty: return html.Div("無法獲取台指期資料"), {}
326
+ final_prediction = simple_lstm_predict(data, predict_days)
327
+ if final_prediction is None: return html.Div("資料不足,無法進行預測"), {}
328
+ current_price, last_date = data['Close'].iloc[-1], data.index[-1]
329
+ predicted_price, change_pct, confidence = final_prediction['predicted_price'], final_prediction['change_pct'], final_prediction['confidence']
330
+ prediction_paths = {1: [1], 5: [1, 5], 10: [1, 5, 10], 20: [1, 10, 20], 60: [1, 10, 20, 60]}
331
+ intervals_to_predict = prediction_paths.get(predict_days, [predict_days])
332
+ prediction_dates, prediction_prices = [last_date], [current_price]
333
+ for days in intervals_to_predict:
334
+ interim_prediction = simple_lstm_predict(data, days)
335
+ if interim_prediction:
336
+ prediction_dates.append(last_date + timedelta(days=days))
337
+ prediction_prices.append(interim_prediction['predicted_price'])
338
+ color, arrow = ('red', '📈') if change_pct >= 0 else ('green', '📉')
339
+ result_card = html.Div([
340
+ html.H4(f"{predict_days}日後預測結果", style={'margin': '0 0 15px 0', 'color': 'white'}),
341
+ 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'}),
342
+ html.P(f"目前價格: {current_price:.2f}", style={'margin': '5px 0'}), html.P(f"預測價格: {predicted_price:.2f}", style={'margin': '5px 0'}),
343
+ html.P(f"信心度: {confidence:.1%}", style={'margin': '5px 0', 'font-size': '14px'})
344
+ ], style={'background': 'rgba(255,255,255,0.1)','padding': '20px','border-radius': '10px','border': '1px solid rgba(255,255,255,0.2)'})
345
+ fig = go.Figure()
346
+ recent_data = data.tail(30)
347
+ fig.add_trace(go.Scatter(x=recent_data.index, y=recent_data['Close'], mode='lines', name='歷史價格', line=dict(color='#FFA726', width=2)))
348
+ 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)))
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')]
356
+ )
357
+ def update_stock_info(selected_stock):
358
+ data = get_stock_data(selected_stock, '5d')
359
+ if data.empty: return html.Div("無法獲取股票資料")
360
+ current_price = data['Close'].iloc[-1]
361
+ prev_price = data['Close'].iloc[-2] if len(data) > 1 else current_price
362
+ change = current_price - prev_price
363
+ change_pct = (change / prev_price) * 100
364
+ stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
365
+ color, arrow = ('red', '▲') if change >= 0 else ('green', '▼')
366
+ return html.Div([
367
+ html.Div([
368
+ html.H3(f"{stock_name} ({selected_stock})", style={'margin': '0'}),
369
+ html.H2(f"${current_price:.2f}", style={'margin': '5px 0', 'color': color}),
370
+ html.P(f"{arrow} {change:+.2f} ({change_pct:+.2f}%)", style={'margin': '0', 'color': color, 'font-weight': 'bold'})
371
+ ], 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'}),
372
+ html.Div([
373
+ html.H4("今日統計", style={'margin': '0 0 10px 0'}),
374
+ html.P(f"最高: ${data['High'].iloc[-1]:.2f}", style={'margin': '5px 0'}),
375
+ html.P(f"最低: ${data['Low'].iloc[-1]:.2f}", style={'margin': '5px 0'}),
376
+ html.P(f"成交量: {data['Volume'].iloc[-1]:,.0f}", style={'margin': '5px 0'})
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'),
384
+ dash.dependencies.Input('period-dropdown', 'value'),
385
+ dash.dependencies.Input('chart-type', 'value')]
386
+ )
387
+ def update_price_chart(selected_stock, period, chart_type):
388
+ data = get_stock_data(selected_stock, period)
389
+ if data.empty: return {}
390
+ data = calculate_technical_indicators(data)
391
+ stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
392
+ fig = make_subplots(rows=1, cols=2, shared_yaxes=True, column_widths=[0.8, 0.2], horizontal_spacing=0.01)
393
+ if chart_type == 'candlestick':
394
+ fig.add_trace(go.Candlestick(x=data.index, open=data['Open'], high=data['High'], low=data['Low'], close=data['Close'], name=stock_name, increasing_line_color='red', decreasing_line_color='green'), row=1, col=1)
395
+ else:
396
+ fig.add_trace(px.line(data, y='Close').data[0], row=1, col=1)
397
+ fig.add_trace(go.Scatter(x=data.index, y=data['MA5'], mode='lines', name='MA5', line=dict(color='orange')), row=1, col=1)
398
+ fig.add_trace(go.Scatter(x=data.index, y=data['MA20'], mode='lines', name='MA20', line=dict(color='blue')), row=1, col=1)
399
+ bin_edges, volume_per_bin, price_centers = calculate_volume_profile(data, num_bins=50)
400
+ if volume_per_bin is not None:
401
+ fig.add_trace(go.Bar(orientation='h', y=price_centers, x=volume_per_bin, name='Volume Profile', text=[f'{vol/1000:.0f}k' for vol in volume_per_bin], textposition='auto', marker=dict(color='rgba(173, 216, 230, 0.6)', line=dict(color='rgba(30, 144, 255, 0.8)', width=1))), row=1, col=2)
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'),
409
+ dash.dependencies.Input('stock-dropdown', 'value'),
410
+ dash.dependencies.Input('period-dropdown', 'value')]
411
+ )
412
+ def update_advanced_technical_chart(indicator, selected_stock, period):
413
+ data = get_stock_data(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)))
421
+ fig.add_hline(y=70, line_dash="dash", line_color="green", annotation_text="超買線(70)")
422
+ fig.add_hline(y=30, line_dash="dash", line_color="red", annotation_text="超賣線(30)")
423
+ fig.add_hline(y=50, line_dash="dot", line_color="gray", annotation_text="中線(50)")
424
+ fig.update_layout(title=f'{stock_name} - RSI 相對強弱指標', xaxis_title='日期', yaxis_title='RSI', height=450, yaxis=dict(range=[0, 100]))
425
+ elif indicator == 'MACD':
426
+ fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1, row_heights=[0.7, 0.3], subplot_titles=('價格走勢', 'MACD 指標'))
427
+ 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)
428
+ 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)
429
+ 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)
430
+ colors = ['red' if x >= 0 else 'green' for x in data['MACD_Histogram']]
431
+ fig.add_trace(go.Bar(x=data.index, y=data['MACD_Histogram'], name='MACD柱狀圖', marker_color=colors), row=2, col=1)
432
+ fig.update_layout(title_text=f'{stock_name} - MACD 指數平滑異同移動平均線', height=550)
433
+ elif indicator == 'BB':
434
+ fig = go.Figure()
435
+ fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價', line=dict(color='black', width=2)))
436
+ fig.add_trace(go.Scatter(x=data.index, y=data['BB_Upper'], mode='lines', name='上軌', line=dict(color='red', width=1, dash='dash')))
437
+ fig.add_trace(go.Scatter(x=data.index, y=data['BB_Middle'], mode='lines', name='中軌(MA20)', line=dict(color='blue', width=1)))
438
+ fig.add_trace(go.Scatter(x=data.index, y=data['BB_Lower'], mode='lines', name='下軌', line=dict(color='green', width=1, dash='dash')))
439
+ fig.update_layout(title=f'{stock_name} - 布林通道 (20日, 2σ)', xaxis_title='日期', yaxis_title='價格 (TWD)', height=450)
440
+ elif indicator == 'KD':
441
+ fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1, row_heights=[0.6, 0.4], subplot_titles=('價格走勢', 'KD指標'))
442
+ fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價', line=dict(color='black', width=1)), row=1, col=1)
443
+ 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)
444
+ 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)
445
+ fig.add_hline(y=80, line_dash="dash", line_color="green", annotation_text="超買線(80)", row=2, col=1)
446
+ fig.add_hline(y=20, line_dash="dash", line_color="red", annotation_text="超賣線(20)", row=2, col=1)
447
+ fig.update_layout(title=f'{stock_name} - KD 隨機指標 (9,3,3)', height=500, yaxis2_range=[0, 100])
448
+ elif indicator == 'WR':
449
+ fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1, row_heights=[0.6, 0.4], subplot_titles=('價格走勢', '威廉指標 %R'))
450
+ fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價', line=dict(color='black', width=1)), row=1, col=1)
451
+ 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)
452
+ fig.add_hline(y=-20, line_dash="dash", line_color="green", annotation_text="超買線(-20)", row=2, col=1)
453
+ fig.add_hline(y=-80, line_dash="dash", line_color="red", annotation_text="超賣線(-80)", row=2, col=1)
454
+ fig.update_layout(title=f'{stock_name} - 威廉指標 %R (14日)', height=500, yaxis2_range=[-100, 0])
455
+ elif indicator == 'DMI':
456
+ fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1, row_heights=[0.6, 0.4], subplot_titles=('價格走勢', 'DMI 指標'))
457
+ data_filtered = data.iloc[14:]
458
+ 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)
459
+ 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)
460
+ 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)
461
+ 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)
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'),
469
+ dash.dependencies.Input('period-dropdown', 'value')]
470
+ )
471
+ def update_volume_chart(selected_stock, period):
472
+ data = get_stock_data(selected_stock, period)
473
+ if data.empty: return {}
474
+ stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
475
+ colors = ['red' if data['Close'].iloc[i] > data['Open'].iloc[i] else 'green' for i in range(len(data))]
476
+ fig = go.Figure(go.Bar(x=data.index, y=data['Volume'], marker_color=colors, name='成交量'))
477
+ fig.update_layout(title=f'{stock_name} 成交量', xaxis_title='日期', yaxis_title='成交量', height=300)
478
+ return fig
479
+
480
+ # 更新產業分析圖表
481
+ @app.callback(
482
+ dash.dependencies.Output('industry-analysis', 'figure'),
483
+ [dash.dependencies.Input('stock-dropdown', 'value')]
484
+ )
485
+ def update_industry_analysis(selected_stock):
486
+ industry_data = []
487
+ for symbol in list(TAIWAN_STOCKS.values())[:10]:
488
+ data = get_stock_data(symbol, '1mo')
489
+ if not data.empty:
490
+ stock_name = [name for name, symbol_code in TAIWAN_STOCKS.items() if symbol_code == symbol][0]
491
+ return_pct = ((data['Close'].iloc[-1] - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
492
+ industry_data.append({'股票': stock_name, '代碼': symbol, '月報酬率(%)': return_pct, '產業': INDUSTRY_MAPPING.get(symbol, '其他')})
493
+ if not industry_data: return {}
494
+ df_industry = pd.DataFrame(industry_data)
495
+ fig = px.pie(df_industry, values='月報酬率(%)', names='股票', title='各股票月報酬率比較', color_discrete_sequence=px.colors.qualitative.Set3)
496
+ fig.update_layout(height=400)
497
+ return fig
498
+
499
+ # 更新景氣燈號圖表
500
+ @app.callback(
501
+ dash.dependencies.Output('business-climate-chart', 'figure'),
502
+ [dash.dependencies.Input('stock-dropdown', 'value')]
503
+ )
504
+ def update_business_climate_chart(selected_stock):
505
+ df = get_business_climate_data()
506
+ if df.empty:
507
+ fig = go.Figure().add_annotation(text="無法���入景氣燈號資料", showarrow=False)
508
+ fig.update_layout(title="台灣景氣燈號", height=300)
509
+ return fig
510
+ def get_light_color(score):
511
+ if score >= 32: return 'red'
512
+ elif score >= 24: return 'orange'
513
+ elif score >= 17: return 'yellow'
514
+ elif score >= 10: return 'lightgreen'
515
+ else: return 'blue'
516
+ colors = [get_light_color(score) for score in df['Index']]
517
+ fig = go.Figure()
518
+ 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'))))
519
+ fig.add_hline(y=32, line_dash="dash", line_color="red", annotation_text="紅燈(32)")
520
+ fig.add_hline(y=17, line_dash="dash", line_color="yellow", annotation_text="黃燈(17)")
521
+ fig.update_layout(title="台灣景氣燈號走勢", xaxis_title='日期', yaxis_title='燈號分數', height=300, yaxis=dict(range=[0, 40]))
522
+ return fig
523
+
524
+ # 更新分析師觀點
525
+ @app.callback(
526
+ [dash.dependencies.Output('technical-analysis-text', 'children'),
527
+ dash.dependencies.Output('fundamental-analysis-text', 'children'),
528
+ dash.dependencies.Output('market-outlook-text', 'children')],
529
+ [dash.dependencies.Input('stock-dropdown', 'value'),
530
+ dash.dependencies.Input('period-dropdown', 'value')]
531
+ )
532
+ def update_analysis_text(selected_stock, period):
533
+ data = get_stock_data(selected_stock, period)
534
+ stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
535
+ if data.empty: return "無法獲取資料", "無法獲取資料", "無法獲取資料"
536
+ data = calculate_technical_indicators(data)
537
+ current_price = data['Close'].iloc[-1]
538
+ price_change = ((current_price - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
539
+ rsi_current = data['RSI'].iloc[-1] if not pd.isna(data['RSI'].iloc[-1]) else 50
540
+ macd_current = data['MACD'].iloc[-1] if not pd.isna(data['MACD'].iloc[-1]) else 0
541
+ macd_signal_current = data['MACD_Signal'].iloc[-1] if not pd.isna(data['MACD_Signal'].iloc[-1]) else 0
542
+ technical_text = html.Div([
543
+ 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}%。"]),
544
+ 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'}), "。"]),
545
+ 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 '空頭'}格局。"]),
546
+ ])
547
+ industry = INDUSTRY_MAPPING.get(selected_stock, '綜合')
548
+ fundamental_text = html.Div([
549
+ 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'}), "。"]),
550
+ html.P([html.Strong("營運展望:"), f"建議持續關注季報表現及未來指引。"]),
551
+ ])
552
+ outlook_tone = "謹慎樂觀" if price_change > 10 else "保守觀望" if price_change < -10 else "中性持平"
553
+ market_outlook = html.Div([
554
+ html.P([html.Strong("整體評估:"), f"基於技術面及基本面分析,對{stock_name}採取", html.Span(f"{outlook_tone}", style={'font-weight': 'bold'}), "態度。"]),
555
+ html.P([html.Strong("投資建議:"), "短線操作注意技術指標,長線投資關注基本面變化。"]),
556
+ ])
557
+ return technical_text, fundamental_text, market_outlook
558
+
559
+ # 更新PMI圖表
560
+ @app.callback(
561
+ dash.dependencies.Output('pmi-chart', 'figure'),
562
+ [dash.dependencies.Input('stock-dropdown', 'value')]
563
+ )
564
+ def update_pmi_chart(selected_stock):
565
+ df = get_pmi_data()
566
+ if df.empty:
567
+ fig = go.Figure().add_annotation(text="無法載入PMI資料", showarrow=False)
568
+ fig.update_layout(title="台灣PMI指數", height=300)
569
+ return fig
570
+ colors = ['red' if value >= 50 else 'green' for value in df['Index']]
571
+ fig = go.Figure()
572
+ 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'))))
573
+ fig.add_hline(y=50, line_dash="dash", line_color="black", annotation_text="榮枯線(50)")
574
+ fig.update_layout(title="台灣PMI指數走勢", xaxis_title='日期', yaxis_title='PMI指數', height=300, yaxis=dict(range=[35, 60]))
575
+ return fig
576
 
577
  def summarize_news_with_gemini(news_list: list) -> str:
578
+ """
579
+ 使用 Gemini API 將英文新聞標題列表摘要成一段繁體中文。
580
+
581
+ Args:
582
+ news_list (list): 包含英文新聞標題字串的列表。
583
+
584
+ Returns:
585
+ str: Gemini 生成的繁體中文摘要,或在發生錯誤時回傳錯誤訊息。
586
+ """
587
+ # 從 Hugging Face Secrets 安全地讀取 API 金鑰
588
  api_key = os.getenv("GEMINI_API_KEY")
589
  if not api_key:
590
  return "錯誤:找不到 GEMINI_API_KEY。請在 Hugging Face Secrets 中設定。"
591
+
592
  try:
593
  genai.configure(api_key=api_key)
594
+ model = genai.GenerativeModel('gemini-1.5-flash') # 使用速度較快的 Flash 模型
595
+
596
+ # 將新聞列表格式化,方便 AI 閱讀
597
  formatted_news = "\n".join([f"- {news}" for news in news_list])
598
+
599
+ # 這就是您對 AI 下的指令 (Prompt)
600
  prompt = f"""
601
  請扮演一位專業的金融市場分析師。
602
+ 以下是幾則最新的英文財經新聞標題,請將它們整合成一段簡潔、流暢、約 200 字的繁體中文市場動態摘要,與利多哪些產業,利空哪些產業。
603
+ 提供3段重點,
604
+ 請專注於可能影響市場情緒和股價的關鍵資訊,並直接提供摘要內容,不要包含任何額外的問候語或說明。
605
 
606
  英文新聞標題如下:
607
  {formatted_news}
608
  """
609
+
610
  response = model.generate_content(prompt)
611
  return response.text
612
+
613
  except Exception as e:
614
  print(f"呼叫 Gemini API 時發生錯誤: {e}")
615
  return f"無法生成新聞摘要,請稍後再試。錯誤訊息:{e}"
616
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
617
 
 
 
 
 
 
 
 
 
 
 
 
 
 
618
 
619
+ # 更新多檔股票比較
620
+ @app.callback(
621
+ [dash.dependencies.Output('comparison-chart', 'figure'),
622
+ dash.dependencies.Output('comparison-table', 'children')],
623
+ [dash.dependencies.Input('comparison-stocks', 'value'),
624
+ dash.dependencies.Input('comparison-period', 'value')]
625
+ )
626
+ def update_comparison_analysis(selected_stocks, period):
627
+ fixed_stock = '0050.TW'
628
+ if not selected_stocks: selected_stocks = [fixed_stock]
629
+ elif fixed_stock not in selected_stocks: selected_stocks.insert(0, fixed_stock)
630
+ selected_stocks = selected_stocks[:5]
631
+ fig = go.Figure()
632
+ comparison_data = []
633
+ for stock in selected_stocks:
634
+ data = get_stock_data(stock, period)
635
+ if not data.empty:
636
+ stock_name = next((name for name, symbol in TAIWAN_STOCKS.items() if symbol == stock), stock)
637
+ normalized_prices = (data['Close'] / data['Close'].iloc[0]) * 100
638
+ fig.add_trace(go.Scatter(x=data.index, y=normalized_prices, mode='lines', name=stock_name, line=dict(width=2)))
639
+ total_return = ((data['Close'].iloc[-1] / data['Close'].iloc[0]) - 1) * 100
640
+ volatility = data['Close'].pct_change().std() * np.sqrt(252) * 100
641
+ comparison_data.append({'name': stock_name, 'return': total_return, 'volatility': volatility, 'current_price': data['Close'].iloc[-1]})
642
+ fig.update_layout(title=f'股票績效比較 - {period}', xaxis_title='日期', yaxis_title='相對績效 (基期=100)', height=400, hovermode='x unified')
643
+ if comparison_data:
644
+ table_rows = []
645
+ for item in sorted(comparison_data, key=lambda x: x['return'], reverse=True):
646
+ color = 'red' if item['return'] > 0 else 'green'
647
+ 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}")]))
648
+ table = html.Table([html.Thead(html.Tr([html.Th("股票"), html.Th("報酬率"), html.Th("波動率"), html.Th("現價")])), html.Tbody(table_rows)], style={'width': '100%'})
649
+ return fig, table
650
+ return fig, html.Div("無可比較資料")
651
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
 
653
+ # ==============================================================================
654
+ # ===== 【修改】市場情緒與新聞分析 (使用真實 BERT 模型) =====
655
+ # ==============================================================================
656
+ @app.callback(
657
+ [dash.dependencies.Output('sentiment-gauge', 'children'),
658
+ dash.dependencies.Output('news-summary', 'children')],
659
+ [dash.dependencies.Input('stock-dropdown', 'value')]
660
  )
661
  def update_sentiment_analysis(selected_stock):
662
+ # 檢查 predictor 是否成功初始化 (這部分邏輯不變)
663
  if predictor is None:
664
  error_fig = go.Figure().add_annotation(text="情緒指標模型載入失敗", showarrow=False)
665
  error_fig.update_layout(height=200)
666
+ return dcc.Graph(figure=error_fig), html.P("新聞分析模型載入失敗,請檢查後台日誌。")
667
+
668
+ # --- 1. 從 predictor 獲取新聞情緒平均分數 (不變) ---
669
  sentiment_score_raw = predictor.get_news_index()
670
+
671
+ # --- 2. 建立情緒指標儀表板 (不變) ---
672
  if sentiment_score_raw is not None:
673
+ sentiment_score_normalized = (sentiment_score_raw + 1) * 50
674
+ sentiment_score_normalized = max(0, min(100, sentiment_score_normalized))
675
+ if sentiment_score_normalized >= 65:
676
+ bar_color, level_text = "#5cb85c", "樂觀"
677
+ elif sentiment_score_normalized >= 35:
678
+ bar_color, level_text = "#f0ad4e", "中性"
679
+ else:
680
+ bar_color, level_text = "#d9534f", "悲觀"
681
  gauge_fig = go.Figure(go.Indicator(
682
+ mode = "gauge+number", value = sentiment_score_normalized,
683
+ domain = {'x': [0, 1], 'y': [0, 1]},
684
+ title = {'text': f"昨日市場情緒: {level_text}", 'font': {'size': 18}},
685
+ gauge = {'axis': {'range': [0, 100]}, 'bar': {'color': bar_color},
686
+ 'steps': [{'range': [0, 35], 'color': "rgba(217, 83, 79, 0.2)"},
687
+ {'range': [35, 65], 'color': "rgba(240, 173, 78, 0.2)"},
688
+ {'range': [65, 100], 'color': "rgba(92, 184, 92, 0.2)"}]}
689
  ))
690
  gauge_fig.update_layout(height=200, margin=dict(l=30, r=30, t=50, b=20))
691
  gauge_content = dcc.Graph(figure=gauge_fig)
 
693
  error_fig = go.Figure().add_annotation(text="今日尚無情緒分數", showarrow=False)
694
  error_fig.update_layout(height=200)
695
  gauge_content = dcc.Graph(figure=error_fig)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
696
 
 
 
 
 
 
 
697
 
698
+ # --- 3. 【核心修改】獲取新聞並使用 Gemini 進行摘要 ---
699
+ top_news_list = predictor.get_news()
700
+ news_content = None # 先初始化
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
701
 
702
+ if top_news_list and isinstance(top_news_list, list): # 確保列表不為空且格式正確
703
+ # *** 呼叫我們的新函式來生成中文摘要 ***
704
+ summary_text = summarize_news_with_gemini(top_news_list)
705
+ # 使用 dcc.Markdown 來顯示,這樣如果摘要包含換行等格式會更好看
706
+ news_content = dcc.Markdown(summary_text, style={
707
+ 'margin': '8px 0', 'padding-left': '5px',
708
+ 'font-size': '15px', 'line-height': '1.7'
709
+ })
710
+ elif top_news_list == []: # 如果是空列表
711
+ news_content = html.P("昨日無重大相關新聞。", style={'text-align': 'center', 'padding-top': '50px'})
712
+ else: # 如果是 None 或其他錯誤
713
+ news_content = html.P("讀取新聞時發生錯誤。", style={'text-align': 'center', 'padding-top': '50px'})
714
 
715
+ return gauge_content, news_content
 
716
 
717
+ # 主程式執行
718
+ if __name__ == '__main__':
719
+ # Hugging Face Spaces 中執行
720
+ app.run(host="0.0.0.0", port=7860, debug=False)