2026年5月3日 星期日

2026 年第 17 周記事

本周五勞動節連假只上四天班, 我週三沒有回鄉下, 因為周四下班就要回去了. 以往勞動節都去加班, 今年不加了, 即使可加也不想去了, 邁入耳順之年後更想要的是放假. 周六老同學大帥與仲仔, 風大師來訪, 因為之前我都下廚在加吃便飯, 坐下來聊天時間不甚多, 這回剛好峰大師四月底退休, 預定了菸樓坊一桌請大伙去吃飯, 但到了餐廳才發現遊覽車一車車進來, 不知要等多久才有空位, 大帥建議取消, 去人少的地方, 我想起鎮上的八分飽, 到餐廳時下車問還好有空桌, 於是便在此用餐. 我看以後還是自己下廚吧, 外面餐廳假日都是人. 

連假三天我都在家測試程式, 一口氣把 Plotly 與 Bokeh 畫 K 線圖的方法搞定. 雖然 AI 時代漸漸都不自己寫程式了, 小白或非科班就算呃, 但做為工程師還是得看得懂 AI 在寫甚麼吧? 

週五舅媽叫小舅帶來涼麵與木瓜粄, 加上峰大師周六帶來一隻烤雞, 所以連假三天都在消耗這些糧食, 甚至今天周日我也沒上市場買菜, 因為下周要去沖繩, 食材放太久也不行. 

下午要去菜園移植韭菜時, 經過車庫看見毛小妹與它的一隻小貓躺在一起睡覺, 看似小貓吃完奶就地睡著了, 那畫面看來好幸福, 趕緊進屋拿手機拍了下來 : 




但晚上爸在曬穀場騎腳踏車時, 一隻小貓跑到路對面去, 在橫越馬路跑回來時被一台機車撞到, 那人也沒停下來逕自騎走了, 爸把小貓報進來, 我看口鼻皆流血已無呼吸, 因它們四兄妹毛色很像, 不確定是否為下午那隻. 我家緊鄰馬路邊, 路雖不大但為交通要道, 來往車子都開太快, 打算買一個太陽能警示燈安裝在路旁電線桿, 希望提醒減速慢行. 

Python 學習筆記 : 用 bokeh 繪製 K 線圖 (二)

Bokeh 的優點是高自由度與高效能, 但其 API 較低階, 故並未內建像 Plotly 的 CandleStick 那樣的類別, 本篇旨在為 Bokey 山寨一個 CandleStick 來簡化 K 線圖的繪製. 為了讓程式碼不至於過度複雜, 以下測試不處理非交易日 K 棒空缺問題. 

本系列全部測試文章索引參考 :



4. 用封裝的類別繪製 K 線圖 :   

下面範例改寫自前一篇測試的 bokeh_candlestick_3.py, 將繪製 K 線圖的邏輯封裝在自訂的 BokehChart 類別裡, 只要呼叫其建構式並傳入 title/width/height 參數建立物件, 然後在主程式中呼叫 add_candlestick(df) 即可繪製 K 線圖, 程式碼如下 : 

# bokeh_candlestick_class_1.py
import yfinance as yf
import pandas as pd
from bokeh.plotting import figure, show
from bokeh.models import ColumnDataSource, HoverTool, CrosshairTool, Span

class BokehChart:
    """封裝 Bokeh K線圖與技術分析工具的繪圖類別"""
    def __init__(self, title="K線圖", width=800, height=400):
        # 建立並初始化畫布
        self.fig=figure(
            x_axis_type='datetime', 
            title=title, 
            width=width,
            height=height,
            tools='xpan, xwheel_zoom, box_zoom, reset, save'
            )
        self.fig.grid.grid_line_alpha=0.3
        
        # 初始化虛線十字游標
        self._add_crosshair()

    def _add_crosshair(self):
        """內部方法:加入虛線十字游標"""
        w_span=Span(dimension="width", line_dash="dashed", line_color="gray", line_alpha=0.6, line_width=1)
        h_span=Span(dimension="height", line_dash="dashed", line_color="gray", line_alpha=0.6, line_width=1)
        self.fig.add_tools(CrosshairTool(overlay=[w_span, h_span]))

    def add_candlestick(self, df, date_col='Date', open_col='Open', high_col='High', low_col='Low', close_col='Close', vol_col='Volume'):
        """對外介面:加入 K 線圖與懸停工具"""
        # 複製資料以避免修改到原始 DataFrame
        data=df.copy()
        
        # 定義漲跌顏色 (紅漲綠跌)
        data['color']=['#ff0000' if c >= o else '#00aa00' for o, c in zip(data[open_col], data[close_col])]
        
        # 建立資料來源
        source=ColumnDataSource(data)
        
        # 繪製上下影線
        self.fig.segment(date_col, high_col, date_col, low_col, color="black", source=source)
        
        # 繪製 K 棒 (12 小時寬度)
        width_ms=12 * 60 * 60 * 1000
        self.fig.vbar(date_col, width_ms, open_col, close_col, 
                      fill_color='color', line_color='black', source=source)
        
        # 加入互動式懸停工具 (HoverTool)
        hover=HoverTool(
            tooltips=[
                ("日期", f"@{date_col}{{%F}}"),
                ("開盤", f"@{open_col}{{0.00}}"),
                ("收盤", f"@{close_col}{{0.00}}"),
                ("最高", f"@{high_col}{{0.00}}"),
                ("最低", f"@{low_col}{{0.00}}"),
                ("成交量", f"@{vol_col}{{0,0}}")
                ],
            formatters={f'@{date_col}': 'datetime'}
            )
        self.fig.add_tools(hover)

    def show(self):
        """顯示圖表"""
        show(self.fig)

# ==========================================
# 主程式:使用類別繪圖 (如同 Plotly 般簡潔)
# ==========================================
if __name__ == "__main__":
    # 1. 下載真實 OHLCV 資料
    df=yf.download('0050.tw', start='2026-03-01', end='2026-04-30', auto_adjust=True)
    df.columns=df.columns.map(lambda x: x[0])
    df=df.reset_index()

    # 2. 實例化畫布並繪圖
    chart=BokehChart(title='0050.TW 台灣50 ETF K線圖 (2026-03 ~ 2026-04)', width=800, height=400)
    chart.add_candlestick(df)
    
    # 3. 顯示圖表
    chart.show()

結果如下 :




5. 用封裝的類別繪製 K 線圖 + 成交量副圖 :   

如果要同時繪製 K 線圖與成交量副圖, 則須匯入 bokeh.layouts.column() 函式進行垂直布局, 讓主圖 (fig_k) 與副圖能共用 X 軸並連動. 程式碼改寫如下 : 

# bokeh_candlestick_class_2.py
import yfinance as yf
import pandas as pd
from bokeh.plotting import figure, show
from bokeh.models import ColumnDataSource, HoverTool, CrosshairTool, Span, CustomJSTickFormatter
from bokeh.layouts import column  # 引入垂直佈局工具

