AlanRex commited on
Commit
541175c
·
1 Parent(s): 570be2c

Update app.py

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