AITEST / app.py
AlanRex's picture
Update app.py
e59eb7c verified
raw
history blame
45.3 kB
# 系統套件
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
# 引用您組員的預測器程式
from Bert_predict import BertPredictor
# 台股代號對應表 (移除台指期,因為它現在是獨立區塊)
TAIWAN_STOCKS = {
'元大台灣50': '0050.TW',
'台積電': '2330.TW',
'聯發科': '2454.TW',
'鴻海': '2317.TW',
'台塑': '1301.TW',
'中華電': '2412.TW',
'富邦金': '2881.TW',
'國泰金': '2882.TW',
'台達電': '2308.TW',
'統一': '1216.TW',
'日月光': '3711.TW',
'長榮': '2603.TW',
'慧洋-KY': '2637.TW',
'上銀': '2049.TW',
'台泥': '1101.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': '電子組件',
'1301.TW': '塑膠',
'2412.TW': '電信',
'2881.TW': '金融',
'2882.TW': '金融',
'2308.TW': '電子',
'1216.TW': '食品',
'3711.TW': '半導體',
'2603.TW': '航運',
'2637.TW': '散裝航運',
'2049.TW': '工具機',
'1101.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_lstm_predict(data, predict_days=5):
"""簡化的LSTM預測模型 (使用統計方法模擬)"""
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
if ma_short > ma_medium > ma_long:
trend_factor = 1.02
elif ma_short < ma_medium < ma_long:
trend_factor = 0.98
else:
trend_factor = 1.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)
}
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
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
all_prices = np.concatenate([df['High'].values, df['Low'].values])
min_price, max_price = all_prices.min(), all_prices.max()
price_for_volume = (df['High'] + df['Low'] + df['Close']) / 3
df_vol_profile = df.copy()
df_vol_profile['Price_Indicator'] = price_for_volume
hist, bin_edges = np.histogram(df_vol_profile['Price_Indicator'], bins=num_bins, range=(min_price, max_price), weights=df_vol_profile['Volume'])
price_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
return bin_edges, hist, price_centers
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()
# ========================= GEMINI 整合 START =========================
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:
return "無法解析 Gemini 回應", response.text
except Exception as e:
error_message = f"呼叫 Gemini API 時發生錯誤: {str(e)}"
print(error_message)
return error_message, "請檢查後台日誌或 API 金鑰設定"
# ========================== GEMINI 整合 END ==========================
# 建立 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},
{'label': '60日後預測', 'value': 60}], 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='2330.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'}) # 預設改為 1mo
], 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("無法獲取台指期資料"), {}
final_prediction = simple_lstm_predict(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 = simple_lstm_predict(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')]
)
def update_price_chart(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(px.line(data, y='Close').data[0], 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)
bin_edges, volume_per_bin, price_centers = calculate_volume_profile(data, num_bins=50)
if volume_per_bin 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
# ========================= MODIFIED SECTION START =========================
@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):
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)
# 1. 技術面分析 (保留客觀數據呈現)
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 '空頭'}。"]),
])
# 2. 基本面與展望分析 (呼叫 Gemini)
# 顯示“正在生成…”提示,改善使用者體驗
loading_text = html.Div([
dcc.Loading(id="loading-analysis", type="dots", children=[html.Div(id="loading-output")])
])
try:
fundamental_text, market_outlook_text = generate_gemini_analysis(stock_name, selected_stock, period, data)
except Exception as e:
fundamental_text = f"生成分析時發生錯誤: {e}"
market_outlook_text = "請檢查 API 金鑰或網路連線。"
return technical_text, fundamental_text, market_outlook_text
# ========================== MODIFIED SECTION END ==========================
@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)