|
|
|
|
|
import os |
|
|
from datetime import datetime, timedelta |
|
|
import pandas as pd |
|
|
import numpy as np |
|
|
import yfinance as yf |
|
|
from dash import Dash, dcc, html, callback |
|
|
import dash |
|
|
import plotly.express as px |
|
|
import plotly.graph_objects as go |
|
|
from plotly.subplots import make_subplots |
|
|
import re |
|
|
from bs4 import BeautifulSoup |
|
|
import requests |
|
|
from Bert_predict import BertPredictor |
|
|
|
|
|
bert_predictor = BertPredictor() |
|
|
|
|
|
print("\n" + "-"*50) |
|
|
print(f"[步驟 2] 測試 get_news_index() 方法...") |
|
|
print(f"將會讀取檔案 '{os.path.basename(bert_predictor.news_csv_path)}' 並計算分數平均值。") |
|
|
|
|
|
avg_score = bert_predictor.get_news_index() |
|
|
|
|
|
if avg_score is not None: |
|
|
print(f"【測試結果】成功!") |
|
|
print(f" └── 所有新聞的平均分數為: {avg_score:.4f}") |
|
|
else: |
|
|
print("【測試結果】失敗或無資料。") |
|
|
print(f" └── 無法從 '{os.path.basename(predictor.news_csv_path)}' 計算出平均分數。") |
|
|
|
|
|
|
|
|
print("\n" + "-"*50) |
|
|
print(f"[步驟 3] 測試 get_news() 方法...") |
|
|
print(f"將會讀取檔案 '{os.path.basename(bert_predictor.news_csv_path)}' 並找出分數絕對值最高的三則新聞。") |
|
|
|
|
|
top_news_content_list = bert_predictor.get_news() |
|
|
|
|
|
if top_news_content_list is not None: |
|
|
if top_news_content_list: |
|
|
print("【測試結果】成功!") |
|
|
print(" └── 分數絕對值最高的三則新聞內容如下:") |
|
|
for i, content in enumerate(top_news_content_list): |
|
|
|
|
|
content_display = (content[:80] + '...') if len(content) > 80 else content |
|
|
print(f" {i+1}. {content_display}") |
|
|
else: |
|
|
print("【測試結果】成功,但無有效新聞可顯示。") |
|
|
print(f" └── 檔案 '{os.path.basename(predictor.news_csv_path)}' 可能為空或不含有效分數。") |
|
|
else: |
|
|
print("【測試結果】失敗。") |
|
|
print(f" └── 讀取檔案 '{os.path.basename(predictor.news_csv_path)}' 時發生錯誤。") |
|
|
|
|
|
|
|
|
print("\n" + "="*50) |
|
|
print("所有測試執行完畢。") |
|
|
print("="*50) |
|
|
|
|
|
|
|
|
TAIWAN_STOCKS = { |
|
|
'元大台灣50': '0050.TW', |
|
|
'台積電': '2330.TW', |
|
|
'聯發科': '2454.TW', |
|
|
'鴻海': '2317.TW', |
|
|
'台塑': '1301.TW', |
|
|
'中華電': '2412.TW', |
|
|
'富邦金': '2881.TW', |
|
|
'國泰金': '2882.TW', |
|
|
'台達電': '2308.TW', |
|
|
'統一': '1216.TW', |
|
|
'日月光': '3711.TW', |
|
|
'長榮': '2603.TW', |
|
|
'慧洋-KY': '2637.TW', |
|
|
'上銀': '2049.TW', |
|
|
'台泥': '1101.TW', |
|
|
'南亞科': '2408.TW', |
|
|
'旺宏': '2337.TW', |
|
|
'譜瑞-KY': '4966.TWO', |
|
|
'貿聯-KY': '3665.TW', |
|
|
'騰雲': '6870.TWO', |
|
|
'穩懋': '3105.TWO' |
|
|
} |
|
|
|
|
|
|
|
|
INDUSTRY_MAPPING = { |
|
|
'0050.TW': 'ETF', |
|
|
'2330.TW': '半導體', |
|
|
'2454.TW': '半導體', |
|
|
'2317.TW': '電子組件', |
|
|
'1301.TW': '塑膠', |
|
|
'2412.TW': '電信', |
|
|
'2881.TW': '金融', |
|
|
'2882.TW': '金融', |
|
|
'2308.TW': '電子', |
|
|
'1216.TW': '食品', |
|
|
'3711.TW': '半導體', |
|
|
'2603.TW': '航運', |
|
|
'2637.TW': '散裝航運', |
|
|
'2049.TW': '工具機', |
|
|
'1101.TW': '營建', |
|
|
'2408.TW': 'DRAM', |
|
|
'2337.TW': 'NFLSH', |
|
|
'1101.TW': '營建', |
|
|
'4966.TWO': '高速傳輸', |
|
|
'3665.TW': '連接器', |
|
|
'6870.TWO': '軟體整合', |
|
|
'3105.TWO': 'PA功率' |
|
|
} |
|
|
|
|
|
def get_stock_data(symbol, period='1y'): |
|
|
"""獲取股票資料""" |
|
|
try: |
|
|
stock = yf.Ticker(symbol) |
|
|
data = stock.history(period=period) |
|
|
|
|
|
|
|
|
if data.empty and symbol == 'TXF=F': |
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
delta = df['Close'].diff() |
|
|
gain = (delta.where(delta > 0, 0)).rolling(window=14).mean() |
|
|
loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean() |
|
|
rs = gain / loss |
|
|
df['RSI'] = 100 - (100 / (1 + rs)) |
|
|
|
|
|
|
|
|
exp1 = df['Close'].ewm(span=12).mean() |
|
|
exp2 = df['Close'].ewm(span=26).mean() |
|
|
df['MACD'] = exp1 - exp2 |
|
|
df['MACD_Signal'] = df['MACD'].ewm(span=9).mean() |
|
|
df['MACD_Histogram'] = df['MACD'] - df['MACD_Signal'] |
|
|
|
|
|
|
|
|
df['BB_Middle'] = df['Close'].rolling(window=20).mean() |
|
|
bb_std = df['Close'].rolling(window=20).std() |
|
|
df['BB_Upper'] = df['BB_Middle'] + (bb_std * 2) |
|
|
df['BB_Lower'] = df['BB_Middle'] - (bb_std * 2) |
|
|
df['BB_Width'] = df['BB_Upper'] - df['BB_Lower'] |
|
|
df['BB_Position'] = (df['Close'] - df['BB_Lower']) / (df['BB_Upper'] - df['BB_Lower']) |
|
|
|
|
|
|
|
|
low_min = df['Low'].rolling(window=9).min() |
|
|
high_max = df['High'].rolling(window=9).max() |
|
|
rsv = (df['Close'] - low_min) / (high_max - low_min) * 100 |
|
|
df['K'] = rsv.ewm(com=2).mean() |
|
|
df['D'] = df['K'].ewm(com=2).mean() |
|
|
|
|
|
|
|
|
low_min_14 = df['Low'].rolling(window=14).min() |
|
|
high_max_14 = df['High'].rolling(window=14).max() |
|
|
df['Williams_R'] = -100 * (high_max_14 - df['Close']) / (high_max_14 - low_min_14) |
|
|
|
|
|
|
|
|
df['up_move'] = df['High'] - df['High'].shift(1) |
|
|
df['down_move'] = df['Low'].shift(1) - df['Low'] |
|
|
df['+DM'] = np.where((df['up_move'] > df['down_move']) & (df['up_move'] > 0), df['up_move'], 0) |
|
|
df['-DM'] = np.where((df['down_move'] > df['up_move']) & (df['down_move'] > 0), df['down_move'], 0) |
|
|
|
|
|
|
|
|
df['TR'] = np.max([df['High'] - df['Low'], abs(df['High'] - df['Close'].shift(1)), abs(df['Low'] - df['Close'].shift(1))], axis=0) |
|
|
|
|
|
|
|
|
df['+DI'] = (df['+DM'].ewm(com=13, adjust=False).mean() / df['TR'].ewm(com=13, adjust=False).mean()) * 100 |
|
|
df['-DI'] = (df['-DM'].ewm(com=13, adjust=False).mean() / df['TR'].ewm(com=13, adjust=False).mean()) * 100 |
|
|
|
|
|
|
|
|
df['DX'] = abs(df['+DI'] - df['-DI']) / (df['+DI'] + df['-DI']) * 100 |
|
|
df['ADX'] = df['DX'].ewm(com=13, adjust=False).mean() |
|
|
|
|
|
return df |
|
|
|
|
|
def calculate_volume_profile(df, num_bins=50): |
|
|
""" |
|
|
計算成交量分佈圖 (Volume Profile) 的數據。 |
|
|
""" |
|
|
if df.empty or 'High' not in df.columns or 'Low' not in df.columns or 'Volume' not in df.columns: |
|
|
return None, None, None |
|
|
|
|
|
all_prices = np.concatenate([df['High'].values, df['Low'].values]) |
|
|
min_price = all_prices.min() |
|
|
max_price = all_prices.max() |
|
|
|
|
|
price_for_volume = (df['High'] + df['Low'] + df['Close']) / 3 |
|
|
df_vol_profile = df.copy() |
|
|
df_vol_profile['Price_Indicator'] = price_for_volume |
|
|
df_vol_profile['Volume'] = df_vol_profile['Volume'] |
|
|
|
|
|
hist, bin_edges = np.histogram(df_vol_profile['Price_Indicator'], bins=num_bins, range=(min_price, max_price), weights=df_vol_profile['Volume']) |
|
|
price_centers = (bin_edges[:-1] + bin_edges[1:]) / 2 |
|
|
|
|
|
return bin_edges, hist, price_centers |
|
|
|
|
|
def get_business_climate_data(): |
|
|
"""獲取台灣景氣燈號資料""" |
|
|
try: |
|
|
|
|
|
if not os.path.exists('business_climate.csv'): |
|
|
print("business_climate.csv 檔案不存在") |
|
|
return pd.DataFrame() |
|
|
|
|
|
|
|
|
df = pd.read_csv('business_climate.csv') |
|
|
|
|
|
|
|
|
if 'Date' not in df.columns: |
|
|
|
|
|
df.columns = ['Date', 'Index'] if len(df.columns) == 2 else df.columns |
|
|
|
|
|
|
|
|
if 'Date' in df.columns: |
|
|
try: |
|
|
|
|
|
df['Date'] = pd.to_datetime(df['Date'] + '-01', format='%Y-%m-%d', errors='coerce') |
|
|
except: |
|
|
df['Date'] = pd.to_datetime(df['Date'], errors='coerce') |
|
|
|
|
|
|
|
|
df = df.dropna(subset=['Date']) |
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
df = pd.read_csv('taiwan_pmi.csv') |
|
|
|
|
|
|
|
|
if 'DATE' in df.columns: |
|
|
df = df.rename(columns={'DATE': 'Date', 'INDEX': 'Index'}) |
|
|
elif len(df.columns) == 2: |
|
|
df.columns = ['Date', 'Index'] |
|
|
|
|
|
|
|
|
if 'Date' in df.columns: |
|
|
try: |
|
|
|
|
|
df['Date'] = pd.to_datetime(df['Date'] + '-01', format='%Y-%m-%d', errors='coerce') |
|
|
except: |
|
|
df['Date'] = pd.to_datetime(df['Date'], errors='coerce') |
|
|
|
|
|
|
|
|
df = df.dropna(subset=['Date']) |
|
|
|
|
|
print(f"成功讀取 PMI 資料:{len(df)} 筆記錄") |
|
|
return df |
|
|
|
|
|
except Exception as e: |
|
|
print(f"無法獲取 PMI 資料: {str(e)}") |
|
|
return pd.DataFrame() |
|
|
|
|
|
|
|
|
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'}), |
|
|
|
|
|
|
|
|
html.Div([ |
|
|
|
|
|
html.Div([ |
|
|
html.Div([ |
|
|
dcc.Graph(id='price-chart') |
|
|
]) |
|
|
], style={'width': '100%', 'display': 'inline-block', 'vertical-align': 'top'}), |
|
|
]), |
|
|
|
|
|
|
|
|
html.Div([ |
|
|
html.H3("📊 進階技術指標分析", style={'margin-bottom': '20px'}), |
|
|
html.Div([ |
|
|
html.Label("選擇技術指標:", style={'font-weight': 'bold', 'margin-right': '10px'}), |
|
|
dcc.Dropdown( |
|
|
id='technical-indicator-selector', |
|
|
options=[ |
|
|
{'label': 'RSI 相對強弱指標', 'value': 'RSI'}, |
|
|
{'label': 'MACD 指數平滑異同移動平均線', 'value': 'MACD'}, |
|
|
{'label': '布林通道 Bollinger Bands', 'value': 'BB'}, |
|
|
{'label': 'KD 隨機指標', 'value': 'KD'}, |
|
|
{'label': '威廉指標 %R', 'value': 'WR'}, |
|
|
{'label': 'DMI 動向指標', 'value': 'DMI'} |
|
|
], |
|
|
value='RSI', |
|
|
style={'width': '100%'} |
|
|
) |
|
|
], style={'margin-bottom': '20px'}), |
|
|
|
|
|
html.Div([ |
|
|
dcc.Graph(id='advanced-technical-chart') |
|
|
]) |
|
|
], style={ |
|
|
'margin-top': '20px', |
|
|
'padding': '20px', |
|
|
'background': 'white', |
|
|
'border-radius': '10px', |
|
|
'box-shadow': '0 2px 10px rgba(0,0,0,0.1)' |
|
|
}), |
|
|
|
|
|
|
|
|
html.Div([ |
|
|
dcc.Graph(id='volume-chart') |
|
|
], style={'margin-top': '20px'}), |
|
|
|
|
|
|
|
|
html.Div([ |
|
|
html.H3("產業表現分析"), |
|
|
dcc.Graph(id='industry-analysis') |
|
|
], style={'margin-top': '30px'}), |
|
|
|
|
|
|
|
|
html.Div([ |
|
|
html.H3("📊 分析師觀點與市場解讀", style={'color': '#2E86AB', 'margin-bottom': '20px'}), |
|
|
html.Div([ |
|
|
|
|
|
html.Div([ |
|
|
html.H4("🔍 技術面分析", style={'color': '#A23B72', 'margin-bottom': '15px'}), |
|
|
html.Div(id='technical-analysis-text', style={ |
|
|
'background': '#f8f9fa', |
|
|
'padding': '15px', |
|
|
'border-radius': '8px', |
|
|
'border-left': '4px solid #A23B72', |
|
|
'min-height': '150px', |
|
|
'font-size': '14px', |
|
|
'line-height': '1.6' |
|
|
}) |
|
|
], style={'width': '48%', 'display': 'inline-block', 'vertical-align': 'top'}), |
|
|
|
|
|
|
|
|
html.Div([ |
|
|
html.H4("📈 基本面分析", style={'color': '#F18F01', 'margin-bottom': '15px'}), |
|
|
html.Div(id='fundamental-analysis-text', style={ |
|
|
'background': '#f8f9fa', |
|
|
'padding': '15px', |
|
|
'border-radius': '8px', |
|
|
'border-left': '4px solid #F18F01', |
|
|
'min-height': '150px', |
|
|
'font-size': '14px', |
|
|
'line-height': '1.6' |
|
|
}) |
|
|
], style={'width': '48%', 'display': 'inline-block', 'margin-left': '4%', 'vertical-align': 'top'}) |
|
|
]), |
|
|
|
|
|
|
|
|
html.Div([ |
|
|
html.H4("🎯 市場展望與投資建議", style={'color': '#C73E1D', 'margin-bottom': '15px', 'margin-top': '25px'}), |
|
|
html.Div(id='market-outlook-text', style={ |
|
|
'background': 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', |
|
|
'color': 'white', |
|
|
'padding': '20px', |
|
|
'border-radius': '10px', |
|
|
'min-height': '100px', |
|
|
'font-size': '15px', |
|
|
'line-height': '1.7', |
|
|
'box-shadow': '0 4px 15px rgba(0,0,0,0.1)' |
|
|
}) |
|
|
]) |
|
|
], style={ |
|
|
'margin-top': '30px', |
|
|
'padding': '25px', |
|
|
'background': 'white', |
|
|
'border-radius': '12px', |
|
|
'box-shadow': '0 4px 20px rgba(0,0,0,0.08)', |
|
|
'border': '1px solid #e9ecef' |
|
|
}), |
|
|
|
|
|
|
|
|
html.Div([ |
|
|
html.H3("景氣燈號與 PMI 分析"), |
|
|
html.Div([ |
|
|
html.Div([ |
|
|
dcc.Graph(id='business-climate-chart') |
|
|
], style={'width': '48%', 'display': 'inline-block'}), |
|
|
html.Div([ |
|
|
dcc.Graph(id='pmi-chart') |
|
|
], style={'width': '48%', 'display': 'inline-block', 'margin-left': '2%'}) |
|
|
]) |
|
|
], style={'margin-top': '30px'}), |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
html.Div([ |
|
|
html.H3("📊 多檔股票比較分析", style={'margin-bottom': '20px'}), |
|
|
html.Div([ |
|
|
html.Div([ |
|
|
html.Label("選擇比較股票(最多5檔):", style={'font-weight': 'bold'}), |
|
|
dcc.Dropdown( |
|
|
id='comparison-stocks', |
|
|
options=[{'label': name, 'value': symbol} for name, symbol in TAIWAN_STOCKS.items()], |
|
|
value=['0050.TW', '2330.TW', '2454.TW'], |
|
|
multi=True, |
|
|
style={'margin-bottom': '5px'} |
|
|
), |
|
|
|
|
|
html.Small( |
|
|
'(元大台灣50 (0050.TW) 為固定比較基準,不可移除)', |
|
|
style={'display': 'block', 'font-style': 'italic', 'color': 'gray'} |
|
|
) |
|
|
], style={'width': '60%', 'display': 'inline-block'}), |
|
|
|
|
|
html.Div([ |
|
|
html.Label("比較期間:", style={'font-weight': 'bold'}), |
|
|
dcc.Dropdown( |
|
|
id='comparison-period', |
|
|
options=[ |
|
|
{'label': '1個月', 'value': '1mo'}, |
|
|
{'label': '3個月', 'value': '3mo'}, |
|
|
{'label': '6個月', 'value': '6mo'}, |
|
|
{'label': '1年', 'value': '1y'} |
|
|
], |
|
|
value='3mo' |
|
|
) |
|
|
], style={'width': '35%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'}) |
|
|
]), |
|
|
|
|
|
html.Div([ |
|
|
html.Div([ |
|
|
dcc.Graph(id='comparison-chart') |
|
|
], style={'width': '65%', 'display': 'inline-block'}), |
|
|
|
|
|
html.Div([ |
|
|
html.H4("比較結果", style={'color': '#2E86AB'}), |
|
|
html.Div(id='comparison-table') |
|
|
], style={'width': '33%', 'display': 'inline-block', 'margin-left': '2%', 'vertical-align': 'top'}) |
|
|
]) |
|
|
], style={ |
|
|
'margin-top': '30px', |
|
|
'padding': '20px', |
|
|
'background': 'white', |
|
|
'border-radius': '10px', |
|
|
'box-shadow': '0 2px 10px rgba(0,0,0,0.1)' |
|
|
}), |
|
|
|
|
|
|
|
|
html.Div([ |
|
|
html.H3("📰 市場情緒與新聞分析", style={'color': '#E74C3C', 'margin-bottom': '20px'}), |
|
|
html.Div([ |
|
|
html.Div([ |
|
|
html.H4("市場情緒指標", style={'color': '#8E44AD'}), |
|
|
html.Div(id='sentiment-gauge') |
|
|
], style={'width': '48%', 'display': 'inline-block'}), |
|
|
|
|
|
html.Div([ |
|
|
html.H4("關鍵新聞摘要", style={'color': '#27AE60'}), |
|
|
html.Div(id='news-summary', style={ |
|
|
'background': '#f8f9fa', |
|
|
'padding': '15px', |
|
|
'border-radius': '8px', |
|
|
'max-height': '200px', |
|
|
'overflow-y': 'auto' |
|
|
}) |
|
|
], style={'width': '48%', 'display': 'inline-block', 'margin-left': '4%'}) |
|
|
]) |
|
|
], style={ |
|
|
'margin-top': '30px', |
|
|
'padding': '20px', |
|
|
'background': 'white', |
|
|
'border-radius': '10px', |
|
|
'box-shadow': '0 2px 10px rgba(0,0,0,0.1)' |
|
|
}) |
|
|
]) |
|
|
|
|
|
|
|
|
@app.callback( |
|
|
[dash.dependencies.Output('taiex-prediction-results', 'children'), |
|
|
dash.dependencies.Output('taiex-prediction-chart', 'figure')], |
|
|
[dash.dependencies.Input('taiex-prediction-period', 'value')] |
|
|
) |
|
|
def update_taiex_prediction(predict_days): |
|
|
|
|
|
data = get_stock_data('^TWII', '2y') |
|
|
if data.empty: |
|
|
return html.Div("無法獲取台指期資料"), {} |
|
|
|
|
|
|
|
|
final_prediction = simple_lstm_predict(data, predict_days) |
|
|
if final_prediction is None: |
|
|
return html.Div("資料不足,無法進行預測"), {} |
|
|
|
|
|
current_price = data['Close'].iloc[-1] |
|
|
last_date = data.index[-1] |
|
|
predicted_price = final_prediction['predicted_price'] |
|
|
change_pct = final_prediction['change_pct'] |
|
|
confidence = final_prediction['confidence'] |
|
|
|
|
|
|
|
|
|
|
|
prediction_paths = { |
|
|
1: [1], |
|
|
5: [1, 5], |
|
|
10: [1, 5, 10], |
|
|
20: [1, 10, 20], |
|
|
60: [1, 10, 20, 60] |
|
|
} |
|
|
intervals_to_predict = prediction_paths.get(predict_days, [predict_days]) |
|
|
|
|
|
|
|
|
prediction_dates = [last_date] |
|
|
prediction_prices = [current_price] |
|
|
|
|
|
|
|
|
for days in intervals_to_predict: |
|
|
interim_prediction = simple_lstm_predict(data, days) |
|
|
if interim_prediction: |
|
|
prediction_dates.append(last_date + timedelta(days=days)) |
|
|
prediction_prices.append(interim_prediction['predicted_price']) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
color = 'red' if change_pct >= 0 else 'green' |
|
|
arrow = '📈' if change_pct >= 0 else '📉' |
|
|
|
|
|
result_card = html.Div([ |
|
|
html.H4(f"{predict_days}日後預測結果", style={'margin': '0 0 15px 0', 'color': 'white'}), |
|
|
html.Div([ |
|
|
html.Span(f"{arrow} ", style={'font-size': '24px'}), |
|
|
html.Span(f"{change_pct:+.2f}%", style={ |
|
|
'font-size': '28px', |
|
|
'font-weight': 'bold', |
|
|
'color': color |
|
|
}) |
|
|
], style={'margin': '10px 0'}), |
|
|
html.P(f"目前價格: {current_price:.2f}", style={'margin': '5px 0'}), |
|
|
html.P(f"預測價格: {predicted_price:.2f}", style={'margin': '5px 0'}), |
|
|
html.P(f"信心度: {confidence:.1%}", style={'margin': '5px 0', 'font-size': '14px'}) |
|
|
], style={ |
|
|
'background': 'rgba(255,255,255,0.1)', |
|
|
'padding': '20px', |
|
|
'border-radius': '10px', |
|
|
'border': '1px solid rgba(255,255,255,0.2)' |
|
|
}) |
|
|
|
|
|
|
|
|
fig = go.Figure() |
|
|
|
|
|
|
|
|
recent_data = data.tail(30) |
|
|
fig.add_trace(go.Scatter( |
|
|
x=recent_data.index, |
|
|
y=recent_data['Close'], |
|
|
mode='lines', |
|
|
name='歷史價格', |
|
|
line=dict(color='#FFA726', width=2) |
|
|
)) |
|
|
|
|
|
|
|
|
|
|
|
fig.add_trace(go.Scatter( |
|
|
x=prediction_dates, |
|
|
y=prediction_prices, |
|
|
mode='lines+markers', |
|
|
name=f'{predict_days}日預測路徑', |
|
|
line=dict(color=color, width=3, dash='dash'), |
|
|
marker=dict(size=8) |
|
|
)) |
|
|
|
|
|
|
|
|
fig.update_layout( |
|
|
title=f'台指期 {predict_days}日預測走勢', |
|
|
xaxis_title='日期', |
|
|
yaxis_title='指數點位', |
|
|
height=350, |
|
|
plot_bgcolor='rgba(0,0,0,0)', |
|
|
paper_bgcolor='rgba(0,0,0,0)', |
|
|
font=dict(color='white') |
|
|
) |
|
|
|
|
|
return result_card, fig |
|
|
|
|
|
|
|
|
@app.callback( |
|
|
dash.dependencies.Output('stock-info-cards', 'children'), |
|
|
[dash.dependencies.Input('stock-dropdown', 'value')] |
|
|
) |
|
|
def update_stock_info(selected_stock): |
|
|
data = get_stock_data(selected_stock, '5d') |
|
|
if data.empty: |
|
|
return html.Div("無法獲取股票資料") |
|
|
|
|
|
current_price = data['Close'].iloc[-1] |
|
|
prev_price = data['Close'].iloc[-2] if len(data) > 1 else current_price |
|
|
change = current_price - prev_price |
|
|
change_pct = (change / prev_price) * 100 |
|
|
|
|
|
|
|
|
stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0] |
|
|
|
|
|
|
|
|
color = 'red' if change >= 0 else 'green' |
|
|
arrow = '▲' if change >= 0 else '▼' |
|
|
|
|
|
return html.Div([ |
|
|
html.Div([ |
|
|
html.H3(f"{stock_name} ({selected_stock})", style={'margin': '0'}), |
|
|
html.H2(f"${current_price:.2f}", style={'margin': '5px 0', 'color': color}), |
|
|
html.P(f"{arrow} {change:+.2f} ({change_pct:+.2f}%)", |
|
|
style={'margin': '0', 'color': color, 'font-weight': 'bold'}) |
|
|
], style={ |
|
|
'background': 'white', |
|
|
'padding': '20px', |
|
|
'border-radius': '10px', |
|
|
'box-shadow': '0 2px 10px rgba(0,0,0,0.1)', |
|
|
'display': 'inline-block', |
|
|
'margin-right': '20px' |
|
|
}), |
|
|
|
|
|
html.Div([ |
|
|
html.H4("今日統計", style={'margin': '0 0 10px 0'}), |
|
|
html.P(f"最高: ${data['High'].iloc[-1]:.2f}", style={'margin': '5px 0'}), |
|
|
html.P(f"最低: ${data['Low'].iloc[-1]:.2f}", style={'margin': '5px 0'}), |
|
|
html.P(f"成交量: {data['Volume'].iloc[-1]:,.0f}", style={'margin': '5px 0'}) |
|
|
], style={ |
|
|
'background': 'white', |
|
|
'padding': '20px', |
|
|
'border-radius': '10px', |
|
|
'box-shadow': '0 2px 10px rgba(0,0,0,0.1)', |
|
|
'display': 'inline-block' |
|
|
}) |
|
|
]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.callback( |
|
|
dash.dependencies.Output('price-chart', 'figure'), |
|
|
[dash.dependencies.Input('stock-dropdown', 'value'), |
|
|
dash.dependencies.Input('period-dropdown', 'value'), |
|
|
dash.dependencies.Input('chart-type', 'value')] |
|
|
) |
|
|
def update_price_chart(selected_stock, period, chart_type): |
|
|
data = get_stock_data(selected_stock, period) |
|
|
if data.empty: |
|
|
return {} |
|
|
|
|
|
data = calculate_technical_indicators(data) |
|
|
stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0] |
|
|
|
|
|
|
|
|
|
|
|
fig = make_subplots( |
|
|
rows=1, cols=2, |
|
|
shared_yaxes=True, |
|
|
column_widths=[0.8, 0.2], |
|
|
horizontal_spacing=0.01 |
|
|
) |
|
|
|
|
|
|
|
|
if chart_type == 'candlestick': |
|
|
|
|
|
fig.add_trace(go.Candlestick( |
|
|
x=data.index, |
|
|
open=data['Open'], |
|
|
high=data['High'], |
|
|
low=data['Low'], |
|
|
close=data['Close'], |
|
|
name=stock_name, |
|
|
increasing_line_color='red', |
|
|
decreasing_line_color='green' |
|
|
), row=1, col=1) |
|
|
else: |
|
|
fig.add_trace(px.line(data, y='Close').data[0], row=1, col=1) |
|
|
|
|
|
|
|
|
fig.add_trace(go.Scatter( |
|
|
x=data.index, y=data['MA5'], mode='lines', |
|
|
name='MA5', line=dict(color='orange') |
|
|
), row=1, col=1) |
|
|
fig.add_trace(go.Scatter( |
|
|
x=data.index, y=data['MA20'], mode='lines', |
|
|
name='MA20', line=dict(color='blue') |
|
|
), row=1, col=1) |
|
|
|
|
|
|
|
|
|
|
|
bin_edges, volume_per_bin, price_centers = calculate_volume_profile(data, num_bins=50) |
|
|
|
|
|
if volume_per_bin is not None: |
|
|
|
|
|
fig.add_trace(go.Bar( |
|
|
orientation='h', |
|
|
y=price_centers, |
|
|
x=volume_per_bin, |
|
|
name='Volume Profile', |
|
|
text=[f'{vol/1000:.0f}k' for vol in volume_per_bin], |
|
|
textposition='auto', |
|
|
marker=dict( |
|
|
color='rgba(173, 216, 230, 0.6)', |
|
|
line=dict(color='rgba(30, 144, 255, 0.8)', width=1) |
|
|
) |
|
|
), row=1, col=2) |
|
|
|
|
|
|
|
|
fig.update_layout( |
|
|
title_text=f'{stock_name} 股價走勢與成交量分佈', |
|
|
height=500, |
|
|
showlegend=True, |
|
|
|
|
|
|
|
|
xaxis1=dict( |
|
|
title='日期', |
|
|
type='date', |
|
|
rangeslider_visible=False |
|
|
), |
|
|
yaxis1=dict( |
|
|
title='價格 (TWD)' |
|
|
), |
|
|
|
|
|
|
|
|
xaxis2=dict( |
|
|
title='成交量', |
|
|
showticklabels=True |
|
|
), |
|
|
yaxis2=dict( |
|
|
showticklabels=False |
|
|
), |
|
|
|
|
|
bargap=0.05 |
|
|
) |
|
|
|
|
|
return fig |
|
|
|
|
|
|
|
|
|
|
|
@app.callback( |
|
|
dash.dependencies.Output('advanced-technical-chart', 'figure'), |
|
|
[dash.dependencies.Input('technical-indicator-selector', 'value'), |
|
|
dash.dependencies.Input('stock-dropdown', 'value'), |
|
|
dash.dependencies.Input('period-dropdown', 'value')] |
|
|
) |
|
|
def update_advanced_technical_chart(indicator, selected_stock, period): |
|
|
data = get_stock_data(selected_stock, period) |
|
|
if data.empty: |
|
|
return {} |
|
|
|
|
|
data = calculate_technical_indicators(data) |
|
|
stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0] |
|
|
|
|
|
if indicator == 'RSI': |
|
|
fig = go.Figure() |
|
|
fig.add_trace(go.Scatter(x=data.index, y=data['RSI'], mode='lines', name='RSI', line=dict(color='purple', width=2))) |
|
|
fig.add_hline(y=70, line_dash="dash", line_color="green", annotation_text="超買線(70)") |
|
|
fig.add_hline(y=30, line_dash="dash", line_color="red", annotation_text="超賣線(30)") |
|
|
fig.add_hline(y=50, line_dash="dot", line_color="gray", annotation_text="中線(50)") |
|
|
fig.add_hrect(y0=70, y1=100, fillcolor="green", opacity=0.1) |
|
|
fig.add_hrect(y0=0, y1=30, fillcolor="red", opacity=0.1) |
|
|
fig.update_layout( |
|
|
title=f'{stock_name} - RSI 相對強弱指標', |
|
|
xaxis_title='日期', |
|
|
yaxis_title='RSI', |
|
|
height=450, |
|
|
yaxis=dict(range=[0, 100]) |
|
|
) |
|
|
|
|
|
elif indicator == 'MACD': |
|
|
fig = make_subplots(rows=2, cols=1, shared_xaxes=True, |
|
|
vertical_spacing=0.1, |
|
|
row_heights=[0.7, 0.3], |
|
|
subplot_titles=('價格走勢', 'MACD 指標')) |
|
|
fig.add_trace(go.Scatter( |
|
|
x=data.index, y=data['Close'], mode='lines', name='收盤價', line=dict(color='black', width=1.5) |
|
|
), row=1, col=1) |
|
|
fig.add_trace(go.Scatter( |
|
|
x=data.index, y=data['MACD'], mode='lines', name='MACD (快線)', line=dict(color='blue', width=2) |
|
|
), row=2, col=1) |
|
|
fig.add_trace(go.Scatter( |
|
|
x=data.index, y=data['MACD_Signal'], mode='lines', name='Signal (慢線)', line=dict(color='red', width=2) |
|
|
), row=2, col=1) |
|
|
colors = ['red' if x >= 0 else 'green' for x in data['MACD_Histogram']] |
|
|
fig.add_trace(go.Bar( |
|
|
x=data.index, y=data['MACD_Histogram'], name='MACD柱狀圖', marker_color=colors |
|
|
), row=2, col=1) |
|
|
fig.add_hline(y=0, line_dash="dash", line_color="gray", row=2, col=1) |
|
|
fig.update_layout( |
|
|
title_text=f'{stock_name} - MACD 指數平滑異同移動平均線', |
|
|
height=550, |
|
|
legend_title_text='圖例', |
|
|
showlegend=True |
|
|
) |
|
|
fig.update_traces(showlegend=False, selector=dict(type='bar')) |
|
|
|
|
|
elif indicator == 'BB': |
|
|
fig = go.Figure() |
|
|
fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價', |
|
|
line=dict(color='black', width=2))) |
|
|
fig.add_trace(go.Scatter(x=data.index, y=data['BB_Upper'], mode='lines', name='上軌', |
|
|
line=dict(color='red', width=1, dash='dash'))) |
|
|
fig.add_trace(go.Scatter(x=data.index, y=data['BB_Middle'], mode='lines', name='中軌(MA20)', |
|
|
line=dict(color='blue', width=1))) |
|
|
fig.add_trace(go.Scatter(x=data.index, y=data['BB_Lower'], mode='lines', name='下軌', |
|
|
line=dict(color='green', width=1, dash='dash'))) |
|
|
fig.add_trace(go.Scatter(x=data.index, y=data['BB_Upper'], mode='lines', |
|
|
line=dict(color='rgba(0,0,0,0)'), showlegend=False)) |
|
|
fig.add_trace(go.Scatter(x=data.index, y=data['BB_Lower'], mode='lines', |
|
|
fill='tonexty', fillcolor='rgba(173,216,230,0.2)', |
|
|
line=dict(color='rgba(0,0,0,0)'), name='布林通道', showlegend=False)) |
|
|
fig.update_layout( |
|
|
title=f'{stock_name} - 布林通道 (20日, 2σ)', |
|
|
xaxis_title='日期', |
|
|
yaxis_title='價格 (TWD)', |
|
|
height=450 |
|
|
) |
|
|
|
|
|
elif indicator == 'KD': |
|
|
fig = make_subplots(rows=2, cols=1, shared_xaxes=True, |
|
|
vertical_spacing=0.1, |
|
|
row_heights=[0.6, 0.4], |
|
|
subplot_titles=('價格走勢', 'KD指標')) |
|
|
fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價', |
|
|
line=dict(color='black', width=1)), row=1, col=1) |
|
|
fig.add_trace(go.Scatter(x=data.index, y=data['K'], mode='lines', name='K線', |
|
|
line=dict(color='blue', width=2)), row=2, col=1) |
|
|
fig.add_trace(go.Scatter(x=data.index, y=data['D'], mode='lines', name='D線', |
|
|
line=dict(color='red', width=2)), row=2, col=1) |
|
|
fig.add_hline(y=80, line_dash="dash", line_color="green", annotation_text="超買線(80)", row=2, col=1) |
|
|
fig.add_hline(y=20, line_dash="dash", line_color="red", annotation_text="超賣線(20)", row=2, col=1) |
|
|
fig.add_hline(y=50, line_dash="dot", line_color="gray", annotation_text="中線(50)", row=2, col=1) |
|
|
fig.add_hrect(y0=80, y1=100, fillcolor="green", opacity=0.1, row=2, col=1) |
|
|
fig.add_hrect(y0=0, y1=20, fillcolor="red", opacity=0.1, row=2, col=1) |
|
|
fig.update_layout( |
|
|
title=f'{stock_name} - KD 隨機指標 (9,3,3)', |
|
|
height=500 |
|
|
) |
|
|
fig.update_yaxes(range=[0, 100], row=2, col=1) |
|
|
|
|
|
elif indicator == 'WR': |
|
|
fig = make_subplots(rows=2, cols=1, shared_xaxes=True, |
|
|
vertical_spacing=0.1, |
|
|
row_heights=[0.6, 0.4], |
|
|
subplot_titles=('價格走勢', '威廉指標 %R')) |
|
|
fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價', |
|
|
line=dict(color='black', width=1)), row=1, col=1) |
|
|
fig.add_trace(go.Scatter(x=data.index, y=data['Williams_R'], mode='lines', name='威廉%R', |
|
|
line=dict(color='purple', width=2)), row=2, col=1) |
|
|
fig.add_hline(y=-20, line_dash="dash", line_color="green", annotation_text="超買線(-20)", row=2, col=1) |
|
|
fig.add_hline(y=-80, line_dash="dash", line_color="red", annotation_text="超賣線(-80)", row=2, col=1) |
|
|
fig.add_hline(y=-50, line_dash="dot", line_color="gray", annotation_text="中線(-50)", row=2, col=1) |
|
|
fig.add_hrect(y0=-20, y1=0, fillcolor="green", opacity=0.1, row=2, col=1) |
|
|
fig.add_hrect(y0=-100, y1=-80, fillcolor="red", opacity=0.1, row=2, col=1) |
|
|
fig.update_layout( |
|
|
title=f'{stock_name} - 威廉指標 %R (14日)', |
|
|
height=500 |
|
|
) |
|
|
fig.update_yaxes(range=[-100, 0], row=2, col=1) |
|
|
|
|
|
elif indicator == 'DMI': |
|
|
fig = make_subplots(rows=2, cols=1, shared_xaxes=True, |
|
|
vertical_spacing=0.1, |
|
|
row_heights=[0.6, 0.4], |
|
|
subplot_titles=('價格走勢', 'DMI 指標')) |
|
|
|
|
|
|
|
|
data_filtered = data.iloc[14:] |
|
|
|
|
|
|
|
|
fig.add_trace(go.Scatter(x=data_filtered.index, y=data_filtered['Close'], mode='lines', name='收盤價', |
|
|
line=dict(color='black', width=1)), row=1, col=1) |
|
|
|
|
|
|
|
|
fig.add_trace(go.Scatter(x=data_filtered.index, y=data_filtered['+DI'], mode='lines', name='+DI', |
|
|
line=dict(color='red', width=2)), row=2, col=1) |
|
|
fig.add_trace(go.Scatter(x=data_filtered.index, y=data_filtered['-DI'], mode='lines', name='-DI', |
|
|
line=dict(color='green', width=2)), row=2, col=1) |
|
|
fig.add_trace(go.Scatter(x=data_filtered.index, y=data_filtered['ADX'], mode='lines', name='ADX', |
|
|
line=dict(color='blue', width=2, dash='dot')), row=2, col=1) |
|
|
|
|
|
|
|
|
fig.add_hline(y=20, line_dash="dash", line_color="gray", annotation_text="ADX強弱線(20)", row=2, col=1) |
|
|
|
|
|
fig.update_layout( |
|
|
title=f'{stock_name} - DMI 動向指標 (14日)', |
|
|
height=500, |
|
|
showlegend=True |
|
|
) |
|
|
fig.update_yaxes(range=[0, 100], row=2, col=1) |
|
|
|
|
|
return fig |
|
|
|
|
|
|
|
|
@app.callback( |
|
|
dash.dependencies.Output('volume-chart', 'figure'), |
|
|
[dash.dependencies.Input('stock-dropdown', 'value'), |
|
|
dash.dependencies.Input('period-dropdown', 'value')] |
|
|
) |
|
|
def update_volume_chart(selected_stock, period): |
|
|
data = get_stock_data(selected_stock, period) |
|
|
if data.empty: |
|
|
return {} |
|
|
|
|
|
stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0] |
|
|
|
|
|
|
|
|
colors = ['red' if data['Close'].iloc[i] > data['Open'].iloc[i] else 'green' for i in range(len(data))] |
|
|
|
|
|
fig = go.Figure() |
|
|
fig.add_trace(go.Bar( |
|
|
x=data.index, |
|
|
y=data['Volume'], |
|
|
marker_color=colors, |
|
|
name='成交量' |
|
|
)) |
|
|
|
|
|
fig.update_layout( |
|
|
title=f'{stock_name} 成交量', |
|
|
xaxis_title='日期', |
|
|
yaxis_title='成交量', |
|
|
height=300 |
|
|
) |
|
|
|
|
|
return fig |
|
|
|
|
|
|
|
|
@app.callback( |
|
|
dash.dependencies.Output('industry-analysis', 'figure'), |
|
|
[dash.dependencies.Input('stock-dropdown', 'value')] |
|
|
) |
|
|
def update_industry_analysis(selected_stock): |
|
|
|
|
|
industry_data = [] |
|
|
|
|
|
for symbol in list(TAIWAN_STOCKS.values())[:10]: |
|
|
data = get_stock_data(symbol, '1mo') |
|
|
if not data.empty: |
|
|
stock_name = [name for name, symbol_code in TAIWAN_STOCKS.items() if symbol_code == symbol][0] |
|
|
latest_price = data['Close'].iloc[-1] |
|
|
first_price = data['Close'].iloc[0] |
|
|
return_pct = ((latest_price - first_price) / first_price) * 100 |
|
|
|
|
|
industry_data.append({ |
|
|
'股票': stock_name, |
|
|
'代碼': symbol, |
|
|
'月報酬率(%)': return_pct, |
|
|
'產業': INDUSTRY_MAPPING.get(symbol, '其他') |
|
|
}) |
|
|
|
|
|
if not industry_data: |
|
|
return {} |
|
|
|
|
|
df_industry = pd.DataFrame(industry_data) |
|
|
|
|
|
|
|
|
fig = px.pie(df_industry, values='月報酬率(%)', names='股票', |
|
|
title='各股票月報酬率比較', |
|
|
color_discrete_sequence=px.colors.qualitative.Set3) |
|
|
|
|
|
fig.update_layout(height=400) |
|
|
return fig |
|
|
|
|
|
|
|
|
@app.callback( |
|
|
dash.dependencies.Output('business-climate-chart', 'figure'), |
|
|
[dash.dependencies.Input('stock-dropdown', 'value')] |
|
|
) |
|
|
def update_business_climate_chart(selected_stock): |
|
|
df = get_business_climate_data() |
|
|
|
|
|
if df.empty: |
|
|
|
|
|
fig = go.Figure() |
|
|
fig.add_annotation( |
|
|
x=0.5, y=0.5, |
|
|
text="無法載入景氣燈號資料<br>請確認網路連線或國發會網站是否可存取", |
|
|
xref="paper", yref="paper", |
|
|
showarrow=False, |
|
|
font=dict(size=14) |
|
|
) |
|
|
fig.update_layout( |
|
|
title="台灣景氣燈號", |
|
|
height=300, |
|
|
showlegend=False |
|
|
) |
|
|
return fig |
|
|
|
|
|
|
|
|
def get_light_color(score): |
|
|
if score >= 32: |
|
|
return 'red' |
|
|
elif score >= 24: |
|
|
return 'orange' |
|
|
elif score >= 17: |
|
|
return 'yellow' |
|
|
elif score >= 10: |
|
|
return 'lightgreen' |
|
|
else: |
|
|
return 'blue' |
|
|
|
|
|
|
|
|
colors = [get_light_color(score) for score in df['Index']] |
|
|
|
|
|
fig = go.Figure() |
|
|
|
|
|
fig.add_trace(go.Scatter( |
|
|
x=df['Date'], |
|
|
y=df['Index'], |
|
|
mode='lines+markers', |
|
|
name='景氣燈號', |
|
|
line=dict(color='darkblue', width=2), |
|
|
marker=dict( |
|
|
size=8, |
|
|
color=colors, |
|
|
line=dict(width=2, color='darkblue') |
|
|
) |
|
|
)) |
|
|
|
|
|
|
|
|
fig.add_hline(y=32, line_dash="dash", line_color="red", annotation_text="紅燈(32)") |
|
|
fig.add_hline(y=24, line_dash="dash", line_color="orange", annotation_text="黃紅燈(24)") |
|
|
fig.add_hline(y=17, line_dash="dash", line_color="yellow", annotation_text="黃燈(17)") |
|
|
fig.add_hline(y=10, line_dash="dash", line_color="lightgreen", annotation_text="黃藍燈(10)") |
|
|
|
|
|
fig.update_layout( |
|
|
title="台灣景氣燈號走勢", |
|
|
xaxis_title='日期', |
|
|
yaxis_title='燈號分數', |
|
|
height=300, |
|
|
yaxis=dict(range=[0, 40]) |
|
|
) |
|
|
|
|
|
return fig |
|
|
|
|
|
|
|
|
@app.callback( |
|
|
[dash.dependencies.Output('technical-analysis-text', 'children'), |
|
|
dash.dependencies.Output('fundamental-analysis-text', 'children'), |
|
|
dash.dependencies.Output('market-outlook-text', 'children')], |
|
|
[dash.dependencies.Input('stock-dropdown', 'value'), |
|
|
dash.dependencies.Input('period-dropdown', 'value')] |
|
|
) |
|
|
def update_analysis_text(selected_stock, period): |
|
|
|
|
|
data = get_stock_data(selected_stock, period) |
|
|
stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0] |
|
|
|
|
|
if data.empty: |
|
|
return "無法獲取資料進行分析", "無法獲取資料進行分析", "無法獲取資料進行分析" |
|
|
|
|
|
|
|
|
data = calculate_technical_indicators(data) |
|
|
|
|
|
|
|
|
current_price = data['Close'].iloc[-1] |
|
|
price_change = ((current_price - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100 |
|
|
volume_avg = data['Volume'].mean() |
|
|
recent_volume = data['Volume'].iloc[-5:].mean() |
|
|
rsi_current = data['RSI'].iloc[-1] if not pd.isna(data['RSI'].iloc[-1]) else 50 |
|
|
|
|
|
|
|
|
macd_current = data['MACD'].iloc[-1] if not pd.isna(data['MACD'].iloc[-1]) else 0 |
|
|
macd_signal_current = data['MACD_Signal'].iloc[-1] if not pd.isna(data['MACD_Signal'].iloc[-1]) else 0 |
|
|
bb_position = data['BB_Position'].iloc[-1] if not pd.isna(data['BB_Position'].iloc[-1]) else 0.5 |
|
|
k_current = data['K'].iloc[-1] if not pd.isna(data['K'].iloc[-1]) else 50 |
|
|
d_current = data['D'].iloc[-1] if not pd.isna(data['D'].iloc[-1]) else 50 |
|
|
pdi_current = data['+DI'].iloc[-1] if not pd.isna(data['+DI'].iloc[-1]) else 0 |
|
|
ndi_current = data['-DI'].iloc[-1] if not pd.isna(data['-DI'].iloc[-1]) else 0 |
|
|
adx_current = data['ADX'].iloc[-1] if not pd.isna(data['ADX'].iloc[-1]) else 0 |
|
|
|
|
|
|
|
|
|
|
|
technical_text = html.Div([ |
|
|
html.P([ |
|
|
html.Strong("價格趨勢:"), |
|
|
f"近期{period}期間內,{stock_name}呈現", |
|
|
html.Span(f"{'上漲' if price_change > 5 else '下跌' if price_change < -5 else '盤整'}", |
|
|
style={'color': 'red' if price_change > 5 else 'green' if price_change < -5 else 'orange', 'font-weight': 'bold'}), |
|
|
f"走勢,累計變動{price_change:+.1f}%。" |
|
|
]), |
|
|
html.P([ |
|
|
html.Strong("RSI指標:"), |
|
|
f"目前為{rsi_current:.1f},", |
|
|
html.Span( |
|
|
"處於超買區間" if rsi_current > 70 else "處於超賣區間" if rsi_current < 30 else "在正常範圍內", |
|
|
style={'color': 'green' if rsi_current > 70 else 'red' if rsi_current < 30 else 'blue', 'font-weight': 'bold'} |
|
|
), |
|
|
"。" |
|
|
]), |
|
|
html.P([ |
|
|
html.Strong("MACD指標:"), |
|
|
f"MACD線({macd_current:.3f})", |
|
|
html.Span( |
|
|
"高於" if macd_current > macd_signal_current else "低於", |
|
|
style={'color': 'red' if macd_current > macd_signal_current else 'green', 'font-weight': 'bold'} |
|
|
), |
|
|
f"信號線({macd_signal_current:.3f}),", |
|
|
f"顯示{'多頭' if macd_current > macd_signal_current else '空頭'}格局。" |
|
|
]), |
|
|
html.P([ |
|
|
html.Strong("布林通道:"), |
|
|
f"股價位於通道", |
|
|
html.Span( |
|
|
"上半部" if bb_position > 0.8 else "下半部" if bb_position < 0.2 else "中段", |
|
|
style={'color': 'green' if bb_position > 0.8 else 'red' if bb_position < 0.2 else 'blue', 'font-weight': 'bold'} |
|
|
), |
|
|
f"({bb_position*100:.0f}%),", |
|
|
f"{'壓力較大' if bb_position > 0.8 else '支撐較強' if bb_position < 0.2 else '整理格局'}。" |
|
|
]), |
|
|
html.P([ |
|
|
html.Strong("KD指標:"), |
|
|
f"K值({k_current:.1f})", |
|
|
html.Span( |
|
|
"高於" if k_current > d_current else "低於", |
|
|
style={'color': 'red' if k_current > d_current else 'green', 'font-weight': 'bold'} |
|
|
), |
|
|
f"D值({d_current:.1f}),", |
|
|
html.Span( |
|
|
"超買警戒" if k_current > 80 else "超賣關注" if k_current < 20 else "正常區間", |
|
|
style={'color': 'green' if k_current > 80 else 'red' if k_current < 20 else 'blue', 'font-weight': 'bold'} |
|
|
), |
|
|
"。" |
|
|
]), |
|
|
html.P([ |
|
|
html.Strong("DMI指標:"), |
|
|
f"目前+DI ({pdi_current:.1f}) 與 -DI ({ndi_current:.1f}),", |
|
|
html.Span( |
|
|
"呈現多頭趨勢" if pdi_current > ndi_current else "呈現空頭趨勢", |
|
|
style={'color': 'red' if pdi_current > ndi_current else 'green', 'font-weight': 'bold'} |
|
|
), |
|
|
f"。ADX值為 {adx_current:.1f},顯示市場趨勢{'強勁' if adx_current > 25 else '不明顯' if adx_current < 20 else '有趨勢'}" |
|
|
]), |
|
|
html.P([ |
|
|
html.Strong("成交量分析:"), |
|
|
f"近期成交量{'放大' if recent_volume > volume_avg * 1.2 else '萎縮' if recent_volume < volume_avg * 0.8 else '平穩'},", |
|
|
f"顯示市場{'關注度提升' if recent_volume > volume_avg * 1.2 else '觀望氣氛濃厚' if recent_volume < volume_avg * 0.8 else '交投正常'}。" |
|
|
]) |
|
|
]) |
|
|
|
|
|
|
|
|
industry = INDUSTRY_MAPPING.get(selected_stock, '綜合') |
|
|
fundamental_text = html.Div([ |
|
|
html.P([ |
|
|
html.Strong("產業地位:"), |
|
|
f"{stock_name}屬於{industry}產業,在產業鏈中具有", |
|
|
html.Span("重要地位" if selected_stock in ['2330.TW', '2454.TW', '2317.TW'] else "一定影響力", |
|
|
style={'font-weight': 'bold'}), |
|
|
"。" |
|
|
]), |
|
|
html.P([ |
|
|
html.Strong("營運展望:"), |
|
|
f"考量{industry}產業前景及公司基本面,建議持續關注季報表現及未來指引。" |
|
|
]), |
|
|
html.P([ |
|
|
html.Strong("風險評估:"), |
|
|
"注意產業週期性變化、國際競爭及法規環境變化等風險因子。" |
|
|
]) |
|
|
]) |
|
|
|
|
|
|
|
|
if price_change > 10: |
|
|
outlook_tone = "謹慎樂觀" |
|
|
outlook_color = "#dc3545" |
|
|
elif price_change < -10: |
|
|
outlook_tone = "保守觀望" |
|
|
outlook_color = "#28a745" |
|
|
else: |
|
|
outlook_tone = "中性持平" |
|
|
outlook_color = "#ffc107" |
|
|
|
|
|
market_outlook = html.Div([ |
|
|
html.P([ |
|
|
html.Strong("整體評估:", style={'font-size': '16px'}), |
|
|
f"基於技術面及基本面分析,對{stock_name}採取", |
|
|
html.Span(f"{outlook_tone}", style={'color': outlook_color, 'font-weight': 'bold', 'font-size': '16px'}), |
|
|
"態度。" |
|
|
]), |
|
|
html.P([ |
|
|
html.Strong("投資建議:"), |
|
|
"建議投資人根據自身風險承受能力,採取適當的資產配置策略。短線操作注意技術指標,長線投資關注基本面變化。" |
|
|
]), |
|
|
html.P([ |
|
|
html.Strong("風險提醒:"), |
|
|
"股票投資具有風險,過去績效不代表未來表現,投資前請詳閱公開說明書並審慎評估。" |
|
|
], style={'font-style': 'italic', 'font-size': '13px'}) |
|
|
]) |
|
|
|
|
|
return technical_text, fundamental_text, market_outlook |
|
|
|
|
|
|
|
|
@app.callback( |
|
|
dash.dependencies.Output('pmi-chart', 'figure'), |
|
|
[dash.dependencies.Input('stock-dropdown', 'value')] |
|
|
) |
|
|
def update_pmi_chart(selected_stock): |
|
|
df = get_pmi_data() |
|
|
|
|
|
if df.empty: |
|
|
|
|
|
fig = go.Figure() |
|
|
fig.add_annotation( |
|
|
x=0.5, y=0.5, |
|
|
text="無法載入PMI資料<br>請確認 taiwan_pmi.csv 檔案是否存在", |
|
|
xref="paper", yref="paper", |
|
|
showarrow=False, |
|
|
font=dict(size=14) |
|
|
) |
|
|
fig.update_layout( |
|
|
title="台灣PMI指數", |
|
|
height=300, |
|
|
showlegend=False |
|
|
) |
|
|
return fig |
|
|
|
|
|
|
|
|
def get_pmi_color(value): |
|
|
return 'red' if value >= 50 else 'green' |
|
|
|
|
|
colors = [get_pmi_color(value) for value in df['Index']] |
|
|
|
|
|
fig = go.Figure() |
|
|
|
|
|
fig.add_trace(go.Scatter( |
|
|
x=df['Date'], |
|
|
y=df['Index'], |
|
|
mode='lines+markers', |
|
|
name='PMI指數', |
|
|
line=dict(color='darkblue', width=2), |
|
|
marker=dict( |
|
|
size=8, |
|
|
color=colors, |
|
|
line=dict(width=2, color='darkblue') |
|
|
) |
|
|
)) |
|
|
|
|
|
|
|
|
fig.add_hline(y=50, line_dash="dash", line_color="black", annotation_text="榮枯線(50)") |
|
|
|
|
|
|
|
|
fig.add_hrect( |
|
|
y0=50, y1=60, |
|
|
fillcolor="lightcoral", opacity=0.2, |
|
|
annotation_text="擴張區間", annotation_position="top left" |
|
|
) |
|
|
fig.add_hrect( |
|
|
y0=40, y1=50, |
|
|
fillcolor="lightgreen", opacity=0.2, |
|
|
annotation_text="緊縮區間", annotation_position="bottom left" |
|
|
) |
|
|
|
|
|
fig.update_layout( |
|
|
title="台灣PMI指數走勢", |
|
|
xaxis_title='日期', |
|
|
yaxis_title='PMI指數', |
|
|
height=300, |
|
|
yaxis=dict(range=[35, 60]) |
|
|
) |
|
|
|
|
|
return fig |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.callback( |
|
|
[dash.dependencies.Output('comparison-chart', 'figure'), |
|
|
dash.dependencies.Output('comparison-table', 'children')], |
|
|
[dash.dependencies.Input('comparison-stocks', 'value'), |
|
|
dash.dependencies.Input('comparison-period', 'value')] |
|
|
) |
|
|
def update_comparison_analysis(selected_stocks, period): |
|
|
|
|
|
fixed_stock = '0050.TW' |
|
|
|
|
|
if not selected_stocks: |
|
|
selected_stocks = [fixed_stock] |
|
|
|
|
|
elif fixed_stock not in selected_stocks: |
|
|
selected_stocks.insert(0, fixed_stock) |
|
|
|
|
|
|
|
|
if not selected_stocks: |
|
|
return {}, html.Div("請選擇要比較的股票") |
|
|
|
|
|
|
|
|
selected_stocks = selected_stocks[:5] |
|
|
|
|
|
fig = go.Figure() |
|
|
comparison_data = [] |
|
|
|
|
|
for stock in selected_stocks: |
|
|
data = get_stock_data(stock, period) |
|
|
if not data.empty: |
|
|
|
|
|
stock_name = next((name for name, symbol in TAIWAN_STOCKS.items() if symbol == stock), stock) |
|
|
|
|
|
|
|
|
normalized_prices = (data['Close'] / data['Close'].iloc[0]) * 100 |
|
|
|
|
|
fig.add_trace(go.Scatter( |
|
|
x=data.index, |
|
|
y=normalized_prices, |
|
|
mode='lines', |
|
|
name=stock_name, |
|
|
line=dict(width=2) |
|
|
)) |
|
|
|
|
|
|
|
|
total_return = ((data['Close'].iloc[-1] / data['Close'].iloc[0]) - 1) * 100 |
|
|
volatility = data['Close'].pct_change().std() * np.sqrt(252) * 100 |
|
|
|
|
|
comparison_data.append({ |
|
|
'name': stock_name, |
|
|
'return': total_return, |
|
|
'volatility': volatility, |
|
|
'current_price': data['Close'].iloc[-1] |
|
|
}) |
|
|
|
|
|
fig.update_layout( |
|
|
title=f'股票績效比較 - {period}', |
|
|
xaxis_title='日期', |
|
|
yaxis_title='相對績效 (基期=100)', |
|
|
height=400, |
|
|
hovermode='x unified' |
|
|
) |
|
|
|
|
|
|
|
|
if comparison_data: |
|
|
table_rows = [] |
|
|
for item in sorted(comparison_data, key=lambda x: x['return'], reverse=True): |
|
|
color = 'red' if item['return'] > 0 else 'green' |
|
|
table_rows.append( |
|
|
html.Tr([ |
|
|
html.Td(item['name'], style={'font-weight': 'bold'}), |
|
|
html.Td(f"{item['return']:+.1f}%", style={'color': color, 'font-weight': 'bold'}), |
|
|
html.Td(f"{item['volatility']:.1f}%"), |
|
|
html.Td(f"${item['current_price']:.2f}") |
|
|
]) |
|
|
) |
|
|
|
|
|
table = html.Table([ |
|
|
html.Thead([ |
|
|
html.Tr([ |
|
|
html.Th("股票", 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): |
|
|
|
|
|
sentiment_score = np.random.uniform(30, 80) |
|
|
|
|
|
|
|
|
gauge_fig = go.Figure(go.Indicator( |
|
|
mode = "gauge+number+delta", |
|
|
value = sentiment_score, |
|
|
domain = {'x': [0, 1], 'y': [0, 1]}, |
|
|
title = {'text': "市場情緒指數"}, |
|
|
delta = {'reference': 50}, |
|
|
gauge = { |
|
|
'axis': {'range': [None, 100]}, |
|
|
'bar': {'color': "darkred"}, |
|
|
'steps': [ |
|
|
{'range': [0, 30], 'color': "lightgreen"}, |
|
|
{'range': [30, 70], 'color': "lightgray"}, |
|
|
{'range': [70, 100], 'color': "lightcoral"} |
|
|
], |
|
|
'threshold': { |
|
|
'line': {'color': "red", 'width': 4}, |
|
|
'thickness': 0.75, |
|
|
'value': 90 |
|
|
} |
|
|
} |
|
|
)) |
|
|
|
|
|
gauge_fig.update_layout(height=200, margin=dict(l=20, r=20, t=40, b=20)) |
|
|
|
|
|
|
|
|
stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0] |
|
|
|
|
|
news_items = [ |
|
|
f"📈 {stock_name}獲外資調升目標價,看好後續發展前景", |
|
|
f"💼 法人預期{stock_name}下季營收將較上季成長5-10%", |
|
|
f"🌐 國際市場波動對{stock_name}影響有限,基本面穩健", |
|
|
f"⚡ 產業景氣回溫,{stock_name}受惠程度值得關注", |
|
|
f"📊 技術面顯示{stock_name}突破關鍵壓力,短線偏多" |
|
|
] |
|
|
|
|
|
news_content = html.Div([ |
|
|
html.P(news, style={ |
|
|
'margin': '8px 0', |
|
|
'padding': '8px', |
|
|
'background': '#f8f9fa', |
|
|
'border-radius': '5px', |
|
|
'border-left': '3px solid #17a2b8', |
|
|
'font-size': '13px' |
|
|
}) for news in news_items[:3] |
|
|
]) |
|
|
|
|
|
return dcc.Graph(figure=gauge_fig), news_content |
|
|
|
|
|
|
|
|
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()}") |
|
|
|
|
|
|
|
|
app.run(host="0.0.0.0", port=7860, debug=False) |