Spaces:
Sleeping
Sleeping
| # 系統套件 | |
| import os | |
| from datetime import datetime, timedelta | |
| # 數據處理 | |
| import pandas as pd | |
| import numpy as np | |
| import yfinance as yf | |
| # Dash & Plotly | |
| 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 | |
| # 台股代號對應表 (移除台指期,因為它現在是獨立區塊) | |
| TAIWAN_STOCKS = { | |
| '台積電': '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() | |
| def calculate_volume_profile(df, num_bins=50): | |
| """ | |
| 計算成交量分佈圖 (Volume Profile) 的數據。 | |
| Args: | |
| df (pd.DataFrame): 包含 'High', 'Low', 'Volume' 欄位的 DataFrame。 | |
| num_bins (int): 分割價格區間的數量。 | |
| Returns: | |
| tuple: 包含 (bin_edges, volume_per_bin, price_centers) 的 tuple。 | |
| bin_edges: 每個區間的邊界。 | |
| volume_per_bin: 每個區間對應的成交量。 | |
| price_centers: 每個區間的中心價格。 | |
| """ | |
| 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'] # 確保 Volume 欄位存在 | |
| # 創建直方圖來計算成交量分佈 | |
| # `density=False` 確保我們得到的是實際的成交量總和,而不是密度 | |
| # `bins=num_bins` 設定價格區間的數量 | |
| # `range` 設定價格的最小值和最大值 | |
| 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 | |
| # 建立 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': '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'}) | |
| ]), | |
| # 新增:成交量分佈圖 (Volume Profile) | |
| html.Div([ | |
| html.H3("📊 成交量分佈圖 (Volume Profile)"), | |
| dcc.Graph(id='volume-profile-chart') | |
| ], 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={'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)' | |
| }) | |
| ]) | |
| # 台指期獨立預測回調函數 (新版本) | |
| 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 = '#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) | |
| )) | |
| # --- 修改處:使用新的座標點繪製預測線 --- | |
| # 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 | |
| # 更新股價資訊卡片 | |
| 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' | |
| }) | |
| ]) | |
| # 更新股價圖表 | |
| 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圖表(保持兼容性) | |
| 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 | |
| # 新增:進階技術指標圖表 | |
| 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': | |
| # 建立兩個垂直排列的子圖,並共享X軸 | |
| fig = make_subplots(rows=2, cols=1, shared_xaxes=True, | |
| vertical_spacing=0.1, # 子圖間的垂直間距 | |
| row_heights=[0.7, 0.3], # 上方圖佔70%,下方圖佔30% | |
| subplot_titles=('價格走勢', 'MACD 指標')) # 設定子圖標題 | |
| # --- 上方子圖 (row=1):只繪製價格走勢 --- | |
| 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) | |
| # --- 下方子圖 (row=2):繪製所有MACD相關指標 --- | |
| # 1. MACD 快線 (DIF) | |
| 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) | |
| # 2. Signal 慢線 (MACD) | |
| 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) | |
| # 3. Histogram 柱狀圖 | |
| 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) | |
| # 在MACD子圖中添加一條零軸水平線,方便觀察 | |
| 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) | |
| # 下方: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 | |
| # 更新成交量圖表 | |
| 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 | |
| # 更新產業分析圖表 | |
| 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 | |
| # 新增:更新景氣燈號圖表 | |
| 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>請確認 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 | |
| # 新增:更新分析師觀點 | |
| 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, '綜合') | |
| 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圖表 | |
| 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 '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 | |
| def update_volume_profile_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] | |
| # 計算 Volume Profile | |
| bin_edges, volume_per_bin, price_centers = calculate_volume_profile(data, num_bins=50) # 您可以調整 num_bins | |
| if bin_edges is None or volume_per_bin is None: | |
| return {} | |
| # 創建 Volume Profile 圖 (通常是水平長條圖) | |
| # 我們將其繪製為一個水平的長條圖,成交量在 X 軸,價格在 Y 軸 | |
| fig = go.Figure(go.Bar( | |
| orientation='h', # 設定為水平長條圖 | |
| y=price_centers, | |
| x=volume_per_bin, | |
| name='Volume Profile', | |
| marker=dict( | |
| color='rgba(173, 216, 230, 0.6)', # 淡藍色 | |
| line=dict(color='rgba(30, 144, 255, 0.8)', width=1) # 邊框線 | |
| ), | |
| # 顯示具體的成交量數字 | |
| text=[f'{vol:.0f}' for vol in volume_per_bin], | |
| textposition='outside', # 將文字顯示在長條圖外面 | |
| hoverinfo='y+text' # hover 時顯示 Y 軸 (價格) 和 text (成交量) | |
| )) | |
| # 獲取最高成交量的價格區間 (Point of Control, POC) | |
| if len(volume_per_bin) > 0: | |
| poc_volume = np.max(volume_per_bin) | |
| poc_index = np.argmax(volume_per_bin) | |
| poc_price = price_centers[poc_index] | |
| # 在 POC 價格線上添加一條垂直線 | |
| fig.add_vline(x=poc_volume, line_dash="dash", line_color="red", | |
| annotation_text=f"POC: ${poc_price:.2f} ({poc_volume:.0f})", | |
| annotation_position="top right") | |
| # 更新圖表佈局 | |
| fig.update_layout( | |
| title=f'{stock_name} 成交量分佈圖 (Volume Profile)', | |
| xaxis_title='成交量', | |
| yaxis_title='價格 (TWD)', | |
| height=450, | |
| yaxis=dict(autorange='reversed'), # 讓價格從高到低排列 | |
| bargap=0, # 讓長條圖緊密排列 | |
| plot_bgcolor='rgba(0,0,0,0)', # 透明背景 | |
| hoverlabel=dict(bgcolor="white", font_size=12, font_family="Rockwell") | |
| ) | |
| return fig | |
| # 新增:多檔股票比較 | |
| 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("無可比較資料") | |
| # 新增:市場情緒分析 | |
| 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()}") | |
| # 在 Hugging Face Spaces 中執行 | |
| app.run(host="0.0.0.0", port=7860, debug=False) |