class BokehChart:
    """封裝 Bokeh K線圖與成交量子圖的繪圖類別"""
    
    # 稍微調整初始化參數,允許分別設定主圖與副圖的高度
    def __init__(self, title="K線圖", width=800, height_k=400, height_v=150):
        # 1. 建立主圖 (K線圖)
        self.fig_k=figure(
            title=title, 
            width=width, height=height_k,
            tools='xpan, xwheel_zoom, box_zoom, reset, save'
            )
        self.fig_k.grid.grid_line_alpha=0.3
        self.fig_k.xaxis.visible=False  # 隱藏主圖的 X 軸,讓畫面更緊湊
        
        # 2. 建立副圖 (成交量圖)
        self.fig_v=figure(
            width=width, height=height_v,
            x_range=self.fig_k.x_range,  # 【關鍵連動】將副圖的 X 軸範圍綁定為主圖的 X 軸
            tools='xpan, xwheel_zoom, reset'
            )
        self.fig_v.grid.grid_line_alpha=0.3
        self.fig_v.y_range.start=0     # 確保成交量 Y 軸從 0 開始
        
        # 3. 組合為垂直佈局
        self.layout=column(self.fig_k, self.fig_v)
        
        # 4. 初始化虛線十字游標 (加在兩張圖上)
        self._add_crosshair(self.fig_k)
        self._add_crosshair(self.fig_v)

    def _add_crosshair(self, fig):
        """內部方法:為指定的畫布加入虛線十字游標"""
        w_span=Span(dimension="width", line_dash="dashed", line_color="gray", line_alpha=0.6, line_width=1)
        h_span=Span(dimension="height", line_dash="dashed", line_color="gray", line_alpha=0.6, line_width=1)
        fig.add_tools(CrosshairTool(overlay=[w_span, h_span]))

    def add_candlestick(self, df, date_col='Date', open_col='Open', high_col='High', low_col='Low', close_col='Close', vol_col='Volume'):
        """對外介面:處理資料並繪製 K 線與成交量"""
        data=df.copy()
        
        # 建立連續整數序列 (消除假日空缺)
        data['seq']=range(len(data))
        
        # 定義漲跌顏色
        data['color']=['#ff0000' if c >= o else '#00aa00' for o, c in zip(data[open_col], data[close_col])]
        
        # 用 Python 預先處理好 Bokeh 風格的日期字串
        date_map={}
        prev_year=None
        for i, d in enumerate(data[date_col]):
            label=f"{d.strftime('%b')} {d.day}"
            if i == 0 or d.year != prev_year:
                label += f"\n{d.year}"
            date_map[str(i)]=label
            prev_year=d.year
            
        source=ColumnDataSource(data)
        width_val=0.8  # 因為 X 軸變成了連續整數,寬度直接用比例 0.8
        
        # --- 繪製主圖 (K線) ---
        self.fig_k.segment('seq', high_col, 'seq', low_col, color="black", source=source)
        self.fig_k.vbar('seq', width_val, open_col, close_col, fill_color='color', line_color='black', source=source)
        
        # --- 繪製副圖 (成交量) ---
        # 使用 vbar,top 參數指定為成交量欄位
        self.fig_v.vbar('seq', width_val, top=vol_col, fill_color='color', line_color='black', source=source)
        
        # --- 設定副圖的 X 軸日期標籤 ---
        js_code="""
            var idx=Math.round(tick).toString();
            if (date_map[idx] !== undefined) {
                return date_map[idx];
            } else {
                return "";
            }
        """
        self.fig_v.xaxis.formatter=CustomJSTickFormatter(code=js_code, args={'date_map': date_map})
        self.fig_v.xaxis.major_label_orientation=0 
        self.fig_v.xaxis.major_label_standoff=15
        
        # --- 加入互動式懸停工具 ---
        hover=HoverTool(
            tooltips=[
                ("日期", f"@{date_col}{{%F}}"),
                ("開盤", f"@{open_col}{{0.00}}"),
                ("收盤", f"@{close_col}{{0.00}}"),
                ("最高", f"@{high_col}{{0.00}}"),
                ("最低", f"@{low_col}{{0.00}}"),
                ("成交量", f"@{vol_col}{{0,0}}")
                ],
            formatters={f'@{date_col}': 'datetime'},
            mode='vline'  # 【推薦】改為 vline 模式,滑鼠對齊同一個垂直線就會觸發
            )
        self.fig_k.add_tools(hover)
        self.fig_v.add_tools(hover)

    def show(self):
        """顯示圖表佈局"""
        show(self.layout)  # 【修改】現在是顯示整個 layout 而不是單一 fig

# ==========================================
# 主程式
# ==========================================
if __name__ == "__main__":
    # 1. 下載實盤 OHLCV 資料
    df=yf.download('0050.tw', start='2026-03-01', end='2026-04-30', auto_adjust=True)
    df.columns=df.columns.map(lambda x: x[0])
    df=df.reset_index()

    # 2. 建立畫布並繪圖
    chart=BokehChart(title='0050.TW 台灣50 ETF (含同步成交量)')
    chart.add_candlestick(df)
    
    # 3. 顯示圖表
    chart.show()

注意, 此例之 BokehChart 類別還封裝了處理非交易日空缺的邏輯, 這讓整個畫布上的 K 棒看起來是連續無空缺的, 結果如下 : 




6. 用封裝的類別繪製 K 線圖 + 成交量 + 技術指標副圖 : 

上面範例中的 BokehChart 類別把主圖與副圖邏輯都寫在類別裡, 每次要添加技術指標副圖都要去改類別並不符合軟工原則, 若要讓程式碼具有擴充性, 必須對架構做一次重構, 要建立一個 動態串列 self.figures = [] 的來儲存所有畫布, 並增加通用副圖方法, 例如 add_bar_subplot() (畫成交量, MACD柱狀圖) 與 add_line_subplot() (畫 RSI, 均線).

程式碼如下 : 

# bokeh_candlestick_class_3.py
import yfinance as yf
import pandas as pd
import pandas_ta as ta  # 
from bokeh.plotting import figure
from bokeh.io import show as show_bokeh # 避免名稱衝突
from bokeh.models import ColumnDataSource, HoverTool, CrosshairTool, Span, CustomJSTickFormatter
from bokeh.layouts import column

class BokehChart:
    """高擴充性 Bokeh 繪圖類別,支援動態疊加無限多個副圖"""
    
    def __init__(self, title="K線圖", width=800, main_height=400):
        self.width=width
        self.title=title
        self.main_height=main_height
        
        # 動態儲存所有畫布的清單 (主圖會在 index 0)
        self.figures=[]
        self.source=None
        self.date_map={}
        self.fig_k=None # 參照主圖,用來同步 x_range

    def _add_crosshair(self, fig):
        """內部方法:加入虛線十字游標"""
        w_span=Span(dimension="width", line_dash="dashed", line_color="gray", line_alpha=0.6, line_width=1)
        h_span=Span(dimension="height", line_dash="dashed", line_color="gray", line_alpha=0.6, line_width=1)
        fig.add_tools(CrosshairTool(overlay=[w_span, h_span]))

    def add_candlestick(self, df, date_col='Date', open_col='Open', high_col='High', low_col='Low', close_col='Close'):
        """【步驟 1】:初始化主圖與共用資料源"""
        data=df.copy()
        
        # 建立連續整數序列 (消除假日空缺)
        data['seq']=range(len(data))
        data['color']=['#ff0000' if c >= o else '#00aa00' for o, c in zip(data[open_col], data[close_col])]
        
        # 製作日期對照表
        self.date_map={}
        prev_year=None
        for i, d in enumerate(data[date_col]):
            label=f"{d.strftime('%b')} {d.day}"
            if i == 0 or d.year != prev_year:
                label += f"\n{d.year}"
            self.date_map[str(i)]=label
            prev_year=d.year
            
        # 建立全局共用的資料源 (此時 df 裡面已經包含外部算好的 RSI 等指標)
        self.source=ColumnDataSource(data)
        
        # 建立主畫布
        self.fig_k=figure(title=self.title, width=self.width, height=self.main_height, tools='xpan, xwheel_zoom, box_zoom, reset, save')
        self.fig_k.grid.grid_line_alpha=0.3
        
        # 繪製 K 線
        self.fig_k.segment('seq', high_col, 'seq', low_col, color="black", source=self.source)
        self.fig_k.vbar('seq', 0.8, open_col, close_col, fill_color='color', line_color='black', source=self.source)
        
        # 設定主圖 Hover
        hover=HoverTool(
            tooltips=[("日期", f"@{date_col}{{%F}}"), ("開盤", f"@{open_col}{{0.00}}"), ("收盤", f"@{close_col}{{0.00}}")],
            formatters={f'@{date_col}': 'datetime'}, mode='vline'
            )
        self.fig_k.add_tools(hover)
        self._add_crosshair(self.fig_k)
        
        # 加入畫布清單
        self.figures.append(self.fig_k)

    def add_bar_subplot(self, col_name, title="", color_col=None, color='blue', height=150):
        """通用介面:新增柱狀圖副圖 (例如:成交量、MACD柱)"""
        fig=figure(width=self.width, height=height, x_range=self.fig_k.x_range, tools='xpan, xwheel_zoom, reset')
        fig.grid.grid_line_alpha=0.3
        fig.y_range.start=0
        
        # 若有指定顏色欄位 (如成交量的紅綠色),則使用該欄位;否則使用單一顏色
        fill_c=color_col if color_col else color
        fig.vbar('seq', 0.8, top=col_name, fill_color=fill_c, line_color='black', source=self.source)
        
        # 專屬 Hover
        hover=HoverTool(tooltips=[("日期", "@Date{%F}"), (title or col_name, f"@{col_name}{{0,0}}")], formatters={'@Date': 'datetime'}, mode='vline')
        fig.add_tools(hover)
        self._add_crosshair(fig)
        self.figures.append(fig)

    def add_line_subplot(self, col_name, title="", color='blue', height=150, hlines=None):
        """通用介面:新增折線圖副圖 (例如:RSI、均線、KD)"""
        fig=figure(width=self.width, height=height, x_range=self.fig_k.x_range, tools='xpan, xwheel_zoom, reset')
        fig.grid.grid_line_alpha=0.3
        
        fig.line('seq', col_name, color=color, line_width=1.5, source=self.source)
        
        # 支援加入水平參考線 (例如 RSI 的 30, 70 超買超賣線)
        if hlines:
            for y_val in hlines:
                line=Span(location=y_val, dimension='width', line_color='gray', line_dash='dashed', line_alpha=0.5)
                fig.add_layout(line)

        # 專屬 Hover
        hover=HoverTool(tooltips=[("日期", "@Date{%F}"), (title or col_name, f"@{col_name}{{0.00}}")], formatters={'@Date': 'datetime'}, mode='vline')
        fig.add_tools(hover)
        self._add_crosshair(fig)
        self.figures.append(fig)

    def show(self):
        """渲染圖表:隱藏除最後一張圖外的 X 軸,並套用自訂日期標籤"""
        for i, fig in enumerate(self.figures):
            # 不是最後一張圖,隱藏 X 軸
            if i < len(self.figures) - 1:
                fig.xaxis.visible=False
            # 是最後一張圖,掛上 JavaScript 日期查表邏輯
            else:
                js_code="""
                    var idx=Math.round(tick).toString();
                    if (date_map[idx] !== undefined) {
                        return date_map[idx];
                        }
                    else {
                        return "";
                        }
                """
                fig.xaxis.formatter=CustomJSTickFormatter(code=js_code, args={'date_map': self.date_map})
                fig.xaxis.major_label_orientation=0
                fig.xaxis.major_label_standoff=15

        # 垂直疊加所有被加入的圖表
        layout=column(*self.figures)
        show_bokeh(layout)


