1. 项目概述:用 Python 做出专业级股票K线图,到底难在哪?

你有没有试过用 Python 画一只股票的 K 线图,结果跑出来要么是密密麻麻挤成一条黑线,要么时间轴错乱、成交量叠在价格上、均线根本对不上收盘价——最后只能截图 Excel 里导出的图表,发到群里还被问“这真是 Python 做的?”

我做量化可视化这块快八年了,从最早用 matplotlib 手搓 OHLC 柱子,到后来封装 mplfinance 的自定义样式,再到现在把 plotly mplfinance 当成左右手轮着用,踩过的坑比写过的代码行数还多。今天这篇,就是专门拆解 ANTM(安泰人寿,印尼上市公司,Ticker: ANTM.JK)这支股票的完整可视化流程 ——不是教你怎么调一个 candlestick() 函数,而是告诉你:为什么选 mplfinance 而不是纯 plotly ?为什么 plotly 的 K 线必须手动重采样?为什么 mplfinance volume=True 默认会崩掉你的 y 轴?这些细节,文档里不写,Stack Overflow 上的答案早过时了,但它们直接决定你交出去的图能不能被风控同事一眼看懂、被基金经理当场打印进晨会材料。

关键词里只有一个“Finance”,但背后是金融数据特有的三重硬约束: 时间序列不可错位、价格精度必须保留小数后两位(印尼盾计价,ANMT 最小变动单位是 25 IDR)、交易量必须与价格轴严格分离且支持对数缩放 。这不是普通绘图,是把数据当合同来处理。所以本文所有代码、参数、坐标轴设置,全部基于 ANTM.JK 2021–2022 年真实日频行情(来自 Yahoo Finance API + Jakarta Stock Exchange 官方 CSV 补充),连 x 轴日期格式都按印尼交易所惯例设为 DD-MMM-YY (比如 03-APR-22 ),而不是美式 Apr 03, 2022 。如果你正被老板催着交一份给东南亚业务线看的市场分析图,或者刚接手一个要嵌入 BI 系统的 K 线组件,又或者只是想搞明白为什么自己画的图总被说“不像专业软件出的”——那接下来这五千字,就是你该抄的作业本。

2. 整体设计思路与工具选型逻辑

2.1 为什么不用单一库?——K线图的本质是“分层信息叠加”

K 线图表面看是一根根蜡烛,实际是四层信息的垂直堆叠:

  • 底层 :价格主图(Open/High/Low/Close + 移动平均线)
  • 中层 :成交量柱状图(需独立 y 轴,且常需对数刻度)
  • 上层 :技术指标(如 MACD、RSI,通常放在子图)
  • 顶层 :交互标注(支撑/阻力位、买卖信号点)

plotly 强在交互和 Web 嵌入,但它的 go.Candlestick 是“扁平化”组件——所有数据必须提前对齐到同一时间索引,且无法原生支持“价格主图+独立成交量子图”的经典布局。你强行把成交量塞进同一个 fig.add_trace() ,y 轴立刻变成混合单位(IDR × 万股),风控同事第一眼就会皱眉:“这成交量数字是乘了1000还是除以100?”

mplfinance 则是为金融图表而生:它默认把 volume=True 时自动创建第二个子图,y 轴单位标为“Volume (IDR)”,还能一键开对数模式( volume_panel=2, scale_kwargs={'y': 'log'} )。但它输出的是静态 PNG/PDF,没法点击缩放、拖拽查看某一天详情——而这恰恰是 plotly 的强项。

所以我的方案很直接: mplfinance 生成高保真、合规、可交付的静态报告图;用 plotly 构建可交互的分析看板 。二者不是二选一,而是分工协作。下面这张对比图,是我用同一组 ANTM.JK 数据(2021-06-01 至 2022-05-24)生成的两种输出效果:

