文章目录
-
- [四、后端 API 设计](#四、后端 API 设计)
-
- [4.1 API 路由总览](#4.1 API 路由总览)
- [4.2 `/api/stock-data` --- 行情数据接口](#4.2
/api/stock-data— 行情数据接口) - [4.3 `/api/chart` --- 图表渲染接口](#4.3
/api/chart— 图表渲染接口) -
- [4.3.1 子进程通信协议](#4.3.1 子进程通信协议)
- [4.3.2 超时保护](#4.3.2 超时保护)
- [4.3.3 Python 路径与运行时](#4.3.3 Python 路径与运行时)
- [4.3.4 错误处理链](#4.3.4 错误处理链)
- [五、Python 图表引擎设计](#五、Python 图表引擎设计)
-
- [5.1 渲染管线总览](#5.1 渲染管线总览)
- [5.2 中文字体处理](#5.2 中文字体处理)
- [5.3 主题与配色体系](#5.3 主题与配色体系)
- [5.4 多面板布局系统](#5.4 多面板布局系统)
- [5.5 市场颜色与样式组装](#5.5 市场颜色与样式组装)
- [5.6 图片输出与 Base64 编码](#5.6 图片输出与 Base64 编码)
- [5.7 统计数据组装](#5.7 统计数据组装)
- 六、技术指标算法原理
-
- [6.1 指标总览](#6.1 指标总览)
- [6.2 MA --- 简单移动平均](#6.2 MA — 简单移动平均)
- [6.3 EMA --- 指数移动平均](#6.3 EMA — 指数移动平均)
- [6.4 MACD --- 异同移动平均线](#6.4 MACD — 异同移动平均线)
- [6.5 RSI --- 相对强弱指标(Wilder 法)](#6.5 RSI — 相对强弱指标(Wilder 法))
- [6.6 BOLL --- 布林带](#6.6 BOLL — 布林带)
- [6.7 指标渲染参数](#6.7 指标渲染参数)
四、后端 API 设计
4.1 API 路由总览
项目定义了三个 API 路由,全部位于 src/app/api/ 下,使用 Next.js App Router 的文件路由约定:
| 路由 | 方法 | Runtime | 功能 | 缓存策略 |
|---|---|---|---|---|
/api |
GET | Node.js | 健康检查返回 {message:"Hello, world!"} |
无 |
/api/stock-data |
POST | Node.js | 生成/获取模拟行情数据 + 返回标的列表 | 内存级 Map 缓存 |
/api/chart |
POST | Node.js | 调度 Python 子进程渲染 K 线图 | 无缓存(每次实时渲染) |
4.2 /api/stock-data --- 行情数据接口
该接口负责根据 symbol 返回对应的 OHLCV 日线序列。核心设计是用内存 Map 做缓存------同一标的的数据只生成一次,后续请求直接命中缓存,避免重复执行随机数生成逻辑。
#mermaid-svg-JwITqzNDbuVeLbMA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-JwITqzNDbuVeLbMA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-JwITqzNDbuVeLbMA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-JwITqzNDbuVeLbMA .error-icon{fill:#552222;}#mermaid-svg-JwITqzNDbuVeLbMA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-JwITqzNDbuVeLbMA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-JwITqzNDbuVeLbMA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-JwITqzNDbuVeLbMA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-JwITqzNDbuVeLbMA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-JwITqzNDbuVeLbMA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-JwITqzNDbuVeLbMA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-JwITqzNDbuVeLbMA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-JwITqzNDbuVeLbMA .marker.cross{stroke:#333333;}#mermaid-svg-JwITqzNDbuVeLbMA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-JwITqzNDbuVeLbMA p{margin:0;}#mermaid-svg-JwITqzNDbuVeLbMA .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-JwITqzNDbuVeLbMA .cluster-label text{fill:#333;}#mermaid-svg-JwITqzNDbuVeLbMA .cluster-label span{color:#333;}#mermaid-svg-JwITqzNDbuVeLbMA .cluster-label span p{background-color:transparent;}#mermaid-svg-JwITqzNDbuVeLbMA .label text,#mermaid-svg-JwITqzNDbuVeLbMA span{fill:#333;color:#333;}#mermaid-svg-JwITqzNDbuVeLbMA .node rect,#mermaid-svg-JwITqzNDbuVeLbMA .node circle,#mermaid-svg-JwITqzNDbuVeLbMA .node ellipse,#mermaid-svg-JwITqzNDbuVeLbMA .node polygon,#mermaid-svg-JwITqzNDbuVeLbMA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-JwITqzNDbuVeLbMA .rough-node .label text,#mermaid-svg-JwITqzNDbuVeLbMA .node .label text,#mermaid-svg-JwITqzNDbuVeLbMA .image-shape .label,#mermaid-svg-JwITqzNDbuVeLbMA .icon-shape .label{text-anchor:middle;}#mermaid-svg-JwITqzNDbuVeLbMA .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-JwITqzNDbuVeLbMA .rough-node .label,#mermaid-svg-JwITqzNDbuVeLbMA .node .label,#mermaid-svg-JwITqzNDbuVeLbMA .image-shape .label,#mermaid-svg-JwITqzNDbuVeLbMA .icon-shape .label{text-align:center;}#mermaid-svg-JwITqzNDbuVeLbMA .node.clickable{cursor:pointer;}#mermaid-svg-JwITqzNDbuVeLbMA .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-JwITqzNDbuVeLbMA .arrowheadPath{fill:#333333;}#mermaid-svg-JwITqzNDbuVeLbMA .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-JwITqzNDbuVeLbMA .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-JwITqzNDbuVeLbMA .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JwITqzNDbuVeLbMA .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-JwITqzNDbuVeLbMA .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JwITqzNDbuVeLbMA .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-JwITqzNDbuVeLbMA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-JwITqzNDbuVeLbMA .cluster text{fill:#333;}#mermaid-svg-JwITqzNDbuVeLbMA .cluster span{color:#333;}#mermaid-svg-JwITqzNDbuVeLbMA div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-JwITqzNDbuVeLbMA .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-JwITqzNDbuVeLbMA rect.text{fill:none;stroke-width:0;}#mermaid-svg-JwITqzNDbuVeLbMA .icon-shape,#mermaid-svg-JwITqzNDbuVeLbMA .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JwITqzNDbuVeLbMA .icon-shape p,#mermaid-svg-JwITqzNDbuVeLbMA .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-JwITqzNDbuVeLbMA .icon-shape .label rect,#mermaid-svg-JwITqzNDbuVeLbMA .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JwITqzNDbuVeLbMA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-JwITqzNDbuVeLbMA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-JwITqzNDbuVeLbMA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 命中
未命中
POST /api/stock-data
body: {symbol}
cache.has(symbol)?
直接返回缓存序列
getStock(symbol) 获取元信息
generateSeries(meta)
mulberry32 种子 → 520 根日线
cache.set(symbol, series)
组装响应 JSON
返回 {ok, meta, data, stocks}
响应结构包含四个字段:ok(成功标志)、meta(标的元信息)、data(OHLCV 数组)、stocks(全部可选标的列表,供前端下拉填充)。
4.3 /api/chart --- 图表渲染接口
这是整个系统最核心的 API,承担 Node.js ↔ Python 的桥接职责。
4.3.1 子进程通信协议
Node.js 通过 child_process.spawn 启动 Python 进程,以 stdin/stdout JSON 管道进行双向通信:
浏览器 kline_chart.py (Python) /api/chart (Node.js) 浏览器 kline_chart.py (Python) /api/chart (Node.js) #mermaid-svg-ciVr8VJcjCfSDsAd{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ciVr8VJcjCfSDsAd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ciVr8VJcjCfSDsAd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ciVr8VJcjCfSDsAd .error-icon{fill:#552222;}#mermaid-svg-ciVr8VJcjCfSDsAd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ciVr8VJcjCfSDsAd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ciVr8VJcjCfSDsAd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ciVr8VJcjCfSDsAd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ciVr8VJcjCfSDsAd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ciVr8VJcjCfSDsAd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ciVr8VJcjCfSDsAd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ciVr8VJcjCfSDsAd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ciVr8VJcjCfSDsAd .marker.cross{stroke:#333333;}#mermaid-svg-ciVr8VJcjCfSDsAd svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ciVr8VJcjCfSDsAd p{margin:0;}#mermaid-svg-ciVr8VJcjCfSDsAd .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ciVr8VJcjCfSDsAd text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-ciVr8VJcjCfSDsAd .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ciVr8VJcjCfSDsAd .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-ciVr8VJcjCfSDsAd .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-ciVr8VJcjCfSDsAd .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-ciVr8VJcjCfSDsAd #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-ciVr8VJcjCfSDsAd .sequenceNumber{fill:white;}#mermaid-svg-ciVr8VJcjCfSDsAd #sequencenumber{fill:#333;}#mermaid-svg-ciVr8VJcjCfSDsAd #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-ciVr8VJcjCfSDsAd .messageText{fill:#333;stroke:none;}#mermaid-svg-ciVr8VJcjCfSDsAd .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ciVr8VJcjCfSDsAd .labelText,#mermaid-svg-ciVr8VJcjCfSDsAd .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-ciVr8VJcjCfSDsAd .loopText,#mermaid-svg-ciVr8VJcjCfSDsAd .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-ciVr8VJcjCfSDsAd .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ciVr8VJcjCfSDsAd .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ciVr8VJcjCfSDsAd .noteText,#mermaid-svg-ciVr8VJcjCfSDsAd .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-ciVr8VJcjCfSDsAd .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ciVr8VJcjCfSDsAd .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ciVr8VJcjCfSDsAd .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ciVr8VJcjCfSDsAd .actorPopupMenu{position:absolute;}#mermaid-svg-ciVr8VJcjCfSDsAd .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-ciVr8VJcjCfSDsAd .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ciVr8VJcjCfSDsAd .actor-man circle,#mermaid-svg-ciVr8VJcjCfSDsAd line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-ciVr8VJcjCfSDsAd :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 校验 body.data.length ≥ 5 spawn('/home/z/.venv/bin/python3', 'kline_chart.py') 设置 env MPLBACKEND=Agg stdin.write(JSON {data, config}) stdin.end() json.loads(stdin) DataFrame 构建 + 指标计算 mplfinance.plot(savefig=BytesIO) base64(image) + 组装 stats stdout.write(JSON {ok,image,stats,error}) JSON.parse(stdout) NextResponse.json(result)
4.3.2 超时保护
渲染请求设置了 90 秒超时(maxDuration = 120 为 Next.js 函数级上限,内部实际使用 90s 的 setTimeout)。超时后向 Python 进程发送 SIGKILL 强制终止,防止 matplotlib 在极端数据下卡死拖垮整个服务:
typescript
const timer = setTimeout(() => {
proc.kill('SIGKILL')
reject(new Error('图表渲染超时(90s)'))
}, 90_000)
4.3.3 Python 路径与运行时
开发环境中 Python 解释器路径硬编码为 /home/z/.venv/bin/python3,这是沙箱预装的虚拟环境。部署环境中则由 start.sh 动态设置 PYTHONPATH 和 PATH,指向打包产物内的 site-packages。
4.3.4 错误处理链
#mermaid-svg-b5tBNzwSpicQHEo7{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-b5tBNzwSpicQHEo7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-b5tBNzwSpicQHEo7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-b5tBNzwSpicQHEo7 .error-icon{fill:#552222;}#mermaid-svg-b5tBNzwSpicQHEo7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-b5tBNzwSpicQHEo7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-b5tBNzwSpicQHEo7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-b5tBNzwSpicQHEo7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-b5tBNzwSpicQHEo7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-b5tBNzwSpicQHEo7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-b5tBNzwSpicQHEo7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-b5tBNzwSpicQHEo7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-b5tBNzwSpicQHEo7 .marker.cross{stroke:#333333;}#mermaid-svg-b5tBNzwSpicQHEo7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-b5tBNzwSpicQHEo7 p{margin:0;}#mermaid-svg-b5tBNzwSpicQHEo7 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-b5tBNzwSpicQHEo7 .cluster-label text{fill:#333;}#mermaid-svg-b5tBNzwSpicQHEo7 .cluster-label span{color:#333;}#mermaid-svg-b5tBNzwSpicQHEo7 .cluster-label span p{background-color:transparent;}#mermaid-svg-b5tBNzwSpicQHEo7 .label text,#mermaid-svg-b5tBNzwSpicQHEo7 span{fill:#333;color:#333;}#mermaid-svg-b5tBNzwSpicQHEo7 .node rect,#mermaid-svg-b5tBNzwSpicQHEo7 .node circle,#mermaid-svg-b5tBNzwSpicQHEo7 .node ellipse,#mermaid-svg-b5tBNzwSpicQHEo7 .node polygon,#mermaid-svg-b5tBNzwSpicQHEo7 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-b5tBNzwSpicQHEo7 .rough-node .label text,#mermaid-svg-b5tBNzwSpicQHEo7 .node .label text,#mermaid-svg-b5tBNzwSpicQHEo7 .image-shape .label,#mermaid-svg-b5tBNzwSpicQHEo7 .icon-shape .label{text-anchor:middle;}#mermaid-svg-b5tBNzwSpicQHEo7 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-b5tBNzwSpicQHEo7 .rough-node .label,#mermaid-svg-b5tBNzwSpicQHEo7 .node .label,#mermaid-svg-b5tBNzwSpicQHEo7 .image-shape .label,#mermaid-svg-b5tBNzwSpicQHEo7 .icon-shape .label{text-align:center;}#mermaid-svg-b5tBNzwSpicQHEo7 .node.clickable{cursor:pointer;}#mermaid-svg-b5tBNzwSpicQHEo7 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-b5tBNzwSpicQHEo7 .arrowheadPath{fill:#333333;}#mermaid-svg-b5tBNzwSpicQHEo7 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-b5tBNzwSpicQHEo7 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-b5tBNzwSpicQHEo7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-b5tBNzwSpicQHEo7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-b5tBNzwSpicQHEo7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-b5tBNzwSpicQHEo7 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-b5tBNzwSpicQHEo7 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-b5tBNzwSpicQHEo7 .cluster text{fill:#333;}#mermaid-svg-b5tBNzwSpicQHEo7 .cluster span{color:#333;}#mermaid-svg-b5tBNzwSpicQHEo7 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-b5tBNzwSpicQHEo7 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-b5tBNzwSpicQHEo7 rect.text{fill:none;stroke-width:0;}#mermaid-svg-b5tBNzwSpicQHEo7 .icon-shape,#mermaid-svg-b5tBNzwSpicQHEo7 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-b5tBNzwSpicQHEo7 .icon-shape p,#mermaid-svg-b5tBNzwSpicQHEo7 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-b5tBNzwSpicQHEo7 .icon-shape .label rect,#mermaid-svg-b5tBNzwSpicQHEo7 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-b5tBNzwSpicQHEo7 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-b5tBNzwSpicQHEo7 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-b5tBNzwSpicQHEo7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
超时
spawn 失败
正常
否
是
否
是
POST 请求到达
body.data ≥ 5 根?
400: K线数据不足
spawn Python
进程正常退出?
kill SIGKILL → 500: 超时
500: 进程异常
stdout 可解析为 JSON?
500: Python 进程异常退出
result.ok?
500: 渲染失败
200: 返回图片+统计
五、Python 图表引擎设计
python/kline_chart.py 是整个项目的渲染核心,298 行代码完成了从 JSON 输入到 Base64 PNG 输出的全部工作。
5.1 渲染管线总览
#mermaid-svg-AHWQLCqGGv6ljENI{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-AHWQLCqGGv6ljENI .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-AHWQLCqGGv6ljENI .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-AHWQLCqGGv6ljENI .error-icon{fill:#552222;}#mermaid-svg-AHWQLCqGGv6ljENI .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-AHWQLCqGGv6ljENI .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-AHWQLCqGGv6ljENI .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-AHWQLCqGGv6ljENI .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-AHWQLCqGGv6ljENI .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-AHWQLCqGGv6ljENI .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-AHWQLCqGGv6ljENI .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-AHWQLCqGGv6ljENI .marker{fill:#333333;stroke:#333333;}#mermaid-svg-AHWQLCqGGv6ljENI .marker.cross{stroke:#333333;}#mermaid-svg-AHWQLCqGGv6ljENI svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-AHWQLCqGGv6ljENI p{margin:0;}#mermaid-svg-AHWQLCqGGv6ljENI .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-AHWQLCqGGv6ljENI .cluster-label text{fill:#333;}#mermaid-svg-AHWQLCqGGv6ljENI .cluster-label span{color:#333;}#mermaid-svg-AHWQLCqGGv6ljENI .cluster-label span p{background-color:transparent;}#mermaid-svg-AHWQLCqGGv6ljENI .label text,#mermaid-svg-AHWQLCqGGv6ljENI span{fill:#333;color:#333;}#mermaid-svg-AHWQLCqGGv6ljENI .node rect,#mermaid-svg-AHWQLCqGGv6ljENI .node circle,#mermaid-svg-AHWQLCqGGv6ljENI .node ellipse,#mermaid-svg-AHWQLCqGGv6ljENI .node polygon,#mermaid-svg-AHWQLCqGGv6ljENI .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-AHWQLCqGGv6ljENI .rough-node .label text,#mermaid-svg-AHWQLCqGGv6ljENI .node .label text,#mermaid-svg-AHWQLCqGGv6ljENI .image-shape .label,#mermaid-svg-AHWQLCqGGv6ljENI .icon-shape .label{text-anchor:middle;}#mermaid-svg-AHWQLCqGGv6ljENI .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-AHWQLCqGGv6ljENI .rough-node .label,#mermaid-svg-AHWQLCqGGv6ljENI .node .label,#mermaid-svg-AHWQLCqGGv6ljENI .image-shape .label,#mermaid-svg-AHWQLCqGGv6ljENI .icon-shape .label{text-align:center;}#mermaid-svg-AHWQLCqGGv6ljENI .node.clickable{cursor:pointer;}#mermaid-svg-AHWQLCqGGv6ljENI .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-AHWQLCqGGv6ljENI .arrowheadPath{fill:#333333;}#mermaid-svg-AHWQLCqGGv6ljENI .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-AHWQLCqGGv6ljENI .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-AHWQLCqGGv6ljENI .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AHWQLCqGGv6ljENI .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-AHWQLCqGGv6ljENI .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AHWQLCqGGv6ljENI .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-AHWQLCqGGv6ljENI .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-AHWQLCqGGv6ljENI .cluster text{fill:#333;}#mermaid-svg-AHWQLCqGGv6ljENI .cluster span{color:#333;}#mermaid-svg-AHWQLCqGGv6ljENI div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-AHWQLCqGGv6ljENI .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-AHWQLCqGGv6ljENI rect.text{fill:none;stroke-width:0;}#mermaid-svg-AHWQLCqGGv6ljENI .icon-shape,#mermaid-svg-AHWQLCqGGv6ljENI .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AHWQLCqGGv6ljENI .icon-shape p,#mermaid-svg-AHWQLCqGGv6ljENI .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-AHWQLCqGGv6ljENI .icon-shape .label rect,#mermaid-svg-AHWQLCqGGv6ljENI .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AHWQLCqGGv6ljENI .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-AHWQLCqGGv6ljENI .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-AHWQLCqGGv6ljENI :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 渲染输出
指标计算与 addplot 组装
主题选择
输入解析
是
否
是
否
stdin JSON
提取 config + data
pd.DataFrame 构建
date 索引 + OHLCV 列
astype(float) + dropna()
theme == dark?
使用 DARK 配色表
使用 LIGHT 配色表
colorScheme == intl?
叠加 INTL 覆盖
(绿涨红跌)
保持原配色
布林带 → 3 条 addplot(panel=0)
均线 MA → 最多 4 条 addplot(panel=0)
MACD → hist柱+DIF+DEA+零线 addplot(panel=N)
RSI → 线+30/70参考线 addplot(panel=N+1)
最新价水平虚线 addplot(panel=0)
make_mpf_style()
marketcolors + rc 参数
计算 panel_ratios
mpf.plot(df, savefig=BytesIO)
base64(buffer)
组装 stats 统计数据
stdout JSON 输出
5.2 中文字体处理
matplotlib 默认不支持中文字符,项目通过 rcParams 显式指定 WenQuanYi Zen Hei (文泉驿正黑)作为首选字体,DejaVu Sans 作为回退。同时设置 axes.unicode_minus = False 防止负号显示为方块:
python
rcParams["font.sans-serif"] = ["WenQuanYi Zen Hei", "DejaVu Sans"]
rcParams["axes.unicode_minus"] = False
这套字体配置在 style 对象的 rc 参数中再次声明,确保 mplfinance 内部创建的 Axes 也能正确渲染中文。
5.3 主题与配色体系
项目定义了 DARK 和 LIGHT 两套配色表,每套包含 16 个颜色键值,覆盖了面板背景、网格、K 线涨跌色、均线色、指标线色等全部视觉元素。通过 get_theme(name, scheme) 函数组合主题与配色方案:
| 配色键 | DARK 值 | LIGHT 值 | 用途 |
|---|---|---|---|
face |
#131722 |
#ffffff |
面板背景色 |
edge |
#2a2e39 |
#e0e3eb |
面板边框色 |
fig |
#131722 |
#ffffff |
画布背景色 |
grid |
#2a2e39 |
#e0e3eb |
网格线色 |
text |
#d1d4dc |
#131722 |
文字色 |
up |
#ef5350 |
#e03131 |
上涨色(CN) |
down |
#26a69a |
#0ca678 |
下跌色(CN) |
ma_colors |
4 色 | 4 色 | MA 均线渐变色 |
last |
#f7b32b |
#e8a30c |
最新价标注线 |
当 colorScheme == "intl" 时,使用 INTL 覆盖表将涨跌色互换(绿涨红跌),与欧美市场的视觉习惯一致。
5.4 多面板布局系统
mplfinance 支持通过 panel 参数将不同指标分配到不同的子图区域。项目动态计算面板编号和高度比例:
#mermaid-svg-QwRVyJH6C4yBwyEl{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-QwRVyJH6C4yBwyEl .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-QwRVyJH6C4yBwyEl .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-QwRVyJH6C4yBwyEl .error-icon{fill:#552222;}#mermaid-svg-QwRVyJH6C4yBwyEl .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-QwRVyJH6C4yBwyEl .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-QwRVyJH6C4yBwyEl .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-QwRVyJH6C4yBwyEl .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-QwRVyJH6C4yBwyEl .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-QwRVyJH6C4yBwyEl .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-QwRVyJH6C4yBwyEl .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-QwRVyJH6C4yBwyEl .marker{fill:#333333;stroke:#333333;}#mermaid-svg-QwRVyJH6C4yBwyEl .marker.cross{stroke:#333333;}#mermaid-svg-QwRVyJH6C4yBwyEl svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-QwRVyJH6C4yBwyEl p{margin:0;}#mermaid-svg-QwRVyJH6C4yBwyEl .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-QwRVyJH6C4yBwyEl .cluster-label text{fill:#333;}#mermaid-svg-QwRVyJH6C4yBwyEl .cluster-label span{color:#333;}#mermaid-svg-QwRVyJH6C4yBwyEl .cluster-label span p{background-color:transparent;}#mermaid-svg-QwRVyJH6C4yBwyEl .label text,#mermaid-svg-QwRVyJH6C4yBwyEl span{fill:#333;color:#333;}#mermaid-svg-QwRVyJH6C4yBwyEl .node rect,#mermaid-svg-QwRVyJH6C4yBwyEl .node circle,#mermaid-svg-QwRVyJH6C4yBwyEl .node ellipse,#mermaid-svg-QwRVyJH6C4yBwyEl .node polygon,#mermaid-svg-QwRVyJH6C4yBwyEl .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-QwRVyJH6C4yBwyEl .rough-node .label text,#mermaid-svg-QwRVyJH6C4yBwyEl .node .label text,#mermaid-svg-QwRVyJH6C4yBwyEl .image-shape .label,#mermaid-svg-QwRVyJH6C4yBwyEl .icon-shape .label{text-anchor:middle;}#mermaid-svg-QwRVyJH6C4yBwyEl .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-QwRVyJH6C4yBwyEl .rough-node .label,#mermaid-svg-QwRVyJH6C4yBwyEl .node .label,#mermaid-svg-QwRVyJH6C4yBwyEl .image-shape .label,#mermaid-svg-QwRVyJH6C4yBwyEl .icon-shape .label{text-align:center;}#mermaid-svg-QwRVyJH6C4yBwyEl .node.clickable{cursor:pointer;}#mermaid-svg-QwRVyJH6C4yBwyEl .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-QwRVyJH6C4yBwyEl .arrowheadPath{fill:#333333;}#mermaid-svg-QwRVyJH6C4yBwyEl .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-QwRVyJH6C4yBwyEl .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-QwRVyJH6C4yBwyEl .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QwRVyJH6C4yBwyEl .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-QwRVyJH6C4yBwyEl .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QwRVyJH6C4yBwyEl .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-QwRVyJH6C4yBwyEl .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-QwRVyJH6C4yBwyEl .cluster text{fill:#333;}#mermaid-svg-QwRVyJH6C4yBwyEl .cluster span{color:#333;}#mermaid-svg-QwRVyJH6C4yBwyEl div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-QwRVyJH6C4yBwyEl .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-QwRVyJH6C4yBwyEl rect.text{fill:none;stroke-width:0;}#mermaid-svg-QwRVyJH6C4yBwyEl .icon-shape,#mermaid-svg-QwRVyJH6C4yBwyEl .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QwRVyJH6C4yBwyEl .icon-shape p,#mermaid-svg-QwRVyJH6C4yBwyEl .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-QwRVyJH6C4yBwyEl .icon-shape .label rect,#mermaid-svg-QwRVyJH6C4yBwyEl .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QwRVyJH6C4yBwyEl .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-QwRVyJH6C4yBwyEl .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-QwRVyJH6C4yBwyEl :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} panel_ratios 动态计算
ratio: 4 或 5
ratio: 1
ratio: 1.4
ratio: 1.1
Panel 0: 主图
K线 + BOLL + MA + 最新价
Panel 1: 成交量
(showVolume=true 时)
Panel 2: MACD
hist柱 + DIF + DEA
Panel 3: RSI
RSI线 + 30/70参考线
底部
面板编号的动态计算逻辑:
python
# MACD 面板编号 = 成交量面板之后
macd_panel = 1 + (1 if show_volume else 0)
# RSI 面板编号 = MACD 面板之后
rsi_panel = (macd_panel + 1) if macd_panel is not None else (1 + (1 if show_volume else 0))
高度比例 panel_ratios 也根据启用的面板动态拼装:主图固定为 4 或 5(有无成交量),成交量为 1,MACD 为 1.4,RSI 为 1.1。这种比例分配确保主图占据主要视觉空间,副图既能清晰展示又不喧宾夺主。
5.5 市场颜色与样式组装
mplfinance 的样式系统通过 make_marketcolors 和 make_mpf_style 两层组装。marketcolors 定义 K 线实体、边框、影线、成交量的涨跌色;mpf_style 则在此基础上叠加背景色、网格、字体大小等全局样式。项目以 nightclouds(深色)和 yahoo(浅色)作为基础样式,再通过 rc 参数覆盖字体、刻度、标题等颜色:
python
style = mpf.make_mpf_style(
marketcolors=mc,
base_mpf_style="nightclouds" if theme_name == "dark" else "yahoo",
facecolor=T["face"], edgecolor=T["edge"], figcolor=T["fig"],
gridcolor=T["grid"], gridstyle="--", gridaxis="both",
rc={...font and color overrides...},
)
5.6 图片输出与 Base64 编码
渲染结果写入 io.BytesIO 内存缓冲区而非磁盘文件,避免 I/O 开销和临时文件清理问题。mplfinance 的 savefig 参数指定 format="png" 和 dpi=132,并使用 bbox_inches="tight" 自动裁剪空白边距。最终通过 base64.b64encode 编码为 ASCII 字符串,与统计数据一起组成 JSON 响应。
5.7 统计数据组装
Python 端在渲染完成后,同步计算并返回一份 stats 对象,包含最新价、涨跌额、涨跌幅、今开/最高/最低、成交量、区间极值以及各指标的最新读数。这些数据让前端无需自行重复计算指标,直接展示在 StatsBar 和 IndicatorReadouts 中:
python
stats = {
"last": ..., "change": ..., "changePct": ...,
"open": ..., "high": ..., "low": ...,
"rangeHigh": ..., "rangeLow": ...,
"volume": ..., "avgVolume": ..., "bars": ...,
"ma": {...}, "boll": {...}, "macd": {...}, "rsi": ...,
}
六、技术指标算法原理
6.1 指标总览
| 指标 | 全称 | 原理简述 | 参数 |
|---|---|---|---|
| MA | Simple Moving Average | 收盘价的 N 日算术平均 | maPeriods 默认 5,10,20,60 |
| EMA | Exponential Moving Average | 赋予近期数据更大权重的指数加权平均 | MACD 内部使用 |
| BOLL | Bollinger Bands | MA ± k × 标准差,刻画价格波动通道 | bollPeriod 默认 20, bollStd 默认 2.0 |
| MACD | Moving Average Convergence Divergence | 快慢 EMA 差值(DIF)及其信号线(DEA)的差离值 | macdFast 12, macdSlow 26, macdSignal 9 |
| RSI | Relative Strength Index | 一段时间内平均涨幅与平均跌幅的比值,衡量超买超卖 | rsiPeriod 默认 14 |
6.2 MA --- 简单移动平均
MA 是最基础的趋势跟踪指标。取最近 N 个交易日的收盘价做算术平均,随时间窗口滑动。Pandas 的 rolling(n, min_periods=n).mean() 天然实现了这一计算,min_periods=n 确保前 N-1 个不完整窗口返回 NaN,不会产生虚假的短均线值。
python
def sma(s: pd.Series, n: int) -> pd.Series:
return s.rolling(n, min_periods=n).mean()
6.3 EMA --- 指数移动平均
EMA 对越近期的数据赋予越大的权重,权重按指数衰减。Pandas 的 ewm(span=n, adjust=False).mean() 实现了标准的 EMA 计算,adjust=False 表示使用递推公式(第一日起即以当日价格为初始 EMA),与金融行业主流计算方式一致:
python
def ema(s: pd.Series, n: int) -> pd.Series:
return s.ewm(span=n, adjust=False).mean()
6.4 MACD --- 异同移动平均线
MACD 利用快慢两条 EMA 的差值来捕捉趋势的方向与动量变化。计算分为三步:
#mermaid-svg-TjOgUqjTacLYpPFH{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-TjOgUqjTacLYpPFH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-TjOgUqjTacLYpPFH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-TjOgUqjTacLYpPFH .error-icon{fill:#552222;}#mermaid-svg-TjOgUqjTacLYpPFH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-TjOgUqjTacLYpPFH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-TjOgUqjTacLYpPFH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-TjOgUqjTacLYpPFH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-TjOgUqjTacLYpPFH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-TjOgUqjTacLYpPFH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-TjOgUqjTacLYpPFH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-TjOgUqjTacLYpPFH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-TjOgUqjTacLYpPFH .marker.cross{stroke:#333333;}#mermaid-svg-TjOgUqjTacLYpPFH svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-TjOgUqjTacLYpPFH p{margin:0;}#mermaid-svg-TjOgUqjTacLYpPFH .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-TjOgUqjTacLYpPFH .cluster-label text{fill:#333;}#mermaid-svg-TjOgUqjTacLYpPFH .cluster-label span{color:#333;}#mermaid-svg-TjOgUqjTacLYpPFH .cluster-label span p{background-color:transparent;}#mermaid-svg-TjOgUqjTacLYpPFH .label text,#mermaid-svg-TjOgUqjTacLYpPFH span{fill:#333;color:#333;}#mermaid-svg-TjOgUqjTacLYpPFH .node rect,#mermaid-svg-TjOgUqjTacLYpPFH .node circle,#mermaid-svg-TjOgUqjTacLYpPFH .node ellipse,#mermaid-svg-TjOgUqjTacLYpPFH .node polygon,#mermaid-svg-TjOgUqjTacLYpPFH .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-TjOgUqjTacLYpPFH .rough-node .label text,#mermaid-svg-TjOgUqjTacLYpPFH .node .label text,#mermaid-svg-TjOgUqjTacLYpPFH .image-shape .label,#mermaid-svg-TjOgUqjTacLYpPFH .icon-shape .label{text-anchor:middle;}#mermaid-svg-TjOgUqjTacLYpPFH .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-TjOgUqjTacLYpPFH .rough-node .label,#mermaid-svg-TjOgUqjTacLYpPFH .node .label,#mermaid-svg-TjOgUqjTacLYpPFH .image-shape .label,#mermaid-svg-TjOgUqjTacLYpPFH .icon-shape .label{text-align:center;}#mermaid-svg-TjOgUqjTacLYpPFH .node.clickable{cursor:pointer;}#mermaid-svg-TjOgUqjTacLYpPFH .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-TjOgUqjTacLYpPFH .arrowheadPath{fill:#333333;}#mermaid-svg-TjOgUqjTacLYpPFH .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-TjOgUqjTacLYpPFH .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-TjOgUqjTacLYpPFH .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-TjOgUqjTacLYpPFH .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-TjOgUqjTacLYpPFH .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-TjOgUqjTacLYpPFH .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-TjOgUqjTacLYpPFH .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-TjOgUqjTacLYpPFH .cluster text{fill:#333;}#mermaid-svg-TjOgUqjTacLYpPFH .cluster span{color:#333;}#mermaid-svg-TjOgUqjTacLYpPFH div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-TjOgUqjTacLYpPFH .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-TjOgUqjTacLYpPFH rect.text{fill:none;stroke-width:0;}#mermaid-svg-TjOgUqjTacLYpPFH .icon-shape,#mermaid-svg-TjOgUqjTacLYpPFH .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-TjOgUqjTacLYpPFH .icon-shape p,#mermaid-svg-TjOgUqjTacLYpPFH .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-TjOgUqjTacLYpPFH .icon-shape .label rect,#mermaid-svg-TjOgUqjTacLYpPFH .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-TjOgUqjTacLYpPFH .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-TjOgUqjTacLYpPFH .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-TjOgUqjTacLYpPFH :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 收盘价 Close
EMA(fast=12)
EMA(slow=26)
DIF = EMA_fast - EMA_slow
DEA = EMA(DIF, signal=9)
HIST = (DIF - DEA) × 2
红绿柱状图
DIF 线(黄)
DEA 线(粉)
-
DIF(差离值):快线 EMA 减慢线 EMA,反映短期趋势相对长期趋势的偏离程度
-
DEA(信号线):DIF 的 9 日 EMA,作为 DIF 的平滑参考
-
HIST(柱状图) :
(DIF - DEA) × 2,乘以 2 是中国市场的惯例写法(国际标准通常不乘 2),柱状图的正负变化是金叉/死叉的直观信号
python
def macd(close, fast, slow, signal):
dif = ema(close, fast) - ema(close, slow)
dea = ema(dif, signal)
hist = (dif - dea) * 2
return dif, dea, hist
6.5 RSI --- 相对强弱指标(Wilder 法)
RSI 衡量一段时间内平均涨幅与平均跌幅的比值,取值范围 0-100。项目采用 Wilder 平滑法(也称 Wilder's Smoothing),这是与 J. Welles Wilder 原始定义一致的实现方式:
#mermaid-svg-5qxwDJDvAhqSvEHH{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-5qxwDJDvAhqSvEHH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5qxwDJDvAhqSvEHH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5qxwDJDvAhqSvEHH .error-icon{fill:#552222;}#mermaid-svg-5qxwDJDvAhqSvEHH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5qxwDJDvAhqSvEHH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5qxwDJDvAhqSvEHH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5qxwDJDvAhqSvEHH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5qxwDJDvAhqSvEHH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5qxwDJDvAhqSvEHH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5qxwDJDvAhqSvEHH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5qxwDJDvAhqSvEHH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5qxwDJDvAhqSvEHH .marker.cross{stroke:#333333;}#mermaid-svg-5qxwDJDvAhqSvEHH svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5qxwDJDvAhqSvEHH p{margin:0;}#mermaid-svg-5qxwDJDvAhqSvEHH .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-5qxwDJDvAhqSvEHH .cluster-label text{fill:#333;}#mermaid-svg-5qxwDJDvAhqSvEHH .cluster-label span{color:#333;}#mermaid-svg-5qxwDJDvAhqSvEHH .cluster-label span p{background-color:transparent;}#mermaid-svg-5qxwDJDvAhqSvEHH .label text,#mermaid-svg-5qxwDJDvAhqSvEHH span{fill:#333;color:#333;}#mermaid-svg-5qxwDJDvAhqSvEHH .node rect,#mermaid-svg-5qxwDJDvAhqSvEHH .node circle,#mermaid-svg-5qxwDJDvAhqSvEHH .node ellipse,#mermaid-svg-5qxwDJDvAhqSvEHH .node polygon,#mermaid-svg-5qxwDJDvAhqSvEHH .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-5qxwDJDvAhqSvEHH .rough-node .label text,#mermaid-svg-5qxwDJDvAhqSvEHH .node .label text,#mermaid-svg-5qxwDJDvAhqSvEHH .image-shape .label,#mermaid-svg-5qxwDJDvAhqSvEHH .icon-shape .label{text-anchor:middle;}#mermaid-svg-5qxwDJDvAhqSvEHH .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-5qxwDJDvAhqSvEHH .rough-node .label,#mermaid-svg-5qxwDJDvAhqSvEHH .node .label,#mermaid-svg-5qxwDJDvAhqSvEHH .image-shape .label,#mermaid-svg-5qxwDJDvAhqSvEHH .icon-shape .label{text-align:center;}#mermaid-svg-5qxwDJDvAhqSvEHH .node.clickable{cursor:pointer;}#mermaid-svg-5qxwDJDvAhqSvEHH .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-5qxwDJDvAhqSvEHH .arrowheadPath{fill:#333333;}#mermaid-svg-5qxwDJDvAhqSvEHH .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-5qxwDJDvAhqSvEHH .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-5qxwDJDvAhqSvEHH .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5qxwDJDvAhqSvEHH .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-5qxwDJDvAhqSvEHH .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5qxwDJDvAhqSvEHH .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-5qxwDJDvAhqSvEHH .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-5qxwDJDvAhqSvEHH .cluster text{fill:#333;}#mermaid-svg-5qxwDJDvAhqSvEHH .cluster span{color:#333;}#mermaid-svg-5qxwDJDvAhqSvEHH div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-5qxwDJDvAhqSvEHH .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-5qxwDJDvAhqSvEHH rect.text{fill:none;stroke-width:0;}#mermaid-svg-5qxwDJDvAhqSvEHH .icon-shape,#mermaid-svg-5qxwDJDvAhqSvEHH .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5qxwDJDvAhqSvEHH .icon-shape p,#mermaid-svg-5qxwDJDvAhqSvEHH .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-5qxwDJDvAhqSvEHH .icon-shape .label rect,#mermaid-svg-5qxwDJDvAhqSvEHH .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5qxwDJDvAhqSvEHH .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-5qxwDJDvAhqSvEHH .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-5qxwDJDvAhqSvEHH :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 日收益率 diff = close.diff()
gain = max(diff, 0)
loss = max(-diff, 0)
avg_gain = gain.ewm(alpha=1/n, adjust=False)
avg_loss = loss.ewm(alpha=1/n, adjust=False)
RS = avg_gain / avg_loss
RSI = 100 - 100/(1+RS)
Wilder 法与简单移动平均的关键区别在于 ewm 的 alpha 参数。Wilder 使用的 alpha = 1/n 等价于 span = 2n - 1,这意味着 14 日 Wilder RSI 实际上等效于 27 日的标准 EMA。这种平滑方式衰减更慢,信号更稳定,是金融行业 RSI 计算的事实标准。
项目还处理了两个边界情况:当 avg_loss == 0(连续上涨无跌幅)时 RSI 直接取 100,当 avg_gain == 0(连续下跌无涨幅)时 RSI 取 0。这两个极端值在实践中极少出现但必须正确处理。
python
def rsi_wilder(close, n):
diff = close.diff()
gain = diff.clip(lower=0.0)
loss = (-diff).clip(lower=0.0)
avg_gain = gain.ewm(alpha=1.0/n, adjust=False, min_periods=n).mean()
avg_loss = loss.ewm(alpha=1.0/n, adjust=False, min_periods=n).mean()
rs = avg_gain / avg_loss.replace(0, np.nan)
out = 100 - 100 / (1 + rs)
out[avg_loss == 0] = 100.0
out[avg_gain == 0] = 0.0
return out
6.6 BOLL --- 布林带
布林带由三条线组成:中轨(N 日 MA)、上轨(中轨 + k × 标准差)、下轨(中轨 - k × 标准差)。标准差使用 ddof=0(总体标准差),与 John Bollinger 的原始定义一致:
python
def bollinger(close, n, k):
mid = sma(close, n)
std = close.rolling(n, min_periods=n).std(ddof=0)
return mid + k * std, mid, mid - k * std
上下轨之间的通道宽度反映了波动率:通道收窄意味着波动率降低(可能酝酿变盘),通道扩张意味着波动率升高。项目还使用 fill_between 在上下轨之间填充半透明色带,增强通道的视觉辨识度。
6.7 指标渲染参数
各指标在 mplfinance 中的渲染方式汇总:
| 指标 | addplot 类型 | panel | 线宽 | 透明度 | 特殊效果 |
|---|---|---|---|---|---|
| BOLL 上轨 | 线 | 0 | 1.0 | 0.9 | 虚线 linestyle="--" |
| BOLL 中轨 | 线 | 0 | 1.2 | 0.9 | 虚线 |
| BOLL 下轨 | 线 | 0 | 1.0 | 0.9 | 虚线 |
| BOLL 色带 | fill_between | 0 | --- | 0.07 | 半透明填充 |
| MA(N) | 线 | 0 | 1.3 | --- | 4 色循环 |
| MACD hist | bar | N | 0.7 | 0.85 | 正负分色 |
| DIF / DEA | 线 | N | 1.3 | --- | 黄/粉双色 |
| 零线 | 线 | N | 0.6 | --- | 点线 linestyle=":" |
| RSI | 线 | N+1 | 1.3 | --- | 单色 |
| 30/70 参考线 | 线 | N+1 | 0.7 | --- | 点线 |
| 最新价 | 线 | 0 | 0.9 | 0.75 | 虚线水平标注 |