# ==========================================
# 主程式:展現架構擴充性的時刻
# ==========================================
if __name__ == "__main__":
    # 1. 獲取資料
    df=yf.download('0050.tw', start='2025-10-01', end='2026-04-30', auto_adjust=True)
    df.columns=df.columns.map(lambda x: x[0])
    df=df.reset_index()

    # 2. 在外部計算所有技術指標 (類別完全不需要知道 RSI 怎麼算的)
    df['RSI']=ta.rsi(df['Close'], length=14)

    # 3. 開始繪圖 (如堆積木般直覺)
    chart=BokehChart(title='0050.TW 台灣50 ETF (整合 RSI 指標)')
    
    # 初始化主圖與資料源
    chart.add_candlestick(df)
    
    # 呼叫通用介面新增成交量 (使用 color 欄位決定紅綠)
    chart.add_bar_subplot('Volume', title='成交量', color_col='color', height=100)
    
    # 呼叫通用介面新增 RSI (自訂顏色,並加上 30/70 水平參考線)
    chart.add_line_subplot('RSI', title='RSI(14)', color='purple', height=150, hlines=[30, 70])
    
    # 顯示成果
    chart.show()

此例使用 pandas_ta 套件計算 RSI 指標值, 結果放進 df['RSI'] 欄位, 結果如下 : 




7. 獨立的 Bokeh K 線圖模組 : 

我們可以將上面範例的 BokehChart 類別寫在一個獨立的模組檔案例如 bokeh_chart.py 中, 再用 import 方式匯入 BokehChart 類別來繪製 K 線圖, 這樣主程式檔就會較簡潔了. 

模組檔案 bokeh_chart.py 內容如下 :

# bokeh_chart.py
import pandas as pd
from bokeh.plotting import figure
from bokeh.io import show as show_bokeh
from bokeh.models import ColumnDataSource, HoverTool, CrosshairTool, Span, CustomJSTickFormatter
from bokeh.layouts import column

class BokehChart:
    """高擴充性 Bokeh 繪圖類別,支援動態疊加無限多個副圖"""
    
    def __init__(self, title='K線圖', width=800, main_height=400):
        self.width=width
        self.title=title
        self.main_height=main_height
        self.figures=[]
        self.source=None
        self.date_map={}
        self.fig_k=None 

    def _add_crosshair(self, fig):
        """加入虛線十字游標"""
        w_span=Span(dimension='width', line_dash='dashed', line_color="gray", line_alpha=0.6, line_width=1)
        h_span=Span(dimension='height', line_dash='dashed', line_color="gray", line_alpha=0.6, line_width=1)
        fig.add_tools(CrosshairTool(overlay=[w_span, h_span]))

    def add_candlestick(self, df, date_col='Date', open_col='Open', high_col='High', low_col='Low', close_col='Close'):
        """初始化主圖與共用資料源"""
        data=df.copy()
        # 建立連續整數序列與漲跌顏色
        data['seq']=range(len(data))
        data['color']=['#ff0000' if c >= o else '#00aa00' for o, c in zip(data[open_col], data[close_col])]
        # 製作日期對照表
        self.date_map={}
        prev_year=None
        for i, d in enumerate(data[date_col]):
            label=f"{d.strftime('%b')} {d.day}"
            if i == 0 or d.year != prev_year:
                label += f"\n{d.year}"
            self.date_map[str(i)]=label
            prev_year=d.year
        self.source=ColumnDataSource(data)
        self.fig_k=figure(title=self.title, width=self.width, height=self.main_height, tools='xpan, xwheel_zoom, box_zoom, reset, save')
        self.fig_k.grid.grid_line_alpha=0.3
        self.fig_k.segment('seq', high_col, 'seq', low_col, color="black", source=self.source)
        self.fig_k.vbar('seq', 0.8, open_col, close_col, fill_color='color', line_color='black', source=self.source)
        hover=HoverTool(
            tooltips=[("日期", f"@{date_col}{{%F}}"), ("開盤", f"@{open_col}{{0.00}}"), ("收盤", f"@{close_col}{{0.00}}")],
            formatters={f'@{date_col}': 'datetime'}, mode='vline'
            )
        self.fig_k.add_tools(hover)
        self._add_crosshair(self.fig_k)
        self.figures.append(self.fig_k)

    def add_bar_subplot(self, col_name, title="", color_col=None, color='blue', height=150, on_fig=None, y_min_zero=False):
        """新增柱狀圖副圖 (支援傳入畫布疊加到現有畫布)"""
        # 如果有指定 on_fig,就用現成的畫布;否則建立新畫布
        if on_fig:
            fig=on_fig
        else:
            fig=figure(width=self.width, height=height, x_range=self.fig_k.x_range, tools='xpan, xwheel_zoom, reset')
            fig.grid.grid_line_alpha=0.3
            self.figures.append(fig) # 只有新畫布才需要加入清單
            self._add_crosshair(fig) # 只有新畫布才需要加十字線
        # 只有成交量這類數值絕對為正的圖表,才需要強制 Y 軸從 0 開始
        if y_min_zero:
            fig.y_range.start=0
        fill_c=color_col if color_col else color
        fig.vbar('seq', 0.8, top=col_name, fill_color=fill_c, line_color='black', source=self.source)
        # 專屬 Hover
        hover=HoverTool(tooltips=[("日期", "@Date{%F}"), (title or col_name, f"@{col_name}{{0.00}}")], formatters={'@Date': 'datetime'}, mode='vline')
        fig.add_tools(hover)
        return fig # 回傳畫布讓後續的線可以畫在它上面

    def add_line_subplot(self, col_name, title="", color='blue', height=150, hlines=None, on_fig=None):
        """新增折線圖副圖 (支援疊加到現有畫布)"""
        # 如果有指定 on_fig,就用現成的畫布;否則建立新畫布
        if on_fig:
            fig = on_fig
        else:
            fig = figure(width=self.width, height=height, x_range=self.fig_k.x_range, tools='xpan, xwheel_zoom, reset')
            fig.grid.grid_line_alpha = 0.3
            self.figures.append(fig)
            self._add_crosshair(fig)
        fig.line('seq', col_name, color=color, line_width=1.5, source=self.source)
        if hlines:
            for y_val in hlines:
                line = Span(location=y_val, dimension='width', line_color='gray', line_dash='dashed', line_alpha=0.5)
                fig.add_layout(line)
        hover = HoverTool(tooltips=[("日期", "@Date{%F}"), (title or col_name, f"@{col_name}{{0.00}}")], formatters={'@Date': 'datetime'}, mode='vline')
        fig.add_tools(hover)
        return fig # 回傳畫布讓後續的線可以畫在它上面

    def show(self):
        """渲染圖表"""
        for i, fig in enumerate(self.figures):
            if i < len(self.figures) - 1:
                fig.xaxis.visible=False
            else:
                js_code="""
                    var idx=Math.round(tick).toString();
                    if (date_map[idx] !== undefined) {
                        return date_map[idx];
                    } else {
                        return "";
                    }
                """
                fig.xaxis.formatter=CustomJSTickFormatter(code=js_code, args={'date_map': self.date_map})
                fig.xaxis.major_label_orientation=0
                fig.xaxis.major_label_standoff=15
        layout=column(*self.figures)
        show_bokeh(layout)

