AlanRex commited on
Commit
e7c18ff
·
verified ·
1 Parent(s): e13730c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +912 -88
app.py CHANGED
@@ -19,8 +19,9 @@ from plotly.subplots import make_subplots
19
  app = dash.Dash(__name__, suppress_callback_exceptions=True)
20
 
21
 
22
- # 台股代號對應表 (移除台指期,因為它現在是獨立區塊)
23
  TAIWAN_STOCKS = {
 
24
  '台積電': '2330.TW',
25
  '聯發科': '2454.TW',
26
  '鴻海': '2317.TW',
@@ -248,63 +249,14 @@ app = dash.Dash(__name__, suppress_callback_exceptions=True)
248
  app.layout = html.Div([
249
  html.H1("台股分析儀表板", style={'text-align': 'center', 'margin-bottom': '30px'}),
250
 
251
- # 【新增】台指期獨立預測區塊 - 置於最上方顯眼位置
252
- html.Div([
253
- html.H2("🤖 AI深度學習預測 - 台指期指數", style={
254
- 'text-align': 'center',
255
- 'color': '#fff',
256
- 'margin-bottom': '25px',
257
- 'font-size': '28px',
258
- 'text-shadow': '2px 2px 4px rgba(0,0,0,0.3)'
259
- }),
260
-
261
- html.Div([
262
- # 左側:預測控制面板
263
- html.Div([
264
- html.Label("預測期間:", style={'font-weight': 'bold', 'color': '#fff', 'font-size': '16px'}),
265
- dcc.Dropdown(
266
- id='taiex-prediction-period',
267
- options=[
268
- {'label': '5日後預測', 'value': 5},
269
- {'label': '10日後預測', 'value': 10},
270
- {'label': '20日後預測', 'value': 20},
271
- {'label': '60日後預測', 'value': 60}
272
- ],
273
- value=5,
274
- style={'margin-bottom': '15px'}
275
- ),
276
- html.Div(id='taiex-prediction-results')
277
- ], style={'width': '35%', 'display': 'inline-block', 'vertical-align': 'top'}),
278
-
279
- # 右側:台指期基本資訊
280
- html.Div([
281
- html.Div(id='taiex-info-card')
282
- ], style={'width': '60%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'})
283
- ]),
284
-
285
- # 預測圖表區域
286
- html.Div([
287
- dcc.Graph(id='taiex-prediction-chart')
288
- ], style={'margin-top': '25px'})
289
-
290
- ], style={
291
- 'background': 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
292
- 'padding': '30px',
293
- 'border-radius': '15px',
294
- 'box-shadow': '0 8px 32px rgba(0,0,0,0.2)',
295
- 'color': 'white',
296
- 'margin-bottom': '40px',
297
- 'border': '2px solid rgba(255,255,255,0.1)'
298
- }),
299
-
300
- # 原有的控制面板 (移除台指期選項)
301
  html.Div([
302
  html.Div([
303
  html.Label("選擇股票:"),
304
  dcc.Dropdown(
305
  id='stock-dropdown',
306
  options=[{'label': name, 'value': symbol} for name, symbol in TAIWAN_STOCKS.items()],
307
- value='2330.TW', # 預設改為台積電
308
  style={'margin-bottom': '10px'}
309
  )
310
  ], style={'width': '30%', 'display': 'inline-block', 'vertical-align': 'top'}),
@@ -342,6 +294,9 @@ app.layout = html.Div([
342
  # 股價資訊卡片
343
  html.Div(id='stock-info-cards', style={'margin-bottom': '30px'}),
344
 
 
 
 
345
  # 主要圖表區域
346
  html.Div([
347
  # 左側:股價走勢圖和技術指標
@@ -548,67 +503,936 @@ app.layout = html.Div([
548
  })
549
  ])
550
 
551
- # 【新增】台指期獨立預測區塊的回調函數
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
552
  @app.callback(
553
- [dash.dependencies.Output('taiex-prediction-results', 'children'),
554
- dash.dependencies.Output('taiex-prediction-chart', 'figure'),
555
- dash.dependencies.Output('taiex-info-card', 'children')],
556
- [dash.dependencies.Input('taiex-prediction-period', 'value')]
557
  )
558
- def update_taiex_prediction(predict_days):
 
 
 
559
  # 獲取台指期歷史資料
560
  data = get_stock_data('^TWII', '2y')
561
  if data.empty:
562
- # 嘗試替代資料源
563
- data = get_stock_data('TXF=F', '2y')
564
- if data.empty:
565
- data = get_stock_data('0050.TW', '2y')
566
-
567
- if data.empty:
568
- return html.Div("無法獲取台指期資料"), {}, html.Div("資料載入失敗")
569
 
570
  # 執行預測
571
  prediction = simple_lstm_predict(data, predict_days)
572
  if prediction is None:
573
- return html.Div("資料不足,無法進行預測"), {}, html.Div("預測失敗")
574
 
575
  current_price = data['Close'].iloc[-1]
576
  predicted_price = prediction['predicted_price']
577
  change_pct = prediction['change_pct']
578
  confidence = prediction['confidence']
579
 
580
- # 計算一些額外統計資料
581
- prev_price = data['Close'].iloc[-2] if len(data) > 1 else current_price
582
- daily_change = current_price - prev_price
583
- daily_change_pct = (daily_change / prev_price) * 100
584
- high_today = data['High'].iloc[-1]
585
- low_today = data['Low'].iloc[-1]
586
- volume_today = data['Volume'].iloc[-1]
587
-
588
  # 預測結果卡片
589
  color = '#00C851' if change_pct >= 0 else '#FF4444'
590
  arrow = '📈' if change_pct >= 0 else '📉'
591
 
592
  result_card = html.Div([
593
- html.H4(f"{predict_days}日後 AI 預測", style={'margin': '0 0 15px 0', 'color': 'white', 'font-size': '20px'}),
594
  html.Div([
595
- html.Span(f"{arrow} ", style={'font-size': '30px'}),
596
  html.Span(f"{change_pct:+.2f}%", style={
597
- 'font-size': '32px',
598
  'font-weight': 'bold',
599
- 'color': color,
600
- 'text-shadow': '2px 2px 4px rgba(0,0,0,0.3)'
601
  })
602
- ], style={'margin': '15px 0'}),
603
- html.P(f"預測價格: {predicted_price:.0f}", style={'margin': '8px 0', 'font-size': '16px'}),
604
- html.P(f"信心度: {confidence:.1%}", style={'margin': '8px 0', 'font-size': '14px'}),
605
- html.P(f"預測模型: LSTM深度學習", style={'margin': '8px 0', 'font-size': '12px', 'font-style': 'italic'})
606
  ], style={
607
- 'background': 'rgba(255,255,255,0.15)',
608
  'padding': '20px',
609
- 'border-radius': '12px',
610
- 'border': '2px solid rgba(255,255,255,0.2)',
611
- 'backdrop-filter': 'blur(10px)'
612
  })
613
 
614
- #
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  app = dash.Dash(__name__, suppress_callback_exceptions=True)
20
 
21
 
22
+ # 台股代號對應表
23
  TAIWAN_STOCKS = {
24
+ '台指期': 'TXF=F', # 台指期貨
25
  '台積電': '2330.TW',
26
  '聯發科': '2454.TW',
27
  '鴻海': '2317.TW',
 
249
  app.layout = html.Div([
250
  html.H1("台股分析儀表板", style={'text-align': 'center', 'margin-bottom': '30px'}),
251
 
252
+ # 控制面板
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  html.Div([
254
  html.Div([
255
  html.Label("選擇股票:"),
256
  dcc.Dropdown(
257
  id='stock-dropdown',
258
  options=[{'label': name, 'value': symbol} for name, symbol in TAIWAN_STOCKS.items()],
259
+ value='TXF=F',
260
  style={'margin-bottom': '10px'}
261
  )
262
  ], style={'width': '30%', 'display': 'inline-block', 'vertical-align': 'top'}),
 
294
  # 股價資訊卡片
295
  html.Div(id='stock-info-cards', style={'margin-bottom': '30px'}),
296
 
297
+ # AI預測區塊 (只在選擇台指期時顯示)
298
+ html.Div(id='prediction-section', style={'margin-bottom': '30px'}),
299
+
300
  # 主要圖表區域
301
  html.Div([
302
  # 左側:股價走勢圖和技術指標
 
503
  })
504
  ])
505
 
506
+ # 更新AI預測區塊
507
+ @app.callback(
508
+ dash.dependencies.Output('prediction-section', 'children'),
509
+ [dash.dependencies.Input('stock-dropdown', 'value')],
510
+ prevent_initial_call=False
511
+ )
512
+ def update_prediction_section(selected_stock):
513
+ if selected_stock != 'TXF=F':
514
+ return html.Div() # 只在選擇台指期時顯示預測功能
515
+
516
+ return html.Div([
517
+ html.H3("🤖 AI深度學習預測 - 台指期指數", style={'text-align': 'center', 'color': '#FFCC22'}),
518
+ html.Div([
519
+ html.Div([
520
+ html.Label("預測期間:", style={'font-weight': 'bold', 'color': '#FFCC22'}),
521
+ dcc.Dropdown(
522
+ id='prediction-period',
523
+ options=[
524
+ {'label': '5日後預測', 'value': 5},
525
+ {'label': '10日後預測', 'value': 10},
526
+ {'label': '20日後預測', 'value': 20},
527
+ {'label': '60日後預測', 'value': 60}
528
+ ],
529
+ value=5,
530
+ style={'margin-bottom': '10px', 'color': '#272727'}
531
+ )
532
+ ], style={'width': '30%', 'display': 'inline-block'}),
533
+
534
+ html.Div(id='prediction-results', style={'width': '65%', 'display': 'inline-block', 'margin-left': '5%'})
535
+ ]),
536
+
537
+ html.Div([
538
+ dcc.Graph(id='prediction-chart')
539
+ ], style={'margin-top': '20px'})
540
+ ], style={
541
+ 'background': 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
542
+ 'padding': '25px',
543
+ 'border-radius': '15px',
544
+ 'box-shadow': '0 8px 25px rgba(0,0,0,0.15)',
545
+ 'color': 'white'
546
+ })
547
+
548
+ # 更新預測結果
549
  @app.callback(
550
+ [dash.dependencies.Output('prediction-results', 'children'),
551
+ dash.dependencies.Output('prediction-chart', 'figure')],
552
+ [dash.dependencies.Input('prediction-period', 'value'),
553
+ dash.dependencies.Input('stock-dropdown', 'value')]
554
  )
555
+ def update_prediction(predict_days, selected_stock):
556
+ if selected_stock != 'TXF=F':
557
+ return html.Div(), {}
558
+
559
  # 獲取台指期歷史資料
560
  data = get_stock_data('^TWII', '2y')
561
  if data.empty:
562
+ return html.Div("無法獲取台指期資料"), {}
 
 
 
 
 
 
563
 
564
  # 執行預測
565
  prediction = simple_lstm_predict(data, predict_days)
566
  if prediction is None:
567
+ return html.Div("資料不足,無法進行預測"), {}
568
 
569
  current_price = data['Close'].iloc[-1]
570
  predicted_price = prediction['predicted_price']
571
  change_pct = prediction['change_pct']
572
  confidence = prediction['confidence']
573
 
 
 
 
 
 
 
 
 
574
  # 預測結果卡片
575
  color = '#00C851' if change_pct >= 0 else '#FF4444'
576
  arrow = '📈' if change_pct >= 0 else '📉'
577
 
578
  result_card = html.Div([
579
+ html.H4(f"{predict_days}日後預測結果", style={'margin': '0 0 15px 0', 'color': 'white'}),
580
  html.Div([
581
+ html.Span(f"{arrow} ", style={'font-size': '24px'}),
582
  html.Span(f"{change_pct:+.2f}%", style={
583
+ 'font-size': '28px',
584
  'font-weight': 'bold',
585
+ 'color': color
 
586
  })
587
+ ], style={'margin': '10px 0'}),
588
+ html.P(f"目前價格: {current_price:.2f}", style={'margin': '5px 0'}),
589
+ html.P(f"預測價格: {predicted_price:.2f}", style={'margin': '5px 0'}),
590
+ html.P(f"信心度: {confidence:.1%}", style={'margin': '5px 0', 'font-size': '14px'})
591
  ], style={
592
+ 'background': 'rgba(255,255,255,0.1)',
593
  'padding': '20px',
594
+ 'border-radius': '10px',
595
+ 'border': '1px solid rgba(255,255,255,0.2)'
 
596
  })
597
 
598
+ # 建立預測趨勢圖
599
+ fig = go.Figure()
600
+
601
+ # 歷史價格 (最近30天)
602
+ recent_data = data.tail(30)
603
+ fig.add_trace(go.Scatter(
604
+ x=recent_data.index,
605
+ y=recent_data['Close'],
606
+ mode='lines',
607
+ name='歷史價格',
608
+ line=dict(color='#FFA726', width=2)
609
+ ))
610
+
611
+ # 預測點
612
+ future_date = recent_data.index[-1] + timedelta(days=predict_days)
613
+ fig.add_trace(go.Scatter(
614
+ x=[recent_data.index[-1], future_date],
615
+ y=[current_price, predicted_price],
616
+ mode='lines+markers',
617
+ name=f'{predict_days}日預測',
618
+ line=dict(color=color, width=3, dash='dash'),
619
+ marker=dict(size=8)
620
+ ))
621
+
622
+ fig.update_layout(
623
+ title=f'台指期 {predict_days}日預測走勢',
624
+ xaxis_title='日期',
625
+ yaxis_title='指數點位',
626
+ height=350,
627
+ plot_bgcolor='rgba(0,0,0,0)',
628
+ paper_bgcolor='rgba(0,0,0,0)',
629
+ font=dict(color='white')
630
+ )
631
+
632
+ return result_card, fig
633
+
634
+ # 更新股價資訊卡片
635
+ @app.callback(
636
+ dash.dependencies.Output('stock-info-cards', 'children'),
637
+ [dash.dependencies.Input('stock-dropdown', 'value')]
638
+ )
639
+ def update_stock_info(selected_stock):
640
+ data = get_stock_data(selected_stock, '5d')
641
+ if data.empty:
642
+ return html.Div("無法獲取股票資料")
643
+
644
+ current_price = data['Close'].iloc[-1]
645
+ prev_price = data['Close'].iloc[-2] if len(data) > 1 else current_price
646
+ change = current_price - prev_price
647
+ change_pct = (change / prev_price) * 100
648
+
649
+ # 找出股票中文名稱
650
+ stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
651
+
652
+ color = 'green' if change >= 0 else 'red'
653
+
654
+ return html.Div([
655
+ html.Div([
656
+ html.H3(f"{stock_name} ({selected_stock})", style={'margin': '0'}),
657
+ html.H2(f"${current_price:.2f}", style={'margin': '5px 0', 'color': color}),
658
+ html.P(f"{'▲' if change >= 0 else '▼'} {change:+.2f} ({change_pct:+.2f}%)",
659
+ style={'margin': '0', 'color': color, 'font-weight': 'bold'})
660
+ ], style={
661
+ 'background': 'white',
662
+ 'padding': '20px',
663
+ 'border-radius': '10px',
664
+ 'box-shadow': '0 2px 10px rgba(0,0,0,0.1)',
665
+ 'display': 'inline-block',
666
+ 'margin-right': '20px'
667
+ }),
668
+
669
+ html.Div([
670
+ html.H4("今日統計", style={'margin': '0 0 10px 0'}),
671
+ html.P(f"最高: ${data['High'].iloc[-1]:.2f}", style={'margin': '5px 0'}),
672
+ html.P(f"最低: ${data['Low'].iloc[-1]:.2f}", style={'margin': '5px 0'}),
673
+ html.P(f"成交量: {data['Volume'].iloc[-1]:,.0f}", style={'margin': '5px 0'})
674
+ ], style={
675
+ 'background': 'white',
676
+ 'padding': '20px',
677
+ 'border-radius': '10px',
678
+ 'box-shadow': '0 2px 10px rgba(0,0,0,0.1)',
679
+ 'display': 'inline-block'
680
+ })
681
+ ])
682
+
683
+ # 更新股價圖表
684
+ @app.callback(
685
+ dash.dependencies.Output('price-chart', 'figure'),
686
+ [dash.dependencies.Input('stock-dropdown', 'value'),
687
+ dash.dependencies.Input('period-dropdown', 'value'),
688
+ dash.dependencies.Input('chart-type', 'value')]
689
+ )
690
+ def update_price_chart(selected_stock, period, chart_type):
691
+ data = get_stock_data(selected_stock, period)
692
+ if data.empty:
693
+ return {}
694
+
695
+ data = calculate_technical_indicators(data)
696
+ stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
697
+
698
+ if chart_type == 'candlestick':
699
+ fig = go.Figure(data=go.Candlestick(
700
+ x=data.index,
701
+ open=data['Open'],
702
+ high=data['High'],
703
+ low=data['Low'],
704
+ close=data['Close'],
705
+ name=stock_name
706
+ ))
707
+ else:
708
+ fig = px.line(data, y='Close', title=f'{stock_name} 股價走勢')
709
+
710
+ # 添加移動平均線
711
+ fig.add_trace(go.Scatter(x=data.index, y=data['MA5'], mode='lines', name='MA5', line=dict(color='orange')))
712
+ fig.add_trace(go.Scatter(x=data.index, y=data['MA20'], mode='lines', name='MA20', line=dict(color='blue')))
713
+
714
+ fig.update_layout(
715
+ title=f'{stock_name} 股價走勢',
716
+ xaxis_title='日期',
717
+ yaxis_title='價格 (TWD)',
718
+ height=400
719
+ )
720
+
721
+ return fig
722
+
723
+ # 更新RSI圖表(保持兼容性)
724
+ @app.callback(
725
+ dash.dependencies.Output('rsi-chart', 'figure'),
726
+ [dash.dependencies.Input('stock-dropdown', 'value'),
727
+ dash.dependencies.Input('period-dropdown', 'value')]
728
+ )
729
+ def update_rsi_chart(selected_stock, period):
730
+ data = get_stock_data(selected_stock, period)
731
+ if data.empty:
732
+ return {}
733
+
734
+ data = calculate_technical_indicators(data)
735
+
736
+ fig = go.Figure()
737
+ fig.add_trace(go.Scatter(x=data.index, y=data['RSI'], mode='lines', name='RSI', line=dict(color='purple', width=2)))
738
+ fig.add_hline(y=70, line_dash="dash", line_color="red", annotation_text="超買線(70)")
739
+ fig.add_hline(y=30, line_dash="dash", line_color="green", annotation_text="超賣線(30)")
740
+ fig.add_hline(y=50, line_dash="dot", line_color="gray", annotation_text="中線(50)")
741
+
742
+ # 添加超買超賣區域背景
743
+ fig.add_hrect(y0=70, y1=100, fillcolor="red", opacity=0.1, annotation_text="超買區")
744
+ fig.add_hrect(y0=0, y1=30, fillcolor="green", opacity=0.1, annotation_text="超賣區")
745
+
746
+ fig.update_layout(
747
+ title='RSI 相對強弱指標',
748
+ xaxis_title='日期',
749
+ yaxis_title='RSI',
750
+ height=400,
751
+ yaxis=dict(range=[0, 100])
752
+ )
753
+
754
+ return fig
755
+
756
+
757
+
758
+ # 新增:進階技術指標圖表
759
+ @app.callback(
760
+ dash.dependencies.Output('advanced-technical-chart', 'figure'),
761
+ [dash.dependencies.Input('technical-indicator-selector', 'value'),
762
+ dash.dependencies.Input('stock-dropdown', 'value'),
763
+ dash.dependencies.Input('period-dropdown', 'value')]
764
+ )
765
+ def update_advanced_technical_chart(indicator, selected_stock, period):
766
+ data = get_stock_data(selected_stock, period)
767
+ if data.empty:
768
+ return {}
769
+
770
+ data = calculate_technical_indicators(data)
771
+ stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
772
+
773
+ if indicator == 'RSI':
774
+ fig = go.Figure()
775
+ fig.add_trace(go.Scatter(x=data.index, y=data['RSI'], mode='lines', name='RSI', line=dict(color='purple', width=2)))
776
+ fig.add_hline(y=70, line_dash="dash", line_color="red", annotation_text="超買線(70)")
777
+ fig.add_hline(y=30, line_dash="dash", line_color="green", annotation_text="超賣線(30)")
778
+ fig.add_hline(y=50, line_dash="dot", line_color="gray", annotation_text="中線(50)")
779
+
780
+ fig.add_hrect(y0=70, y1=100, fillcolor="red", opacity=0.1)
781
+ fig.add_hrect(y0=0, y1=30, fillcolor="green", opacity=0.1)
782
+
783
+ fig.update_layout(
784
+ title=f'{stock_name} - RSI 相對強弱指標',
785
+ xaxis_title='日期',
786
+ yaxis_title='RSI',
787
+ height=450,
788
+ yaxis=dict(range=[0, 100])
789
+ )
790
+
791
+ elif indicator == 'MACD':
792
+ fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
793
+ vertical_spacing=0.1,
794
+ row_heights=[0.7, 0.3],
795
+ subplot_titles=('價格與MACD線', 'MACD柱狀圖'))
796
+
797
+ # 上方:價格線
798
+ fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價',
799
+ line=dict(color='black', width=1)), row=1, col=1)
800
+
801
+ # MACD線和信號線
802
+ fig.add_trace(go.Scatter(x=data.index, y=data['MACD'], mode='lines', name='MACD',
803
+ line=dict(color='blue', width=2)), row=1, col=1)
804
+ fig.add_trace(go.Scatter(x=data.index, y=data['MACD_Signal'], mode='lines', name='信號線',
805
+ line=dict(color='red', width=2)), row=1, col=1)
806
+
807
+ # 下方:MACD柱狀圖
808
+ colors = ['green' if x >= 0 else 'red' for x in data['MACD_Histogram']]
809
+ fig.add_trace(go.Bar(x=data.index, y=data['MACD_Histogram'], name='MACD柱狀圖',
810
+ marker_color=colors), row=2, col=1)
811
+
812
+ fig.add_hline(y=0, line_dash="dash", line_color="gray", row=1, col=1)
813
+ fig.add_hline(y=0, line_dash="dash", line_color="gray", row=2, col=1)
814
+
815
+ fig.update_layout(
816
+ title=f'{stock_name} - MACD 指數平滑異同移動平均線',
817
+ height=500
818
+ )
819
+
820
+ elif indicator == 'BB':
821
+ fig = go.Figure()
822
+
823
+ # 價格線
824
+ fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價',
825
+ line=dict(color='black', width=2)))
826
+
827
+ # 布林通道上軌
828
+ fig.add_trace(go.Scatter(x=data.index, y=data['BB_Upper'], mode='lines', name='上軌',
829
+ line=dict(color='red', width=1, dash='dash')))
830
+
831
+ # 布林通道中軌
832
+ fig.add_trace(go.Scatter(x=data.index, y=data['BB_Middle'], mode='lines', name='中軌(MA20)',
833
+ line=dict(color='blue', width=1)))
834
+
835
+ # 布林通道下軌
836
+ fig.add_trace(go.Scatter(x=data.index, y=data['BB_Lower'], mode='lines', name='下軌',
837
+ line=dict(color='green', width=1, dash='dash')))
838
+
839
+ # 填充通道區域
840
+ fig.add_trace(go.Scatter(x=data.index, y=data['BB_Upper'], mode='lines',
841
+ line=dict(color='rgba(0,0,0,0)'), showlegend=False))
842
+ fig.add_trace(go.Scatter(x=data.index, y=data['BB_Lower'], mode='lines',
843
+ fill='tonexty', fillcolor='rgba(173,216,230,0.2)',
844
+ line=dict(color='rgba(0,0,0,0)'), name='布林通道', showlegend=False))
845
+
846
+ fig.update_layout(
847
+ title=f'{stock_name} - 布林通道 (20日, 2σ)',
848
+ xaxis_title='日期',
849
+ yaxis_title='價格 (TWD)',
850
+ height=450
851
+ )
852
+
853
+ elif indicator == 'KD':
854
+ fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
855
+ vertical_spacing=0.1,
856
+ row_heights=[0.6, 0.4],
857
+ subplot_titles=('價格走勢', 'KD指標'))
858
+
859
+ # 上方:價格線
860
+ fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價',
861
+ line=dict(color='black', width=1)), row=1, col=1)
862
+
863
+ # 下方:KD線
864
+ fig.add_trace(go.Scatter(x=data.index, y=data['K'], mode='lines', name='K線',
865
+ line=dict(color='blue', width=2)), row=2, col=1)
866
+ fig.add_trace(go.Scatter(x=data.index, y=data['D'], mode='lines', name='D線',
867
+ line=dict(color='red', width=2)), row=2, col=1)
868
+
869
+ # KD指標參考線
870
+ fig.add_hline(y=80, line_dash="dash", line_color="red", annotation_text="超買線(80)", row=2, col=1)
871
+ fig.add_hline(y=20, line_dash="dash", line_color="green", annotation_text="超賣線(20)", row=2, col=1)
872
+ fig.add_hline(y=50, line_dash="dot", line_color="gray", annotation_text="中線(50)", row=2, col=1)
873
+
874
+ # 超買超賣區域
875
+ fig.add_hrect(y0=80, y1=100, fillcolor="red", opacity=0.1, row=2, col=1)
876
+ fig.add_hrect(y0=0, y1=20, fillcolor="green", opacity=0.1, row=2, col=1)
877
+
878
+ fig.update_layout(
879
+ title=f'{stock_name} - KD 隨機指標 (9,3,3)',
880
+ height=500
881
+ )
882
+ fig.update_yaxes(range=[0, 100], row=2, col=1)
883
+
884
+ elif indicator == 'WR':
885
+ fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
886
+ vertical_spacing=0.1,
887
+ row_heights=[0.6, 0.4],
888
+ subplot_titles=('價格走勢', '威廉指標 %R'))
889
+
890
+ # 上方:價格線
891
+ fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價',
892
+ line=dict(color='black', width=1)), row=1, col=1)
893
+
894
+ # 下方:威廉指標
895
+ fig.add_trace(go.Scatter(x=data.index, y=data['Williams_R'], mode='lines', name='威廉%R',
896
+ line=dict(color='purple', width=2)), row=2, col=1)
897
+
898
+ # 威廉指標參考線
899
+ fig.add_hline(y=-20, line_dash="dash", line_color="red", annotation_text="超買線(-20)", row=2, col=1)
900
+ fig.add_hline(y=-80, line_dash="dash", line_color="green", annotation_text="超賣線(-80)", row=2, col=1)
901
+ fig.add_hline(y=-50, line_dash="dot", line_color="gray", annotation_text="中線(-50)", row=2, col=1)
902
+
903
+ # 超買超賣區域
904
+ fig.add_hrect(y0=-20, y1=0, fillcolor="red", opacity=0.1, row=2, col=1)
905
+ fig.add_hrect(y0=-100, y1=-80, fillcolor="green", opacity=0.1, row=2, col=1)
906
+
907
+ fig.update_layout(
908
+ title=f'{stock_name} - 威廉指標 %R (14日)',
909
+ height=500
910
+ )
911
+ fig.update_yaxes(range=[-100, 0], row=2, col=1)
912
+
913
+ return fig
914
+
915
+ # 更新成交量圖表
916
+ @app.callback(
917
+ dash.dependencies.Output('volume-chart', 'figure'),
918
+ [dash.dependencies.Input('stock-dropdown', 'value'),
919
+ dash.dependencies.Input('period-dropdown', 'value')]
920
+ )
921
+ def update_volume_chart(selected_stock, period):
922
+ data = get_stock_data(selected_stock, period)
923
+ if data.empty:
924
+ return {}
925
+
926
+ stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
927
+
928
+ fig = px.bar(data, y='Volume', title=f'{stock_name} 成交量')
929
+ fig.update_layout(
930
+ xaxis_title='日期',
931
+ yaxis_title='成交量',
932
+ height=300
933
+ )
934
+
935
+ return fig
936
+
937
+ # 更新產業分析圖表
938
+ @app.callback(
939
+ dash.dependencies.Output('industry-analysis', 'figure'),
940
+ [dash.dependencies.Input('stock-dropdown', 'value')]
941
+ )
942
+ def update_industry_analysis(selected_stock):
943
+ # 獲取多檔股票資料進行產業比較
944
+ industry_data = []
945
+
946
+ for symbol in list(TAIWAN_STOCKS.values())[:10]: # 取前10檔做示範
947
+ data = get_stock_data(symbol, '1mo')
948
+ if not data.empty:
949
+ stock_name = [name for name, symbol_code in TAIWAN_STOCKS.items() if symbol_code == symbol][0]
950
+ latest_price = data['Close'].iloc[-1]
951
+ first_price = data['Close'].iloc[0]
952
+ return_pct = ((latest_price - first_price) / first_price) * 100
953
+
954
+ industry_data.append({
955
+ '股票': stock_name,
956
+ '代碼': symbol,
957
+ '月報酬率(%)': return_pct,
958
+ '產業': INDUSTRY_MAPPING.get(symbol, '其他')
959
+ })
960
+
961
+ if not industry_data:
962
+ return {}
963
+
964
+ df_industry = pd.DataFrame(industry_data)
965
+
966
+ # 建立產業表現圓餅圖
967
+ fig = px.pie(df_industry, values='月報酬率(%)', names='股票',
968
+ title='各股票月報酬率比較',
969
+ color_discrete_sequence=px.colors.qualitative.Set3)
970
+
971
+ fig.update_layout(height=400)
972
+ return fig
973
+
974
+ # 新增:更新景氣燈號圖表
975
+ @app.callback(
976
+ dash.dependencies.Output('business-climate-chart', 'figure'),
977
+ [dash.dependencies.Input('stock-dropdown', 'value')] # 雖然不會影響圖表,但需要觸發
978
+ )
979
+ def update_business_climate_chart(selected_stock):
980
+ df = get_business_climate_data()
981
+
982
+ if df.empty:
983
+ # 如果沒有資料,顯示提示圖表
984
+ fig = go.Figure()
985
+ fig.add_annotation(
986
+ x=0.5, y=0.5,
987
+ text="無法載入景氣燈號資料<br>請確認 business_climate.csv 檔案是否存在",
988
+ xref="paper", yref="paper",
989
+ showarrow=False,
990
+ font=dict(size=14)
991
+ )
992
+ fig.update_layout(
993
+ title="台灣景氣燈號",
994
+ height=300,
995
+ showlegend=False
996
+ )
997
+ return fig
998
+
999
+ # 定義燈號顏色
1000
+ def get_light_color(score):
1001
+ if score >= 32:
1002
+ return 'red' # 紅燈
1003
+ elif score >= 24:
1004
+ return 'orange' # 黃紅燈
1005
+ elif score >= 17:
1006
+ return 'yellow' # 黃燈
1007
+ elif score >= 10:
1008
+ return 'lightgreen' # 黃藍燈
1009
+ else:
1010
+ return 'blue' # 藍燈
1011
+
1012
+ # 為每個點設定顏色
1013
+ colors = [get_light_color(score) for score in df['Index']]
1014
+
1015
+ fig = go.Figure()
1016
+
1017
+ fig.add_trace(go.Scatter(
1018
+ x=df['Date'],
1019
+ y=df['Index'],
1020
+ mode='lines+markers',
1021
+ name='景氣燈號',
1022
+ line=dict(color='darkblue', width=2),
1023
+ marker=dict(
1024
+ size=8,
1025
+ color=colors,
1026
+ line=dict(width=2, color='darkblue')
1027
+ )
1028
+ ))
1029
+
1030
+ # 添加燈號區間線
1031
+ fig.add_hline(y=32, line_dash="dash", line_color="red", annotation_text="紅燈(32)")
1032
+ fig.add_hline(y=24, line_dash="dash", line_color="orange", annotation_text="黃紅燈(24)")
1033
+ fig.add_hline(y=17, line_dash="dash", line_color="yellow", annotation_text="黃燈(17)")
1034
+ fig.add_hline(y=10, line_dash="dash", line_color="lightgreen", annotation_text="黃藍燈(10)")
1035
+
1036
+ fig.update_layout(
1037
+ title="台灣景氣燈號走勢",
1038
+ xaxis_title='日期',
1039
+ yaxis_title='燈號分數',
1040
+ height=300,
1041
+ yaxis=dict(range=[0, 40])
1042
+ )
1043
+
1044
+ return fig
1045
+
1046
+ # 新增:更新分析師觀點
1047
+ @app.callback(
1048
+ [dash.dependencies.Output('technical-analysis-text', 'children'),
1049
+ dash.dependencies.Output('fundamental-analysis-text', 'children'),
1050
+ dash.dependencies.Output('market-outlook-text', 'children')],
1051
+ [dash.dependencies.Input('stock-dropdown', 'value'),
1052
+ dash.dependencies.Input('period-dropdown', 'value')]
1053
+ )
1054
+ def update_analysis_text(selected_stock, period):
1055
+ # 獲取股票資料進行分析
1056
+ data = get_stock_data(selected_stock, period)
1057
+ stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
1058
+
1059
+ if data.empty:
1060
+ return "無法獲取資料進行分析", "無法獲取資料進行分析", "無法獲取資料進行分析"
1061
+
1062
+ # 計算技術指標
1063
+ data = calculate_technical_indicators(data)
1064
+
1065
+ # 基本數據
1066
+ current_price = data['Close'].iloc[-1]
1067
+ price_change = ((current_price - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
1068
+ volume_avg = data['Volume'].mean()
1069
+ recent_volume = data['Volume'].iloc[-5:].mean()
1070
+ rsi_current = data['RSI'].iloc[-1] if not pd.isna(data['RSI'].iloc[-1]) else 50
1071
+
1072
+ # 新增技術指標數據
1073
+ macd_current = data['MACD'].iloc[-1] if not pd.isna(data['MACD'].iloc[-1]) else 0
1074
+ macd_signal_current = data['MACD_Signal'].iloc[-1] if not pd.isna(data['MACD_Signal'].iloc[-1]) else 0
1075
+ bb_position = data['BB_Position'].iloc[-1] if not pd.isna(data['BB_Position'].iloc[-1]) else 0.5
1076
+ k_current = data['K'].iloc[-1] if not pd.isna(data['K'].iloc[-1]) else 50
1077
+ d_current = data['D'].iloc[-1] if not pd.isna(data['D'].iloc[-1]) else 50
1078
+
1079
+ # 技術面分析
1080
+ technical_text = html.Div([
1081
+ html.P([
1082
+ html.Strong("價格趨勢:"),
1083
+ f"近期{period}期間內,{stock_name}呈現",
1084
+ html.Span(f"{'上漲' if price_change > 5 else '下跌' if price_change < -5 else '盤整'}",
1085
+ style={'color': 'green' if price_change > 5 else 'red' if price_change < -5 else 'orange', 'font-weight': 'bold'}),
1086
+ f"走勢,累計變動{price_change:+.1f}%。"
1087
+ ]),
1088
+ html.P([
1089
+ html.Strong("RSI指標:"),
1090
+ f"目前為{rsi_current:.1f},",
1091
+ html.Span(
1092
+ "處於超買區間" if rsi_current > 70 else "處於超賣區間" if rsi_current < 30 else "在正常範圍內",
1093
+ style={'color': 'red' if rsi_current > 70 else 'green' if rsi_current < 30 else 'blue', 'font-weight': 'bold'}
1094
+ ),
1095
+ "。"
1096
+ ]),
1097
+ html.P([
1098
+ html.Strong("MACD指標:"),
1099
+ f"MACD線({macd_current:.3f})",
1100
+ html.Span(
1101
+ "高於" if macd_current > macd_signal_current else "低於",
1102
+ style={'color': 'green' if macd_current > macd_signal_current else 'red', 'font-weight': 'bold'}
1103
+ ),
1104
+ f"信號線({macd_signal_current:.3f}),",
1105
+ f"顯示{'多頭' if macd_current > macd_signal_current else '空頭'}格局。"
1106
+ ]),
1107
+ html.P([
1108
+ html.Strong("布林通道:"),
1109
+ f"股價位於通道",
1110
+ html.Span(
1111
+ "上半部" if bb_position > 0.8 else "下半部" if bb_position < 0.2 else "中段",
1112
+ style={'color': 'red' if bb_position > 0.8 else 'green' if bb_position < 0.2 else 'blue', 'font-weight': 'bold'}
1113
+ ),
1114
+ f"({bb_position*100:.0f}%),",
1115
+ f"{'壓力較大' if bb_position > 0.8 else '支撐較強' if bb_position < 0.2 else '整理格局'}。"
1116
+ ]),
1117
+ html.P([
1118
+ html.Strong("KD指標:"),
1119
+ f"K值({k_current:.1f})",
1120
+ html.Span(
1121
+ "高於" if k_current > d_current else "低於",
1122
+ style={'color': 'green' if k_current > d_current else 'red', 'font-weight': 'bold'}
1123
+ ),
1124
+ f"D值({d_current:.1f}),",
1125
+ html.Span(
1126
+ "超買警戒" if k_current > 80 else "超賣關注" if k_current < 20 else "正常區間",
1127
+ style={'color': 'red' if k_current > 80 else 'green' if k_current < 20 else 'blue', 'font-weight': 'bold'}
1128
+ ),
1129
+ "。"
1130
+ ]),
1131
+ html.P([
1132
+ html.Strong("成交量分析:"),
1133
+ f"近期成交量{'放大' if recent_volume > volume_avg * 1.2 else '萎縮' if recent_volume < volume_avg * 0.8 else '平穩'},",
1134
+ f"顯示市場{'關注度提升' if recent_volume > volume_avg * 1.2 else '觀望氣氛濃厚' if recent_volume < volume_avg * 0.8 else '交投正常'}。"
1135
+ ])
1136
+ ])
1137
+
1138
+ # 基本面分析
1139
+ industry = INDUSTRY_MAPPING.get(selected_stock, '綜合')
1140
+ if selected_stock == 'TXF=F':
1141
+ fundamental_text = html.Div([
1142
+ html.P([
1143
+ html.Strong("總體經濟:"),
1144
+ "台指期反映台股整體表現,當前需關注聯準會政策、國際貿易情勢及台灣出口動能。"
1145
+ ]),
1146
+ html.P([
1147
+ html.Strong("產業輪動:"),
1148
+ "觀察半導體、電子等權重產業表現,以及傳統產業復甦力道。"
1149
+ ]),
1150
+ html.P([
1151
+ html.Strong("資金面:"),
1152
+ "外資動向、匯率變化及市場流動性為主要觀察重點。"
1153
+ ])
1154
+ ])
1155
+ else:
1156
+ fundamental_text = html.Div([
1157
+ html.P([
1158
+ html.Strong("產業地位:"),
1159
+ f"{stock_name}屬於{industry}產業,在產業鏈中具有",
1160
+ html.Span("重要地位" if selected_stock in ['2330.TW', '2454.TW', '2317.TW'] else "一定影響力",
1161
+ style={'font-weight': 'bold'}),
1162
+ "。"
1163
+ ]),
1164
+ html.P([
1165
+ html.Strong("營運展望:"),
1166
+ f"考量{industry}產業前景及公司基本面,建議持續關注季報表現及未來指引。"
1167
+ ]),
1168
+ html.P([
1169
+ html.Strong("風險評估:"),
1170
+ "注意產業週期性變化、國際競爭及法規環境變化等風險因子。"
1171
+ ])
1172
+ ])
1173
+
1174
+ # 市場展望
1175
+ if price_change > 10:
1176
+ outlook_tone = "謹慎樂觀"
1177
+ outlook_color = "#28a745"
1178
+ elif price_change < -10:
1179
+ outlook_tone = "保守觀望"
1180
+ outlook_color = "#dc3545"
1181
+ else:
1182
+ outlook_tone = "中性持平"
1183
+ outlook_color = "#ffc107"
1184
+
1185
+ market_outlook = html.Div([
1186
+ html.P([
1187
+ html.Strong("整體評估:", style={'font-size': '16px'}),
1188
+ f"基於技術面及基本面分析,對{stock_name}採取",
1189
+ html.Span(f"{outlook_tone}", style={'color': outlook_color, 'font-weight': 'bold', 'font-size': '16px'}),
1190
+ "態度。"
1191
+ ]),
1192
+ html.P([
1193
+ html.Strong("投資建議:"),
1194
+ "建議投資人根據自身風險承受能力,採取適當的資產配置策略。短線操作注意技術指標,長線投資關注基本面變化。"
1195
+ ]),
1196
+ html.P([
1197
+ html.Strong("風險提醒:"),
1198
+ "股票投資具有風險,過去績效不代表未來表現,投資前請詳閱公開說明書並審慎評估。"
1199
+ ], style={'font-style': 'italic', 'font-size': '13px'})
1200
+ ])
1201
+
1202
+ return technical_text, fundamental_text, market_outlook
1203
+
1204
+ # 新增:更新PMI圖表
1205
+ @app.callback(
1206
+ dash.dependencies.Output('pmi-chart', 'figure'),
1207
+ [dash.dependencies.Input('stock-dropdown', 'value')] # 雖然不會影響圖表,但需要觸發
1208
+ )
1209
+ def update_pmi_chart(selected_stock):
1210
+ df = get_pmi_data()
1211
+
1212
+ if df.empty:
1213
+ # 如果沒有資料,顯示提示圖表
1214
+ fig = go.Figure()
1215
+ fig.add_annotation(
1216
+ x=0.5, y=0.5,
1217
+ text="無法載入PMI資料<br>請確認 taiwan_pmi.csv 檔案是否存在",
1218
+ xref="paper", yref="paper",
1219
+ showarrow=False,
1220
+ font=dict(size=14)
1221
+ )
1222
+ fig.update_layout(
1223
+ title="台灣PMI指數",
1224
+ height=300,
1225
+ showlegend=False
1226
+ )
1227
+ return fig
1228
+
1229
+ # 定義PMI顏色 (50以上擴張,以下緊縮)
1230
+ def get_pmi_color(value):
1231
+ return 'green' if value >= 50 else 'red'
1232
+
1233
+ colors = [get_pmi_color(value) for value in df['Index']]
1234
+
1235
+ fig = go.Figure()
1236
+
1237
+ fig.add_trace(go.Scatter(
1238
+ x=df['Date'],
1239
+ y=df['Index'],
1240
+ mode='lines+markers',
1241
+ name='PMI指數',
1242
+ line=dict(color='darkblue', width=2),
1243
+ marker=dict(
1244
+ size=8,
1245
+ color=colors,
1246
+ line=dict(width=2, color='darkblue')
1247
+ )
1248
+ ))
1249
+
1250
+ # 添加榮枯線
1251
+ fig.add_hline(y=50, line_dash="dash", line_color="black", annotation_text="榮枯線(50)")
1252
+
1253
+ # 添加背景色區域
1254
+ fig.add_hrect(
1255
+ y0=50, y1=60,
1256
+ fillcolor="lightgreen", opacity=0.2,
1257
+ annotation_text="擴張區間", annotation_position="top left"
1258
+ )
1259
+ fig.add_hrect(
1260
+ y0=40, y1=50,
1261
+ fillcolor="lightcoral", opacity=0.2,
1262
+ annotation_text="緊縮區間", annotation_position="bottom left"
1263
+ )
1264
+
1265
+ fig.update_layout(
1266
+ title="台灣PMI指數走勢",
1267
+ xaxis_title='日期',
1268
+ yaxis_title='PMI指數',
1269
+ height=300,
1270
+ yaxis=dict(range=[35, 60])
1271
+ )
1272
+
1273
+ return fig
1274
+
1275
+ # 新增:多檔股票比較
1276
+ @app.callback(
1277
+ [dash.dependencies.Output('comparison-chart', 'figure'),
1278
+ dash.dependencies.Output('comparison-table', 'children')],
1279
+ [dash.dependencies.Input('comparison-stocks', 'value'),
1280
+ dash.dependencies.Input('comparison-period', 'value')]
1281
+ )
1282
+ def update_comparison_analysis(selected_stocks, period):
1283
+ if not selected_stocks:
1284
+ return {}, html.Div("請選擇要比較的股票")
1285
+
1286
+ # 限制最多5檔
1287
+ selected_stocks = selected_stocks[:5]
1288
+
1289
+ fig = go.Figure()
1290
+ comparison_data = []
1291
+
1292
+ for stock in selected_stocks:
1293
+ data = get_stock_data(stock, period)
1294
+ if not data.empty:
1295
+ stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == stock][0]
1296
+
1297
+ # 正規化價格(以期初為基準100)
1298
+ normalized_prices = (data['Close'] / data['Close'].iloc[0]) * 100
1299
+
1300
+ fig.add_trace(go.Scatter(
1301
+ x=data.index,
1302
+ y=normalized_prices,
1303
+ mode='lines',
1304
+ name=stock_name,
1305
+ line=dict(width=2)
1306
+ ))
1307
+
1308
+ # 計算績效數據
1309
+ total_return = ((data['Close'].iloc[-1] / data['Close'].iloc[0]) - 1) * 100
1310
+ volatility = data['Close'].pct_change().std() * np.sqrt(252) * 100 # 年化波動率
1311
+
1312
+ comparison_data.append({
1313
+ 'name': stock_name,
1314
+ 'return': total_return,
1315
+ 'volatility': volatility,
1316
+ 'current_price': data['Close'].iloc[-1]
1317
+ })
1318
+
1319
+ fig.update_layout(
1320
+ title=f'股票績效比較 - {period}',
1321
+ xaxis_title='日期',
1322
+ yaxis_title='相對績效 (基期=100)',
1323
+ height=400,
1324
+ hovermode='x unified'
1325
+ )
1326
+
1327
+ # 建立比較表格
1328
+ if comparison_data:
1329
+ table_rows = []
1330
+ for item in sorted(comparison_data, key=lambda x: x['return'], reverse=True):
1331
+ color = 'green' if item['return'] > 0 else 'red'
1332
+ table_rows.append(
1333
+ html.Tr([
1334
+ html.Td(item['name'], style={'font-weight': 'bold'}),
1335
+ html.Td(f"{item['return']:+.1f}%", style={'color': color, 'font-weight': 'bold'}),
1336
+ html.Td(f"{item['volatility']:.1f}%"),
1337
+ html.Td(f"${item['current_price']:.2f}")
1338
+ ])
1339
+ )
1340
+
1341
+ table = html.Table([
1342
+ html.Thead([
1343
+ html.Tr([
1344
+ html.Th("股票", style={'text-align': 'center'}),
1345
+ html.Th("報酬率", style={'text-align': 'center'}),
1346
+ html.Th("波動率", style={'text-align': 'center'}),
1347
+ html.Th("現價", style={'text-align': 'center'})
1348
+ ])
1349
+ ]),
1350
+ html.Tbody(table_rows)
1351
+ ], style={
1352
+ 'width': '100%',
1353
+ 'border-collapse': 'collapse',
1354
+ 'font-size': '12px'
1355
+ })
1356
+
1357
+ return fig, table
1358
+
1359
+ return fig, html.Div("無可比較資料")
1360
+
1361
+ # 新增:市場情緒分析
1362
+ @app.callback(
1363
+ [dash.dependencies.Output('sentiment-gauge', 'children'),
1364
+ dash.dependencies.Output('news-summary', 'children')],
1365
+ [dash.dependencies.Input('stock-dropdown', 'value')]
1366
+ )
1367
+ def update_sentiment_analysis(selected_stock):
1368
+ # 模擬情緒指標(實際應用中可接入新聞API或情緒分析服務)
1369
+ sentiment_score = np.random.uniform(30, 80) # 模擬情緒分數 0-100
1370
+
1371
+ # 建立情緒指標圓形圖
1372
+ gauge_fig = go.Figure(go.Indicator(
1373
+ mode = "gauge+number+delta",
1374
+ value = sentiment_score,
1375
+ domain = {'x': [0, 1], 'y': [0, 1]},
1376
+ title = {'text': "市場情緒指數"},
1377
+ delta = {'reference': 50},
1378
+ gauge = {
1379
+ 'axis': {'range': [None, 100]},
1380
+ 'bar': {'color': "darkblue"},
1381
+ 'steps': [
1382
+ {'range': [0, 30], 'color': "lightcoral"},
1383
+ {'range': [30, 70], 'color': "lightgray"},
1384
+ {'range': [70, 100], 'color': "lightgreen"}
1385
+ ],
1386
+ 'threshold': {
1387
+ 'line': {'color': "red", 'width': 4},
1388
+ 'thickness': 0.75,
1389
+ 'value': 90
1390
+ }
1391
+ }
1392
+ ))
1393
+
1394
+ gauge_fig.update_layout(height=200, margin=dict(l=20, r=20, t=40, b=20))
1395
+
1396
+ # 模擬新聞摘要
1397
+ stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
1398
+
1399
+ news_items = [
1400
+ f"📈 {stock_name}獲外資調升目標價,看好後續發展前景",
1401
+ f"💼 法人預期{stock_name}下季營收將較上季成長5-10%",
1402
+ f"🌐 國際市場波動對{stock_name}影響有限,基本面穩健",
1403
+ f"⚡ 產業景氣回溫,{stock_name}受惠程度值得關注",
1404
+ f"📊 技術面顯示{stock_name}突破關鍵壓力,短線偏多"
1405
+ ]
1406
+
1407
+ news_content = html.Div([
1408
+ html.P(news, style={
1409
+ 'margin': '8px 0',
1410
+ 'padding': '8px',
1411
+ 'background': '#e8f4f8',
1412
+ 'border-radius': '5px',
1413
+ 'border-left': '3px solid #17a2b8',
1414
+ 'font-size': '13px'
1415
+ }) for news in news_items[:3] # 顯示前3條
1416
+ ])
1417
+
1418
+ return dcc.Graph(figure=gauge_fig), news_content
1419
+
1420
+ # 在 Colab 中執行的設定
1421
+ if __name__ == '__main__':
1422
+ # 在執行前先測試檔案讀取
1423
+ print("測試檔案讀取...")
1424
+ business_data = get_business_climate_data()
1425
+ pmi_data = get_pmi_data()
1426
+
1427
+ if not business_data.empty:
1428
+ print(f"景氣燈號資料預覽:\n{business_data.head()}")
1429
+ if not pmi_data.empty:
1430
+ print(f"PMI資料預覽:\n{pmi_data.head()}")
1431
+
1432
+ # 在 Colab 中需要使用以下方式啟動
1433
+ app.run(host="0.0.0.0", port=7860, debug=True)
1434
+
1435
+
1436
+
1437
+ # 如果在本地環境執行,使用以下方式
1438
+ # app.run_server(debug=True)