AITEST / app.py
AlanRex's picture
Update app.py
63a564e verified
raw
history blame
53.7 kB
# HUGING_FACE_V4.2(輕量AI版).py - 已整合 XGBoost 模型
# 系統套件
import os
from datetime import datetime, timedelta
import google.generativeai as genai
import pandas as pd
import numpy as np
import yfinance as yf
from dash import Dash, dcc, html, callback
import dash
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import re
from bs4 import BeautifulSoup
import requests
import time # 引用 time 模組以處理時間戳
# ========================= 引用外部模組 START =========================
# 引用您組員的預測器程式
from Bert_predict import BertPredictor
# 【修改 1】: 匯入 XGBoostModel 類別
from model_predictor import XGBoostModel
# ========================== 引用外部模組 END ==========================
# ========================= 全域設定 START =========================
# 【修改 2】: 將開關設為 True 來啟用您的 XGBoost 模型
USE_ADVANCED_MODEL = True
# ========================= CACHE 設定 START =========================
# 分析結果的快取字典
ANALYSIS_CACHE = {}
# 快取有效時間(秒),例如:8 小時 = 8 * 60 * 60 = 28800 秒
CACHE_DURATION_SECONDS = 8 * 60 * 60
# ========================== CACHE 設定 END ==========================
# 【修改 3】: 在應用程式啟動時,預先載入 XGBoost 模型
try:
print("正在初始化 XGBoost 預測模型...")
xgb_model = XGBoostModel(default_model='xgboost_model')
print("XGBoost 預測模型初始化成功。")
except Exception as e:
print(f"錯誤:XGBoost 預測模型初始化失敗 - {e}")
# 如果模型載入失敗,則強制關閉進階模型開關,退回簡易模式
USE_ADVANCED_MODEL = False
xgb_model = None
print("警告:已自動切換回簡易統計模型模式。")
# ========================== 全域設定 END ==========================
# 台股代號對應表
TAIWAN_STOCKS = {
'元大台灣50': '0050.TW', '台積電': '2330.TW', '聯發科': '2454.TW',
'鴻海': '2317.TW', '台達電': '2308.TW', '廣達': '2382.TW', '富邦金': '2881.TW',
'中信金': '2891.TW', '國泰金': '2882.TW', '聯電': '2303.TW', '中華電': '2412.TW',
'玉山金': '2884.TW', '兆豐金': '2886.TW', '日月光投控': '3711.TW', '華碩': '2357.TW',
'統一': '1216.TW', '元大金': '2885.TW', '智邦': '2345.TW', '緯創': '3231.TW',
'聯詠': '3034.TW', '第一金': '2892.TW', '瑞昱': '2379.TW', '緯穎': '6669.TWO',
'永豐金': '2890.TW', '合庫金': '5880.TW', '華南金': '2880.TW', '台光電': '2383.TW',
'世芯-KY': '3661.TWO', '奇鋐': '3017.TW', '凱基金': '2883.TW', '大立光': '3008.TW',
'長榮': '2603.TW', '光寶科': '2301.TW', '中鋼': '2002.TW', '中租-KY': '5871.TW',
'國巨': '2327.TW', '台新金': '2887.TW', '上海商銀': '5876.TW', '台泥': '1101.TW',
'台灣大': '3045.TW', '和碩': '4938.TW', '遠傳': '4904.TW', '和泰車': '2207.TW',
'研華': '2395.TW', '台塑': '1301.TW', '統一超': '2912.TW', '藥華藥': '6446.TWO',
'南亞': '1303.TW', '陽明': '2609.TW', '萬海': '2615.TW', '台塑化': '6505.TW',
'慧洋-KY': '2637.TW', '上銀': '2049.TW', '南亞科': '2408.TW', '旺宏': '2337.TW',
'譜瑞-KY': '4966.TWO', '貿聯-KY': '3665.TW', '騰雲': '6870.TWO', '穩懋': '3105.TWO'
}
# 產業分類
INDUSTRY_MAPPING = {
'0050.TW': 'ETF', '2330.TW': '半導體', '2454.TW': '半導體', '2317.TW': '電子組件',
'2308.TW': '電子', '2382.TW': '電子', '2881.TW': '金融', '2891.TW': '金融',
'2882.TW': '金融', '2303.TW': '半導體', '2412.TW': '電信', '2884.TW': '金融',
'2886.TW': '金融', '3711.TW': '半導體', '2357.TW': '電子', '1216.TW': '食品',
'2885.TW': '金融', '2345.TW': '網通設備', '3231.TW': '電子', '3034.TW': '半導體',
'2892.TW': '金融', '2379.TW': '半導體', '6669.TWO': '電子', '2890.TW': '金融',
'5880.TW': '金融', '2880.TW': '金融', '2383.TW': '電子', '3661.TWO': '半導體',
'3017.TW': '電子', '2883.TW': '金融', '3008.TW': '光學', '2603.TW': '航運',
'2301.TW': '電子', '2002.TW': '鋼鐵', '5871.TW': '金融', '2327.TW': '電子被動元件',
'2887.TW': '金融', '5876.TW': '金融', '1101.TW': '營建', '3045.TW': '電信',
'4938.TW': '電子', '4904.TW': '電信', '2207.TW': '汽車', '2395.TW': '電腦周邊',
'1301.TW': '塑膠', '2912.TW': '百貨', '6446.TWO': '生技', '1303.TW': '塑膠',
'2609.TW': '航運', '2615.TW': '航運', '6505.TW': '塑膠', '2637.TW': '散裝航運',
'2049.TW': '工具機', '2408.TW': 'DRAM', '2337.TW': 'NFLSH', '4966.TWO': '高速傳輸',
'3665.TW': '連接器', '6870.TWO': '軟體整合', '3105.TWO': 'PA功率'
}
def get_stock_data(symbol, period='1y'):
"""獲取股票資料"""
try:
stock = yf.Ticker(symbol)
data = stock.history(period=period)
if data.empty and symbol == 'TXF=F':
stock = yf.Ticker('0050.TW')
data = stock.history(period=period)
if data.empty:
stock = yf.Ticker('^TWII')
data = stock.history(period=period)
return data
except:
return pd.DataFrame()
def simple_statistical_predict(data, predict_days=5):
"""【備用模型】簡化的統計預測模型。"""
if len(data) < 60: return None
prices = data['Close'].values
ma_short = np.mean(prices[-5:])
ma_medium = np.mean(prices[-20:])
ma_long = np.mean(prices[-60:])
recent_trend = np.polyfit(range(20), prices[-20:], 1)[0]
volatility = np.std(prices[-20:]) / np.mean(prices[-20:])
base_change = recent_trend * predict_days
trend_factor = 1.0 + (0.02 if ma_short > ma_medium > ma_long else -0.02 if ma_short < ma_medium < ma_long else 0)
noise_factor = np.random.normal(1, volatility * 0.1)
predicted_price = prices[-1] * trend_factor + base_change + (prices[-1] * noise_factor * 0.01)
change_pct = ((predicted_price - prices[-1]) / prices[-1]) * 100
return {'predicted_price': predicted_price, 'change_pct': change_pct, 'confidence': max(0.6, 1 - volatility * 2)}
# 【修改 4】: 建立一個新的函式來處理 XGBoost 模型的輸入和輸出
# 修正後的 advanced_xgboost_predict 函數
def advanced_xgboost_predict(data, predict_days):
"""
【進階模型橋接函式】
- 準備 XGBoost 模型所需的輸入 DataFrame。
- 呼叫模型進行預測。
- 將模型的輸出格式轉換為主程式所需的格式。
"""
if xgb_model is None or data.empty:
print("XGBoost 模型未載入或數據為空")
return None
# 1. 準備輸入資料
# 確保數據有足夠的歷史記錄
if len(data) < 20:
print("歷史數據不足,無法使用 XGBoost 模型")
return None
# 使用最新的資料點來進行未來預測
input_df = data.tail(1).copy()
# 檢查必要欄位是否存在
required_columns = ['Open', 'High', 'Low', 'Close', 'Volume']
missing_columns = [col for col in required_columns if col not in input_df.columns]
if missing_columns:
print(f"缺少必要欄位: {missing_columns}")
return None
try:
# 2. 呼叫模型預測
print(f"呼叫 XGBoost 模型進行 {predict_days} 天預測...")
predictions = xgb_model.predict('xgboost_model', input_df)
# 3. 根據 predict_days 解析輸出
# 建立預測天數到模型輸出鍵的映射
day_to_key_map = {
1: 'Close_t0_pred', # 假設 t0 代表 1 天後
5: 'Close_t5_pred',
10: 'Close_t10_pred',
20: 'Close_t20_pred'
}
# 找到對應的預測鍵
prediction_key = day_to_key_map.get(predict_days)
if prediction_key is None or prediction_key not in predictions:
print(f"警告: XGBoost 模型沒有提供 {predict_days} 天的預測結果。可用鍵值: {list(predictions.keys())}")
# 如果沒有對應的預測期間,嘗試使用最接近的
available_days = [1, 5, 10, 20]
closest_day = min(available_days, key=lambda x: abs(x - predict_days))
prediction_key = day_to_key_map[closest_day]
print(f"使用最接近的預測期間: {closest_day} 天")
predicted_price = predictions[prediction_key]
current_price = data['Close'].iloc[-1]
change_pct = ((predicted_price - current_price) / current_price) * 100
# 4. 包裝成主程式所需的格式
result = {
'predicted_price': float(predicted_price),
'change_pct': float(change_pct),
'confidence': 0.85 # XGBoost 模型通常有較高的信心度
}
print(f"XGBoost 預測成功: 當前價格={current_price:.2f}, 預測價格={predicted_price:.2f}, 變化={change_pct:.2f}%")
return result
except Exception as e:
print(f"執行 XGBoost 預測時發生錯誤: {e}")
import traceback
traceback.print_exc()
return None
def get_prediction(data, predict_days=5):
"""
【【模型預測控制器】】
根據 USE_ADVANCED_MODEL 的設定,呼叫對應的預測模型。
"""
if USE_ADVANCED_MODEL:
print(f"模式: 進階XGBoost模型 | 預測天期: {predict_days}天")
# 【修改 5】: 呼叫新的 XGBoost 橋接函式
prediction = advanced_xgboost_predict(data, predict_days)
# 如果進階模型預測失敗,則自動降級使用簡易模型
if prediction is not None:
return prediction
else:
print("進階模型預測失敗或無對應天期,自動降級為簡易統計模型。")
# 預設或降級時執行簡易模型
print(f"模式: 簡易統計模型 | 預測天期: {predict_days}天")
return simple_statistical_predict(data, predict_days)
def calculate_technical_indicators(df):
"""計算技術指標"""
if df.empty: return df
df['MA5'] = df['Close'].rolling(window=5).mean()
df['MA20'] = df['Close'].rolling(window=20).mean()
delta = df['Close'].diff()
gain = (delta.where(delta > 0, 0)).rolling(window=14).mean()
loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
rs = gain / loss
df['RSI'] = 100 - (100 / (1 + rs))
exp1 = df['Close'].ewm(span=12).mean()
exp2 = df['Close'].ewm(span=26).mean()
df['MACD'] = exp1 - exp2
df['MACD_Signal'] = df['MACD'].ewm(span=9).mean()
df['MACD_Histogram'] = df['MACD'] - df['MACD_Signal']
df['BB_Middle'] = df['Close'].rolling(window=20).mean()
bb_std = df['Close'].rolling(window=20).std()
df['BB_Upper'] = df['BB_Middle'] + (bb_std * 2)
df['BB_Lower'] = df['BB_Middle'] - (bb_std * 2)
low_min = df['Low'].rolling(window=9).min()
high_max = df['High'].rolling(window=9).max()
rsv = (df['Close'] - low_min) / (high_max - low_min) * 100
df['K'] = rsv.ewm(com=2).mean()
df['D'] = df['K'].ewm(com=2).mean()
low_min_14 = df['Low'].rolling(window=14).min()
high_max_14 = df['High'].rolling(window=14).max()
df['Williams_R'] = -100 * (high_max_14 - df['Close']) / (high_max_14 - low_min_14)
df['up_move'] = df['High'] - df['High'].shift(1)
df['down_move'] = df['Low'].shift(1) - df['Low']
df['+DM'] = np.where((df['up_move'] > df['down_move']) & (df['up_move'] > 0), df['up_move'], 0)
df['-DM'] = np.where((df['down_move'] > df['up_move']) & (df['down_move'] > 0), df['down_move'], 0)
df['TR'] = np.max([df['High'] - df['Low'], abs(df['High'] - df['Close'].shift(1)), abs(df['Low'] - df['Close'].shift(1))], axis=0)
df['+DI'] = (df['+DM'].ewm(com=13, adjust=False).mean() / df['TR'].ewm(com=13, adjust=False).mean()) * 100
df['-DI'] = (df['-DM'].ewm(com=13, adjust=False).mean() / df['TR'].ewm(com=13, adjust=False).mean()) * 100
df['DX'] = abs(df['+DI'] - df['-DI']) / (df['+DI'] + df['-DI']) * 100
df['ADX'] = df['DX'].ewm(com=13, adjust=False).mean()
return df
# 修正後的 calculate_volume_profile 函數
def calculate_volume_profile(df, num_bins=50):
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
# 確保沒有 NaN 值
df_clean = df.dropna(subset=['High', 'Low', 'Close', 'Volume'])
if df_clean.empty:
return None, None, None
all_prices = np.concatenate([df_clean['High'].values, df_clean['Low'].values])
min_price, max_price = all_prices.min(), all_prices.max()
# 使用典型價格 (High + Low + Close) / 3 作為價格指標
price_for_volume = (df_clean['High'] + df_clean['Low'] + df_clean['Close']) / 3
# 移除 NaN 值並確保對應的權重也被移除
price_indicator = price_for_volume.dropna()
corresponding_volume = df_clean['Volume'].loc[price_indicator.index]
# 再次檢查是否有空數據
if len(price_indicator) == 0 or len(corresponding_volume) == 0:
return None, None, None
try:
hist, bin_edges = np.histogram(
price_indicator.values,
bins=num_bins,
range=(min_price, max_price),
weights=corresponding_volume.values
)
price_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
return bin_edges, hist, price_centers
except Exception as e:
print(f"Volume profile 計算錯誤: {e}")
return None, None, None
def get_business_climate_data():
try:
if not os.path.exists('business_climate.csv'): return pd.DataFrame()
df = pd.read_csv('business_climate.csv')
if 'Date' not in df.columns: df.columns = ['Date', 'Index'] if len(df.columns) == 2 else df.columns
if 'Date' in df.columns:
try: df['Date'] = pd.to_datetime(df['Date'] + '-01', format='%Y-%m-%d', errors='coerce')
except: df['Date'] = pd.to_datetime(df['Date'], errors='coerce')
df = df.dropna(subset=['Date'])
return df
except Exception as e:
print(f"無法獲取景氣燈號資料: {str(e)}")
return pd.DataFrame()
def get_pmi_data():
try:
if not os.path.exists('taiwan_pmi.csv'): return pd.DataFrame()
df = pd.read_csv('taiwan_pmi.csv')
if 'DATE' in df.columns: df = df.rename(columns={'DATE': 'Date', 'INDEX': 'Index'})
elif len(df.columns) == 2: df.columns = ['Date', 'Index']
if 'Date' in df.columns:
try: df['Date'] = pd.to_datetime(df['Date'] + '-01', format='%Y-%m-%d', errors='coerce')
except: df['Date'] = pd.to_datetime(df['Date'], errors='coerce')
df = df.dropna(subset=['Date'])
return df
except Exception as e:
print(f"無法獲取 PMI 資料: {str(e)}")
return pd.DataFrame()
def generate_gemini_analysis(stock_name, stock_symbol, period, data):
"""
使用 Gemini API 生成基本面和市場展望分析。
"""
api_key = os.getenv("GEMINI_API_KEY")
if not api_key:
return "無法讀取 GEMINI API 金鑰", "請在系統環境變數中設定您的金鑰"
try:
genai.configure(api_key=api_key)
model = genai.GenerativeModel('gemini-1.5-flash')
price_change = ((data['Close'].iloc[-1] - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
rsi_current = data['RSI'].iloc[-1]
macd_current = data['MACD'].iloc[-1]
macd_signal_current = data['MACD_Signal'].iloc[-1]
industry = INDUSTRY_MAPPING.get(stock_symbol, '綜合')
prompt = f"""
請扮演一位專業、資深的台灣股市金融分析師。
我將提供一檔台股的即時技術指標數據,請你基於這些數據,結合你對這家公司、其所在產業以及當前市場趨勢的理解,為我生成一段專業的「基本面分析」和一段「市場展望與投資建議」。
**股票資訊:**
- **公司名稱:** {stock_name} ({stock_symbol})
- **分析期間:** 最近 {period}
- **所屬產業:** {industry}
- **期間價格變動:** {price_change:+.2f}%
- **目前 RSI 指標:** {rsi_current:.2f}
- **目前 MACD 指標:** MACD線為 {macd_current:.3f}, 信號線為 {macd_signal_current:.3f}
**你的任務:**
1. **基本面分析 (約 150 字):**
- 評論這家公司的產業地位、近期營運亮點或挑戰。
- 提及任何可能影響其基本面的關鍵因素 (例如:財報、法說會、政策、供應鏈變化等)。
- 請用專業、客觀的語氣撰寫。
2. **市場展望與投資建議 (約 150 字):**
- 基於上述所有資訊,提供對該股票的短期和中期市場展望。
- 提出具體的投資建議,例如:適合何種類型的投資人、潛在的風險點。
- 請直接提供分析內容,不要包含任何問候語。
**輸出格式:**
請嚴格按照以下格式回傳,使用"$$"作為兩個段落之間的分隔符:
[基本面分析內容]$$[市場展望與投資建議內容]
"""
response = model.generate_content(prompt)
parts = response.text.split('$$')
if len(parts) == 2:
fundamental_analysis = parts[0].strip()
market_outlook = parts[1].strip()
return dcc.Markdown(fundamental_analysis), dcc.Markdown(market_outlook)
else:
# Fallback for unexpected response format
return dcc.Markdown("無法解析 Gemini 回應,請稍後再試。"), dcc.Markdown(response.text)
except Exception as e:
error_message = f"呼叫 Gemini API 時發生錯誤: {str(e)}"
print(error_message)
return dcc.Markdown(error_message), dcc.Markdown("請檢查後台日誌或 API 金鑰設定")
# 建立 Dash 應用程式
app = dash.Dash(__name__, suppress_callback_exceptions=True)
try:
print("正在初始化新聞情緒分析模型...")
predictor = BertPredictor(max_news_per_keyword=5)
print("新聞情緒分析模型初始化成功。")
except Exception as e:
print(f"錯誤:新聞情緒分析模型初始化失敗 - {e}")
predictor = None
# 應用程式佈局
app.layout = html.Div([
html.H1("台股分析儀表板", style={'text-align': 'center', 'margin-bottom': '30px'}),
html.Div([
html.H2("🤖 AI深度學習預測 - 台指期指數", style={'text-align': 'center','color': '#FFCC22','margin-bottom': '25px'}),
html.Div([
html.Div([
html.Label("預測期間:", style={'font-weight': 'bold', 'color': '#FFCC22'}),
dcc.Dropdown(id='taiex-prediction-period',
options=[
{'label': '1日後預測', 'value': 1},{'label': '5日後預測', 'value': 5},
{'label': '10日後預測', 'value': 10},{'label': '20日後預測', 'value': 20}],
value=5,
style={'margin-bottom': '10px', 'color': '#272727'})
], style={'width': '30%', 'display': 'inline-block'}),
html.Div(id='taiex-prediction-results', style={'width': '65%', 'display': 'inline-block', 'margin-left': '5%'})
]),
html.Div([dcc.Graph(id='taiex-prediction-chart')], style={'margin-top': '20px'})
], 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'}),
html.Div([
html.H3("📰 市場情緒與新聞分析", style={'color': '#E74C3C', 'margin-bottom': '20px'}),
html.Div([
html.Div([
html.H4("市場情緒指標", style={'color': '#8E44AD'}),
html.Div(id='sentiment-gauge')
], style={'width': '48%', 'display': 'inline-block'}),
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%'})
])
], style={'margin-top': '30px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
html.Div([
html.H3("景氣燈號與 PMI 分析"),
html.Div([
html.Div([dcc.Graph(id='business-climate-chart')], style={'width': '48%', 'display': 'inline-block'}),
html.Div([dcc.Graph(id='pmi-chart')], style={'width': '48%', 'display': 'inline-block', 'margin-left': '2%'})
])
], style={'margin-top': '30px'}),
html.Div([
html.Div([
html.Label("選擇股票:"),
dcc.Dropdown(id='stock-dropdown', options=[{'label': name, 'value': symbol} for name, symbol in TAIWAN_STOCKS.items()], value='0050.TW', style={'margin-bottom': '10px'})
], style={'width': '30%', 'display': 'inline-block', 'vertical-align': 'top'}),
html.Div([
html.Label("時間範圍:"),
dcc.Dropdown(id='period-dropdown',
options=[{'label': '1個月', 'value': '1mo'},{'label': '3個月', 'value': '3mo'},{'label': '6個月', 'value': '6mo'},{'label': '1年', 'value': '1y'},{'label': '2年', 'value': '2y'}],
value='1mo', style={'margin-bottom': '10px'})
], style={'width': '30%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'}),
html.Div([
html.Label("圖表類型:"),
dcc.Dropdown(id='chart-type', options=[{'label': '線圖', 'value': 'line'},{'label': '蠟燭圖', 'value': 'candlestick'}], value='candlestick', style={'margin-bottom': '10px'})
], style={'width': '30%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'})
], style={'margin-bottom': '30px'}),
html.Div(id='stock-info-cards', style={'margin-bottom': '30px'}),
html.Div([html.Div([dcc.Graph(id='price-chart')], style={'width': '100%', 'display': 'inline-block', 'vertical-align': 'top'})]),
html.Div([
html.H3("📊 進階技術指標分析", style={'margin-bottom': '20px'}),
html.Div([
html.Label("選擇技術指標:", style={'font-weight': 'bold', 'margin-right': '10px'}),
dcc.Dropdown(id='technical-indicator-selector',
options=[{'label': 'RSI 相對強弱指標', 'value': 'RSI'},{'label': 'MACD 指數平滑異同移動平均線', 'value': 'MACD'},{'label': '布林通道 Bollinger Bands', 'value': 'BB'},
{'label': 'KD 隨機指標', 'value': 'KD'},{'label': '威廉指標 %R', 'value': 'WR'},{'label': 'DMI 動向指標', 'value': 'DMI'}],
value='RSI', style={'width': '100%'})
], style={'margin-bottom': '20px'}),
html.Div([dcc.Graph(id='advanced-technical-chart')])
], style={'margin-top': '20px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
html.Div([dcc.Graph(id='volume-chart')], style={'margin-top': '20px'}),
html.Div([html.H3("產業表現分析"), dcc.Graph(id='industry-analysis')], style={'margin-top': '30px'}),
html.Div([
html.H3("📊 分析師觀點與市場解讀", style={'color': '#2E86AB', 'margin-bottom': '20px'}),
html.Div([
html.Div([
html.H4("🔍 技術面分析", style={'color': '#A23B72', 'margin-bottom': '15px'}),
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'})
], style={'width': '48%', 'display': 'inline-block', 'vertical-align': 'top'}),
html.Div([
html.H4("📈 基本面分析 (AI 生成)", style={'color': '#F18F01', 'margin-bottom': '15px'}),
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'})
], style={'width': '48%', 'display': 'inline-block', 'margin-left': '4%', 'vertical-align': 'top'})
]),
html.Div([
html.H4("🎯 市場展望與投資建議 (AI 生成)", style={'color': '#C73E1D', 'margin-bottom': '15px', 'margin-top': '25px'}),
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)'})
])
], 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'}),
html.Div([
html.H3("📊 多檔股票比較分析", style={'margin-bottom': '20px'}),
html.Div([
html.Div([
html.Label("選擇比較股票(最多5檔):", style={'font-weight': 'bold'}),
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'}),
html.Small('(元大台灣50 (0050.TW) 為固定比較基準,不可移除)', style={'display': 'block', 'font-style': 'italic', 'color': 'gray'})
], style={'width': '60%', 'display': 'inline-block'}),
html.Div([
html.Label("比較期間:", style={'font-weight': 'bold'}),
dcc.Dropdown(id='comparison-period', options=[{'label': '1個月', 'value': '1mo'},{'label': '3個月', 'value': '3mo'},{'label': '6個月', 'value': '6mo'},{'label': '1年', 'value': '1y'}], value='3mo')
], style={'width': '35%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'})
]),
html.Div([
html.Div([dcc.Graph(id='comparison-chart')], style={'width': '65%', 'display': 'inline-block'}),
html.Div([html.H4("比較結果", style={'color': '#2E86AB'}), html.Div(id='comparison-table')], style={'width': '33%', 'display': 'inline-block', 'margin-left': '2%', 'vertical-align': 'top'})
])
], style={'margin-top': '30px','padding': '20px','background': 'white','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)'}),
])
@app.callback(
[dash.dependencies.Output('taiex-prediction-results', 'children'),
dash.dependencies.Output('taiex-prediction-chart', 'figure')],
[dash.dependencies.Input('taiex-prediction-period', 'value')]
)
def update_taiex_prediction(predict_days):
data = get_stock_data('^TWII', '2y')
if data.empty: return html.Div("無法獲取台指期資料"), {}
# === 呼叫 get_prediction 控制器,它會自動選擇模型 ===
final_prediction = get_prediction(data, predict_days)
if final_prediction is None: return html.Div("資料不足,無法進行預測"), {}
current_price, last_date = data['Close'].iloc[-1], data.index[-1]
predicted_price, change_pct, confidence = final_prediction['predicted_price'], final_prediction['change_pct'], final_prediction['confidence']
prediction_paths = {1: [1], 5: [1, 5], 10: [1, 5, 10], 20: [1, 10, 20], 60: [1, 10, 20, 60]}
intervals_to_predict = prediction_paths.get(predict_days, [predict_days])
prediction_dates, prediction_prices = [last_date], [current_price]
for days in intervals_to_predict:
# === 迴圈內也使用統一的預測控制器 ===
interim_prediction = get_prediction(data, days)
if interim_prediction:
prediction_dates.append(last_date + timedelta(days=days))
prediction_prices.append(interim_prediction['predicted_price'])
# (後續繪圖邏輯不變)
color, arrow = ('red', '📈') if change_pct >= 0 else ('green', '📉')
result_card = html.Div([
html.H4(f"{predict_days}日後預測結果", style={'margin': '0 0 15px 0', 'color': 'white'}),
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'}),
html.P(f"目前價格: {current_price:.2f}", style={'margin': '5px 0'}), html.P(f"預測價格: {predicted_price:.2f}", style={'margin': '5px 0'}),
html.P(f"信心度: {confidence:.1%}", style={'margin': '5px 0', 'font-size': '14px'})
], style={'background': 'rgba(255,255,255,0.1)','padding': '20px','border-radius': '10px','border': '1px solid rgba(255,255,255,0.2)'})
fig = go.Figure()
recent_data = data.tail(30)
fig.add_trace(go.Scatter(x=recent_data.index, y=recent_data['Close'], mode='lines', name='歷史價格', line=dict(color='#FFA726', width=2)))
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)))
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'))
return result_card, fig
@app.callback(
dash.dependencies.Output('stock-info-cards', 'children'),
[dash.dependencies.Input('stock-dropdown', 'value')]
)
def update_stock_info(selected_stock):
data = get_stock_data(selected_stock, '5d')
if data.empty: return html.Div("無法獲取股票資料")
current_price = data['Close'].iloc[-1]
prev_price = data['Close'].iloc[-2] if len(data) > 1 else current_price
change = current_price - prev_price
change_pct = (change / prev_price) * 100
stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
color, arrow = ('red', '▲') if change >= 0 else ('green', '▼')
return html.Div([
html.Div([
html.H3(f"{stock_name} ({selected_stock})", style={'margin': '0'}),
html.H2(f"${current_price:.2f}", style={'margin': '5px 0', 'color': color}),
html.P(f"{arrow} {change:+.2f} ({change_pct:+.2f}%)", style={'margin': '0', 'color': color, 'font-weight': 'bold'})
], 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'}),
html.Div([
html.H4("今日統計", style={'margin': '0 0 10px 0'}),
html.P(f"最高: ${data['High'].iloc[-1]:.2f}", style={'margin': '5px 0'}),
html.P(f"最低: ${data['Low'].iloc[-1]:.2f}", style={'margin': '5px 0'}),
html.P(f"成交量: {data['Volume'].iloc[-1]:,.0f}", style={'margin': '5px 0'})
], style={'background': 'white','padding': '20px','border-radius': '10px','box-shadow': '0 2px 10px rgba(0,0,0,0.1)','display': 'inline-block'})
])
@app.callback(
dash.dependencies.Output('price-chart', 'figure'),
[dash.dependencies.Input('stock-dropdown', 'value'),
dash.dependencies.Input('period-dropdown', 'value'),
dash.dependencies.Input('chart-type', 'value')]
)
# 修正後的 update_price_chart callback 函數的相關部分
def update_price_chart_fixed(selected_stock, period, chart_type):
data = get_stock_data(selected_stock, period)
if data.empty:
return {}
data = calculate_technical_indicators(data)
stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
fig = make_subplots(rows=1, cols=2, shared_yaxes=True,
column_widths=[0.8, 0.2], horizontal_spacing=0.01)
if chart_type == 'candlestick':
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)
else:
fig.add_trace(go.Scatter(
x=data.index,
y=data['Close'],
mode='lines',
name=stock_name
), row=1, col=1)
# 添加移動平均線
fig.add_trace(go.Scatter(
x=data.index,
y=data['MA5'],
mode='lines',
name='MA5',
line=dict(color='orange')
), row=1, col=1)
fig.add_trace(go.Scatter(
x=data.index,
y=data['MA20'],
mode='lines',
name='MA20',
line=dict(color='blue')
), row=1, col=1)
# 修正後的 Volume Profile 計算
bin_edges, volume_per_bin, price_centers = calculate_volume_profile(data, num_bins=50)
if volume_per_bin is not None and price_centers is not None:
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)
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
)
return fig
@app.callback(
dash.dependencies.Output('advanced-technical-chart', 'figure'),
[dash.dependencies.Input('technical-indicator-selector', 'value'),
dash.dependencies.Input('stock-dropdown', 'value'),
dash.dependencies.Input('period-dropdown', 'value')]
)
def update_advanced_technical_chart(indicator, selected_stock, period):
data = get_stock_data(selected_stock, period)
if data.empty: return {}
data = calculate_technical_indicators(data)
stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
fig = go.Figure()
if indicator == 'RSI':
fig = go.Figure()
fig.add_trace(go.Scatter(x=data.index, y=data['RSI'], mode='lines', name='RSI', line=dict(color='purple', width=2)))
fig.add_hline(y=70, line_dash="dash", line_color="green", annotation_text="超買線(70)")
fig.add_hline(y=30, line_dash="dash", line_color="red", annotation_text="超賣線(30)")
fig.add_hline(y=50, line_dash="dot", line_color="gray", annotation_text="中線(50)")
fig.update_layout(title=f'{stock_name} - RSI 相對強弱指標', xaxis_title='日期', yaxis_title='RSI', height=450, yaxis=dict(range=[0, 100]))
elif indicator == 'MACD':
fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1, row_heights=[0.7, 0.3], subplot_titles=('價格走勢', 'MACD 指標'))
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)
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)
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)
colors = ['red' if x >= 0 else 'green' for x in data['MACD_Histogram']]
fig.add_trace(go.Bar(x=data.index, y=data['MACD_Histogram'], name='MACD柱狀圖', marker_color=colors), row=2, col=1)
fig.update_layout(title_text=f'{stock_name} - MACD 指數平滑異同移動平均線', height=550)
elif indicator == 'BB':
fig = go.Figure()
fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價', line=dict(color='black', width=2)))
fig.add_trace(go.Scatter(x=data.index, y=data['BB_Upper'], mode='lines', name='上軌', line=dict(color='red', width=1, dash='dash')))
fig.add_trace(go.Scatter(x=data.index, y=data['BB_Middle'], mode='lines', name='中軌(MA20)', line=dict(color='blue', width=1)))
fig.add_trace(go.Scatter(x=data.index, y=data['BB_Lower'], mode='lines', name='下軌', line=dict(color='green', width=1, dash='dash')))
fig.update_layout(title=f'{stock_name} - 布林通道 (20日, 2σ)', xaxis_title='日期', yaxis_title='價格 (TWD)', height=450)
elif indicator == 'KD':
fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1, row_heights=[0.6, 0.4], subplot_titles=('價格走勢', 'KD指標'))
fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價', line=dict(color='black', width=1)), row=1, col=1)
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)
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)
fig.add_hline(y=80, line_dash="dash", line_color="green", annotation_text="超買線(80)", row=2, col=1)
fig.add_hline(y=20, line_dash="dash", line_color="red", annotation_text="超賣線(20)", row=2, col=1)
fig.update_layout(title=f'{stock_name} - KD 隨機指標 (9,3,3)', height=500, yaxis2_range=[0, 100])
elif indicator == 'WR':
fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1, row_heights=[0.6, 0.4], subplot_titles=('價格走勢', '威廉指標 %R'))
fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價', line=dict(color='black', width=1)), row=1, col=1)
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)
fig.add_hline(y=-20, line_dash="dash", line_color="green", annotation_text="超買線(-20)", row=2, col=1)
fig.add_hline(y=-80, line_dash="dash", line_color="red", annotation_text="超賣線(-80)", row=2, col=1)
fig.update_layout(title=f'{stock_name} - 威廉指標 %R (14日)', height=500, yaxis2_range=[-100, 0])
elif indicator == 'DMI':
fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1, row_heights=[0.6, 0.4], subplot_titles=('價格走勢', 'DMI 指標'))
data_filtered = data.iloc[14:]
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)
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)
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)
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)
fig.update_layout(title=f'{stock_name} - DMI 動向指標 (14日)', height=500, showlegend=True, yaxis2_range=[0, 100])
return fig
@app.callback(
dash.dependencies.Output('volume-chart', 'figure'),
[dash.dependencies.Input('stock-dropdown', 'value'),
dash.dependencies.Input('period-dropdown', 'value')]
)
def update_volume_chart(selected_stock, period):
data = get_stock_data(selected_stock, period)
if data.empty: return {}
stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
colors = ['red' if data['Close'].iloc[i] > data['Open'].iloc[i] else 'green' for i in range(len(data))]
fig = go.Figure(go.Bar(x=data.index, y=data['Volume'], marker_color=colors, name='成交量'))
fig.update_layout(title=f'{stock_name} 成交量', xaxis_title='日期', yaxis_title='成交量', height=300)
return fig
@app.callback(
dash.dependencies.Output('industry-analysis', 'figure'),
[dash.dependencies.Input('stock-dropdown', 'value')]
)
def update_industry_analysis(selected_stock):
performance_data = []
for name, symbol in TAIWAN_STOCKS.items():
data = get_stock_data(symbol, '1mo')
if not data.empty and len(data) > 1:
return_pct = ((data['Close'].iloc[-1] - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
performance_data.append({
'股票': name,
'代碼': symbol,
'月報酬率(%)': return_pct,
'絕對波動': abs(return_pct)
})
if not performance_data:
fig = go.Figure().add_annotation(text="無法計算產業資料", showarrow=False)
fig.update_layout(title="近一月市場波動最大標的", height=400)
return fig
df_performance = pd.DataFrame(performance_data)
df_top_movers = df_performance.sort_values(by='絕對波動', ascending=False).head(10)
fig = px.pie(
df_top_movers,
values='絕對波動',
names='股票',
title='近一月市場波動最大 Top 10 標的',
hover_data={'月報酬率(%)': ':.2f'}
)
fig.update_traces(
textposition='inside',
textinfo='percent+label',
hovertemplate="<b>%{label}</b><br>月報酬率: %{customdata[0]:.2f}%<extra></extra>"
)
fig.update_layout(height=400, showlegend=False)
return fig
@app.callback(
dash.dependencies.Output('business-climate-chart', 'figure'),
[dash.dependencies.Input('stock-dropdown', 'value')]
)
def update_business_climate_chart(selected_stock):
df = get_business_climate_data()
if df.empty:
fig = go.Figure().add_annotation(text="無法載入景氣燈號資料", showarrow=False)
fig.update_layout(title="台灣景氣燈號", height=300)
return fig
def get_light_color(score):
if score >= 32: return 'red'
elif score >= 24: return 'orange'
elif score >= 17: return 'yellow'
elif score >= 10: return 'lightgreen'
else: return 'blue'
colors = [get_light_color(score) for score in df['Index']]
fig = go.Figure()
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'))))
fig.add_hline(y=32, line_dash="dash", line_color="red", annotation_text="紅燈(32)")
fig.add_hline(y=17, line_dash="dash", line_color="yellow", annotation_text="黃燈(17)")
fig.update_layout(title="台灣景氣燈號走勢", xaxis_title='日期', yaxis_title='燈號分數', height=300, yaxis=dict(range=[0, 40]))
return fig
@app.callback(
[dash.dependencies.Output('technical-analysis-text', 'children'),
dash.dependencies.Output('fundamental-analysis-text', 'children'),
dash.dependencies.Output('market-outlook-text', 'children')],
[dash.dependencies.Input('stock-dropdown', 'value'),
dash.dependencies.Input('period-dropdown', 'value')]
)
def update_analysis_text(selected_stock, period):
cache_key = f"{selected_stock}-{period}"
current_time = time.time()
if cache_key in ANALYSIS_CACHE:
cached_data = ANALYSIS_CACHE[cache_key]
if current_time - cached_data['timestamp'] < CACHE_DURATION_SECONDS:
print(f"從快取載入分析: {cache_key}")
return cached_data['technical'], cached_data['fundamental'], cached_data['outlook']
print(f"重新生成分析: {cache_key}")
data = get_stock_data(selected_stock, period)
stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
if data.empty or len(data) < 20:
return "資料不足,無法分析", "資料不足,無法分析", "資料不足,無法分析"
data = calculate_technical_indicators(data)
price_change = ((data['Close'].iloc[-1] - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
rsi_current = data['RSI'].iloc[-1] if not pd.isna(data['RSI'].iloc[-1]) else 50
macd_current = data['MACD'].iloc[-1] if not pd.isna(data['MACD'].iloc[-1]) else 0
macd_signal_current = data['MACD_Signal'].iloc[-1] if not pd.isna(data['MACD_Signal'].iloc[-1]) else 0
technical_text = html.Div([
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}%。"]),
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'}), "。"]),
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 '空頭'}。"]),
])
fundamental_text, market_outlook_text = generate_gemini_analysis(stock_name, selected_stock, period, data)
ANALYSIS_CACHE[cache_key] = {
'technical': technical_text,
'fundamental': fundamental_text,
'outlook': market_outlook_text,
'timestamp': current_time
}
return technical_text, fundamental_text, market_outlook_text
@app.callback(
dash.dependencies.Output('pmi-chart', 'figure'),
[dash.dependencies.Input('stock-dropdown', 'value')]
)
def update_pmi_chart(selected_stock):
df = get_pmi_data()
if df.empty:
fig = go.Figure().add_annotation(text="無法載入PMI資料", showarrow=False)
fig.update_layout(title="台灣PMI指數", height=300)
return fig
colors = ['red' if value >= 50 else 'green' for value in df['Index']]
fig = go.Figure()
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'))))
fig.add_hline(y=50, line_dash="dash", line_color="black", annotation_text="榮枯線(50)")
fig.update_layout(title="台灣PMI指數走勢", xaxis_title='日期', yaxis_title='PMI指數', height=300, yaxis=dict(range=[35, 60]))
return fig
def summarize_news_with_gemini(news_list: list) -> str:
"""
使用 Gemini API 將英文新聞標題列表摘要成一段繁體中文。
"""
api_key = os.getenv("GEMINI_API_KEY")
if not api_key:
return "錯誤:找不到 GEMINI_API_KEY。請在 Hugging Face Secrets 中設定。"
try:
genai.configure(api_key=api_key)
model = genai.GenerativeModel('gemini-1.5-flash')
formatted_news = "\n".join([f"- {news}" for news in news_list])
prompt = f"""
請扮演一位專業的金融市場分析師。
以下是幾則最新的英文財經新聞標題,請將它們整合成一段簡潔、流暢、約 200 字的繁體中文市場動態摘要,與利多哪些產業,利空哪些產業。
提供3段重點,
請專注於可能影響市場情緒和股價的關鍵資訊,並直接提供摘要內容,不要包含任何額外的問候語或說明。
英文新聞標題如下:
{formatted_news}
"""
response = model.generate_content(prompt)
return response.text
except Exception as e:
print(f"呼叫 Gemini API 時發生錯誤: {e}")
return f"無法生成新聞摘要,請稍後再試。錯誤訊息:{e}"
@app.callback(
[dash.dependencies.Output('comparison-chart', 'figure'),
dash.dependencies.Output('comparison-table', 'children')],
[dash.dependencies.Input('comparison-stocks', 'value'),
dash.dependencies.Input('comparison-period', 'value')]
)
def update_comparison_analysis(selected_stocks, period):
fixed_stock = '0050.TW'
if not selected_stocks: selected_stocks = [fixed_stock]
elif fixed_stock not in selected_stocks: selected_stocks.insert(0, fixed_stock)
selected_stocks = selected_stocks[:5]
fig = go.Figure()
comparison_data = []
for stock in selected_stocks:
data = get_stock_data(stock, period)
if not data.empty:
stock_name = next((name for name, symbol in TAIWAN_STOCKS.items() if symbol == stock), stock)
normalized_prices = (data['Close'] / data['Close'].iloc[0]) * 100
fig.add_trace(go.Scatter(x=data.index, y=normalized_prices, mode='lines', name=stock_name, line=dict(width=2)))
total_return = ((data['Close'].iloc[-1] / data['Close'].iloc[0]) - 1) * 100
volatility = data['Close'].pct_change().std() * np.sqrt(252) * 100
comparison_data.append({'name': stock_name, 'return': total_return, 'volatility': volatility, 'current_price': data['Close'].iloc[-1]})
fig.update_layout(title=f'股票績效比較 - {period}', xaxis_title='日期', yaxis_title='相對績效 (基期=100)', height=400, hovermode='x unified')
if comparison_data:
table_rows = []
for item in sorted(comparison_data, key=lambda x: x['return'], reverse=True):
color = 'red' if item['return'] > 0 else 'green'
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}")]))
table = html.Table([html.Thead(html.Tr([html.Th("股票"), html.Th("報酬率"), html.Th("波動率"), html.Th("現價")])), html.Tbody(table_rows)], style={'width': '100%'})
return fig, table
return fig, html.Div("無可比較資料")
@app.callback(
[dash.dependencies.Output('sentiment-gauge', 'children'),
dash.dependencies.Output('news-summary', 'children')],
[dash.dependencies.Input('stock-dropdown', 'value')]
)
def update_sentiment_analysis(selected_stock):
if predictor is None:
error_fig = go.Figure().add_annotation(text="情緒指標模型載入失敗", showarrow=False)
error_fig.update_layout(height=200)
return dcc.Graph(figure=error_fig), html.P("新聞分析模型載入失敗,請檢查後台日誌。")
sentiment_score_raw = predictor.get_news_index()
if sentiment_score_raw is not None:
sentiment_score_normalized = (sentiment_score_raw + 1) * 50
sentiment_score_normalized = max(0, min(100, sentiment_score_normalized))
if sentiment_score_normalized >= 65:
bar_color, level_text = "#5cb85c", "樂觀"
elif sentiment_score_normalized >= 35:
bar_color, level_text = "#f0ad4e", "中性"
else:
bar_color, level_text = "#d9534f", "悲觀"
gauge_fig = go.Figure(go.Indicator(
mode = "gauge+number", value = sentiment_score_normalized,
domain = {'x': [0, 1], 'y': [0, 1]},
title = {'text': f"昨日市場情緒: {level_text}", 'font': {'size': 18}},
gauge = {'axis': {'range': [0, 100]}, 'bar': {'color': bar_color},
'steps': [{'range': [0, 35], 'color': "rgba(217, 83, 79, 0.2)"},
{'range': [35, 65], 'color': "rgba(240, 173, 78, 0.2)"},
{'range': [65, 100], 'color': "rgba(92, 184, 92, 0.2)"}]}
))
gauge_fig.update_layout(height=200, margin=dict(l=30, r=30, t=50, b=20))
gauge_content = dcc.Graph(figure=gauge_fig)
else:
error_fig = go.Figure().add_annotation(text="今日尚無情緒分數", showarrow=False)
error_fig.update_layout(height=200)
gauge_content = dcc.Graph(figure=error_fig)
top_news_list = predictor.get_news()
news_content = None
if top_news_list and isinstance(top_news_list, list):
summary_text = summarize_news_with_gemini(top_news_list)
news_content = dcc.Markdown(summary_text, style={
'margin': '8px 0', 'padding-left': '5px',
'font-size': '15px', 'line-height': '1.7'
})
elif top_news_list == []:
news_content = html.P("昨日無重大相關新聞。", style={'text-align': 'center', 'padding-top': '50px'})
else:
news_content = html.P("讀取新聞時發生錯誤。", style={'text-align': 'center', 'padding-top': '50px'})
return gauge_content, news_content
# 主程式執行
if __name__ == '__main__':
app.run(host="0.0.0.0", port=7860, debug=False)