欲繪製 K 線圖+成交量+技術指標時, 主程式檔只要先從 bokeh_chart.py 匯入 BokehChart 類別並建立 BokehChart 物件後, 呼叫 add_candlestick() 繪製主圖 (K 線圖), 呼叫 add_bar_subplot() 或 add_line_subplot() 添加副圖 (成交量 & 技術指標), 下面範例添加了 RSI 與 MACD 副圖 :

# bokeh_candlestick_class_4.py
import yfinance as yf
import pandas as pd
import pandas_ta as ta
from bokeh_chart import BokehChart

if __name__ == "__main__":
    # 1. 獲取資料
    df=yf.download('0050.tw', start='2025-10-01', end='2026-04-30', auto_adjust=True)
    df.columns=df.columns.map(lambda x: x[0])
    df=df.reset_index()

    # 2. 計算技術指標
    # RSI
    df['RSI']=ta.rsi(df['Close'], length=14)
    # MACD (將計算結果合併回原本的 df)
    macd_df=ta.macd(df['Close'])
    df=pd.concat([df, macd_df], axis=1)
    # 計算 MACD 柱狀圖的顏色 (大於等於 0 紅色,小於 0 綠色)
    df['macd_color']=['#ff0000' if m >= 0 else '#00aa00' for m in df['MACDh_12_26_9']]

    # 3. 畫圖
    # 畫主圖 : K 線圖
    chart=BokehChart(title='0050.TW 台灣50 ETF (整合 MACD)')
    chart.add_candlestick(df)  
    # 畫成交量副圖 (加上 y_min_zero=True,避免長條圖懸空)
    chart.add_bar_subplot('Volume', title='成交量', color_col='color', height=100, y_min_zero=True)
    # ==========================================
    # 繪製 MACD 複合副圖
    # ==========================================
    # 第一步:先畫 MACD 柱狀圖,並用變數 macd_fig 把畫布接住
    macd_fig=chart.add_bar_subplot('MACDh_12_26_9', title='MACD柱', color_col='macd_color', height=150)
    # 第二步:把 MACD 線畫在 macd_fig 身上
    chart.add_line_subplot('MACD_12_26_9', title='MACD線', color='blue', on_fig=macd_fig)
    # 第三步:把 Signal 線也畫在 macd_fig 身上
    chart.add_line_subplot('MACDs_12_26_9', title='Signal線', color='orange', on_fig=macd_fig)
    # ==========================================

    chart.show()

注意, 此處 MACD 指標因為含有快線, 慢線, 與柱狀圖三個圖形, 通常都是疊在一個畫布上, 所以要分成三個步驟把三個圖層疊上去, 首先繪製柱狀圖, 然後將傳回的畫布物件 macd_fig 在後續繪製快線與慢線時傳給 on_fonfig 參數, 這樣三張圖就會疊在同一張畫布上了. 

結果如下 : 




可見將繪圖類別獨立出去後, 主程式結構就清爽易讀多了. 


Python 學習筆記 : 用 bokeh 繪製 K 線圖 (一)

Bokeh 不像 Plotly 那樣有內建專門繪製 K 線圖的 CandleStick 類別, 它需透過 bokeh.models 模組中的 Segment 物件 (用來繪製上下影線) 與 Vbar 物件 (用來繪製實體紅黑棒) 來達成, 方法雖然較低階, 但卻比 Plotly 擁有更高的自由度與效能, 尤其是要在瀏覽器中渲染數萬根 K 線時, Bokeh 的 WebGL 渲染模式通常比 Plotly 更為輕快, 且保持流暢的縮放和平移效果. 

本系列全部測試文章索引參考 :


首先匯入繪圖用的函式 figure() 與 show() : 

>>> from bokeh.plotting import figure, show     

然後從元件模組 bokeh.models 中匯入匯入用來繪製 K 棒的 ColumnDataSource 類別, 以及繪製互動效果的懸停工具類別 HoverTool : 
  
>>> from bokeh.models import ColumnDataSource, HoverTool  

接著匯入 Pandas 來處理 OHLC 資料 :

>>> import pandas as pd 
>>> data={
    'date': pd.to_datetime(['2026-05-01', '2026-05-02', '2026-05-03', '2026-05-04']),
    'open':  [100, 110, 105, 120],
    'high':  [115, 120, 110, 130],
    'low':   [95, 105, 100, 115],
    'close': [110, 105, 108, 125]
    }
>>> df=pd.DataFrame(data)   

在 df 中添加一個 color 欄位來記錄漲跌顏色 (紅漲綠跌) :

>>> df['color']=['#ff0000' if c >= o else '#00aa00' for o, c in zip(df.open, df.close)]   

呼叫 ColumnDataSource 類別的建構式 ColumnDataSource() 並傳入 df 建立 ColumnDataSource 物件 : 

>>> source=ColumnDataSource(df)   
>>> type(source)   
<class 'bokeh.models.sources.ColumnDataSource'>  

呼叫 figure() 函式建立畫布物件 : 

>>> fig=figure(  
    x_axis_type='datetime', 
    title='Bokeh K線圖', 
    width=800,
    height=400
    )

呼叫 segment() 函式繪製上下影線 :

>>> fig.segment('date', 'high', 'date', 'low', color='black', source=source)    
GlyphRenderer(id='p1138', ...)   

呼叫 Figure 物件的 vbar() 方法繪製 K 棒 : 

>>> fig.vbar('date', pd.Timedelta(hours=12), 'open', 'close', 
       fill_color='color', line_color='black', source=source)  
GlyphRenderer(id='p1147', ...)

呼叫 HoverTool 類別的建構式建立懸停工具物件 : 

>>> hover=HoverTool(
    tooltips=[
        ("日期", "@date{%F}"),
        ("開盤", "@open"),
        ("收盤", "@close"),
        ("最高", "@high"),
        ("最低", "@low")
        ],
    formatters={'@date': 'datetime'}
    )

呼叫 Figure 物件的 add_tool() 方法將懸停工具物件加入畫布中 :

>>> fig.add_tools(hover)

這樣便可呼叫 show() 函式來顯示畫布了 :

>>> show(fig)   

Bokeh 會自動開啟瀏覽器顯示網頁來呈現畫布內容 :




完整程式碼如下 : 

# bokeh_candlestick_1.py
import pandas as pd
from bokeh.plotting import figure, show
from bokeh.models import ColumnDataSource, HoverTool

# OHLC 資料 :
data={
    'date': pd.to_datetime(['2026-05-01', '2026-05-02', '2026-05-03', '2026-05-04']),
    'open':  [100, 110, 105, 120],
    'high':  [115, 120, 110, 130],
    'low':   [95, 105, 100, 115],
    'close': [110, 105, 108, 125]
    }
df=pd.DataFrame(data)

# 定義漲跌顏色
df['color']=['#ff0000' if c >= o else '#00aa00' for o, c in zip(df.open, df.close)]
source=ColumnDataSource(df)

# 建立畫布
fig=figure(
    x_axis_type='datetime', 
    title='Bokeh K線圖', 
    width=800,
    height=400,
    #tools='pan, wheel_zoom, box_zoom, reset, save'
    )

# 繪製上下影線 (Segment 物件)
fig.segment('date', 'high', 'date', 'low', color="black", source=source)

