bert / app.py
tkkbbo332's picture
Update app.py
696e8c1
# 系統套件
import os
from datetime import datetime, timedelta
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
bert_predictor = BertPredictor()
# --- 2. 測試 get_news_index() 方法 ---
print("\n" + "-"*50)
print(f"[步驟 2] 測試 get_news_index() 方法...")
print(f"將會讀取檔案 '{os.path.basename(bert_predictor.news_csv_path)}' 並計算分數平均值。")
avg_score = bert_predictor.get_news_index()
if avg_score is not None:
print(f"【測試結果】成功!")
print(f" └── 所有新聞的平均分數為: {avg_score:.4f}")
else:
print("【測試結果】失敗或無資料。")
print(f" └── 無法從 '{os.path.basename(predictor.news_csv_path)}' 計算出平均分數。")
# --- 3. 測試 get_news() 方法 ---
print("\n" + "-"*50)
print(f"[步驟 3] 測試 get_news() 方法...")
print(f"將會讀取檔案 '{os.path.basename(bert_predictor.news_csv_path)}' 並找出分數絕對值最高的三則新聞。")
top_news_content_list = bert_predictor.get_news()
if top_news_content_list is not None:
if top_news_content_list: # List 不為空
print("【測試結果】成功!")
print(" └── 分數絕對值最高的三則新聞內容如下:")
for i, content in enumerate(top_news_content_list):
# 為了美觀地顯示,限制內容長度
content_display = (content[:80] + '...') if len(content) > 80 else content
print(f" {i+1}. {content_display}")
else: # List 為空 []
print("【測試結果】成功,但無有效新聞可顯示。")
print(f" └── 檔案 '{os.path.basename(predictor.news_csv_path)}' 可能為空或不含有效分數。")
else: # 回傳值為 None,代表發生錯誤
print("【測試結果】失敗。")
print(f" └── 讀取檔案 '{os.path.basename(predictor.news_csv_path)}' 時發生錯誤。")
print("\n" + "="*50)
print("所有測試執行完畢。")
print("="*50)
# 台股代號對應表 (移除台指期,因為它現在是獨立區塊)
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',
'1101.TW': '營建',
'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':
# 嘗試使用台灣50ETF作為替代
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 create_lstm_dataset(data, time_step=60):
"""建立LSTM訓練資料集"""
X, y = [], []
for i in range(time_step, len(data)):
X.append(data[i-time_step:i, 0])
y.append(data[i, 0])
return np.array(X), np.array(y)
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()
# RSI
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))
# MACD (12, 26, 9)
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']
# 布林通道 (20日, 2倍標準差)
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)
df['BB_Width'] = df['BB_Upper'] - df['BB_Lower']
df['BB_Position'] = (df['Close'] - df['BB_Lower']) / (df['BB_Upper'] - df['BB_Lower'])
# KD指標 (9, 3, 3)
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() # com=2 相當於 span=3
df['D'] = df['K'].ewm(com=2).mean()
# 威廉指標 %R (14日)
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)
# DMI (Directional Movement Index)
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)
# 計算真實範圍 (TR)
df['TR'] = np.max([df['High'] - df['Low'], abs(df['High'] - df['Close'].shift(1)), abs(df['Low'] - df['Close'].shift(1))], axis=0)
# 計算平滑後的 +DM, -DM, TR (通常使用 14 天)
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
# 計算 ADX
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):
"""
計算成交量分佈圖 (Volume Profile) 的數據。
"""
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 = all_prices.min()
max_price = 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
df_vol_profile['Volume'] = df_vol_profile['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'):
print("business_climate.csv 檔案不存在")
return pd.DataFrame()
# 讀取CSV檔案,假設列名為 Date 和 Index
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
# 轉換日期格式 (處理 YYYY-MM 格式)
if 'Date' in df.columns:
try:
# 如果是 YYYY-MM 格式,轉換為日期
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'])
print(f"成功讀取景氣燈號資料:{len(df)} 筆記錄")
return df
except Exception as e:
print(f"無法獲取景氣燈號資料: {str(e)}")
return pd.DataFrame()
def get_pmi_data():
"""獲取台灣 PMI 資料"""
try:
# 檢查檔案是否存在
if not os.path.exists('taiwan_pmi.csv'):
print("taiwan_pmi.csv 檔案不存在")
return pd.DataFrame()
# 讀取CSV檔案
df = pd.read_csv('taiwan_pmi.csv')
# 檢查列名並調整 (處理 DATE/INDEX 或其他可能的列名)
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:
# 如果是 YYYY-MM 格式,轉換為日期
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'])
print(f"成功讀取 PMI 資料:{len(df)} 筆記錄")
return df
except Exception as e:
print(f"無法獲取 PMI 資料: {str(e)}")
return pd.DataFrame()
# 建立 Dash 應用程式
app = dash.Dash(__name__, suppress_callback_exceptions=True)
# 應用程式佈局
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.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='6mo',
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'}),
# 主要圖表區域 - 移除RSI圖表
html.Div([
# 左側:股價走勢圖 (現在包含成交量分佈)
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("📈 基本面分析", 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("🎯 市場展望與投資建議", 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'
}),
# 景氣燈號與 PMI 分析
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.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'], # 修改:預設包含0050
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)'
}),
# 新聞情感分析區域(模擬)
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)'
})
])
# 台指期獨立預測回調函數 (新版本)
@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 = data['Close'].iloc[-1]
last_date = data.index[-1]
predicted_price = final_prediction['predicted_price']
change_pct = final_prediction['change_pct']
confidence = final_prediction['confidence']
# --- 主要修改處:計算預測路徑 ---
# 1. 定義不同預測天期所包含的中間節點
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])
# 2. 準備儲存預測路徑的座標點 (起始點為目前價格)
prediction_dates = [last_date]
prediction_prices = [current_price]
# 3. 循環計算路徑上每個點的預測值
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 = 'red' if change_pct >= 0 else 'green'
arrow = '📈' if change_pct >= 0 else '📉'
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()
# 歷史價格 (最近30天)
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)
))
# --- 修改處:使用新的座標點繪製預測線 ---
# 4. 繪製由多個預測點連接而成的路徑
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 = 'red' if change >= 0 else 'green'
arrow = '▲' if change >= 0 else '▼'
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]
# --- 1. 建立共享 Y 軸的子圖 ---
# 建立一個 1x2 的網格,設定欄位寬度比例,並共享 Y 軸
fig = make_subplots(
rows=1, cols=2,
shared_yaxes=True,
column_widths=[0.8, 0.2], # 左側圖佔80%,右側圖佔20%
horizontal_spacing=0.01 # 子圖間的水平間距
)
# --- 2. 在左側子圖 (col=1) 繪製股價圖 ---
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)
# --- 3. 在右側子圖 (col=2) 繪製成交量分佈圖 ---
# 計算 Volume Profile 數據
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)
# --- 4. 更新整體圖表佈局 ---
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 # 因為共享Y軸,所以隱藏右側的Y軸標籤
),
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]
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.add_hrect(y0=70, y1=100, fillcolor="green", opacity=0.1)
fig.add_hrect(y0=0, y1=30, fillcolor="red", opacity=0.1)
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.add_hline(y=0, line_dash="dash", line_color="gray", row=2, col=1)
fig.update_layout(
title_text=f'{stock_name} - MACD 指數平滑異同移動平均線',
height=550,
legend_title_text='圖例',
showlegend=True
)
fig.update_traces(showlegend=False, selector=dict(type='bar'))
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.add_trace(go.Scatter(x=data.index, y=data['BB_Upper'], mode='lines',
line=dict(color='rgba(0,0,0,0)'), showlegend=False))
fig.add_trace(go.Scatter(x=data.index, y=data['BB_Lower'], mode='lines',
fill='tonexty', fillcolor='rgba(173,216,230,0.2)',
line=dict(color='rgba(0,0,0,0)'), name='布林通道', showlegend=False))
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.add_hline(y=50, line_dash="dot", line_color="gray", annotation_text="中線(50)", row=2, col=1)
fig.add_hrect(y0=80, y1=100, fillcolor="green", opacity=0.1, row=2, col=1)
fig.add_hrect(y0=0, y1=20, fillcolor="red", opacity=0.1, row=2, col=1)
fig.update_layout(
title=f'{stock_name} - KD 隨機指標 (9,3,3)',
height=500
)
fig.update_yaxes(range=[0, 100], row=2, col=1)
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.add_hline(y=-50, line_dash="dot", line_color="gray", annotation_text="中線(-50)", row=2, col=1)
fig.add_hrect(y0=-20, y1=0, fillcolor="green", opacity=0.1, row=2, col=1)
fig.add_hrect(y0=-100, y1=-80, fillcolor="red", opacity=0.1, row=2, col=1)
fig.update_layout(
title=f'{stock_name} - 威廉指標 %R (14日)',
height=500
)
fig.update_yaxes(range=[-100, 0], row=2, col=1)
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 指標'))
# 過濾掉不穩定的初始數據(通常為14天)
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)
# 下方:DMI 線
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)
# DMI 參考線
fig.add_hline(y=20, line_dash="dash", line_color="gray", annotation_text="ADX強弱線(20)", row=2, col=1)
fig.update_layout(
title=f'{stock_name} - DMI 動向指標 (14日)',
height=500,
showlegend=True
)
fig.update_yaxes(range=[0, 100], row=2, col=1)
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()
fig.add_trace(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):
# 獲取多檔股票資料進行產業比較
industry_data = []
for symbol in list(TAIWAN_STOCKS.values())[:10]: # 取前10檔做示範
data = get_stock_data(symbol, '1mo')
if not data.empty:
stock_name = [name for name, symbol_code in TAIWAN_STOCKS.items() if symbol_code == symbol][0]
latest_price = data['Close'].iloc[-1]
first_price = data['Close'].iloc[0]
return_pct = ((latest_price - first_price) / first_price) * 100
industry_data.append({
'股票': stock_name,
'代碼': symbol,
'月報酬率(%)': return_pct,
'產業': INDUSTRY_MAPPING.get(symbol, '其他')
})
if not industry_data:
return {}
df_industry = pd.DataFrame(industry_data)
# 建立產業表現圓餅圖
fig = px.pie(df_industry, values='月報酬率(%)', names='股票',
title='各股票月報酬率比較',
color_discrete_sequence=px.colors.qualitative.Set3)
fig.update_layout(height=400)
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()
fig.add_annotation(
x=0.5, y=0.5,
text="無法載入景氣燈號資料<br>請確認網路連線或國發會網站是否可存取",
xref="paper", yref="paper",
showarrow=False,
font=dict(size=14)
)
fig.update_layout(
title="台灣景氣燈號",
height=300,
showlegend=False
)
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=24, line_dash="dash", line_color="orange", annotation_text="黃紅燈(24)")
fig.add_hline(y=17, line_dash="dash", line_color="yellow", annotation_text="黃燈(17)")
fig.add_hline(y=10, line_dash="dash", line_color="lightgreen", annotation_text="黃藍燈(10)")
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):
# 獲取股票資料進行分析
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:
return "無法獲取資料進行分析", "無法獲取資料進行分析", "無法獲取資料進行分析"
# 計算技術指標
data = calculate_technical_indicators(data)
# 基本數據
current_price = data['Close'].iloc[-1]
price_change = ((current_price - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
volume_avg = data['Volume'].mean()
recent_volume = data['Volume'].iloc[-5:].mean()
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
bb_position = data['BB_Position'].iloc[-1] if not pd.isna(data['BB_Position'].iloc[-1]) else 0.5
k_current = data['K'].iloc[-1] if not pd.isna(data['K'].iloc[-1]) else 50
d_current = data['D'].iloc[-1] if not pd.isna(data['D'].iloc[-1]) else 50
pdi_current = data['+DI'].iloc[-1] if not pd.isna(data['+DI'].iloc[-1]) else 0
ndi_current = data['-DI'].iloc[-1] if not pd.isna(data['-DI'].iloc[-1]) else 0
adx_current = data['ADX'].iloc[-1] if not pd.isna(data['ADX'].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_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'}
),
"。"
]),
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 '空頭'}格局。"
]),
html.P([
html.Strong("布林通道:"),
f"股價位於通道",
html.Span(
"上半部" if bb_position > 0.8 else "下半部" if bb_position < 0.2 else "中段",
style={'color': 'green' if bb_position > 0.8 else 'red' if bb_position < 0.2 else 'blue', 'font-weight': 'bold'}
),
f"({bb_position*100:.0f}%),",
f"{'壓力較大' if bb_position > 0.8 else '支撐較強' if bb_position < 0.2 else '整理格局'}。"
]),
html.P([
html.Strong("KD指標:"),
f"K值({k_current:.1f})",
html.Span(
"高於" if k_current > d_current else "低於",
style={'color': 'red' if k_current > d_current else 'green', 'font-weight': 'bold'}
),
f"D值({d_current:.1f}),",
html.Span(
"超買警戒" if k_current > 80 else "超賣關注" if k_current < 20 else "正常區間",
style={'color': 'green' if k_current > 80 else 'red' if k_current < 20 else 'blue', 'font-weight': 'bold'}
),
"。"
]),
html.P([
html.Strong("DMI指標:"),
f"目前+DI ({pdi_current:.1f}) 與 -DI ({ndi_current:.1f}),",
html.Span(
"呈現多頭趨勢" if pdi_current > ndi_current else "呈現空頭趨勢",
style={'color': 'red' if pdi_current > ndi_current else 'green', 'font-weight': 'bold'}
),
f"。ADX值為 {adx_current:.1f},顯示市場趨勢{'強勁' if adx_current > 25 else '不明顯' if adx_current < 20 else '有趨勢'}"
]),
html.P([
html.Strong("成交量分析:"),
f"近期成交量{'放大' if recent_volume > volume_avg * 1.2 else '萎縮' if recent_volume < volume_avg * 0.8 else '平穩'},",
f"顯示市場{'關注度提升' if recent_volume > volume_avg * 1.2 else '觀望氣氛濃厚' if recent_volume < volume_avg * 0.8 else '交投正常'}。"
])
])
# 基本面分析
industry = INDUSTRY_MAPPING.get(selected_stock, '綜合')
fundamental_text = html.Div([
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'}),
"。"
]),
html.P([
html.Strong("營運展望:"),
f"考量{industry}產業前景及公司基本面,建議持續關注季報表現及未來指引。"
]),
html.P([
html.Strong("風險評估:"),
"注意產業週期性變化、國際競爭及法規環境變化等風險因子。"
])
])
# 市場展望
if price_change > 10:
outlook_tone = "謹慎樂觀"
outlook_color = "#dc3545"
elif price_change < -10:
outlook_tone = "保守觀望"
outlook_color = "#28a745"
else:
outlook_tone = "中性持平"
outlook_color = "#ffc107"
market_outlook = html.Div([
html.P([
html.Strong("整體評估:", style={'font-size': '16px'}),
f"基於技術面及基本面分析,對{stock_name}採取",
html.Span(f"{outlook_tone}", style={'color': outlook_color, 'font-weight': 'bold', 'font-size': '16px'}),
"態度。"
]),
html.P([
html.Strong("投資建議:"),
"建議投資人根據自身風險承受能力,採取適當的資產配置策略。短線操作注意技術指標,長線投資關注基本面變化。"
]),
html.P([
html.Strong("風險提醒:"),
"股票投資具有風險,過去績效不代表未來表現,投資前請詳閱公開說明書並審慎評估。"
], style={'font-style': 'italic', 'font-size': '13px'})
])
return technical_text, fundamental_text, market_outlook
# 新增:更新PMI圖表
@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()
fig.add_annotation(
x=0.5, y=0.5,
text="無法載入PMI資料<br>請確認 taiwan_pmi.csv 檔案是否存在",
xref="paper", yref="paper",
showarrow=False,
font=dict(size=14)
)
fig.update_layout(
title="台灣PMI指數",
height=300,
showlegend=False
)
return fig
# 定義PMI顏色 (50以上擴張,以下緊縮)
def get_pmi_color(value):
return 'red' if value >= 50 else 'green'
colors = [get_pmi_color(value) 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.add_hrect(
y0=50, y1=60,
fillcolor="lightcoral", opacity=0.2,
annotation_text="擴張區間", annotation_position="top left"
)
fig.add_hrect(
y0=40, y1=50,
fillcolor="lightgreen", opacity=0.2,
annotation_text="緊縮區間", annotation_position="bottom left"
)
fig.update_layout(
title="台灣PMI指數走勢",
xaxis_title='日期',
yaxis_title='PMI指數',
height=300,
yaxis=dict(range=[35, 60])
)
return fig
# ==============================================================================
# ===== 修改後的多檔股票比較回呼函式 =====
# ==============================================================================
@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):
# --- 新增:確保 0050.TW 始終存在 ---
fixed_stock = '0050.TW'
# 如果列表為空或 None,則只顯示 0050
if not selected_stocks:
selected_stocks = [fixed_stock]
# 如果 0050 不在列表中,則將其插入到最前面
elif fixed_stock not in selected_stocks:
selected_stocks.insert(0, fixed_stock)
# --- 修改結束 ---
if not selected_stocks:
return {}, html.Div("請選擇要比較的股票")
# 限制最多5檔
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)
# 正規化價格(以期初為基準100)
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("股票", style={'text-align': 'center'}),
html.Th("報酬率", style={'text-align': 'center'}),
html.Th("波動率", style={'text-align': 'center'}),
html.Th("現價", style={'text-align': 'center'})
])
]),
html.Tbody(table_rows)
], style={
'width': '100%',
'border-collapse': 'collapse',
'font-size': '12px'
})
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):
# 模擬情緒指標(實際應用中可接入新聞API或情緒分析服務)
sentiment_score = np.random.uniform(30, 80) # 模擬情緒分數 0-100
# 建立情緒指標圓形圖
gauge_fig = go.Figure(go.Indicator(
mode = "gauge+number+delta",
value = sentiment_score,
domain = {'x': [0, 1], 'y': [0, 1]},
title = {'text': "市場情緒指數"},
delta = {'reference': 50},
gauge = {
'axis': {'range': [None, 100]},
'bar': {'color': "darkred"},
'steps': [
{'range': [0, 30], 'color': "lightgreen"},
{'range': [30, 70], 'color': "lightgray"},
{'range': [70, 100], 'color': "lightcoral"}
],
'threshold': {
'line': {'color': "red", 'width': 4},
'thickness': 0.75,
'value': 90
}
}
))
gauge_fig.update_layout(height=200, margin=dict(l=20, r=20, t=40, b=20))
# 模擬新聞摘要
stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
news_items = [
f"📈 {stock_name}獲外資調升目標價,看好後續發展前景",
f"💼 法人預期{stock_name}下季營收將較上季成長5-10%",
f"🌐 國際市場波動對{stock_name}影響有限,基本面穩健",
f"⚡ 產業景氣回溫,{stock_name}受惠程度值得關注",
f"📊 技術面顯示{stock_name}突破關鍵壓力,短線偏多"
]
news_content = html.Div([
html.P(news, style={
'margin': '8px 0',
'padding': '8px',
'background': '#f8f9fa',
'border-radius': '5px',
'border-left': '3px solid #17a2b8',
'font-size': '13px'
}) for news in news_items[:3] # 顯示前3條
])
return dcc.Graph(figure=gauge_fig), news_content
# 在 Colab 中執行的設定
if __name__ == '__main__':
# 在執行前先測試檔案讀取
print("測試檔案讀取...")
business_data = get_business_climate_data()
pmi_data = get_pmi_data()
if not business_data.empty:
print(f"景氣燈號資料預覽:\n{business_data.head()}")
if not pmi_data.empty:
print(f"PMI資料預覽:\n{pmi_data.head()}")
# 在 Hugging Face Spaces 中執行
app.run(host="0.0.0.0", port=7860, debug=False)