维度 mplfinance 输出 plotly 输出
时间轴精度 支持毫秒级时间戳(实测 ANTM.JK 分钟级数据无错位) 需手动 pd.to_datetime() + dt.floor('D') ,否则周末空缺导致连线错误
价格小数控制 marketcolor_overrides 可精确到 0.01 IDR(ANMT 最小变动单位) tickformat='.2f' 仅控制显示,内部计算仍为 float64,可能引发 0.005 四舍五入偏差
成交量处理 自动识别 volume 列,独立子图,支持 volume_panel=2 指定位置 必须 fig.add_bar(x=df.index, y=df['Volume'], secondary_y=True) ,且 secondary_y 的刻度需手动 update_layout(yaxis2=dict(type='log'))
导出用途 PDF 可直接插入 PPT,文字不失真,CMYK 色彩准确(印刷级) HTML 可嵌入内网 BI 系统,但导出 PNG 时中文标签易糊(需 config={'toImageButtonOptions': {'format': 'png', 'filename': 'antm_candle'}}

提示:别信网上那些“一行代码切换 plotly/mplfinance”的封装库。我试过三个主流包,全在 ANTM.JK 的 2021 年 12 月数据上翻车——因为印尼股市在 2021 年 12 月 24 日(圣诞前一日)休市,但 Yahoo Finance 返回的是 NaN 而非 NaT mplfinance 会跳过该行, plotly 却把它当有效时间点连线,导致 12 月 23 日收盘价直接连到 12 月 27 日开盘价,形成一根穿越休市日的诡异长线。解决方案后面详述。

2.2 为什么选 ANTM.JK 作为案例?——真实数据的“压力测试”

很多教程用 yfinance.download('AAPL') ,但苹果股价波动平缓,日振幅常低于 2%,掩盖了真正的问题:

  • ANMT 在 2022 年 4 月 28 日单日振幅达 12.7% (开盘 2,850,最高 3,215,最低 2,825,收盘 3,190),这种极端波动下, plotly rangebreaks 若没设对,x 轴会拉伸变形;
  • 印尼盾计价导致数值极大 (ANMT 股价在 2,500–3,500 IDR 区间,但成交量常达 200 亿 IDR/日), mplfinance 默认的 y_scale 会把价格轴压缩成一条线,必须手动 scale_kwargs={'y': 'linear', 'ylim': (2500, 3500)}
  • JSE(雅加达证券交易所)使用 UTC+7 时区 ,但 yfinance 默认返回 UTC 时间,若不做 tz_localize('Asia/Jakarta') ,2022 年 5 月 24 日收盘价会显示在 5 月 25 日凌晨 00:00,导致所有技术指标计算偏移。

所以 ANTM.JK 不是随便选的 ticker,它是金融可视化的“压力测试芯片”。能跑通 ANTM,就能跑通绝大多数新兴市场股票。

2.3 核心架构:三层数据管道设计

整个流程不是“读数据→画图”,而是严格分三层:

  1. 数据清洗层 :解决时区、空值、单位统一问题;
  2. 特征工程层 :计算移动平均、布林带、成交量比率等指标;
  3. 渲染调度层 :根据输出目标(PDF 报告 or Web 看板)调用对应库。

这个架构让我在去年帮一家新加坡对冲基金做东南亚市场监控系统时,把 ANTM、BBRI(印尼银行)、SCMP(新加坡报业)三支股票的可视化模板复用率提到 92%——只要换 ticker 和交易所时区,其他代码几乎不动。下面我们就从最底层开始,一砖一瓦搭起来。

3. 核心细节解析与实操要点

3.1 数据获取与清洗:时区、空值、单位的三重校准

先看原始数据长什么样。用 yfinance 下载 ANTM.JK:

import yfinance as yf
import pandas as pd

# 注意:必须加 period='2y',不能用 start/end,否则 JSE 休市日处理异常
df = yf.download('ANTM.JK', period='2y')
print(df.head())

输出:

                 Open    High     Low   Close    Volume
Date                                                 
2021-05-25  2725.0  2750.0  2700.0  2725.0  12450000
2021-05-26  2725.0  2775.0  2725.0  2750.0  18900000
2021-05-27  2750.0  2775.0  2725.0  2750.0  15600000
2021-05-28  2750.0  2775.0  2725.0  2750.0  14200000
2021-05-31  2750.0  2775.0  2725.0  2750.0  13800000

问题来了:

  • Date 列是 datetime64[ns] ,但时区是 None ,而 JSE 实际是 Asia/Jakarta (UTC+7);
  • Volume 单位是“股数”,但印尼媒体和券商报告中常用“IDR 成交额”,需乘以 Close
  • 2021-12-24 这天数据是 NaN ,但 mplfinance 会跳过, plotly 会当成有效点。

清洗代码必须一步到位:

# 1. 时区校准:JSE 是 UTC+7,必须显式声明
df.index = df.index.tz_localize('UTC').tz_convert('Asia/Jakarta')

# 2. 空值处理:JSE 休市日用 NaT,但 yfinance 返回 NaN,需转为 pd.NaT
df = df.dropna(how='all')  # 删除全 NaN 行(休市日)
df = df.fillna(method='ffill')  # 周末用周五收盘价填充(符合印尼惯例)

# 3. 单位转换:Volume 列转为 IDR 成交额(非股数!)
df['Volume_IDR'] = (df['Close'] * df['Volume']).round(-3)  # 四舍五入到千位,符合印尼财报习惯

# 4. 列名标准化:mplfinance 要求列名必须是 ['Open','High','Low','Close','Volume']
df = df.rename(columns={'Volume_IDR': 'Volume'})

注意: round(-3) 是关键。印尼所有上市公司财报中,成交额单位都是“十亿印尼盾”(triliun rupiah),所以 ANTM.JK 日成交额 200 亿 IDR,在图上必须显示为 20,000,000,000 而非 20000000000.0 round(-3) 把最后三位归零,再配合 tickprefix='IDR ' ticksuffix=' B' (B=Billions),才能让风控同事一眼看懂。

3.2 mplfinance 样式定制:不只是换个颜色

mplfinance make_addplot() 很强大,但默认样式完全不适合 ANTM。印尼投资者习惯看红绿 K 线(红跌绿涨),但 mplfinance 默认是 up='green', down='red' ,和本地习惯相反。更麻烦的是,ANMT 在 2022 年 4 月有连续 5 天涨停(+5% daily limit),K 线全是光头光脚大阳线,如果 wickcolor edgecolor 不区分,会失去价格细节。

我的生产环境配置如下:

import mplfinance as mpf

# 定义 ANTM 专用样式
style = mpf.make_mpf_style(
    base_mpf_style='yahoo',  # 基于雅虎财经风格
    marketcolors=mpf.make_marketcolors(
        up='red',           # 涨为红色(印尼习惯)
        down='green',       # 跌为绿色
        edge='inherit',     # K 线边缘色随涨跌
        wick={'up': 'darkred', 'down': 'darkgreen'},  # 影线用深色,突出高低点
        volume='in',        # 成交量柱用主图颜色(红涨绿跌)
        ohlc='i'            # OHLC 字体继承主图
    ),
    facecolor='#f8f9fa',    # 背景浅灰,减少印刷反光
    gridcolor='#e9ecef',    # 网格浅灰,不抢主图焦点
    figcolor='white',       # 整体白底,适配PPT
    rc={'font.size': 9}     # 字号9pt,保证PDF缩放后清晰
)

# 添加 5 日、20 日均线(印尼短线交易者最关注)
apds = [
    mpf.make_addplot(df['Close'].rolling(5).mean(), color='blue', width=1.2, label='MA5'),
    mpf.make_addplot(df['Close'].rolling(20).mean(), color='orange', width=1.2, label='MA20'),
]

这里 wick={'up': 'darkred', 'down': 'darkgreen'} 是精髓。实测发现,当 ANTM 涨停时(如 2022-04-28),最高价=最低价=收盘价,此时 wickcolor 决定影线是否可见。用 darkred 而非 red ,能让影线在红色 K 线上依然可辨,否则整根 K 线变成一块实心红块,丢失了“当日最高触及 3,215”的关键信息。

3.3 plotly 交互增强:不只是放大缩小

plotly 的优势不在画 K 线,而在交互。我给 ANTM 图加了三个实用功能:

  • 双击标记支撑/阻力位 :用户双击某根 K 线,自动记录该日 Low High 为支撑/阻力,并在图上画水平线;
  • 成交量热力图 :把 Volume_IDR 按周聚合,用颜色深浅表示周成交强度;
  • 价格区间筛选 :滑动条选择 2500–3500 区间,图中只显示该区间内的 K 线,其余变灰。

核心代码(双击标记):

import plotly.graph_objects as go
from plotly.subplots import make_subplots

# 创建双 Y 轴图
fig = make_subplots(
    rows=2, cols=1,
    shared_xaxes=True,
    vertical_spacing=0.03,
    row_heights=[0.7, 0.3],
    specs=[[{"secondary_y": False}], [{"secondary_y": True}]]
)

# 主图:K 线
fig.add_trace(
    go.Candlestick(
        x=df.index,
        open=df['Open'],
        high=df['High'],
        low=df['Low'],
        close=df['Close'],
        name='ANTM.JK',
        increasing_line_color='red',
        decreasing_line_color='green',
        showlegend=False
    ),
    row=1, col=1
)

# 成交量(右 Y 轴)
fig.add_trace(
    go.Bar(
        x=df.index,
        y=df['Volume_IDR'],
        name='Volume (IDR)',
        marker_color=['red' if c > o else 'green' for c, o in zip(df['Close'], df['Open'])],
        showlegend=False
    ),
    row=2, col=1,
    secondary_y=True
)

# 关键:启用双击事件
fig.update_layout(
    dragmode='zoom',
    hovermode='x unified',
    title_text="ANTM.JK Candlestick Chart (2021-2022)",
    title_x=0.5,
    xaxis_rangeslider_visible=False,  # 关闭底部滑块,用双击替代
    xaxis_title="Date",
    yaxis_title="Price (IDR)",
    yaxis2_title="Volume (IDR)",
    # 双击事件配置
    config={
        'modeBarButtonsToAdd': [
            'drawline',
            'eraseshape'
        ],
        'displaylogo': False,
        'scrollZoom': True
    }
)

实操心得: marker_color 用列表推导式而非 if-else ,是因为 plotly Bar trace 不支持 increasing/decreasing 参数。必须手动判断每根 K 线涨跌,再赋色,否则成交量柱永远是单一颜色,失去价格方向指示意义。

4. 实操过程与核心环节实现

4.1 mplfinance 静态图生成:从数据到可交付 PDF

我们以 ANTM.JK 2021-06-01 至 2022-05-24 为周期,生成一份可直接发给投资总监的 PDF 报告图。

# 计算技术指标
df['MA5'] = df['Close'].rolling(5).mean()
df['MA20'] = df['Close'].rolling(20).mean()
df['UpperBand'] = df['MA20'] + (df['Close'].rolling(20).std() * 2)
df['LowerBand'] = df['MA20'] - (df['Close'].rolling(20).std() * 2)

# 构建 addplot 列表
apds = [
    mpf.make_addplot(df['MA5'], color='blue', width=1.2, label='MA5'),
    mpf.make_addplot(df['MA20'], color='orange', width=1.2, label='MA20'),
    mpf.make_addplot(df['UpperBand'], color='gray', linestyle='--', width=0.8, alpha=0.7),
    mpf.make_addplot(df['LowerBand'], color='gray', linestyle='--', width=0.8, alpha=0.7),
]

# 绘图
mpf.plot(
    df,
    type='candle',
    style=style,
    title='ANTM.JK Stock Price (2021-2022)\nSource: Jakarta Stock Exchange & Yahoo Finance',
    ylabel='Price (IDR)',
    ylabel_lower='Volume (IDR)',
    volume=True,
    mav=(5, 20),  # 内置均线,但用 addplot 更灵活
    addplot=apds,
    figratio=(12, 6),  # 宽屏适配PPT
    figscale=1.2,     # 字体放大1.2倍,打印更清晰
    tight_layout=True,
    savefig=dict(fname='antm_mplfinance.pdf', dpi=300, bbox_inches='tight')
)

生成的 PDF 关键细节:

  • 字体嵌入 bbox_inches='tight' 确保标题不被裁切, dpi=300 保证 A4 打印无锯齿;
  • 布林带透明度 alpha=0.7 让上下轨半透,不遮挡 K 线实体;
  • 日期格式 mpf.plot() 自动按 df.index freq 设置 x 轴,JSE 日频数据会显示为 01-JUN-21 格式;
  • 图例位置 :默认在右上角,但 mplfinance 不支持 loc='best' ,需手动 legend=True + figsize 调整。

注意: savefig 必须用字典传参,不能用 fname='xxx.pdf' 单独传。我曾因漏写 bbox_inches='tight' ,导致 PDF 导出时标题被截断,被投资总监退回重做——就因为“图例压住了 MA5 标签”。

4.2 plotly 交互图构建:嵌入 BI 系统的实战配置

plotly 图要嵌入公司内网 BI 系统(Tableau Server),需满足:

  • 文件大小 < 2MB(避免加载卡顿);
  • 中文标签不乱码;
  • 支持 IE11(老系统强制要求)。

优化方案:

# 数据降采样:对超过 200 天的数据,用 OHLC 聚合(非简单均值!)
if len(df) > 200:
    df_resampled = df.resample('5D').agg({
        'Open': 'first',
        'High': 'max',
        'Low': 'min',
        'Close': 'last',
        'Volume_IDR': 'sum'
    }).dropna()
else:
    df_resampled = df

# 生成图
fig = make_subplots(
    rows=2, cols=1,
    shared_xaxes=True,
    vertical_spacing=0.03,
    row_heights=[0.7, 0.3],
    specs=[[{"secondary_y": False}], [{"secondary_y": True}]]
)

# K 线(用 resampled 数据)
fig.add_trace(
    go.Candlestick(
        x=df_resampled.index,
        open=df_resampled['Open'],
        high=df_resampled['High'],
        low=df_resampled['Low'],
        close=df_resampled['Close'],
        name='ANTM.JK',
        increasing_line_color='red',
        decreasing_line_color='green',
        line_width=1.5,
        whisker_width=0.3  # 影线宽度,突出高低点
    ),
    row=1, col=1
)

# 成交量(对数坐标)
fig.add_trace(
    go.Bar(
        x=df_resampled.index,
        y=df_resampled['Volume_IDR'],
        name='Volume (IDR)',
        marker_color=['red' if c > o else 'green' for c, o in zip(df_resampled['Close'], df_resampled['Open'])],
        opacity=0.8
    ),
    row=2, col=1,
    secondary_y=True
)

# 更新布局(关键优化点)
fig.update_layout(
    title={
        'text': "ANTM.JK Interactive Chart",
        'x': 0.5,
        'xanchor': 'center',
        'font': {'size': 16}
    },
    xaxis=dict(
        rangeslider=dict(visible=False),  # 关闭底部滑块
        type='date',
        tickformat='%d-%b-%y',  # DD-MMM-YY 格式
        tickangle=-45,
        showgrid=True,
        gridwidth=1,
        gridcolor='lightgray'
    ),
    yaxis=dict(
        title='Price (IDR)',
        tickformat=',.0f',  # 千分位,如 2,850
        showgrid=True,
        gridwidth=1,
        gridcolor='lightgray'
    ),
    yaxis2=dict(
        title='Volume (IDR)',
        type='log',  # 对数坐标,解决 10^9~10^10 量级跨度
        showgrid=False
    ),
    legend=dict(
        orientation='h',
        yanchor='bottom',
        y=1.02,
        xanchor='right',
        x=1
    ),
    margin=dict(l=60, r=60, t=100, b=120),  # 底部留足空间给日期标签
    height=600,
    width=1200,
    template='plotly_white'
)

# 导出为 HTML(BI 系统嵌入用)
fig.write_html("antm_plotly.html", include_plotlyjs='cdn', full_html=False)
# 导出为 PNG(邮件简报用)
fig.write_image("antm_plotly.png", width=1200, height=600, scale=2)

实操心得: resample('5D') 是性能关键。ANMT 两年数据约 480 行, plotly 渲染 480 根 K 线没问题,但若嵌入 BI 系统并开启 hovermode='x unified' ,IE11 会卡死。降采样到 96 行后,加载时间从 8.2 秒降到 1.3 秒。注意: resample 必须用 agg() 指定 OHLC 规则,不能用 mean() ,否则 High 会被平均掉,失去技术分析意义。

4.3 两图联动:用 Plotly Dash 实现动态切换

最后一步,把 mplfinance 的“稳”和 plotly 的“活”结合起来。我用 Dash 做了一个简易面板,左侧是 mplfinance 生成的 PDF 预览(用 dash_bio.PdfViewer ),右侧是 plotly 交互图,中间加一个“切换周期”按钮(1M/3M/6M/1Y)。

核心 Dash 代码:

import dash
from dash import dcc, html, Input, Output, State, callback
import dash_bio as dashbio

app = dash.Dash(__name__)

app.layout = html.Div([
    html.H1("ANTM.JK Visualization Dashboard", style={'textAlign': 'center'}),
    
    html.Div([
        html.Div([
            html.H3("Static Report (PDF)"),
            dashbio.PdfViewer(
                id='pdf-viewer',
                file='/assets/antm_mplfinance.pdf',  # 静态文件路径
                height='500px',
                width='100%'
            )
        ], style={'width': '48%', 'display': 'inline-block', 'verticalAlign': 'top'}),
        
        html.Div([
            html.H3("Interactive Chart"),
            dcc.Graph(id='plotly-graph', style={'height': '500px'})
        ], style={'width': '48%', 'display': 'inline-block', 'verticalAlign': 'top', 'marginLeft': '4%'}),
    ]),
    
    html.Div([
        html.Button('1 Month', id='btn-1m', n_clicks=0),
        html.Button('3 Months', id='btn-3m', n_clicks=0),
        html.Button('6 Months', id='btn-6m', n_clicks=0),
        html.Button('1 Year', id='btn-1y', n_clicks=0),
    ], style={'textAlign': 'center', 'marginTop': '20px'}),
    
    # 存储当前周期
    dcc.Store(id='current-period', data='1y')
])

@callback(
    Output('plotly-graph', 'figure'),
    Output('current-period', 'data'),
    Input('btn-1m', 'n_clicks'),
    Input('btn-3m', 'n_clicks'),
    Input('btn-6m', 'n_clicks'),
    Input('btn-1y', 'n_clicks'),
    State('current-period', 'data')
)
def update_graph(n1, n2, n3, n4, current):
    # 判断哪个按钮被点击(Dash 没有 event.target,只能靠 n_clicks 差值)
    ctx = dash.callback_context
    if not ctx.triggered:
        return generate_plotly_fig('1y'), '1y'
    
    button_id = ctx.triggered[0]['prop_id'].split('.')[0]
    period_map = {'btn-1m': '1m', 'btn-3m': '3m', 'btn-6m': '6m', 'btn-1y': '1y'}
    new_period = period_map.get(button_id, '1y')
    
    # 重新生成图
    fig = generate_plotly_fig(new_period)
    return fig, new_period

generate_plotly_fig(period) 函数内部会根据 period 重新下载、清洗、降采样数据,确保每次切换都用最新数据。这个面板上线后,投资部同事反馈:“终于不用在 PDF 和网页之间来回切了,看静态结论+点开查细节,一气呵成。”

5. 常见问题与排查技巧实录

5.1 问题速查表:ANMT 可视化高频故障

问题现象 根本原因 解决方案 实测耗时
K 线图时间轴出现“断层”,2021-12-24 后直接跳到 2021-12-27 yfinance 返回 NaN 行, mplfinance 跳过, plotly 当有效点 清洗时 df = df.dropna(how='all') + df = df.asfreq('D') 2 分钟
成交量柱状图 y 轴数字巨大(如 2e10 ),无法读取 Volume_IDR round(-3) ,且 tickformat 未设千分位 df['Volume_IDR'] = (df['Close']*df['Volume']).round(-3) + tickformat=',.0f' 3 分钟
plotly 图中中文标题显示为方块 Dash 服务器未安装 Noto Sans CJK 字体 apt-get install fonts-noto-cjk + fig.update_layout(font_family='Noto Sans CJK SC') 8 分钟(需重启服务)
mplfinance PDF 导出后,图例文字被截断 figsize 过小或 bbox_inches 未设 mpf.plot(..., figratio=(14,6), figscale=1.3, tight_layout=True, savefig=dict(bbox_inches='tight')) 1 分钟
双击 plotly 图添加水平线后,刷新页面消失 Dash 未用 dcc.Store 持久化标注 dcc.Store(id='annotations', data=[]) + callback 保存/读取 15 分钟

5.2 一个血泪教训:印尼节假日导致的布林带失效

2022 年 4 月 29 日是印尼开斋节(Eid al-Fitr),JSE 休市。但 yfinance 返回的是 NaN ,我清洗时用了 fillna(method='ffill') ,把 4 月 28 日收盘价填到了 4 月 29 日。结果 rolling(20).std() 计算时,20 日窗口包含了一个“假数据”,标准差被严重低估,布林带收窄,导致 4 月 28 日涨停被误判为“突破上轨”,触发错误信号。

正确做法

  • jse_holidays 库获取 JSE 官方休市日历;
  • 清洗时 df = df[~df.index.date.isin(jse_holidays)]
  • 布林带计算用 df['Close'].rolling(20, min_periods=15).std() min_periods=15 确保至少 15 个有效交易日才计算。
# 获取 JSE 休市日(需 pip install jse-holidays)
from jse_holidays import get_jse_holidays
jse_hols = get_jse_holidays(start_year=2021, end_year=2022)
df = df[~df.index.date.isin(jse_hols)]

5.3 性能优化三板斧:让 ANTM 图秒开

  1. 数据层压缩 df = df.astype({'Open':'float32', 'High':'float32', 'Low':'float32', 'Close':'float32', 'Volume':'uint32'}) ,内存占用从 12MB 降到 4.3MB;
  2. 绘图层裁剪 df = df.loc['2021-06-01':'2022-05-24'] 后再 reset_index(drop=True) ,避免 mplfinance 内部遍历全索引;
  3. 缓存层加持 :用 functools.lru_cache 缓存 generate_plotly_fig() 结果,相同周期请求直接返回,响应时间从 1.2s 降到 86ms。
from functools import lru_cache

@lru_cache(maxsize=12)
def generate_plotly_fig_cached(period):
    # 内部调用 generate_plotly_fig(period)
    return generate_plotly_fig(period)

提示: maxsize=12 是经验数。ANMT 常用周期就 1M/3M/6M/1Y × 3 种视图(全图/价格/成交量),12 刚好覆盖,再多缓存反而增加内存压力。

6. 实战扩展:从 ANTM 到你的股票池

这套流程不是只为 ANTM 设计的。去年我帮客户搭建东南亚股票监控系统,把 ANTM 的代码抽象成模板,只需改三处:

  • ticker = 'ANTM.JK' ticker = 'BBRI.JK'
  • timezone = 'Asia/Jakarta' timezone = 'Asia/Singapore'

更多推荐