# 繪製 K 棒 (Vbar 物件)
# width 設定為 12 小時 (以毫秒計算) 以確保條形之間有間隔
fig.vbar('date', pd.Timedelta(hours=12), 'open', 'close', 
       fill_color='color', line_color='black', source=source)

# 加入互動式懸停工具 (HoverTool)
hover=HoverTool(
    tooltips=[
        ("日期", "@date{%F}"),
        ("開盤", "@open"),
        ("收盤", "@close"),
        ("最高", "@high"),
        ("最低", "@low")
        ],
    formatters={'@date': 'datetime'}
    )
fig.add_tools(hover)

# 輸出與顯示
#output_file("candlestick.html")
show(fig)


1. 繪製從 yfinance 下載的實盤 K 線圖 : 

下面範例改從 yfinance 抓取實盤 OHLC 資料來繪製 K 線圖, 程式碼如下 :

# bokeh_candlestick_2.py
import yfinance as yf
import pandas as pd
from bokeh.plotting import figure, show
from bokeh.models import ColumnDataSource, HoverTool

# 1. 下載真實 OHLCV 資料
df=yf.download('0050.tw', start='2026-03-01', end='2026-04-30', auto_adjust=True)
# 攤平 yfinance 最新版本回傳的 MultiIndex 欄位
df.columns=df.columns.map(lambda x: x[0])
# yfinance 的日期會被設為索引, 重設為一般欄位 以利 ColumnDataSource 讀取
df=df.reset_index()  

# 2. 定義漲跌顏色 (紅漲綠跌)
df['color']=['#ff0000' if c >= o else '#00aa00' for o, c in zip(df['Open'], df['Close'])]

# 3. 建立資料來源
source=ColumnDataSource(df)

# 4. 建立畫布
fig=figure(
    x_axis_type='datetime', 
    title='0050.TW 台灣50 ETF K線圖 (2026-03 ~ 2026-04)', 
    width=800,
    height=400,
    # 加入十字游標 (crosshair) 與鎖定 X 軸縮放(xwheel_zoom) 以利看盤
    tools='xpan, xwheel_zoom, box_zoom, crosshair, reset, save'
    )
# 讓網格線淡一點,視覺更聚焦在 K 棒上
fig.grid.grid_line_alpha=0.3

# 5. 繪製上下影線
fig.segment('Date', 'High', 'Date', 'Low', color="black", source=source)

# 6. 繪製 K 棒
# Bokeh 處理時間軸時寬度用毫秒計算最為精準與穩定。12小時=12 * 60 * 60 * 1000 毫秒
width_ms=12 * 60 * 60 * 1000
fig.vbar('Date', width_ms, 'Open', 'Close', 
         fill_color='color', line_color='black', source=source)

# 7. 加入互動式懸停工具 (HoverTool)
# 加上了數值格式化 {0.00} 讓小數點對齊並順手加入成交量 (Volume)
hover=HoverTool(
    tooltips=[
        ("日期", "@Date{%F}"),
        ("開盤", "@Open{0.00}"),
        ("收盤", "@Close{0.00}"),
        ("最高", "@High{0.00}"),
        ("最低", "@Low{0.00}"),
        ("成交量", "@Volume{0,0}")
        ],
    formatters={'@Date': 'datetime'}
    )
fig.add_tools(hover)

# 8. 輸出與顯示
show(fig)

此例的關鍵之處是 yf 下載的 df 日期欄位會被設為索引, 所以必須呼叫 df.reset_index() 將日期欄位重設為一般欄位, 這樣 ColumnDataSource 物件才能讀得到, 另外, 懸停工具則添加了 yf 取得的成交量資訊, 結果如下 :




可見十字線會隨著滑鼠在畫布中移動, 當指向 K 棒時會出現懸停提示, 顯示 OHLCV 數據. 


2. 客製化的虛線十字輔助線 :   

預設的十字線是實線, 如果要改為虛線須取消 tools 參數中的 crosshair, 然後匯入 CrosshairTool, Span 這兩個類別 :

from bokeh.models import ColumnDataSource, HoverTool, CrosshairTool, Span 

其中 CrosshairTool 用來建立客製化十字輔助線, 而 Span 則用來建立具有 line_dash 屬性的 Span (跨距線) 物件, 然後把這些線覆蓋 (overlay) 到 CrosshairTool 物件上, 程式改寫如下 :

# bokeh_candlestick_3.py
import yfinance as yf
import pandas as pd
from bokeh.plotting import figure, show
from bokeh.models import ColumnDataSource, HoverTool, CrosshairTool, Span

# 1. 下載真實 OHLCV 資料
df=yf.download('0050.tw', start='2026-03-01', end='2026-04-30', auto_adjust=True)
# 攤平 yfinance 最新版本回傳的 MultiIndex 欄位
df.columns=df.columns.map(lambda x: x[0])
# yfinance 的日期會被設為索引, 重設為一般欄位 以利 ColumnDataSource 讀取
df=df.reset_index()

# 2. 定義漲跌顏色 (紅漲綠跌)
df['color']=['#ff0000' if c >= o else '#00aa00' for o, c in zip(df['Open'], df['Close'])]

# 3. 建立資料來源
source=ColumnDataSource(df)

# 4. 建立畫布
fig=figure(
    x_axis_type='datetime', 
    title='0050.TW 台灣50 ETF K線圖 (2026-03 ~ 2026-04)', 
    width=800,
    height=400,
    # 鎖定 X 軸縮放(xwheel_zoom) 以利看盤 (取消 crosshair)
    tools='xpan, xwheel_zoom, box_zoom, , reset, save'
    )
# 讓網格線淡一點,視覺更聚焦在 K 棒上
fig.grid.grid_line_alpha=0.3

# 5. 繪製上下影線
fig.segment('Date', 'High', 'Date', 'Low', color="black", source=source)

# 6. 繪製 K 棒
# Bokeh 處理時間軸時寬度用毫秒計算最為精準與穩定。12小時=12 * 60 * 60 * 1000 毫秒
width_ms=12 * 60 * 60 * 1000
fig.vbar('Date', width_ms, 'Open', 'Close', 
         fill_color='color', line_color='black', source=source)

# 7. 加入互動式懸停工具 (HoverTool)
# 加上了數值格式化 {0.00} 讓小數點對齊並順手加入成交量 (Volume)
hover=HoverTool(
    tooltips=[
        ("日期", "@Date{%F}"),
        ("開盤", "@Open{0.00}"),
        ("收盤", "@Close{0.00}"),
        ("最高", "@High{0.00}"),
        ("最低", "@Low{0.00}"),
        ("成交量", "@Volume{0,0}")
        ],
    formatters={'@Date': 'datetime'}
    )
fig.add_tools(hover)

# 8. 加入虛線十字游標 (建立水平與垂直方向的 Span 物件, 設定為 dashed 虛線)
w_span=Span(dimension="width", line_dash="dashed", line_color="gray", line_alpha=0.6, line_width=1)
h_span=Span(dimension="height", line_dash="dashed", line_color="gray", line_alpha=0.6, line_width=1)
fig.add_tools(CrosshairTool(overlay=[w_span, h_span]))

# 9. 輸出與顯示
show(fig)

結果如下 :




可見十字輔助線已經變成虛線了. 


3. 消除非交日空缺 :   

與 Plotly 一樣, Bokeh 在繪製 K 線圖時會自動把 yfinance 下載的 OHLCV 資料 X 軸的日期序列補齊, 使得 K 棒在非交易日無 K 棒出現不連續的空缺. Plotly 的 Figure 物件有內建的 rangebreaks 參數可用來隱藏這些空缺, 但 Bokeh 並沒有. 

在 Bokeh 中可以用 Index Mapping 來解決此問題. 概念是不要把 X 軸當成時間, 而是把它當成 連續的整數數列 (0, 1, 2, 3...), 然後透過一個標籤轉換器騙過使用者的眼睛, 讓 X 軸的數字顯示成對應的日期. 具體而言是透過 CustomJSTickFormatter 來實現, 但如果要維持原本 Bokeh 的 X 軸標籤顯示風格, 必須修改 Javascript 程式碼, 例如 :

# bokeh_candlestick_4.py
import yfinance as yf
import pandas as pd
from bokeh.plotting import figure, show
from bokeh.models import ColumnDataSource, HoverTool, CrosshairTool, Span, CustomJSTickFormatter

