AlanRex commited on
Commit
dc6cdf9
·
verified ·
1 Parent(s): 91c79a8

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +1359 -0
app.py CHANGED
@@ -532,6 +532,1365 @@ app.layout = html.Div([
532
  'box-shadow': '0 2px 10px rgba(0,0,0,0.1)'
533
  })
534
  ])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
535
  # 創建一個列表來儲存所有的預測點數據
536
  all_predictions_data = []
537
  # 台指期獨立預測回調函數
 
532
  'box-shadow': '0 2px 10px rgba(0,0,0,0.1)'
533
  })
534
  ])
535
+
536
+ # 台指期獨立預測回調函數
537
+ @app.callback(
538
+ [dash.dependencies.Output('taiex-prediction-results', 'children'),
539
+ dash.dependencies.Output('taiex-prediction-chart', 'figure')],
540
+ [dash.dependencies.Input('taiex-prediction-period', 'value')]
541
+ )
542
+ def update_taiex_prediction(predict_days):
543
+ # 獲取台指期歷史資料
544
+ data = get_stock_data('^TWII', '2y')
545
+ if data.empty:
546
+ return html.Div("無法獲取台指期資料"), {}
547
+
548
+ # 執行預測
549
+ prediction = simple_lstm_predict(data, predict_days)
550
+ if prediction is None:
551
+ return html.Div("資料不足,無法進行預測"), {}
552
+
553
+ current_price = data['Close'].iloc[-1]
554
+ predicted_price = prediction['predicted_price']
555
+ change_pct = prediction['change_pct']
556
+ confidence = prediction['confidence']
557
+
558
+ # 預測結果卡片
559
+ color = '#00C851' if change_pct >= 0 else '#FF4444'
560
+ arrow = '📈' if change_pct >= 0 else '📉'
561
+
562
+ result_card = html.Div([
563
+ html.H4(f"{predict_days}日後預測結果", style={'margin': '0 0 15px 0', 'color': 'white'}),
564
+ html.Div([
565
+ html.Span(f"{arrow} ", style={'font-size': '24px'}),
566
+ html.Span(f"{change_pct:+.2f}%", style={
567
+ 'font-size': '28px',
568
+ 'font-weight': 'bold',
569
+ 'color': color
570
+ })
571
+ ], style={'margin': '10px 0'}),
572
+ html.P(f"目前價格: {current_price:.2f}", style={'margin': '5px 0'}),
573
+ html.P(f"預測價格: {predicted_price:.2f}", style={'margin': '5px 0'}),
574
+ html.P(f"信心度: {confidence:.1%}", style={'margin': '5px 0', 'font-size': '14px'})
575
+ ], style={
576
+ 'background': 'rgba(255,255,255,0.1)',
577
+ 'padding': '20px',
578
+ 'border-radius': '10px',
579
+ 'border': '1px solid rgba(255,255,255,0.2)'
580
+ })
581
+
582
+ # 建立預測趨勢圖
583
+ fig = go.Figure()
584
+
585
+ # 歷史價格 (最近30天)
586
+ recent_data = data.tail(30)
587
+ fig.add_trace(go.Scatter(
588
+ x=recent_data.index,
589
+ y=recent_data['Close'],
590
+ mode='lines',
591
+ name='歷史價格',
592
+ line=dict(color='#FFA726', width=2)
593
+ ))
594
+
595
+ # --- 關鍵修正從這裡開始 ---
596
+ # 定義所有要顯示的預測天數點
597
+ all_predict_days = [1, 5, 10, 20, 60]
598
+
599
+ # 過濾出所有小於或等於使用者選擇的預測天數
600
+ points_to_show = [d for d in all_predict_days if d <= predict_days]
601
+
602
+ # 為每個要顯示的預測點創建圖表軌跡
603
+ for d in points_to_show:
604
+ # 重新計算每個點的預測值
605
+ point_prediction = simple_lstm_predict(data, d)
606
+ if point_prediction:
607
+ point_predicted_price = point_prediction['predicted_price']
608
+ point_future_date = recent_data.index[-1] + timedelta(days=d)
609
+
610
+ # 決定點的顏色
611
+ point_color = '#00C851' if point_predicted_price >= current_price else '#FF4444'
612
+
613
+ # 添加預測點
614
+ fig.add_trace(go.Scatter(
615
+ x=[point_future_date],
616
+ y=[point_predicted_price],
617
+ mode='markers',
618
+ name=f'{d}日預測點',
619
+ marker=dict(size=10, color=point_color)
620
+ ))
621
+
622
+ # 為使用者選擇的最終天數添加趨勢線
623
+ final_future_date = recent_data.index[-1] + timedelta(days=predict_days)
624
+ fig.add_trace(go.Scatter(
625
+ x=[recent_data.index[-1], final_future_date],
626
+ y=[current_price, predicted_price],
627
+ mode='lines',
628
+ name='最終預測線',
629
+ line=dict(color=color, width=3, dash='dash')
630
+ ))
631
+
632
+ # --- 修正結束 ---
633
+
634
+ fig.update_layout(
635
+ title=f'台指期 {predict_days}日預測走勢',
636
+ xaxis_title='日期',
637
+ yaxis_title='指數點位',
638
+ height=350,
639
+ plot_bgcolor='rgba(0,0,0,0)',
640
+ paper_bgcolor='rgba(0,0,0,0)',
641
+ font=dict(color='white')
642
+ )
643
+
644
+ return result_card, fig
645
+
646
+ # 更新股價資訊卡片
647
+ @app.callback(
648
+ dash.dependencies.Output('stock-info-cards', 'children'),
649
+ [dash.dependencies.Input('stock-dropdown', 'value')]
650
+ )
651
+ def update_stock_info(selected_stock):
652
+ data = get_stock_data(selected_stock, '5d')
653
+ if data.empty:
654
+ return html.Div("無法獲取股票資料")
655
+
656
+ current_price = data['Close'].iloc[-1]
657
+ prev_price = data['Close'].iloc[-2] if len(data) > 1 else current_price
658
+ change = current_price - prev_price
659
+ change_pct = (change / prev_price) * 100
660
+
661
+ # 找出股票中文名稱
662
+ stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
663
+
664
+ color = 'green' if change >= 0 else 'red'
665
+
666
+ return html.Div([
667
+ html.Div([
668
+ html.H3(f"{stock_name} ({selected_stock})", style={'margin': '0'}),
669
+ html.H2(f"${current_price:.2f}", style={'margin': '5px 0', 'color': color}),
670
+ html.P(f"{'▲' if change >= 0 else '▼'} {change:+.2f} ({change_pct:+.2f}%)",
671
+ style={'margin': '0', 'color': color, 'font-weight': 'bold'})
672
+ ], style={
673
+ 'background': 'white',
674
+ 'padding': '20px',
675
+ 'border-radius': '10px',
676
+ 'box-shadow': '0 2px 10px rgba(0,0,0,0.1)',
677
+ 'display': 'inline-block',
678
+ 'margin-right': '20px'
679
+ }),
680
+
681
+ html.Div([
682
+ html.H4("今日統計", style={'margin': '0 0 10px 0'}),
683
+ html.P(f"最高: ${data['High'].iloc[-1]:.2f}", style={'margin': '5px 0'}),
684
+ html.P(f"最低: ${data['Low'].iloc[-1]:.2f}", style={'margin': '5px 0'}),
685
+ html.P(f"成交量: {data['Volume'].iloc[-1]:,.0f}", style={'margin': '5px 0'})
686
+ ], style={
687
+ 'background': 'white',
688
+ 'padding': '20px',
689
+ 'border-radius': '10px',
690
+ 'box-shadow': '0 2px 10px rgba(0,0,0,0.1)',
691
+ 'display': 'inline-block'
692
+ })
693
+ ])
694
+
695
+ # 更新股價圖表
696
+ @app.callback(
697
+ dash.dependencies.Output('price-chart', 'figure'),
698
+ [dash.dependencies.Input('stock-dropdown', 'value'),
699
+ dash.dependencies.Input('period-dropdown', 'value'),
700
+ dash.dependencies.Input('chart-type', 'value')]
701
+ )
702
+ def update_price_chart(selected_stock, period, chart_type):
703
+ data = get_stock_data(selected_stock, period)
704
+ if data.empty:
705
+ return {}
706
+
707
+ data = calculate_technical_indicators(data)
708
+ stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
709
+
710
+ if chart_type == 'candlestick':
711
+ fig = go.Figure(data=go.Candlestick(
712
+ x=data.index,
713
+ open=data['Open'],
714
+ high=data['High'],
715
+ low=data['Low'],
716
+ close=data['Close'],
717
+ name=stock_name
718
+ ))
719
+ else:
720
+ fig = px.line(data, y='Close', title=f'{stock_name} 股價走勢')
721
+
722
+ # 添加移動平均線
723
+ fig.add_trace(go.Scatter(x=data.index, y=data['MA5'], mode='lines', name='MA5', line=dict(color='orange')))
724
+ fig.add_trace(go.Scatter(x=data.index, y=data['MA20'], mode='lines', name='MA20', line=dict(color='blue')))
725
+
726
+ fig.update_layout(
727
+ title=f'{stock_name} 股價走勢',
728
+ xaxis_title='日期',
729
+ yaxis_title='價格 (TWD)',
730
+ height=400
731
+ )
732
+
733
+ return fig
734
+
735
+ # 更新RSI圖表(保持兼容性)
736
+ @app.callback(
737
+ dash.dependencies.Output('rsi-chart', 'figure'),
738
+ [dash.dependencies.Input('stock-dropdown', 'value'),
739
+ dash.dependencies.Input('period-dropdown', 'value')]
740
+ )
741
+ def update_rsi_chart(selected_stock, period):
742
+ data = get_stock_data(selected_stock, period)
743
+ if data.empty:
744
+ return {}
745
+
746
+ data = calculate_technical_indicators(data)
747
+
748
+ fig = go.Figure()
749
+ fig.add_trace(go.Scatter(x=data.index, y=data['RSI'], mode='lines', name='RSI', line=dict(color='purple', width=2)))
750
+ fig.add_hline(y=70, line_dash="dash", line_color="red", annotation_text="超買線(70)")
751
+ fig.add_hline(y=30, line_dash="dash", line_color="green", annotation_text="超賣線(30)")
752
+ fig.add_hline(y=50, line_dash="dot", line_color="gray", annotation_text="中線(50)")
753
+
754
+ # 添加超買超賣區域背景
755
+ fig.add_hrect(y0=70, y1=100, fillcolor="red", opacity=0.1, annotation_text="超買區")
756
+ fig.add_hrect(y0=0, y1=30, fillcolor="green", opacity=0.1, annotation_text="超賣區")
757
+
758
+ fig.update_layout(
759
+ title='RSI 相對強弱指標',
760
+ xaxis_title='日期',
761
+ yaxis_title='RSI',
762
+ height=400,
763
+ yaxis=dict(range=[0, 100])
764
+ )
765
+
766
+ return fig
767
+
768
+ # 新增:進階技術指標圖表
769
+ @app.callback(
770
+ dash.dependencies.Output('advanced-technical-chart', 'figure'),
771
+ [dash.dependencies.Input('technical-indicator-selector', 'value'),
772
+ dash.dependencies.Input('stock-dropdown', 'value'),
773
+ dash.dependencies.Input('period-dropdown', 'value')]
774
+ )
775
+ def update_advanced_technical_chart(indicator, selected_stock, period):
776
+ data = get_stock_data(selected_stock, period)
777
+ if data.empty:
778
+ return {}
779
+
780
+ data = calculate_technical_indicators(data)
781
+ stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
782
+
783
+ if indicator == 'RSI':
784
+ fig = go.Figure()
785
+ fig.add_trace(go.Scatter(x=data.index, y=data['RSI'], mode='lines', name='RSI', line=dict(color='purple', width=2)))
786
+ fig.add_hline(y=70, line_dash="dash", line_color="red", annotation_text="超買線(70)")
787
+ fig.add_hline(y=30, line_dash="dash", line_color="green", annotation_text="超賣線(30)")
788
+ fig.add_hline(y=50, line_dash="dot", line_color="gray", annotation_text="中線(50)")
789
+
790
+ fig.add_hrect(y0=70, y1=100, fillcolor="red", opacity=0.1)
791
+ fig.add_hrect(y0=0, y1=30, fillcolor="green", opacity=0.1)
792
+
793
+ fig.update_layout(
794
+ title=f'{stock_name} - RSI 相對強弱指標',
795
+ xaxis_title='日期',
796
+ yaxis_title='RSI',
797
+ height=450,
798
+ yaxis=dict(range=[0, 100])
799
+ )
800
+
801
+ elif indicator == 'MACD':
802
+ fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
803
+ vertical_spacing=0.1,
804
+ row_heights=[0.7, 0.3],
805
+ subplot_titles=('價格與MACD線', 'MACD柱狀圖'))
806
+
807
+ # 上方:價格線
808
+ fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價',
809
+ line=dict(color='black', width=1)), row=1, col=1)
810
+
811
+ # MACD線和信號線
812
+ fig.add_trace(go.Scatter(x=data.index, y=data['MACD'], mode='lines', name='MACD',
813
+ line=dict(color='blue', width=2)), row=1, col=1)
814
+ fig.add_trace(go.Scatter(x=data.index, y=data['MACD_Signal'], mode='lines', name='信號線',
815
+ line=dict(color='red', width=2)), row=1, col=1)
816
+
817
+ # 下方:MACD柱狀圖
818
+ colors = ['green' if x >= 0 else 'red' for x in data['MACD_Histogram']]
819
+ fig.add_trace(go.Bar(x=data.index, y=data['MACD_Histogram'], name='MACD柱狀圖',
820
+ marker_color=colors), row=2, col=1)
821
+
822
+ fig.add_hline(y=0, line_dash="dash", line_color="gray", row=1, col=1)
823
+ fig.add_hline(y=0, line_dash="dash", line_color="gray", row=2, col=1)
824
+
825
+ fig.update_layout(
826
+ title=f'{stock_name} - MACD 指數平滑異同移動平均線',
827
+ height=500
828
+ )
829
+
830
+ elif indicator == 'BB':
831
+ fig = go.Figure()
832
+
833
+ # 價格線
834
+ fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價',
835
+ line=dict(color='black', width=2)))
836
+
837
+ # 布林通道上軌
838
+ fig.add_trace(go.Scatter(x=data.index, y=data['BB_Upper'], mode='lines', name='上軌',
839
+ line=dict(color='red', width=1, dash='dash')))
840
+
841
+ # 布林通道中軌
842
+ fig.add_trace(go.Scatter(x=data.index, y=data['BB_Middle'], mode='lines', name='中軌(MA20)',
843
+ line=dict(color='blue', width=1)))
844
+
845
+ # 布林通道下軌
846
+ fig.add_trace(go.Scatter(x=data.index, y=data['BB_Lower'], mode='lines', name='下軌',
847
+ line=dict(color='green', width=1, dash='dash')))
848
+
849
+ # 填充通道區域
850
+ fig.add_trace(go.Scatter(x=data.index, y=data['BB_Upper'], mode='lines',
851
+ line=dict(color='rgba(0,0,0,0)'), showlegend=False))
852
+ fig.add_trace(go.Scatter(x=data.index, y=data['BB_Lower'], mode='lines',
853
+ fill='tonexty', fillcolor='rgba(173,216,230,0.2)',
854
+ line=dict(color='rgba(0,0,0,0)'), name='布林通道', showlegend=False))
855
+
856
+ fig.update_layout(
857
+ title=f'{stock_name} - 布林通道 (20日, 2σ)',
858
+ xaxis_title='日期',
859
+ yaxis_title='價格 (TWD)',
860
+ height=450
861
+ )
862
+
863
+ elif indicator == 'KD':
864
+ fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
865
+ vertical_spacing=0.1,
866
+ row_heights=[0.6, 0.4],
867
+ subplot_titles=('價格走勢', 'KD指標'))
868
+
869
+ # 上方:價格線
870
+ fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價',
871
+ line=dict(color='black', width=1)), row=1, col=1)
872
+
873
+ # 下方:KD線
874
+ fig.add_trace(go.Scatter(x=data.index, y=data['K'], mode='lines', name='K線',
875
+ line=dict(color='blue', width=2)), row=2, col=1)
876
+ fig.add_trace(go.Scatter(x=data.index, y=data['D'], mode='lines', name='D線',
877
+ line=dict(color='red', width=2)), row=2, col=1)
878
+
879
+ # KD指標參考線
880
+ fig.add_hline(y=80, line_dash="dash", line_color="red", annotation_text="超買線(80)", row=2, col=1)
881
+ fig.add_hline(y=20, line_dash="dash", line_color="green", annotation_text="超賣線(20)", row=2, col=1)
882
+ fig.add_hline(y=50, line_dash="dot", line_color="gray", annotation_text="中線(50)", row=2, col=1)
883
+
884
+ # 超買超賣區域
885
+ fig.add_hrect(y0=80, y1=100, fillcolor="red", opacity=0.1, row=2, col=1)
886
+ fig.add_hrect(y0=0, y1=20, fillcolor="green", opacity=0.1, row=2, col=1)
887
+
888
+ fig.update_layout(
889
+ title=f'{stock_name} - KD 隨機指標 (9,3,3)',
890
+ height=500
891
+ )
892
+ fig.update_yaxes(range=[0, 100], row=2, col=1)
893
+
894
+ elif indicator == 'WR':
895
+ fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
896
+ vertical_spacing=0.1,
897
+ row_heights=[0.6, 0.4],
898
+ subplot_titles=('價格走勢', '威廉指標 %R'))
899
+
900
+ # 上方:價格線
901
+ fig.add_trace(go.Scatter(x=data.index, y=data['Close'], mode='lines', name='收盤價',
902
+ line=dict(color='black', width=1)), row=1, col=1)
903
+
904
+ # 下方:威廉指標
905
+ fig.add_trace(go.Scatter(x=data.index, y=data['Williams_R'], mode='lines', name='威廉%R',
906
+ line=dict(color='purple', width=2)), row=2, col=1)
907
+
908
+ # 威廉指標參考線
909
+ fig.add_hline(y=-20, line_dash="dash", line_color="red", annotation_text="超買線(-20)", row=2, col=1)
910
+ fig.add_hline(y=-80, line_dash="dash", line_color="green", annotation_text="超賣線(-80)", row=2, col=1)
911
+ fig.add_hline(y=-50, line_dash="dot", line_color="gray", annotation_text="中線(-50)", row=2, col=1)
912
+
913
+ # 超買超賣區域
914
+ fig.add_hrect(y0=-20, y1=0, fillcolor="red", opacity=0.1, row=2, col=1)
915
+ fig.add_hrect(y0=-100, y1=-80, fillcolor="green", opacity=0.1, row=2, col=1)
916
+
917
+ fig.update_layout(
918
+ title=f'{stock_name} - 威廉指標 %R (14日)',
919
+ height=500
920
+ )
921
+ fig.update_yaxes(range=[-100, 0], row=2, col=1)
922
+
923
+ return fig
924
+
925
+ # 更新成交量圖表
926
+ @app.callback(
927
+ dash.dependencies.Output('volume-chart', 'figure'),
928
+ [dash.dependencies.Input('stock-dropdown', 'value'),
929
+ dash.dependencies.Input('period-dropdown', 'value')]
930
+ )
931
+ def update_volume_chart(selected_stock, period):
932
+ data = get_stock_data(selected_stock, period)
933
+ if data.empty:
934
+ return {}
935
+
936
+ stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
937
+
938
+ fig = px.bar(data, y='Volume', title=f'{stock_name} 成交量')
939
+ fig.update_layout(
940
+ xaxis_title='日期',
941
+ yaxis_title='成交量',
942
+ height=300
943
+ )
944
+
945
+ return fig
946
+
947
+ # 更新產業分析圖表
948
+ @app.callback(
949
+ dash.dependencies.Output('industry-analysis', 'figure'),
950
+ [dash.dependencies.Input('stock-dropdown', 'value')]
951
+ )
952
+ def update_industry_analysis(selected_stock):
953
+ # 獲取多檔股票資料進行產業比較
954
+ industry_data = []
955
+
956
+ for symbol in list(TAIWAN_STOCKS.values())[:10]: # 取前10檔做示範
957
+ data = get_stock_data(symbol, '1mo')
958
+ if not data.empty:
959
+ stock_name = [name for name, symbol_code in TAIWAN_STOCKS.items() if symbol_code == symbol][0]
960
+ latest_price = data['Close'].iloc[-1]
961
+ first_price = data['Close'].iloc[0]
962
+ return_pct = ((latest_price - first_price) / first_price) * 100
963
+
964
+ industry_data.append({
965
+ '股票': stock_name,
966
+ '代碼': symbol,
967
+ '月報酬率(%)': return_pct,
968
+ '產業': INDUSTRY_MAPPING.get(symbol, '其他')
969
+ })
970
+
971
+ if not industry_data:
972
+ return {}
973
+
974
+ df_industry = pd.DataFrame(industry_data)
975
+
976
+ # 建立產業表現圓餅圖
977
+ fig = px.pie(df_industry, values='月報酬率(%)', names='股票',
978
+ title='各股票月報酬率比較',
979
+ color_discrete_sequence=px.colors.qualitative.Set3)
980
+
981
+ fig.update_layout(height=400)
982
+ return fig
983
+
984
+ # 新增:更新景氣燈號圖表
985
+ @app.callback(
986
+ dash.dependencies.Output('business-climate-chart', 'figure'),
987
+ [dash.dependencies.Input('stock-dropdown', 'value')] # 雖然不會影響圖表,但需要觸發
988
+ )
989
+ def update_business_climate_chart(selected_stock):
990
+ df = get_business_climate_data()
991
+
992
+ if df.empty:
993
+ # 如果沒有資料,顯示提示圖表
994
+ fig = go.Figure()
995
+ fig.add_annotation(
996
+ x=0.5, y=0.5,
997
+ text="無法載入景氣燈號資料<br>請確認 business_climate.csv 檔案是否存在",
998
+ xref="paper", yref="paper",
999
+ showarrow=False,
1000
+ font=dict(size=14)
1001
+ )
1002
+ fig.update_layout(
1003
+ title="台灣景氣燈號",
1004
+ height=300,
1005
+ showlegend=False
1006
+ )
1007
+ return fig
1008
+
1009
+ # 定義燈號顏色
1010
+ def get_light_color(score):
1011
+ if score >= 32:
1012
+ return 'red' # 紅燈
1013
+ elif score >= 24:
1014
+ return 'orange' # 黃紅燈
1015
+ elif score >= 17:
1016
+ return 'yellow' # 黃燈
1017
+ elif score >= 10:
1018
+ return 'lightgreen' # 黃藍燈
1019
+ else:
1020
+ return 'blue' # 藍燈
1021
+
1022
+ # 為每個點設定顏色
1023
+ colors = [get_light_color(score) for score in df['Index']]
1024
+
1025
+ fig = go.Figure()
1026
+
1027
+ fig.add_trace(go.Scatter(
1028
+ x=df['Date'],
1029
+ y=df['Index'],
1030
+ mode='lines+markers',
1031
+ name='景氣燈號',
1032
+ line=dict(color='darkblue', width=2),
1033
+ marker=dict(
1034
+ size=8,
1035
+ color=colors,
1036
+ line=dict(width=2, color='darkblue')
1037
+ )
1038
+ ))
1039
+
1040
+ # 添加燈號區間線
1041
+ fig.add_hline(y=32, line_dash="dash", line_color="red", annotation_text="紅燈(32)")
1042
+ fig.add_hline(y=24, line_dash="dash", line_color="orange", annotation_text="黃紅燈(24)")
1043
+ fig.add_hline(y=17, line_dash="dash", line_color="yellow", annotation_text="黃燈(17)")
1044
+ fig.add_hline(y=10, line_dash="dash", line_color="lightgreen", annotation_text="黃藍燈(10)")
1045
+
1046
+ fig.update_layout(
1047
+ title="台灣景氣燈號走勢",
1048
+ xaxis_title='日期',
1049
+ yaxis_title='燈號分數',
1050
+ height=300,
1051
+ yaxis=dict(range=[0, 40])
1052
+ )
1053
+
1054
+ return fig
1055
+
1056
+ # 新增:更新分析師觀點
1057
+ @app.callback(
1058
+ [dash.dependencies.Output('technical-analysis-text', 'children'),
1059
+ dash.dependencies.Output('fundamental-analysis-text', 'children'),
1060
+ dash.dependencies.Output('market-outlook-text', 'children')],
1061
+ [dash.dependencies.Input('stock-dropdown', 'value'),
1062
+ dash.dependencies.Input('period-dropdown', 'value')]
1063
+ )
1064
+ def update_analysis_text(selected_stock, period):
1065
+ # 獲取股票資料進行分析
1066
+ data = get_stock_data(selected_stock, period)
1067
+ stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
1068
+
1069
+ if data.empty:
1070
+ return "無法獲取資料進行分析", "無法獲取資料進行分析", "無法獲取資料進行分析"
1071
+
1072
+ # 計算技術指標
1073
+ data = calculate_technical_indicators(data)
1074
+
1075
+ # 基本數據
1076
+ current_price = data['Close'].iloc[-1]
1077
+ price_change = ((current_price - data['Close'].iloc[0]) / data['Close'].iloc[0]) * 100
1078
+ volume_avg = data['Volume'].mean()
1079
+ recent_volume = data['Volume'].iloc[-5:].mean()
1080
+ rsi_current = data['RSI'].iloc[-1] if not pd.isna(data['RSI'].iloc[-1]) else 50
1081
+
1082
+ # 新增技術指標數據
1083
+ macd_current = data['MACD'].iloc[-1] if not pd.isna(data['MACD'].iloc[-1]) else 0
1084
+ macd_signal_current = data['MACD_Signal'].iloc[-1] if not pd.isna(data['MACD_Signal'].iloc[-1]) else 0
1085
+ bb_position = data['BB_Position'].iloc[-1] if not pd.isna(data['BB_Position'].iloc[-1]) else 0.5
1086
+ k_current = data['K'].iloc[-1] if not pd.isna(data['K'].iloc[-1]) else 50
1087
+ d_current = data['D'].iloc[-1] if not pd.isna(data['D'].iloc[-1]) else 50
1088
+
1089
+ # 技術面分析
1090
+ technical_text = html.Div([
1091
+ html.P([
1092
+ html.Strong("價格趨勢:"),
1093
+ f"近期{period}期間內,{stock_name}呈現",
1094
+ html.Span(f"{'上漲' if price_change > 5 else '下跌' if price_change < -5 else '盤整'}",
1095
+ style={'color': 'green' if price_change > 5 else 'red' if price_change < -5 else 'orange', 'font-weight': 'bold'}),
1096
+ f"走勢,累計變動{price_change:+.1f}%。"
1097
+ ]),
1098
+ html.P([
1099
+ html.Strong("RSI指標:"),
1100
+ f"目前為{rsi_current:.1f},",
1101
+ html.Span(
1102
+ "處於超買區間" if rsi_current > 70 else "處於超賣區間" if rsi_current < 30 else "在正常範圍內",
1103
+ style={'color': 'red' if rsi_current > 70 else 'green' if rsi_current < 30 else 'blue', 'font-weight': 'bold'}
1104
+ ),
1105
+ "。"
1106
+ ]),
1107
+ html.P([
1108
+ html.Strong("MACD指標:"),
1109
+ f"MACD線({macd_current:.3f})",
1110
+ html.Span(
1111
+ "高於" if macd_current > macd_signal_current else "低於",
1112
+ style={'color': 'green' if macd_current > macd_signal_current else 'red', 'font-weight': 'bold'}
1113
+ ),
1114
+ f"信號線({macd_signal_current:.3f}),",
1115
+ f"顯示{'多頭' if macd_current > macd_signal_current else '空頭'}格局。"
1116
+ ]),
1117
+ html.P([
1118
+ html.Strong("布林通道:"),
1119
+ f"股價位於通道",
1120
+ html.Span(
1121
+ "上半部" if bb_position > 0.8 else "下半部" if bb_position < 0.2 else "中段",
1122
+ style={'color': 'red' if bb_position > 0.8 else 'green' if bb_position < 0.2 else 'blue', 'font-weight': 'bold'}
1123
+ ),
1124
+ f"({bb_position*100:.0f}%),",
1125
+ f"{'壓力較大' if bb_position > 0.8 else '支撐較強' if bb_position < 0.2 else '整理格局'}。"
1126
+ ]),
1127
+ html.P([
1128
+ html.Strong("KD指標:"),
1129
+ f"K值({k_current:.1f})",
1130
+ html.Span(
1131
+ "高於" if k_current > d_current else "低於",
1132
+ style={'color': 'green' if k_current > d_current else 'red', 'font-weight': 'bold'}
1133
+ ),
1134
+ f"D值({d_current:.1f}),",
1135
+ html.Span(
1136
+ "超買警戒" if k_current > 80 else "超賣關注" if k_current < 20 else "正常區間",
1137
+ style={'color': 'red' if k_current > 80 else 'green' if k_current < 20 else 'blue', 'font-weight': 'bold'}
1138
+ ),
1139
+ "。"
1140
+ ]),
1141
+ html.P([
1142
+ html.Strong("成交量分析:"),
1143
+ f"近期成交量{'放大' if recent_volume > volume_avg * 1.2 else '萎縮' if recent_volume < volume_avg * 0.8 else '平穩'},",
1144
+ f"顯示市場{'關注度提升' if recent_volume > volume_avg * 1.2 else '觀望氣氛濃厚' if recent_volume < volume_avg * 0.8 else '交投正常'}。"
1145
+ ])
1146
+ ])
1147
+
1148
+ # 基本面分析
1149
+ industry = INDUSTRY_MAPPING.get(selected_stock, '綜合')
1150
+ fundamental_text = html.Div([
1151
+ html.P([
1152
+ html.Strong("產業地位:"),
1153
+ f"{stock_name}屬於{industry}產業,在產業鏈中具有",
1154
+ html.Span("重要地位" if selected_stock in ['2330.TW', '2454.TW', '2317.TW'] else "一定影響力",
1155
+ style={'font-weight': 'bold'}),
1156
+ "。"
1157
+ ]),
1158
+ html.P([
1159
+ html.Strong("營運展望:"),
1160
+ f"考量{industry}產業前景及公司基本面,建議持續關注季報表現及未來指引。"
1161
+ ]),
1162
+ html.P([
1163
+ html.Strong("風險評估:"),
1164
+ "注意產業週期性變化、國際競爭及法規環境變化等風險因子。"
1165
+ ])
1166
+ ])
1167
+
1168
+ # 市場展望
1169
+ if price_change > 10:
1170
+ outlook_tone = "謹慎樂觀"
1171
+ outlook_color = "#28a745"
1172
+ elif price_change < -10:
1173
+ outlook_tone = "保守觀望"
1174
+ outlook_color = "#dc3545"
1175
+ else:
1176
+ outlook_tone = "中性持平"
1177
+ outlook_color = "#ffc107"
1178
+
1179
+ market_outlook = html.Div([
1180
+ html.P([
1181
+ html.Strong("整體評估:", style={'font-size': '16px'}),
1182
+ f"基於技術面及基本面分析,對{stock_name}採取",
1183
+ html.Span(f"{outlook_tone}", style={'color': outlook_color, 'font-weight': 'bold', 'font-size': '16px'}),
1184
+ "態度。"
1185
+ ]),
1186
+ html.P([
1187
+ html.Strong("投資建議:"),
1188
+ "建議投資人根據自身風險承受能力,採取適當的資產配置策略。短線操作注意技術指標,長線投資關注基本面變化。"
1189
+ ]),
1190
+ html.P([
1191
+ html.Strong("風險提醒:"),
1192
+ "股票投資具有風險,過去績效不代表未來表現,投資前請詳閱公開說明書並審慎評估。"
1193
+ ], style={'font-style': 'italic', 'font-size': '13px'})
1194
+ ])
1195
+
1196
+ return technical_text, fundamental_text, market_outlook
1197
+
1198
+ # 新增:更新PMI圖表
1199
+ @app.callback(
1200
+ dash.dependencies.Output('pmi-chart', 'figure'),
1201
+ [dash.dependencies.Input('stock-dropdown', 'value')] # 雖然不會影響圖表,但需要觸發
1202
+ )
1203
+ def update_pmi_chart(selected_stock):
1204
+ df = get_pmi_data()
1205
+
1206
+ if df.empty:
1207
+ # 如果沒有資料,顯示提示圖表
1208
+ fig = go.Figure()
1209
+ fig.add_annotation(
1210
+ x=0.5, y=0.5,
1211
+ text="無法載入PMI資料<br>請確認 taiwan_pmi.csv 檔案是否存在",
1212
+ xref="paper", yref="paper",
1213
+ showarrow=False,
1214
+ font=dict(size=14)
1215
+ )
1216
+ fig.update_layout(
1217
+ title="台灣PMI指數",
1218
+ height=300,
1219
+ showlegend=False
1220
+ )
1221
+ return fig
1222
+
1223
+ # 定義PMI顏色 (50以上擴張,以下緊縮)
1224
+ def get_pmi_color(value):
1225
+ return 'green' if value >= 50 else 'red'
1226
+
1227
+ colors = [get_pmi_color(value) for value in df['Index']]
1228
+
1229
+ fig = go.Figure()
1230
+
1231
+ fig.add_trace(go.Scatter(
1232
+ x=df['Date'],
1233
+ y=df['Index'],
1234
+ mode='lines+markers',
1235
+ name='PMI指數',
1236
+ line=dict(color='darkblue', width=2),
1237
+ marker=dict(
1238
+ size=8,
1239
+ color=colors,
1240
+ line=dict(width=2, color='darkblue')
1241
+ )
1242
+ ))
1243
+
1244
+ # 添加榮枯線
1245
+ fig.add_hline(y=50, line_dash="dash", line_color="black", annotation_text="榮枯線(50)")
1246
+
1247
+ # 添加背景色區域
1248
+ fig.add_hrect(
1249
+ y0=50, y1=60,
1250
+ fillcolor="lightgreen", opacity=0.2,
1251
+ annotation_text="擴張區間", annotation_position="top left"
1252
+ )
1253
+ fig.add_hrect(
1254
+ y0=40, y1=50,
1255
+ fillcolor="lightcoral", opacity=0.2,
1256
+ annotation_text="緊縮區間", annotation_position="bottom left"
1257
+ )
1258
+
1259
+ fig.update_layout(
1260
+ title="台灣PMI指數走勢",
1261
+ xaxis_title='日期',
1262
+ yaxis_title='PMI指數',
1263
+ height=300,
1264
+ yaxis=dict(range=[35, 60])
1265
+ )
1266
+
1267
+ return fig
1268
+
1269
+ # 新增:多檔股票比較
1270
+ @app.callback(
1271
+ [dash.dependencies.Output('comparison-chart', 'figure'),
1272
+ dash.dependencies.Output('comparison-table', 'children')],
1273
+ [dash.dependencies.Input('comparison-stocks', 'value'),
1274
+ dash.dependencies.Input('comparison-period', 'value')]
1275
+ )
1276
+ def update_comparison_analysis(selected_stocks, period):
1277
+ if not selected_stocks:
1278
+ return {}, html.Div("請選擇要比較的股票")
1279
+
1280
+ # 限制最多5檔
1281
+ selected_stocks = selected_stocks[:5]
1282
+
1283
+ fig = go.Figure()
1284
+ comparison_data = []
1285
+
1286
+ for stock in selected_stocks:
1287
+ data = get_stock_data(stock, period)
1288
+ if not data.empty:
1289
+ stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == stock][0]
1290
+
1291
+ # 正規化價格(以期初為基準100)
1292
+ normalized_prices = (data['Close'] / data['Close'].iloc[0]) * 100
1293
+
1294
+ fig.add_trace(go.Scatter(
1295
+ x=data.index,
1296
+ y=normalized_prices,
1297
+ mode='lines',
1298
+ name=stock_name,
1299
+ line=dict(width=2)
1300
+ ))
1301
+
1302
+ # 計算績效數據
1303
+ total_return = ((data['Close'].iloc[-1] / data['Close'].iloc[0]) - 1) * 100
1304
+ volatility = data['Close'].pct_change().std() * np.sqrt(252) * 100 # 年化波動率
1305
+
1306
+ comparison_data.append({
1307
+ 'name': stock_name,
1308
+ 'return': total_return,
1309
+ 'volatility': volatility,
1310
+ 'current_price': data['Close'].iloc[-1]
1311
+ })
1312
+
1313
+ fig.update_layout(
1314
+ title=f'股票績效比較 - {period}',
1315
+ xaxis_title='日期',
1316
+ yaxis_title='相對績效 (基期=100)',
1317
+ height=400,
1318
+ hovermode='x unified'
1319
+ )
1320
+
1321
+ # 建立比較表格
1322
+ if comparison_data:
1323
+ table_rows = []
1324
+ for item in sorted(comparison_data, key=lambda x: x['return'], reverse=True):
1325
+ color = 'green' if item['return'] > 0 else 'red'
1326
+ table_rows.append(
1327
+ html.Tr([
1328
+ html.Td(item['name'], style={'font-weight': 'bold'}),
1329
+ html.Td(f"{item['return']:+.1f}%", style={'color': color, 'font-weight': 'bold'}),
1330
+ html.Td(f"{item['volatility']:.1f}%"),
1331
+ html.Td(f"${item['current_price']:.2f}")
1332
+ ])
1333
+ )
1334
+
1335
+ table = html.Table([
1336
+ html.Thead([
1337
+ html.Tr([
1338
+ html.Th("股票", style={'text-align': 'center'}),
1339
+ html.Th("報酬率", style={'text-align': 'center'}),
1340
+ html.Th("波動率", style={'text-align': 'center'}),
1341
+ html.Th("現價", style={'text-align': 'center'})
1342
+ ])
1343
+ ]),
1344
+ html.Tbody(table_rows)
1345
+ ], style={
1346
+ 'width': '100%',
1347
+ 'border-collapse': 'collapse',
1348
+ 'font-size': '12px'
1349
+ })
1350
+
1351
+ return fig, table
1352
+
1353
+ return fig, html.Div("無可比較資料")
1354
+
1355
+ # 新增:市場情緒分析
1356
+ @app.callback(
1357
+ [dash.dependencies.Output('sentiment-gauge', 'children'),
1358
+ dash.dependencies.Output('news-summary', 'children')],
1359
+ [dash.dependencies.Input('stock-dropdown', 'value')]
1360
+ )
1361
+ def update_sentiment_analysis(selected_stock):
1362
+ # 模擬情緒指標(實際應用中可接入新聞API或情緒分析服務)
1363
+ sentiment_score = np.random.uniform(30, 80) # 模擬情緒分數 0-100
1364
+
1365
+ # 建立情緒指標圓形圖
1366
+ gauge_fig = go.Figure(go.Indicator(
1367
+ mode = "gauge+number+delta",
1368
+ value = sentiment_score,
1369
+ domain = {'x': [0, 1], 'y': [0, 1]},
1370
+ title = {'text': "市場情緒指數"},
1371
+ delta = {'reference': 50},
1372
+ gauge = {
1373
+ 'axis': {'range': [None, 100]},
1374
+ 'bar': {'color': "darkblue"},
1375
+ 'steps': [
1376
+ {'range': [0, 30], 'color': "lightcoral"},
1377
+ {'range': [30, 70], 'color': "lightgray"},
1378
+ {'range': [70, 100], 'color': "lightgreen"}
1379
+ ],
1380
+ 'threshold': {
1381
+ 'line': {'color': "red", 'width': 4},
1382
+ 'thickness': 0.75,
1383
+ 'value': 90
1384
+ }
1385
+ }
1386
+ ))
1387
+
1388
+ gauge_fig.update_layout(height=200, margin=dict(l=20, r=20, t=40, b=20))
1389
+
1390
+ # 模擬新聞摘要
1391
+ stock_name = [name for name, symbol in TAIWAN_STOCKS.items() if symbol == selected_stock][0]
1392
+
1393
+ news_items = [
1394
+ f"📈 {stock_name}獲外資調升目標價,看好後續發展前景",
1395
+ f"💼 法人預期{stock_name}下季營收將較上季成長5-10%",
1396
+ f"🌐 國際市場波動對{stock_name}影響有限,基本面穩健",
1397
+ f"⚡ 產業景氣回溫,{stock_name}受惠程度值得關注",
1398
+ f"📊 技術面顯示{stock_name}突破關鍵壓力,短線偏多"
1399
+ ]
1400
+
1401
+ news_content = html.Div([
1402
+ html.P(news, style={
1403
+ 'margin': '8px 0',
1404
+ 'padding': '8px',
1405
+ 'background': '#e8f4f8',
1406
+ 'border-radius': '5px',
1407
+ 'border-left': '3px solid #17a2b8',
1408
+ 'font-size': '13px'
1409
+ }) for news in news_items[:3] # 顯示前3條
1410
+ ])
1411
+
1412
+ return dcc.Graph(figure=gauge_fig), news_content
1413
+
1414
+ # 在 Colab 中執行的設定
1415
+ if __name__ == '__main__':
1416
+ # 在執行前先測試檔案讀取
1417
+ print("測試檔案讀取...")
1418
+ business_data = get_business_climate_data()
1419
+ pmi_data = get_pmi_data()
1420
+
1421
+ if not business_data.empty:
1422
+ print(f"景氣燈號資料預覽:\n{business_data.head()}")
1423
+ if not pmi_data.empty:
1424
+ print(f"PMI資料預覽:\n{pmi_data.head()}")
1425
+
1426
+ # 在 Hugging Face Spaces 中執行
1427
+ app.run(host="0.0.0.0", port=7860, debug=False) if data.empty:
1428
+ # 最後嘗試使用加權指數
1429
+ stock = yf.Ticker('^TWII')
1430
+ data = stock.history(period=period)
1431
+
1432
+ return data
1433
+ except:
1434
+ return pd.DataFrame()
1435
+
1436
+ def create_lstm_dataset(data, time_step=60):
1437
+ """建立LSTM訓練資料集"""
1438
+ X, y = [], []
1439
+ for i in range(time_step, len(data)):
1440
+ X.append(data[i-time_step:i, 0])
1441
+ y.append(data[i, 0])
1442
+ return np.array(X), np.array(y)
1443
+
1444
+ def simple_lstm_predict(data, predict_days=5):
1445
+ """簡化的LSTM預測模型 (使用統計方法模擬)"""
1446
+ if len(data) < 60:
1447
+ return None
1448
+
1449
+ # 使用移動平均和趨勢分析來模擬深度學習預測
1450
+ prices = data['Close'].values
1451
+
1452
+ # 計算短期和長期移動平均
1453
+ ma_short = np.mean(prices[-5:])
1454
+ ma_medium = np.mean(prices[-20:])
1455
+ ma_long = np.mean(prices[-60:])
1456
+
1457
+ # 計算價格變化趨勢
1458
+ recent_trend = np.polyfit(range(20), prices[-20:], 1)[0]
1459
+ volatility = np.std(prices[-20:]) / np.mean(prices[-20:])
1460
+
1461
+ # 模擬預測邏輯
1462
+ base_change = recent_trend * predict_days
1463
+ trend_factor = 1.0
1464
+
1465
+ if ma_short > ma_medium > ma_long:
1466
+ trend_factor = 1.02 # 上升趨勢
1467
+ elif ma_short < ma_medium < ma_long:
1468
+ trend_factor = 0.98 # 下降趨勢
1469
+ else:
1470
+ trend_factor = 1.0 # 盤整
1471
+
1472
+ # 加入隨機性模擬市場不確定性
1473
+ noise_factor = np.random.normal(1, volatility * 0.1)
1474
+
1475
+ predicted_price = prices[-1] * trend_factor + base_change + (prices[-1] * noise_factor * 0.01)
1476
+ change_pct = ((predicted_price - prices[-1]) / prices[-1]) * 100
1477
+
1478
+ return {
1479
+ 'predicted_price': predicted_price,
1480
+ 'change_pct': change_pct,
1481
+ 'confidence': max(0.6, 1 - volatility * 2) # 基於波動率的信心度
1482
+ }
1483
+
1484
+ def calculate_technical_indicators(df):
1485
+ """計算技術指標"""
1486
+ if df.empty:
1487
+ return df
1488
+
1489
+ # 移動平均線
1490
+ df['MA5'] = df['Close'].rolling(window=5).mean()
1491
+ df['MA20'] = df['Close'].rolling(window=20).mean()
1492
+
1493
+ # RSI
1494
+ delta = df['Close'].diff()
1495
+ gain = (delta.where(delta > 0, 0)).rolling(window=14).mean()
1496
+ loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
1497
+ rs = gain / loss
1498
+ df['RSI'] = 100 - (100 / (1 + rs))
1499
+
1500
+ # MACD (12, 26, 9)
1501
+ exp1 = df['Close'].ewm(span=12).mean()
1502
+ exp2 = df['Close'].ewm(span=26).mean()
1503
+ df['MACD'] = exp1 - exp2
1504
+ df['MACD_Signal'] = df['MACD'].ewm(span=9).mean()
1505
+ df['MACD_Histogram'] = df['MACD'] - df['MACD_Signal']
1506
+
1507
+ # 布林通道 (20日, 2倍標準差)
1508
+ df['BB_Middle'] = df['Close'].rolling(window=20).mean()
1509
+ bb_std = df['Close'].rolling(window=20).std()
1510
+ df['BB_Upper'] = df['BB_Middle'] + (bb_std * 2)
1511
+ df['BB_Lower'] = df['BB_Middle'] - (bb_std * 2)
1512
+ df['BB_Width'] = df['BB_Upper'] - df['BB_Lower']
1513
+ df['BB_Position'] = (df['Close'] - df['BB_Lower']) / (df['BB_Upper'] - df['BB_Lower'])
1514
+
1515
+ # KD指標 (9, 3, 3)
1516
+ low_min = df['Low'].rolling(window=9).min()
1517
+ high_max = df['High'].rolling(window=9).max()
1518
+ rsv = (df['Close'] - low_min) / (high_max - low_min) * 100
1519
+ df['K'] = rsv.ewm(com=2).mean() # com=2 相當於 span=3
1520
+ df['D'] = df['K'].ewm(com=2).mean()
1521
+
1522
+ # 威廉指標 %R (14日)
1523
+ low_min_14 = df['Low'].rolling(window=14).min()
1524
+ high_max_14 = df['High'].rolling(window=14).max()
1525
+ df['Williams_R'] = -100 * (high_max_14 - df['Close']) / (high_max_14 - low_min_14)
1526
+
1527
+ return df
1528
+
1529
+ def get_business_climate_data():
1530
+ """獲取台灣景氣燈號資料"""
1531
+ try:
1532
+ # 檢查檔案是否存在
1533
+ if not os.path.exists('business_climate.csv'):
1534
+ print("business_climate.csv 檔案不存在")
1535
+ return pd.DataFrame()
1536
+
1537
+ # 讀取CSV檔案,假設列名為 Date 和 Index
1538
+ df = pd.read_csv('business_climate.csv')
1539
+
1540
+ # 檢查列名並調整
1541
+ if 'Date' not in df.columns:
1542
+ # 如果第一列是日期,重新命名
1543
+ df.columns = ['Date', 'Index'] if len(df.columns) == 2 else df.columns
1544
+
1545
+ # 轉換日期格式 (處理 YYYY-MM 格式)
1546
+ if 'Date' in df.columns:
1547
+ try:
1548
+ # 如果是 YYYY-MM 格式,轉換為日期
1549
+ df['Date'] = pd.to_datetime(df['Date'] + '-01', format='%Y-%m-%d', errors='coerce')
1550
+ except:
1551
+ df['Date'] = pd.to_datetime(df['Date'], errors='coerce')
1552
+
1553
+ # 移除日期轉換失敗的行
1554
+ df = df.dropna(subset=['Date'])
1555
+
1556
+ print(f"成功讀取景氣燈號資料:{len(df)} 筆記錄")
1557
+ return df
1558
+
1559
+ except Exception as e:
1560
+ print(f"無法獲取景氣燈號資料: {str(e)}")
1561
+ return pd.DataFrame()
1562
+
1563
+ def get_pmi_data():
1564
+ """獲取台灣 PMI 資料"""
1565
+ try:
1566
+ # 檢查檔案是否存在
1567
+ if not os.path.exists('taiwan_pmi.csv'):
1568
+ print("taiwan_pmi.csv 檔案不存在")
1569
+ return pd.DataFrame()
1570
+
1571
+ # 讀取CSV檔案
1572
+ df = pd.read_csv('taiwan_pmi.csv')
1573
+
1574
+ # 檢查列名並調整 (處理 DATE/INDEX 或其他可能的列名)
1575
+ if 'DATE' in df.columns:
1576
+ df = df.rename(columns={'DATE': 'Date', 'INDEX': 'Index'})
1577
+ elif len(df.columns) == 2:
1578
+ df.columns = ['Date', 'Index']
1579
+
1580
+ # 轉換日期格式
1581
+ if 'Date' in df.columns:
1582
+ try:
1583
+ # 如果是 YYYY-MM 格式,轉換為日期
1584
+ df['Date'] = pd.to_datetime(df['Date'] + '-01', format='%Y-%m-%d', errors='coerce')
1585
+ except:
1586
+ df['Date'] = pd.to_datetime(df['Date'], errors='coerce')
1587
+
1588
+ # 移除日期轉換失敗的行
1589
+ df = df.dropna(subset=['Date'])
1590
+
1591
+ print(f"成功讀取 PMI 資料:{len(df)} 筆記錄")
1592
+ return df
1593
+
1594
+ except Exception as e:
1595
+ print(f"無法獲取 PMI 資料: {str(e)}")
1596
+ return pd.DataFrame()
1597
+
1598
+ # 建立 Dash 應用程式
1599
+ app = dash.Dash(__name__, suppress_callback_exceptions=True)
1600
+
1601
+ # 應用程式佈局
1602
+ app.layout = html.Div([
1603
+ html.H1("台股分析儀表板", style={'text-align': 'center', 'margin-bottom': '30px'}),
1604
+
1605
+ # 台指期獨立預測區塊 - 置於頂部
1606
+ html.Div([
1607
+ html.H2("🤖 AI深度學習預測 - 台指期指數", style={
1608
+ 'text-align': 'center',
1609
+ 'color': '#FFCC22',
1610
+ 'margin-bottom': '25px'
1611
+ }),
1612
+ html.Div([
1613
+ html.Div([
1614
+ html.Label("預測期間:", style={'font-weight': 'bold', 'color': '#FFCC22'}),
1615
+ dcc.Dropdown(
1616
+ id='taiex-prediction-period',
1617
+ options=[
1618
+ {'label': '1日後預測', 'value': 1},
1619
+ {'label': '5日後預測', 'value': 5},
1620
+ {'label': '10日後預測', 'value': 10},
1621
+ {'label': '20日後預測', 'value': 20},
1622
+ {'label': '60日後預測', 'value': 60}
1623
+ ],
1624
+ value=5,
1625
+ style={'margin-bottom': '10px', 'color': '#272727'}
1626
+ )
1627
+ ], style={'width': '30%', 'display': 'inline-block'}),
1628
+
1629
+ html.Div(id='taiex-prediction-results', style={'width': '65%', 'display': 'inline-block', 'margin-left': '5%'})
1630
+ ]),
1631
+
1632
+ html.Div([
1633
+ dcc.Graph(id='taiex-prediction-chart')
1634
+ ], style={'margin-top': '20px'})
1635
+ ], style={
1636
+ 'background': 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
1637
+ 'padding': '25px',
1638
+ 'border-radius': '15px',
1639
+ 'box-shadow': '0 8px 25px rgba(0,0,0,0.15)',
1640
+ 'color': 'white',
1641
+ 'margin-bottom': '40px'
1642
+ }),
1643
+
1644
+ # 控制面板 (移除台指期選項)
1645
+ html.Div([
1646
+ html.Div([
1647
+ html.Label("選擇股票:"),
1648
+ dcc.Dropdown(
1649
+ id='stock-dropdown',
1650
+ options=[{'label': name, 'value': symbol} for name, symbol in TAIWAN_STOCKS.items()],
1651
+ value='2330.TW', # 預設改為台積電
1652
+ style={'margin-bottom': '10px'}
1653
+ )
1654
+ ], style={'width': '30%', 'display': 'inline-block', 'vertical-align': 'top'}),
1655
+
1656
+ html.Div([
1657
+ html.Label("時間範圍:"),
1658
+ dcc.Dropdown(
1659
+ id='period-dropdown',
1660
+ options=[
1661
+ {'label': '1個月', 'value': '1mo'},
1662
+ {'label': '3個月', 'value': '3mo'},
1663
+ {'label': '6個月', 'value': '6mo'},
1664
+ {'label': '1年', 'value': '1y'},
1665
+ {'label': '2年', 'value': '2y'}
1666
+ ],
1667
+ value='6mo',
1668
+ style={'margin-bottom': '10px'}
1669
+ )
1670
+ ], style={'width': '30%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'}),
1671
+
1672
+ html.Div([
1673
+ html.Label("圖表類型:"),
1674
+ dcc.Dropdown(
1675
+ id='chart-type',
1676
+ options=[
1677
+ {'label': '線圖', 'value': 'line'},
1678
+ {'label': '蠟燭圖', 'value': 'candlestick'}
1679
+ ],
1680
+ value='candlestick',
1681
+ style={'margin-bottom': '10px'}
1682
+ )
1683
+ ], style={'width': '30%', 'display': 'inline-block', 'margin-left': '5%', 'vertical-align': 'top'})
1684
+ ], style={'margin-bottom': '30px'}),
1685
+
1686
+ # 股價資訊卡片
1687
+ html.Div(id='stock-info-cards', style={'margin-bottom': '30px'}),
1688
+
1689
+ # 主要圖表區域
1690
+ html.Div([
1691
+ # 左側:股價走勢圖和技術指標
1692
+ html.Div([
1693
+ html.Div([
1694
+ dcc.Graph(id='price-chart')
1695
+ ], style={'margin-bottom': '20px'}),
1696
+
1697
+ html.Div([
1698
+ dcc.Graph(id='rsi-chart')
1699
+ ])
1700
+ ], style={'width': '65%', 'display': 'inline-block', 'vertical-align': 'top'}),
1701
+
1702
+ # 右側:分析資訊面板
1703
+ html.Div([
1704
+ html.Div(id='analysis-panel')
1705
+ ], style={'width': '33%', 'display': 'inline-block', 'margin-left': '2%', 'vertical-align': 'top'})
1706
+ ]),
1707
+
1708
+ # 技術指標選擇區域
1709
+ html.Div([
1710
+ html.H3("📊 進階技術指標分析", style={'margin-bottom': '20px'}),
1711
+ html.Div([
1712
+ html.Label("選擇技術指標:", style={'font-weight': 'bold', 'margin-right': '10px'}),
1713
+ dcc.Dropdown(
1714
+ id='technical-indicator-selector',
1715
+ options=[
1716
+ {'label': 'RSI 相對強弱指標', 'value': 'RSI'},
1717
+ {'label': 'MACD 指數平滑異同移動平均線', 'value': 'MACD'},
1718
+ {'label': '布林通道 Bollinger Bands', 'value': 'BB'},
1719
+ {'label': 'KD 隨機指標', 'value': 'KD'},
1720
+ {'label': '威廉指標 %R', 'value': 'WR'}
1721
+ ],
1722
+ value='RSI',
1723
+ style={'width': '100%'}
1724
+ )
1725
+ ], style={'margin-bottom': '20px'}),
1726
+
1727
+ html.Div([
1728
+ dcc.Graph(id='advanced-technical-chart')
1729
+ ])
1730
+ ], style={
1731
+ 'margin-top': '20px',
1732
+ 'padding': '20px',
1733
+ 'background': 'white',
1734
+ 'border-radius': '10px',
1735
+ 'box-shadow': '0 2px 10px rgba(0,0,0,0.1)'
1736
+ }),
1737
+
1738
+ # 成交量圖
1739
+ html.Div([
1740
+ dcc.Graph(id='volume-chart')
1741
+ ], style={'margin-top': '20px'}),
1742
+
1743
+ # 產業分析
1744
+ html.Div([
1745
+ html.H3("產業表現分析"),
1746
+ dcc.Graph(id='industry-analysis')
1747
+ ], style={'margin-top': '30px'}),
1748
+
1749
+ # 分析師觀點區域
1750
+ html.Div([
1751
+ html.H3("📊 分析師觀點與市場解讀", style={'color': '#2E86AB', 'margin-bottom': '20px'}),
1752
+ html.Div([
1753
+ # 左側:技術分析觀點
1754
+ html.Div([
1755
+ html.H4("🔍 技術面分析", style={'color': '#A23B72', 'margin-bottom': '15px'}),
1756
+ html.Div(id='technical-analysis-text', style={
1757
+ 'background': '#f8f9fa',
1758
+ 'padding': '15px',
1759
+ 'border-radius': '8px',
1760
+ 'border-left': '4px solid #A23B72',
1761
+ 'min-height': '150px',
1762
+ 'font-size': '14px',
1763
+ 'line-height': '1.6'
1764
+ })
1765
+ ], style={'width': '48%', 'display': 'inline-block', 'vertical-align': 'top'}),
1766
+
1767
+ # 右側:基本面分析觀點
1768
+ html.Div([
1769
+ html.H4("📈 基本面分析", style={'color': '#F18F01', 'margin-bottom': '15px'}),
1770
+ html.Div(id='fundamental-analysis-text', style={
1771
+ 'background': '#f8f9fa',
1772
+ 'padding': '15px',
1773
+ 'border-radius': '8px',
1774
+ 'border-left': '4px solid #F18F01',
1775
+ 'min-height': '150px',
1776
+ 'font-size': '14px',
1777
+ 'line-height': '1.6'
1778
+ })
1779
+ ], style={'width': '48%', 'display': 'inline-block', 'margin-left': '4%', 'vertical-align': 'top'})
1780
+ ]),
1781
+
1782
+ # 底部:市場展望
1783
+ html.Div([
1784
+ html.H4("🎯 市場展望與投資建議", style={'color': '#C73E1D', 'margin-bottom': '15px', 'margin-top': '25px'}),
1785
+ html.Div(id='market-outlook-text', style={
1786
+ 'background': 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
1787
+ 'color': 'white',
1788
+ 'padding': '20px',
1789
+ 'border-radius': '10px',
1790
+ 'min-height': '100px',
1791
+ 'font-size': '15px',
1792
+ 'line-height': '1.7',
1793
+ 'box-shadow': '0 4px 15px rgba(0,0,0,0.1)'
1794
+ })
1795
+ ])
1796
+ ], style={
1797
+ 'margin-top': '30px',
1798
+ 'padding': '25px',
1799
+ 'background': 'white',
1800
+ 'border-radius': '12px',
1801
+ 'box-shadow': '0 4px 20px rgba(0,0,0,0.08)',
1802
+ 'border': '1px solid #e9ecef'
1803
+ }),
1804
+
1805
+ # 景氣燈號與 PMI 分析
1806
+ html.Div([
1807
+ html.H3("景氣燈號與 PMI 分析"),
1808
+ html.Div([
1809
+ html.Div([
1810
+ dcc.Graph(id='business-climate-chart')
1811
+ ], style={'width': '48%', 'display': 'inline-block'}),
1812
+ html.Div([
1813
+ dcc.Graph(id='pmi-chart')
1814
+ ], style={'width': '48%', 'display': 'inline-block', 'margin-left': '2%'})
1815
+ ])
1816
+ ], style={'margin-top': '30px'}),
1817
+
1818
+ # 多檔股票比較區域
1819
+ html.Div([
1820
+ html.H3("📊 多檔股票比較分析", style={'margin-bottom': '20px'}),
1821
+ html.Div([
1822
+ html.Div([
1823
+ html.Label("選擇比較股票(最多5檔):", style={'font-weight': 'bold'}),
1824
+ dcc.Dropdown(
1825
+ id='comparison-stocks',
1826
+ options=[{'label': name, 'value': symbol} for name, symbol in TAIWAN_STOCKS.items()],
1827
+ value=['2330.TW', '2454.TW', '2317.TW'], # 預設選擇
1828
+ multi=True,
1829
+ style={'margin-bottom': '15px'}
1830
+ )
1831
+ ], style={'width': '60%', 'display': 'inline-block'}),
1832
+
1833
+ html.Div([
1834
+ html.Label("比較期間:", style={'font-weight': 'bold'}),
1835
+ dcc.Dropdown(
1836
+ id='comparison-period',
1837
+ options=[
1838
+ {'label': '1個月', 'value': '1mo'},
1839
+ {'label': '3個月', 'value': '3mo'},
1840
+ {'label': '6個月', 'value': '6mo'},
1841
+ {'label': '1年', 'value': '1y'}
1842
+ ],
1843
+ value='3mo'
1844
+ )
1845
+ ], style={'width': '35%', 'display': 'inline-block', 'margin-left': '5%'})
1846
+ ]),
1847
+
1848
+ html.Div([
1849
+ html.Div([
1850
+ dcc.Graph(id='comparison-chart')
1851
+ ], style={'width': '65%', 'display': 'inline-block'}),
1852
+
1853
+ html.Div([
1854
+ html.H4("比較結果", style={'color': '#2E86AB'}),
1855
+ html.Div(id='comparison-table')
1856
+ ], style={'width': '33%', 'display': 'inline-block', 'margin-left': '2%', 'vertical-align': 'top'})
1857
+ ])
1858
+ ], style={
1859
+ 'margin-top': '30px',
1860
+ 'padding': '20px',
1861
+ 'background': 'white',
1862
+ 'border-radius': '10px',
1863
+ 'box-shadow': '0 2px 10px rgba(0,0,0,0.1)'
1864
+ }),
1865
+
1866
+ # 新聞情感分析區域(模擬)
1867
+ html.Div([
1868
+ html.H3("📰 市場情緒與新聞分析", style={'color': '#E74C3C', 'margin-bottom': '20px'}),
1869
+ html.Div([
1870
+ html.Div([
1871
+ html.H4("市場情緒指標", style={'color': '#8E44AD'}),
1872
+ html.Div(id='sentiment-gauge')
1873
+ ], style={'width': '48%', 'display': 'inline-block'}),
1874
+
1875
+ html.Div([
1876
+ html.H4("關鍵新聞摘要", style={'color': '#27AE60'}),
1877
+ html.Div(id='news-summary', style={
1878
+ 'background': '#f8f9fa',
1879
+ 'padding': '15px',
1880
+ 'border-radius': '8px',
1881
+ 'max-height': '200px',
1882
+ 'overflow-y': 'auto'
1883
+ })
1884
+ ], style={'width': '48%', 'display': 'inline-block', 'margin-left': '4%'})
1885
+ ])
1886
+ ], style={
1887
+ 'margin-top': '30px',
1888
+ 'padding': '20px',
1889
+ 'background': 'white',
1890
+ 'border-radius': '10px',
1891
+ 'box-shadow': '0 2px 10px rgba(0,0,0,0.1)'
1892
+ })
1893
+ ])
1894
  # 創建一個列表來儲存所有的預測點數據
1895
  all_predictions_data = []
1896
  # 台指期獨立預測回調函數