diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,1459 +1,1448 @@ -import pandas as pd -import dash -from dash import dcc, html, callback -import plotly.express as px -import plotly.graph_objects as go -from datetime import datetime, timedelta -import yfinance as yf -import numpy as np -from plotly.subplots import make_subplots -import os - -# 安裝必要套件 (在 Colab 中執行) -# !pip install dash plotly yfinance pandas numpy - -# 台股代號對應表 -TAIWAN_STOCKS = { - '台指期': 'TXF=F', # 台指期貨 - '台積電': '2330.TW', - '聯發科': '2454.TW', - '鴻海': '2317.TW', - '台塑': '1301.TW', - '中華電': '2412.TW', - '富邦金': '2881.TW', - '國泰金': '2882.TW', - '台達電': '2308.TW', - '統一': '1216.TW', - '日月光': '2311.TW', - '長榮': '2306.TW', - '慧洋-KY': '2637.TW', - '上銀' : '2049.TW', - '台泥' : '1101.TW', - '譜瑞-KY' :'4966.TW', - '貿聯-KY' :'3665.TW' -} - -# 產業分類 -INDUSTRY_MAPPING = { - '2330.TW': '半導體', - '2454.TW': '半導體', - '2317.TW': '電子組件', - '1301.TW': '塑膠', - '2412.TW': '電信', - '2881.TW': '金融', - '2882.TW': '金融', - '2308.TW': '電子', - '1216.TW': '食品', - '2311.TW': '半導體', - '2306.TW': '航運', - '2637.TW': '散裝航運', - '2049.TW' : '工具機', - '1101.TW' : '營建', - '4966.TW' :'高速傳輸', - '3665.TW' :'連接器' -} - -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) - - return df - -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.Div([ - html.Label("選擇股票:"), - dcc.Dropdown( - id='stock-dropdown', - options=[{'label': name, 'value': symbol} for name, symbol in TAIWAN_STOCKS.items()], - value='TXF=F', - 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'}), - - # AI預測區塊 (只在選擇台指期時顯示) - html.Div(id='prediction-section', style={'margin-bottom': '30px'}), - - # 主要圖表區域 - html.Div([ - # 左側:股價走勢圖和技術指標 - html.Div([ - html.Div([ - dcc.Graph(id='price-chart') - ], style={'margin-bottom': '20px'}), - - html.Div([ - dcc.Graph(id='rsi-chart') - ]) - ], style={'width': '65%', 'display': 'inline-block', 'vertical-align': 'top'}), - - # 右側:分析資訊面板 - html.Div([ - html.Div(id='analysis-panel') - ], style={'width': '33%', 'display': 'inline-block', 'margin-left': '2%', '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'} - ], - 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=['2330.TW', '2454.TW', '2317.TW'], # 預設選擇 - multi=True, - style={'margin-bottom': '15px'} - ) - ], 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%'}) - ]), - - 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)' - }) -]) - -# 更新AI預測區塊 -@app.callback( - dash.dependencies.Output('prediction-section', 'children'), - [dash.dependencies.Input('stock-dropdown', 'value')], - prevent_initial_call=False -) -def update_prediction_section(selected_stock): - if selected_stock != 'TXF=F': - return html.Div() # 只在選擇台指期時顯示預測功能 - - return html.Div([ - html.H3("🤖 AI深度學習預測 - 台指期指數", style={'text-align': 'center', 'color': '#FFCC22'}), - html.Div([ - html.Div([ - html.Label("預測期間:", style={'font-weight': 'bold', 'color': '#FFCC22'}), - dcc.Dropdown( - id='prediction-period', - options=[ - {'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='prediction-results', style={'width': '65%', 'display': 'inline-block', 'margin-left': '5%'}) - ]), - - html.Div([ - dcc.Graph(id='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' - }) - -# 更新預測結果 -@app.callback( - [dash.dependencies.Output('prediction-results', 'children'), - dash.dependencies.Output('prediction-chart', 'figure')], - [dash.dependencies.Input('prediction-period', 'value'), - dash.dependencies.Input('stock-dropdown', 'value')] -) -def update_prediction(predict_days, selected_stock): - if selected_stock != 'TXF=F': - return html.Div(), {} - - # 獲取台指期歷史資料 - data = get_stock_data('^TWII', '2y') - if data.empty: - return html.Div("無法獲取台指期資料"), {} - - # 執行預測 - prediction = simple_lstm_predict(data, predict_days) - if prediction is None: - return html.Div("資料不足,無法進行預測"), {} - - current_price = data['Close'].iloc[-1] - predicted_price = prediction['predicted_price'] - change_pct = prediction['change_pct'] - confidence = prediction['confidence'] - - # 預測結果卡片 - color = '#00C851' if change_pct >= 0 else '#FF4444' - 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) - )) - - # 預測點 - future_date = recent_data.index[-1] + timedelta(days=predict_days) - fig.add_trace(go.Scatter( - x=[recent_data.index[-1], future_date], - y=[current_price, predicted_price], - 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 = 'green' if change >= 0 else 'red' - - 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"{'▲' if change >= 0 else '▼'} {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] - - if chart_type == 'candlestick': - fig = go.Figure(data=go.Candlestick( - x=data.index, - open=data['Open'], - high=data['High'], - low=data['Low'], - close=data['Close'], - name=stock_name - )) - else: - fig = px.line(data, y='Close', title=f'{stock_name} 股價走勢') - - # 添加移動平均線 - fig.add_trace(go.Scatter(x=data.index, y=data['MA5'], mode='lines', name='MA5', line=dict(color='orange'))) - fig.add_trace(go.Scatter(x=data.index, y=data['MA20'], mode='lines', name='MA20', line=dict(color='blue'))) - - fig.update_layout( - title=f'{stock_name} 股價走勢', - xaxis_title='日期', - yaxis_title='價格 (TWD)', - height=400 - ) - - return fig - -# 更新RSI圖表(保持兼容性) -@app.callback( - dash.dependencies.Output('rsi-chart', 'figure'), - [dash.dependencies.Input('stock-dropdown', 'value'), - dash.dependencies.Input('period-dropdown', 'value')] -) -def update_rsi_chart(selected_stock, period): - data = get_stock_data(selected_stock, period) - if data.empty: - return {} - - data = calculate_technical_indicators(data) - - 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="red", annotation_text="超買線(70)") - fig.add_hline(y=30, line_dash="dash", line_color="green", 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="red", opacity=0.1, annotation_text="超買區") - fig.add_hrect(y0=0, y1=30, fillcolor="green", opacity=0.1, annotation_text="超賣區") - - fig.update_layout( - title='RSI 相對強弱指標', - xaxis_title='日期', - yaxis_title='RSI', - height=400, - yaxis=dict(range=[0, 100]) - ) - - 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="red", annotation_text="超買線(70)") - fig.add_hline(y=30, line_dash="dash", line_color="green", 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="red", opacity=0.1) - fig.add_hrect(y0=0, y1=30, fillcolor="green", 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線', 'MACD柱狀圖')) - - # 上方:價格線 - fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價', - line=dict(color='black', width=1)), row=1, col=1) - - # MACD線和信號線 - fig.add_trace(go.Scatter(x=data.index, y=data['MACD'], mode='lines', name='MACD', - line=dict(color='blue', width=2)), row=1, col=1) - fig.add_trace(go.Scatter(x=data.index, y=data['MACD_Signal'], mode='lines', name='信號線', - line=dict(color='red', width=2)), row=1, col=1) - - # 下方:MACD柱狀圖 - colors = ['green' if x >= 0 else 'red' 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=1, col=1) - fig.add_hline(y=0, line_dash="dash", line_color="gray", row=2, col=1) - - fig.update_layout( - title=f'{stock_name} - MACD 指數平滑異同移動平均線', - height=500 - ) - - 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) - - # 下方:KD線 - 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) - - # KD指標參考線 - fig.add_hline(y=80, line_dash="dash", line_color="red", annotation_text="超買線(80)", 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=50, line_dash="dot", line_color="gray", annotation_text="中線(50)", row=2, col=1) - - # 超買超賣區域 - fig.add_hrect(y0=80, y1=100, fillcolor="red", opacity=0.1, row=2, col=1) - fig.add_hrect(y0=0, y1=20, fillcolor="green", 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="red", annotation_text="超買線(-20)", 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=-50, line_dash="dot", line_color="gray", annotation_text="中線(-50)", row=2, col=1) - - # 超買超賣區域 - fig.add_hrect(y0=-20, y1=0, fillcolor="red", opacity=0.1, row=2, col=1) - fig.add_hrect(y0=-100, y1=-80, fillcolor="green", 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) - - 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] - - fig = px.bar(data, y='Volume', title=f'{stock_name} 成交量') - fig.update_layout( - 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="無法載入景氣燈號資料
請確認 business_climate.csv 檔案是否存在", - 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 - - # 技術面分析 - 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': 'green' if price_change > 5 else 'red' 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': 'red' if rsi_current > 70 else 'green' 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': 'green' if macd_current > macd_signal_current else 'red', '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': 'red' if bb_position > 0.8 else 'green' 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': 'green' if k_current > d_current else 'red', 'font-weight': 'bold'} - ), - f"D值({d_current:.1f}),", - html.Span( - "超買警戒" if k_current > 80 else "超賣關注" if k_current < 20 else "正常區間", - style={'color': 'red' if k_current > 80 else 'green' if k_current < 20 else 'blue', 'font-weight': 'bold'} - ), - "。" - ]), - 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, '綜合') - if selected_stock == 'TXF=F': - fundamental_text = html.Div([ - html.P([ - html.Strong("總體經濟:"), - "台指期反映台股整體表現,當前需關注聯準會政策、國際貿易情勢及台灣出口動能。" - ]), - html.P([ - html.Strong("產業輪動:"), - "觀察半導體、電子等權重產業表現,以及傳統產業復甦力道。" - ]), - html.P([ - html.Strong("資金面:"), - "外資動向、匯率變化及市場流動性為主要觀察重點。" - ]) - ]) - else: - 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 = "#28a745" - elif price_change < -10: - outlook_tone = "保守觀望" - outlook_color = "#dc3545" - 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資料
請確認 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 'green' if value >= 50 else 'red' - - 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="lightgreen", opacity=0.2, - annotation_text="擴張區間", annotation_position="top left" - ) - fig.add_hrect( - y0=40, y1=50, - fillcolor="lightcoral", 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): - 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 = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == stock][0] - - # 正規化價格(以期初為基準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 = 'green' if item['return'] > 0 else 'red' - 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': "darkblue"}, - 'steps': [ - {'range': [0, 30], 'color': "lightcoral"}, - {'range': [30, 70], 'color': "lightgray"}, - {'range': [70, 100], 'color': "lightgreen"} - ], - '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': '#e8f4f8', - '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()}") - - # 在 Colab 中需要使用以下方式啟動 - app.run(mode='inline', port=8050, debug=True) - - # 如果在本地環境執行,使用以下方式 - # app.run_server(debug=True) - -# 使用說明: -""" -在 Google Colab 中執行此程式的步驟: - -1. 首先安裝必要套件: - !pip install dash plotly yfinance pandas numpy - -2. 確保你的 CSV 檔案格式正確: - - business_climate.csv: 包含 Date 和 Index 兩欄 - - taiwan_pmi.csv: 包含 DATE 和 INDEX 兩欄 (或 Date 和 Index) - - 日期格式建議為 YYYY-MM (例如: 2023-07) - -3. 將 CSV 檔案上傳到 Colab 的檔案區域,確保檔名正確 - -4. 將上述程式碼貼到 Colab cell 中執行 - -5. 程式會先測試檔案讀取,然後啟動儀表板 - -修正內容: -- 新增了 os 模組來檢查檔案是否存在 -- 改善了 CSV 檔案讀取函數,加入錯誤處理和格式檢查 -- 新增了景氣燈號和 PMI 圖表的回調函數 -- 修正了 dash.Output 的語法(使用 dash.dependencies.Output) -- 加入了日期格式處理,支援 YYYY-MM 格式 -- 新增了檔案讀取測試功能 - -如果仍有問題,請檢查: -1. CSV 檔案是否在正確位置 -2. 檔案格式是否正確 -3. 日期欄位是否包含有效日期 +# 安裝必要套件 (在 Colab 中執行) +# !pip install dash plotly yfinance pandas numpy + +# 台股代號對應表 +TAIWAN_STOCKS = { + '台指期': 'TXF=F', # 台指期貨 + '台積電': '2330.TW', + '聯發科': '2454.TW', + '鴻海': '2317.TW', + '台塑': '1301.TW', + '中華電': '2412.TW', + '富邦金': '2881.TW', + '國泰金': '2882.TW', + '台達電': '2308.TW', + '統一': '1216.TW', + '日月光': '2311.TW', + '長榮': '2306.TW', + '慧洋-KY': '2637.TW', + '上銀' : '2049.TW', + '台泥' : '1101.TW', + '譜瑞-KY' :'4966.TW', + '貿聯-KY' :'3665.TW' +} + +# 產業分類 +INDUSTRY_MAPPING = { + '2330.TW': '半導體', + '2454.TW': '半導體', + '2317.TW': '電子組件', + '1301.TW': '塑膠', + '2412.TW': '電信', + '2881.TW': '金融', + '2882.TW': '金融', + '2308.TW': '電子', + '1216.TW': '食品', + '2311.TW': '半導體', + '2306.TW': '航運', + '2637.TW': '散裝航運', + '2049.TW' : '工具機', + '1101.TW' : '營建', + '4966.TW' :'高速傳輸', + '3665.TW' :'連接器' +} + +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) + + return df + +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.Div([ + html.Label("選擇股票:"), + dcc.Dropdown( + id='stock-dropdown', + options=[{'label': name, 'value': symbol} for name, symbol in TAIWAN_STOCKS.items()], + value='TXF=F', + 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'}), + + # AI預測區塊 (只在選擇台指期時顯示) + html.Div(id='prediction-section', style={'margin-bottom': '30px'}), + + # 主要圖表區域 + html.Div([ + # 左側:股價走勢圖和技術指標 + html.Div([ + html.Div([ + dcc.Graph(id='price-chart') + ], style={'margin-bottom': '20px'}), + + html.Div([ + dcc.Graph(id='rsi-chart') + ]) + ], style={'width': '65%', 'display': 'inline-block', 'vertical-align': 'top'}), + + # 右側:分析資訊面板 + html.Div([ + html.Div(id='analysis-panel') + ], style={'width': '33%', 'display': 'inline-block', 'margin-left': '2%', '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'} + ], + 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=['2330.TW', '2454.TW', '2317.TW'], # 預設選擇 + multi=True, + style={'margin-bottom': '15px'} + ) + ], 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%'}) + ]), + + 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)' + }) +]) + +# 更新AI預測區塊 +@app.callback( + dash.dependencies.Output('prediction-section', 'children'), + [dash.dependencies.Input('stock-dropdown', 'value')], + prevent_initial_call=False +) +def update_prediction_section(selected_stock): + if selected_stock != 'TXF=F': + return html.Div() # 只在選擇台指期時顯示預測功能 + + return html.Div([ + html.H3("🤖 AI深度學習預測 - 台指期指數", style={'text-align': 'center', 'color': '#FFCC22'}), + html.Div([ + html.Div([ + html.Label("預測期間:", style={'font-weight': 'bold', 'color': '#FFCC22'}), + dcc.Dropdown( + id='prediction-period', + options=[ + {'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='prediction-results', style={'width': '65%', 'display': 'inline-block', 'margin-left': '5%'}) + ]), + + html.Div([ + dcc.Graph(id='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' + }) + +# 更新預測結果 +@app.callback( + [dash.dependencies.Output('prediction-results', 'children'), + dash.dependencies.Output('prediction-chart', 'figure')], + [dash.dependencies.Input('prediction-period', 'value'), + dash.dependencies.Input('stock-dropdown', 'value')] +) +def update_prediction(predict_days, selected_stock): + if selected_stock != 'TXF=F': + return html.Div(), {} + + # 獲取台指期歷史資料 + data = get_stock_data('^TWII', '2y') + if data.empty: + return html.Div("無法獲取台指期資料"), {} + + # 執行預測 + prediction = simple_lstm_predict(data, predict_days) + if prediction is None: + return html.Div("資料不足,無法進行預測"), {} + + current_price = data['Close'].iloc[-1] + predicted_price = prediction['predicted_price'] + change_pct = prediction['change_pct'] + confidence = prediction['confidence'] + + # 預測結果卡片 + color = '#00C851' if change_pct >= 0 else '#FF4444' + 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) + )) + + # 預測點 + future_date = recent_data.index[-1] + timedelta(days=predict_days) + fig.add_trace(go.Scatter( + x=[recent_data.index[-1], future_date], + y=[current_price, predicted_price], + 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 = 'green' if change >= 0 else 'red' + + 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"{'▲' if change >= 0 else '▼'} {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] + + if chart_type == 'candlestick': + fig = go.Figure(data=go.Candlestick( + x=data.index, + open=data['Open'], + high=data['High'], + low=data['Low'], + close=data['Close'], + name=stock_name + )) + else: + fig = px.line(data, y='Close', title=f'{stock_name} 股價走勢') + + # 添加移動平均線 + fig.add_trace(go.Scatter(x=data.index, y=data['MA5'], mode='lines', name='MA5', line=dict(color='orange'))) + fig.add_trace(go.Scatter(x=data.index, y=data['MA20'], mode='lines', name='MA20', line=dict(color='blue'))) + + fig.update_layout( + title=f'{stock_name} 股價走勢', + xaxis_title='日期', + yaxis_title='價格 (TWD)', + height=400 + ) + + return fig + +# 更新RSI圖表(保持兼容性) +@app.callback( + dash.dependencies.Output('rsi-chart', 'figure'), + [dash.dependencies.Input('stock-dropdown', 'value'), + dash.dependencies.Input('period-dropdown', 'value')] +) +def update_rsi_chart(selected_stock, period): + data = get_stock_data(selected_stock, period) + if data.empty: + return {} + + data = calculate_technical_indicators(data) + + 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="red", annotation_text="超買線(70)") + fig.add_hline(y=30, line_dash="dash", line_color="green", 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="red", opacity=0.1, annotation_text="超買區") + fig.add_hrect(y0=0, y1=30, fillcolor="green", opacity=0.1, annotation_text="超賣區") + + fig.update_layout( + title='RSI 相對強弱指標', + xaxis_title='日期', + yaxis_title='RSI', + height=400, + yaxis=dict(range=[0, 100]) + ) + + 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="red", annotation_text="超買線(70)") + fig.add_hline(y=30, line_dash="dash", line_color="green", 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="red", opacity=0.1) + fig.add_hrect(y0=0, y1=30, fillcolor="green", 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線', 'MACD柱狀圖')) + + # 上方:價格線 + fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價', + line=dict(color='black', width=1)), row=1, col=1) + + # MACD線和信號線 + fig.add_trace(go.Scatter(x=data.index, y=data['MACD'], mode='lines', name='MACD', + line=dict(color='blue', width=2)), row=1, col=1) + fig.add_trace(go.Scatter(x=data.index, y=data['MACD_Signal'], mode='lines', name='信號線', + line=dict(color='red', width=2)), row=1, col=1) + + # 下方:MACD柱狀圖 + colors = ['green' if x >= 0 else 'red' 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=1, col=1) + fig.add_hline(y=0, line_dash="dash", line_color="gray", row=2, col=1) + + fig.update_layout( + title=f'{stock_name} - MACD 指數平滑異同移動平均線', + height=500 + ) + + 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) + + # 下方:KD線 + 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) + + # KD指標參考線 + fig.add_hline(y=80, line_dash="dash", line_color="red", annotation_text="超買線(80)", 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=50, line_dash="dot", line_color="gray", annotation_text="中線(50)", row=2, col=1) + + # 超買超賣區域 + fig.add_hrect(y0=80, y1=100, fillcolor="red", opacity=0.1, row=2, col=1) + fig.add_hrect(y0=0, y1=20, fillcolor="green", 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="red", annotation_text="超買線(-20)", 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=-50, line_dash="dot", line_color="gray", annotation_text="中線(-50)", row=2, col=1) + + # 超買超賣區域 + fig.add_hrect(y0=-20, y1=0, fillcolor="red", opacity=0.1, row=2, col=1) + fig.add_hrect(y0=-100, y1=-80, fillcolor="green", 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) + + 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] + + fig = px.bar(data, y='Volume', title=f'{stock_name} 成交量') + fig.update_layout( + 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="無法載入景氣燈號資料
請確認 business_climate.csv 檔案是否存在", + 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 + + # 技術面分析 + 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': 'green' if price_change > 5 else 'red' 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': 'red' if rsi_current > 70 else 'green' 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': 'green' if macd_current > macd_signal_current else 'red', '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': 'red' if bb_position > 0.8 else 'green' 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': 'green' if k_current > d_current else 'red', 'font-weight': 'bold'} + ), + f"D值({d_current:.1f}),", + html.Span( + "超買警戒" if k_current > 80 else "超賣關注" if k_current < 20 else "正常區間", + style={'color': 'red' if k_current > 80 else 'green' if k_current < 20 else 'blue', 'font-weight': 'bold'} + ), + "。" + ]), + 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, '綜合') + if selected_stock == 'TXF=F': + fundamental_text = html.Div([ + html.P([ + html.Strong("總體經濟:"), + "台指期反映台股整體表現,當前需關注聯準會政策、國際貿易情勢及台灣出口動能。" + ]), + html.P([ + html.Strong("產業輪動:"), + "觀察半導體、電子等權重產業表現,以及傳統產業復甦力道。" + ]), + html.P([ + html.Strong("資金面:"), + "外資動向、匯率變化及市場流動性為主要觀察重點。" + ]) + ]) + else: + 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 = "#28a745" + elif price_change < -10: + outlook_tone = "保守觀望" + outlook_color = "#dc3545" + 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資料
請確認 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 'green' if value >= 50 else 'red' + + 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="lightgreen", opacity=0.2, + annotation_text="擴張區間", annotation_position="top left" + ) + fig.add_hrect( + y0=40, y1=50, + fillcolor="lightcoral", 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): + 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 = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == stock][0] + + # 正規化價格(以期初為基準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 = 'green' if item['return'] > 0 else 'red' + 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': "darkblue"}, + 'steps': [ + {'range': [0, 30], 'color': "lightcoral"}, + {'range': [30, 70], 'color': "lightgray"}, + {'range': [70, 100], 'color': "lightgreen"} + ], + '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': '#e8f4f8', + '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()}") + + # 在 Colab 中需要使用以下方式啟動 + app.run(mode='inline', port=8050, debug=True) + + # 如果在本地環境執行,使用以下方式 + # app.run_server(debug=True) + +# 使用說明: +""" +在 Google Colab 中執行此程式的步驟: + +1. 首先安裝必要套件: + !pip install dash plotly yfinance pandas numpy + +2. 確保你的 CSV 檔案格式正確: + - business_climate.csv: 包含 Date 和 Index 兩欄 + - taiwan_pmi.csv: 包含 DATE 和 INDEX 兩欄 (或 Date 和 Index) + - 日期格式建議為 YYYY-MM (例如: 2023-07) + +3. 將 CSV 檔案上傳到 Colab 的檔案區域,確保檔名正確 + +4. 將上述程式碼貼到 Colab cell 中執行 + +5. 程式會先測試檔案讀取,然後啟動儀表板 + +修正內容: +- 新增了 os 模組來檢查檔案是否���在 +- 改善了 CSV 檔案讀取函數,加入錯誤處理和格式檢查 +- 新增了景氣燈號和 PMI 圖表的回調函數 +- 修正了 dash.Output 的語法(使用 dash.dependencies.Output) +- 加入了日期格式處理,支援 YYYY-MM 格式 +- 新增了檔案讀取測試功能 + +如果仍有問題,請檢查: +1. CSV 檔案是否在正確位置 +2. 檔案格式是否正確 +3. 日期欄位是否包含有效日期 """ \ No newline at end of file