# 1. 下載真實 OHLCV 資料
df=yf.download('0050.tw', start='2026-03-01', end='2026-04-30', auto_adjust=True)
df.columns=df.columns.map(lambda x: x[0])
df=df.reset_index()

# 2. 建立連續整數序列
df['seq']=range(len(df))

# 用 Python 預先將日期轉為 Bokeh 風格的 "Apr 1" 格式
date_map={}
prev_year=None
for i, d in enumerate(df['Date']):
    # 取得月份縮寫與日期 (例如 "Apr 1")
    # strftime('%b') 會輸出英文月份縮寫,d.day 會輸出不補零的日期
    label=f"{d.strftime('%b')} {d.day}"
    # 如果是圖表的第一根 K 棒,或者是跨年的第一根 K 棒,在底下加上年份
    # 使用 \n 來讓 Bokeh 產生換行效果
    if i == 0 or d.year != prev_year:
        label += f"\n{d.year}"
    date_map[str(i)]=label
    prev_year=d.year

# 3. 定義漲跌顏色
df['color']=['#ff0000' if c >= o else '#00aa00' for o, c in zip(df['Open'], df['Close'])]

# 4. 建立資料來源
source=ColumnDataSource(df)

# 5. 建立畫布
fig=figure(
    title='0050.TW 台灣50 ETF K線圖 (完美日期標籤版)', 
    width=800,
    height=400,
    tools='xpan, xwheel_zoom, box_zoom, reset, save'
    )
fig.grid.grid_line_alpha=0.3

# 6. 使用 JavaScript 取出排版好的字串
js_code="""
    var idx=Math.round(tick).toString();
    if (date_map[idx] !== undefined) {
        return date_map[idx];
    } else {
        return "";
    }
"""
fig.xaxis.formatter=CustomJSTickFormatter(code=js_code, args={'date_map': date_map})
# 把傾斜拿掉 (設為 0) 讓標籤恢復水平,這樣帶有 \n 年份的文字才會對齊
fig.xaxis.major_label_orientation=0
# 拉開標籤與 X 軸的間距 (預設通常是 5)
fig.xaxis.major_label_standoff=15

# 7. 繪製上下影線 
fig.segment('seq', 'High', 'seq', 'Low', color="black", source=source)

# 8. 繪製 K 棒
width_val=0.8
fig.vbar('seq', width_val, 'Open', 'Close', 
         fill_color='color', line_color='black', source=source)

# 9. 加入互動式懸停工具 
hover=HoverTool(
    tooltips=[
        ("日期", "@Date{%F}"),
        ("開盤", "@Open{0.00}"),
        ("收盤", "@Close{0.00}"),
        ("最高", "@High{0.00}"),
        ("最低", "@Low{0.00}"),
        ("成交量", "@Volume{0,0}")
        ],
    formatters={'@Date': 'datetime'}
    )
fig.add_tools(hover)

# 10. 加入虛線十字游標 
w_span=Span(dimension="width", line_dash="dashed", line_color="gray", line_alpha=0.6, line_width=1)
h_span=Span(dimension="height", line_dash="dashed", line_color="gray", line_alpha=0.6, line_width=1)
fig.add_tools(CrosshairTool(overlay=[w_span, h_span]))

# 11. 輸出與顯示
show(fig)

結果如下 :




不過這樣的程式碼看起來就有點複雜了. 

2026年5月2日 星期六

Python 學習筆記 : 利用語言模型計算技術指標 (二)

前一篇測試中, 我們透過串接 LLM 模型 (OpenAI & Gemini) 要求 AI 生成計算技術指標的程式碼在本機執行運算, 毋須使用例如 Ta-Lib 或 ta, pandas-ta 等技術指標函式庫. 本篇旨在前一篇的基礎上取得 AI 生成的程式碼後算出技術指標 (MACD) 數據, 並繪製 K 線圖. 

本系列全部測試文章索引參考 : 


關於 MACD 技術指標計算, 參考 : 



1. 用 pandas_ta 套件計算 MACD 指標 : 

MACD 由快線 (DIF), 慢線 (DEA) 和 柱狀圖 (Histogram) 三部分組成, 當快速線向上突破訊號線時為買進訊號; 快速線向下跌破訊號線時為賣出訊號. 柱狀圖用來觀察市場動能強弱, 柱狀圖由負轉正表示多方動能變強; 柱狀圖由正轉負表示空方動能變強. 訊號線則是 MACD 快速線的平滑化版本, 用來確認趨勢是否開始反轉. 

MACD 指標預設參數通常是快線 12 日, 慢線 26 日, 信號線 9 日, 以 pandas_ta 套件來說, 呼叫 df.ta.macd() 或 ta.macd() 即可計算這三組 MACD 指標數值. 注意, 在之前測試中呼叫 df.ta.macd() 時傳入 append=True, 這樣 pandas_ta 會將計算出來的 MACD 指標值自動插入 df 的新增欄位中, 預設欄位名稱依序是 MACD_12_26_9 (MACD 線), MACDh_12_26_9 (MACD 柱狀圖), 與 MACDs_12_26_9 (MACD 信號線), 為了後續繪製 K 線圖方便, 這次不使用 append=True 參數, 而是在 df 手動指定傳回的三個 Series 的欄位名稱 :

df[['MACD_line', 'MACD_histogram', 'MACD_signal']]=df.ta.macd(close='Close') 

程式碼如下 : 
 
# ai_stock_test_5.py
import yfinance as yf
import pandas as pd 
import pandas_ta as ta
from kbar import KBar
          
if __name__ == "__main__":
    df=yf.download('0050.tw', start='2024-11-06', end='2025-01-09', auto_adjust=True)
    df.columns=df.columns.map(lambda x: x[0])
    df[['MACD_line', 'MACD_histogram', 'MACD_signal']]=df.ta.macd(close='Close')
    print(df.tail())
    macd_line_name='MACD_line'       # MACD 線
    macd_signal_name='MACD_signal'   # MACD 訊號線
    macd_histogram_name='MACD_histogram'  # MACD 柱狀圖
    kb=KBar(df)
    histogram_colors=['g' if v >= 0 else 'r' for v in df[macd_histogram_name]]  
    macd_panel=2 
    kb.addplot(
        df[macd_histogram_name],
        panel=macd_panel,
        type='bar',
        color=histogram_colors,
        alpha=0.4,
        ylabel='MACD' # 設定此副圖的 Y 軸標籤
        )
    kb.addplot(
        df[macd_line_name],
        panel=macd_panel, # 必須使用與柱狀圖相同的 Panel
        color='fuchsia', # MACD 線顏色
        width=1.0
        )
    kb.addplot(
        df[macd_signal_name],
        panel=macd_panel, # 必須使用與柱狀圖相同的 Panel
        color='blue',    # 信號線顏色
        width=1.0
        )
    kb.plot(
        volume=True,  # 顯示成交量副圖 (Panel 1)
        mav=(5, 10),  # K 線圖上疊加 5 日和 10 日均線
        title='K 線圖與 MACD 指標'
        )
    
結果如下 :

>>> %Run ai_stock_test_5.py   
[*********************100%***********************]  1 of 1 completed
                Close       High  ...  MACD_histogram  MACD_signal
Date                              ...                             
2025-01-02  46.854988  47.241319  ...       -0.011482     0.202802
2025-01-03  47.325832  47.555217  ...       -0.023311     0.196974
2025-01-06  48.810799  48.847020  ...        0.063469     0.212842
2025-01-07  49.354084  49.740419  ...        0.147264     0.249658
2025-01-08  48.617638  49.100554  ...        0.142791     0.285355

[5 rows x 8 columns]
設定字型為: Microsoft JhengHei
使用指定字型: Microsoft JhengHei
字型候選清單: ['Microsoft JhengHei', 'DejaVu Sans', 'Arial']





2. 串接 OpenAI API 計算 SMA 指標 : 

下面範例是用提示詞要求 LLM 提供 MACD 指標計算函式, 讓本地程式呼叫 exce() 來執行 MACD 計算函式, 最後用 kbar 套件繪製 K 線圖, 提示詞要明確給 LLM 計算 MACD 所需參數, 即快慢線與信號線週期, 另外, 為了後面用 kbar 繪製 K 線圖時取值與運算方便, 還需明確指定 MACD 傳回計算結果時的欄位名稱 :

    計算 MACD 指標, 參數 : fast=12 日, slow=26 日, signal=9 日。
    計算結果的欄位名稱 : MACD_line, MACD_signal, MACD_histogram。
    請注意:務必在 ewm() 函式中設定 min_periods 參數(例如 slow 設為 min_periods=26),
    以確保前期資料不足天數時,算出的結果必須是 NaN。

程式碼如下 : 

# ai_stock_test_6.py
from  openai import OpenAI, APIError 
import yfinance as yf
import pandas as pd
import pandas_ta as ta
from dotenv import dotenv_values
from kbar import KBar

def ask_gpt(
    messages: list[dict[str, str]],
    model: str='gpt-3.5-turbo'
    ) -> str:
    try:
        reply=client.chat.completions.create(
            model=model, 
            messages=messages
            )
        return reply.choices[0].message.content or ''
    except APIError as e:
        return e.message

def ai_helper(df, user_msg):
    role=f'''
        作為一個專業的程式碼生成機器人,
        我需要您的協助來根據特定的用戶需求生成 Python 程式碼。
        為了進行下去,我將提供給您一個遵循格式 {list(df.columns)} 的 DataFrame(df)。
        您的任務是仔細分析用戶的需求並相應地生成 Python 程式碼。
        請注意,您的回應須僅包含代碼本身,並且不應包含任何額外的資訊。
        '''
    # 把 user_msg 加入到 task 的敘述中,讓 AI 知道要算什麼
    task=f'''
        您的任務是開發一個名為 'calculate(df)' 的 Python 函式。
        這個函式應接受一個 DataFrame 作為其參數。確保您僅使用資料集中存在的欄,
        特別是 {list(df.columns)}。        
        用戶的具體運算需求為:【 {user_msg} 】        
        處理後,該函式應返回處理過的 DataFrame。
        您的回應應嚴格包含 'calculate(df)' 函式的 Python 程式碼,
        並排除任何無關的內容。
        '''
    msg=[{"role": "system", "content": role},
         {"role": "user", "content": task}]
    reply_data=ask_gpt(msg)
    # 清理 markdown 語法
    cleaned_code=reply_data.replace("```", "")
    cleaned_code=cleaned_code.replace("python", "")      
    cleaned_code=cleaned_code.strip() # 建議加上 strip() 去除頭尾多餘的空白或換行
    # 傳回程式碼
    return cleaned_code
          
if __name__ == "__main__":
    config=dotenv_values('.env') 
    openai_api_key=config.get('OPENAI_API_KEY')
    client=OpenAI(api_key=openai_api_key)
    df=yf.download('0050.tw', start='2024-11-06', end='2025-01-09', auto_adjust=True)
    df.columns=df.columns.map(lambda x: x[0])
    prompt='''
    計算 MACD 指標, 參數 : fast=12 日, slow=26 日, signal=9 日。
    計算結果的欄位名稱 : MACD_line, MACD_signal, MACD_histogram。
    請注意:務必在 ewm() 函式中設定 min_periods 參數(例如 slow 設為 min_periods=26),
    以確保前期資料不足天數時,算出的結果必須是 NaN。
    '''
    code_str=ai_helper(df, prompt)
    print(code_str)
    exec(code_str)
    df=calculate(df)
    print(df.tail())
    macd_line_name='MACD_line'       # 快速線
    macd_signal_name='MACD_signal'   # 訊號線
    macd_histogram_name='MACD_histogram'  # 柱狀圖
    kb=KBar(df)
    histogram_colors=['g' if v >= 0 else 'r' for v in df[macd_histogram_name]]  
    macd_panel=2 
    kb.addplot(
        df[macd_histogram_name],
        panel=macd_panel,
        type='bar',
        color=histogram_colors,
        alpha=0.4,
        ylabel='MACD' # 設定此副圖的 Y 軸標籤
        )
    kb.addplot(
        df[macd_line_name],
        panel=macd_panel, # 必須使用與柱狀圖相同的 Panel
        color='fuchsia', # MACD 線顏色
        width=1.0
        )
    kb.addplot(
        df[macd_signal_name],
        panel=macd_panel, # 必須使用與柱狀圖相同的 Panel
        color='blue',    # 信號線顏色
        width=1.0
        )
    kb.plot(
        volume=True,  # 顯示成交量副圖 (Panel 1)
        mav=(5, 10),  # K 線圖上疊加 5 日和 10 日均線
        title='K 線圖與 MACD 指標'
        )

執行結果如下 :

>>> %Run ai_stock_test_6.py   
[*********************100%***********************]  1 of 1 completed
def calculate(df):
    fast = 12
    slow = 26
    signal = 9
    
    ema_fast = df['Close'].ewm(span=fast, min_periods=fast).mean()
    ema_slow = df['Close'].ewm(span=slow, min_periods=slow).mean()
    
    macd_line = ema_fast - ema_slow
    signal_line = macd_line.ewm(span=signal, min_periods=signal).mean()
    macd_histogram = macd_line - signal_line
    
    df['MACD_line'] = macd_line
    df['MACD_signal'] = signal_line
    df['MACD_histogram'] = macd_histogram
    
    return df
                Close       High  ...  MACD_signal  MACD_histogram
Date                              ...                             
2025-01-02  46.854992  47.241323  ...     0.237614       -0.050295
2025-01-03  47.325832  47.555217  ...     0.223323       -0.055554
2025-01-06  48.810799  48.847020  ...     0.231812        0.033190
2025-01-07  49.354080  49.740415  ...     0.261899        0.118182
2025-01-08  48.617638  49.100554  ...     0.291621        0.117175

[5 rows x 8 columns]
設定字型為: Microsoft JhengHei
使用指定字型: Microsoft JhengHei
字型候選清單: ['Microsoft JhengHei', 'DejaVu Sans', 'Arial']



但是, 這結果與上面用 pandas_ta 計算出來的有些出入, 圖形大致是一致, 但仔細看 MACD 數據有些誤差, 我詢問 AI 這個 LLM 生成的 calculate() 算法是否正確, 結論是 : 讓 LLM 生成以 Pandas 為基礎的函式, 會與專業金融套件例如 pandas_ta 計算的結果有些出入, 因為它們在底層實作指數移動平均 (EMA) 遞迴公式時, 對第一天的 EMA 該怎麼決定有所分歧, Pandas 會直接拿資料陣列中的第一筆收盤價當作 EMA 的初始種子, 然後從第二天開始套用 EMA 公式; 而 pandas_ta 在計算 12 日 EMA 時會先精準計算前 12 天的簡單移動平均 (SMA)作為第 12 天的 EMA 初始種子, 然後從第 13 天開始才套用 EMA 公式.

在前一篇 SMA 測試不會出現因計算細節分岐產生的錯誤 (因為 SMA 沒有遞迴依賴, 也不需要初始種子), 只要指標名稱裡帶有 "E" (Exponential 指數型, 例如 EMA, MACD) 或者是 "S" / "W" (Smoothed / Weighted 平滑加權型, 例如 RSI, WMA), 在跨套件核對時往往就需要特別去確認底層對第一筆初始值的實作邏輯. 

從以上測試得到一個教訓 : 金融計算應該用專業套件自行編碼, 不要靠提示詞叫 AI 生成, 如果直接執行這些未經審查的程式碼, 那麼計算出來的金融資料可能是錯誤的. 如果用 pandas_ta 一行指令就能搞定的事, 為何要用附帶許多注意事項的提示詞來要求 AI 生成可能計算出錯誤結果的程式碼? 本系列測試將到此為止, 量化投資程式在計算金融資訊部分還是要用專業套件去計算, 不要透過 AI. 

2026年5月1日 星期五

Python 學習筆記 : 用 plotly 繪製 K 線圖 (二)

在前一篇測試中已對 Plotly 的 K 線圖類別 plotly.graph_objects.CandleStick 的用法有了基本了解, 本篇旨在利用 Figure 物件的 add_trace() 方法在畫布上添加子圖 (例如成交量或 RSI 等技術指標), 同時透過 plotly.subplots.make_subplot() 函式來規劃畫布上的子圖佈局. 

本系列測試文章索引參考 :



4. 添加成交量子圖 : 

要在畫布上繪製子圖有兩種方式, 第一種是使用較低階的絕對領域 (Domain) 劃分法, 此方法須手動切割 Y 軸空間自行布局, 好處是可以客製化布局, 缺點是維護難度較高, 調整彈性較差, 在 "最強 AI 投資分析" 這本書的第四章就是採用此方法, 參考 :


第二種方式是使用 plotly.subplots.make_subplot() 函式, 這是採用網格系統來進行排版的高階 API, 只要指定子圖的列 (row) 與欄 (col), Plotly 會在底層自動計算所有圖表的高度, 對齊與間距, 是一種較簡易的低耦合設計. 

首先匯入子圖布局函式 make_subplots() :

from plotly.subplots import make_subplots 

make_subplots() 的參數說明如下表 :


 參數名稱  說明
 rows  子圖的列數(預設為 1)
 cols  子圖的欄數(預設為 1)
 shared_xaxes  是否共用 X 軸(True / False / 'all' / 'rows' / 'columns')
 shared_yaxes  是否共用 Y 軸(True / False / 'all' / 'rows' / 'columns')
 start_cell  子圖起始位置('top-left' 或 'bottom-left')
 subplot_titles  每個子圖的標題(list)
 specs  自訂每個子圖的型態與配置(例如 type='xy', 'domain', 'scene' 等)
 row_heights  各列高度比例(list,例如 [0.7, 0.3])
 column_widths  各欄寬度比例(list,例如 [0.6, 0.4])
 horizontal_spacing  子圖之間的水平間距(0~1)
 vertical_spacing  子圖之間的垂直間距(0~1)
 insets  設定內嵌子圖(inset charts)的位置與大小
 column_titles  每一欄的標題(list)
 row_titles  每一列的標題(list)
 x_title  整體 X 軸標題
 y_title  整體 Y 軸標題
 figure  將子圖加入既有的 Figure 物件


其中最常用的參數是 rows, cols, shared_xaxes, vertical_spacing, 與 row_heights 等. 

下列程式將畫布做 2 列 1 欄布局, 第一列高度占 70% 放 K 線圖 trace; 第二列高度占 30% 放成交量長條圖 (有做漲紅跌綠顏色設定) :

# plotly_candlestick_5.py
import yfinance as yf
import plotly.graph_objects as go
import pandas as pd
from plotly.subplots import make_subplots

if __name__ == "__main__":
    df=yf.download('0050.tw', start='2026-03-01', end='2026-04-30', auto_adjust=True)
    df.columns=df.columns.map(lambda x: x[0])

    # === 建立子圖 (2 rows: 上列放 K 線圖,下列放成交量) ===
    fig=make_subplots(
        rows=2,
        cols=1,
        shared_xaxes=True,
        vertical_spacing=0.03,
        row_heights=[0.7, 0.3]   # 列高度占比
        )

    # === K 線圖 ===
    price=go.Candlestick(
        x=df.index,
        open=df['Open'],
        high=df['High'],
        low=df['Low'],
        close=df['Close'],
        increasing_line_color='red',
        decreasing_line_color='green',
        name='Price'
        )
    fig.add_trace(price, row=1, col=1)   # K 線圖放 row 1

    # === 成交量顏色(漲紅跌綠) ===
    colors=['red' if c >= o else 'green' for c, o in zip(df['Close'], df['Open'])]
    volume=go.Bar(
        x=df.index,
        y=df['Volume'],
        marker_color=colors,
        name='Volume'
        )
    fig.add_trace(volume, row=2, col=1)  # 成交量放 row 2

    # === 移除非交易日 ===
    date_range=pd.date_range(start=df.index.min(), end=df.index.max())
    breaks=date_range[~date_range.isin(df.index)]
    breaks_list=breaks.tolist()
    fig.update_xaxes(
        rangebreaks=[{'values': breaks_list}],
        showspikes=True,
        spikethickness=1,
        spikecolor='blue',
        spikedash='dot',
        spikemode='across'
        )

    # === Y 軸 spike(兩個子圖都套用)===
    fig.update_yaxes(
        showspikes=True,
        spikethickness=1,
        spikecolor='blue',
        spikedash='dot',
        spikemode='across'
        )

    # === Layout ===
    fig.update_layout(
        title='台灣五十 (0050) K線 + 成交量',
        width=800,
        height=700,
        xaxis_rangeslider_visible=False,
        hovermode='x unified'   # 很重要:跨子圖同步 hover
        )

    fig.show()

注意, 之前沒有子圖時是呼叫 fig=go.Figure() 建立 Figure 物件, 但此處是用 make_subplots() 來建立. 其次, 有子圖的情況時, hovermode 參數務必要設為 'x unified', 否則會有多個 tooltip 分散, 以及子圖之間不同步問題 (無子圖時因所有 trace 都在同一個座標系故不會有這些問題). 

結果如下 :






5. 添加技術指標子圖 : 

接下來要在成交量下面添加 MACD 指標, 使用 pandas-ta 套件計算, 參考 :


這樣畫布上總共有三個子圖 : K 線圖, 成交量, 與 MACD. 

程式碼如下 :

# plotly_candlestick_6.py
import yfinance as yf
import plotly.graph_objects as go
import pandas as pd
import pandas_ta as ta
from plotly.subplots import make_subplots

if __name__ == "__main__":
    # === 下載資料 ===
    df=yf.download('0050.tw', start='2026-03-01', end='2026-04-30', auto_adjust=True)
    df.columns=df.columns.map(lambda x: x[0])

    # === 計算 MACD(pandas-ta)===
    macd=ta.macd(df['Close'], fast=12, slow=26, signal=9)

    # pandas-ta 會回傳三欄 :contentReference[oaicite:0]{index=0}
    df['MACD']=macd['MACD_12_26_9']
    df['MACDs']=macd['MACDs_12_26_9']   # signal
    df['MACDh']=macd['MACDh_12_26_9']   # histogram

    # === 建立子圖 ===
    fig=make_subplots(
        rows=3,
        cols=1,
        shared_xaxes=True,
        vertical_spacing=0.02,
        row_heights=[0.5, 0.2, 0.3]
        )

    # =====================
    # 1. K線圖
    # =====================
    fig.add_trace(
        go.Candlestick(
            x=df.index,
            open=df['Open'],
            high=df['High'],
            low=df['Low'],
            close=df['Close'],
            increasing_line_color='red',
            decreasing_line_color='green',
            name='Price'
            ),
        row=1, col=1
        )

    # =====================
    # 2. 成交量
    # =====================
    vol_colors=['red' if c >= o else 'green'
                  for c, o in zip(df['Close'], df['Open'])]
    fig.add_trace(
        go.Bar(
            x=df.index,
            y=df['Volume'],
            marker_color=vol_colors,
            name='Volume'
            ),
        row=2, col=1
        )

    # =====================
    # 3. MACD
    # =====================
    # Histogram(紅綠柱)
    macd_colors=['red' if v >= 0 else 'green' for v in df['MACDh']]
    fig.add_trace(
        go.Bar(
            x=df.index,
            y=df['MACDh'],
            marker_color=macd_colors,
            name='MACD Hist'
            ),
        row=3, col=1
        )

    # MACD 線
    fig.add_trace(
        go.Scatter(
            x=df.index,
            y=df['MACD'],
            line=dict(color='blue'),
            name='MACD'
            ),
        row=3, col=1
        )
    # Signal 線
    fig.add_trace(
        go.Scatter(
            x=df.index,
            y=df['MACDs'],
            line=dict(color='orange'),
            name='Signal'
            ),
        row=3, col=1
        )

    # =====================
    # 移除非交易日
    # =====================
    date_range=pd.date_range(start=df.index.min(), end=df.index.max())
    breaks=date_range[~date_range.isin(df.index)]
    fig.update_xaxes(
        rangebreaks=[{'values': breaks.tolist()}],
        showspikes=True,
        spikethickness=1,
        spikecolor='blue',
        spikedash='dot',
        spikemode='across'
        )
    fig.update_yaxes(
        showspikes=True,
        spikethickness=1,
        spikecolor='blue',
        spikedash='dot',
        spikemode='across'
        )

    # =====================
    # Layout
    # =====================
    fig.update_layout(
        title='0050 台灣五十 - K線 + 成交量 + MACD',
        width=900,
        height=800,
        xaxis_rangeslider_visible=False,
        hovermode='x unified'
        )

    fig.show()

注意, MACD 的紅綠柱, MACD 線與信號線都是要畫在 row=3 子圖疊在一起, 結果如下 :