
简介这是一份用于微信小程序开发学习与参考的模拟炒股类项目源码面向小程序初学者与前端开发者帮助理解完整的小程序工程结构、图表展示和股票行情相关交互逻辑同时涵盖页面跳转、数据存储及图表渲染等常见能力。压缩包共46个文件包括12个JSON配置、11个JavaScript逻辑文件、9个WXSS样式、8个WXML页面结构与5个PNG图片资源整体仅702KB其中JSON负责项目与页面配置JS处理页面逻辑与交互WXSS定义视觉样式WXML搭建页面骨架PNG提供界面图标。项目页面模块清晰涵盖登录、自选股、个股详情、指数查看及个人中心等典型炒股场景工具函数与全局配置独立组织适合边看边改或作为课程设计基础。目前已有422人学习下载可作为快速上手小程序开发、了解行情图表类界面搭建的实用参考也适合高校课程设计或毕业设计选题时直接借鉴其工程结构。1. 模拟炒股小程序为什么值得做数据、图表、交易三块硬骨头一次练齐小程序练手项目里“展示型 Demo”占了大半一个列表、一个详情页、几张写死的图片做完对技术的提升很有限。而「微信小程序开发模拟同花顺炒股小程序源码图表」这类项目名字叫“炒股”实际上不接任何真实行情、不产生真实交易更像一套练手组合题行情列表、K线分时、自选股、模拟买卖、状态同步每一块都要自己造对准备求职小程序开发、或者想彻底把 canvas 图表和页面状态管理啃下来的人来说这个方向比再做一遍商城划算太多。标题里“图表”两个字是含金量所在。第三方图表库在小程序里普遍体积大、定制受限而这个项目的核心做法是用 Canvas 手绘 K 线和分时图周期、均线、成交量全由数据驱动。接下来我按「数据层怎么造 → 图表怎么画 → 交易怎么算 → 哪些坑真机必踩」的顺序把这类源码项目最常见的实现路径完整拆给你看。2. 先拆项目骨架自造数据、双写状态、组件化图表一个能跑的模拟炒股小程序通常包含行情页、个股详情页、交易页、持仓页四个页面外加一套自选股逻辑。核心难点不在页面多而在四件事数据从哪来、页面之间的状态怎么同步、图表画在哪一层、交易规则怎么抽象。我把这个结构叫作“四层骨架”代码拿到手以后先按这个顺序读别急着改页面。2.1 行情数据为什么选择自造而不是接真实接口从数据可控角度算一笔账很多人拿到这类项目的第一反应是找真实行情接口我在一开始就劝你放弃。真实行情接口大多有服务协议和申请门槛个人开发者能拿到的 key 往往因为域名白名单、请求频率、用途限制被卡住更现实的是接口不稳定时 K 线缺根、分时断点排错成本会远超写业务代码本身。模拟数据则完全可控断网能跑、测试能跑、给面试官演示也能跑。我一般会把“行情真实性”控制在两个层面。第一数据结构真实日 K 线必须有 open、close、high、low、volume 五个字段分时数据要有时间和成交量的粒度第二数值关系真实high 必须是当天最高、low 必须是最低、open 和 close 落在二者区间内否则画出来就是一条乱线。做到这两点数据源即使是随机生成的用户也看不出破绽。2.2 用固定种子随机数生成 K 线单日 O/H/L/C 的约束关系是关键K 线生成不是简单抛硬币。我常用的一种做法是给每支股票一个固定种子比如股票代码的 hash 值用 mulberry32 伪随机数算法生成稳定的“走势”这样每次启动小程序数据一致调试时不会出现“这次涨下次跌”的玄学问题。下面这段代码就是完整的日 K 生成器。// 固定种子随机数同样的 code 永远生成同样的行情序列 function mulberry32(seed) { return function () { seed | 0; seed (seed 0x6D2B79F5) | 0; let t Math.imul(seed ^ (seed 15), 1 | seed); t (t Math.imul(t ^ (t 7), 61 | t)) ^ t; return ((t ^ (t 14)) 0) / 4294967296; }; } function hashCode(str) { let h 0; for (let i 0; i str.length; i) h (h * 31 str.charCodeAt(i)) | 0; return h; } // 生成日 K 线close 是核心open/high/low 围绕它展开 function generateKLine(code, days 120) { const rand mulberry32(hashCode(code)); let close 10 rand() * 40; // 初始价格 10~50 元 const list []; for (let i 0; i days; i) { const change (rand() - 0.48) * 0.06; // 单日涨跌 -3%~3% const open close * (1 (rand() - 0.5) * 0.02); // 开盘相对昨收 ±1% const closeNext close * (1 change); // 收盘由涨跌幅决定 // 关键约束high 必须大于 open/closelow 必须小于 open/close const high Math.max(open, closeNext) * (1 rand() * 0.015); const low Math.min(open, closeNext) * (1 - rand() * 0.015); list.push({ date: formatDate(i), open: round2(open), close: round2(closeNext), high: round2(high), low: round2(low), volume: Math.floor(5000 rand() * 20000) }); close closeNext; // 今天的收盘就是明天的昨收 } return list; }逻辑说明mulberry32是确定性随机数发生器输入同一个种子会得到完全相同的序列这样自选股列表、图表数据在不同页面间保持一致hashCode(code)把股票代码映射成一个整数种子不同股票走势不同同一股票每次启动数据一致。open和closeNext的计算顺序也刻意安排先算开盘、再算收盘high 和 low 再取两者的上下外扩这样四条价格线的逻辑关系永远不会破。参数上days 120表示生成 120 根日 K覆盖大约半年的交易日0.015是最高价和最低价的影线余量比例越大 K 线越长看起来越“妖”。涨跌幅0.06控制在 ±3%A 股日常波动一般就在这个范围附近如果把它改成 0.1 就是 ±5%、改成 0.2 就是 ±10%走势会变得很夸张做演示时按需调整。2.3 全局变量 Storage 双写解决“A 页面改了 B 页面不刷新”模拟炒股小程序最常见的状态问题是交易页买入成功、自选页添加成功但持仓页和行情页的数据还是旧的。原因是小程序每个页面是独立实例页面 A 修改数据后页面 B 不会自动感知。我一般会用“全局变量 Storage 双写”的轻量方案全局变量负责页面间快速读取Storage 负责持久化页面onShow时统一从全局读一次。// app.js App({ globalData: { positions: [], // 持仓 favorites: [], // 自选股 cash: 100000 // 虚拟资金 }, onLaunch() { // 冷启动时从 Storage 恢复 this.globalData.positions wx.getStorageSync(positions) || []; this.globalData.favorites wx.getStorageSync(favorites) || []; this.globalData.cash wx.getStorageSync(cash) || 100000; } });// 任意页面里读取和写入 const app getApp(); app.globalData.positions.push(newTrade); wx.setStorageSync(positions, app.globalData.positions);这里的核心原则是所有写操作同时更新globalData和Storage读操作优先走globalData页面onShow时重新拉一次。注意要直接把整个数组传给setStorageSync不要只传新增的那一项否则下次冷启动会丢数据。2.4 拿到源码先认这五个文件目录结构与职责这类项目的源码目录通常不会太散我建议先按下面这张表找对应关系。路径职责重点关注pages/index/行情列表页自选/全部切换、涨跌幅排序列表复用、滚动加载pages/detail/个股详情页K线/分时切换、最新价图表组件接入、跨页面通信pages/trade/模拟买卖、持仓、成交记录交易规则、资金计算utils/stock.js行情生成器、交易计算、费用计算所有纯逻辑都在这components/kline/Canvas 图表组件K线和分时图canvas 生命周期、尺寸适配第五个文件是重中之重。K 线图千万不要写在详情页的onReady里而是封装成自定义组件因为走势图、分时图、收益曲线都会用到同一套绘制逻辑组件化之后顺便解决 canvas 在多个页面间重复初始化的问题。拿到源码先跑通utils/stock.js的导出数据再去看组件这是最快的路径。3. 用 Canvas 手绘 K 线和分时图图表不是贴图是自己画的很多项目里图表是引第三方库或者用 image 标签贴图前者体积大、定制麻烦后者根本没法做十字光标这类交互。这类模拟炒股项目给的图表主流做法是原生 Canvas 2d 手绘。做之前先弄清楚一个前提小程序里 canvas 有两种形态老接口canvas type2d之前是原生组件层级最高、容易遮挡弹窗现在推荐type2d的新接口走同层渲染可以正常跟 view 一起滚动。下面的代码全部按新接口写。3.1 坐标转换三件套价格区间、像素坐标、留白参数K 线图的本质是把“价格区间”映射到“画布像素矩形”。三个坐标系要分清数据索引是横轴价格是纵轴画布坐标是最终输出。我封装一个布局对象所有绘制函数都从它取坐标避免每个函数自己算一遍留白。// 图表布局把价格区间换算成画布坐标 function createLayout(width, height, padding) { const left padding.left || 8; const right padding.right || 8; const top padding.top || 20; // 顶部留给最高价标签 const bottom padding.bottom || 16; return { innerWidth: width - left - right, innerHeight: height - top - bottom, // i: 第几根K线, step: 每根K线占的像素宽度 makeX(i, step) { return left i * step; }, // price: 当前价格, max/min: 整个图表的最高最低价 makeY(price, max, min) { return top ((max - price) / (max - min)) * (height - top - bottom); } }; }参数说明step由 K 线实体宽度和间距相加得到比如蜡烛宽 7px、间距 2pxstep 就是 9pxtop 20是因为最高价标签要画在图表区域上方不预留会被裁掉min为 0 时公式会把 0 价映射到图表底部所以真实绘图时max取最高价、min取最低价而不是 0。这里有个容易出错的地方makeY的坐标轴方向。画布 y 轴向下增长价格越高 y 值越小所以要用(max - price) / (max - min)而不是(price - min) / (max - min)。3.2 蜡烛图绘制影线、实体、MA5/MA10/MA20 都从数据算出来绘制顺序我一般固定为先算均线数组再画均线最后画蜡烛实体。这样交叉处蜡烛能盖住均线视觉上不会出现均线穿过实体内部的脏线。红涨绿跌是国内行情软件的通用习惯代码里用两个颜色常量统一控制。const UP_COLOR #ef4444; // 涨红 const DOWN_COLOR #10b981; // 跌绿 const CANDLE_WIDTH 7; // 蜡烛实体宽度 const CANDLE_GAP 2; // 蜡烛间距 // 简单均线MA5 / MA10 / MA20 function calcMA(list, n) { return list.map((item, i) { if (i n - 1) return null; const slice list.slice(i - n 1, i 1); return round2(slice.reduce((s, v) s v.close, 0) / n); }); } function drawKLine(ctx, list, layout) { const { innerWidth, innerHeight, makeX, makeY } layout; const allHigh Math.max(...list.map((it) it.high)); const allLow Math.min(...list.map((it) it.low)); const step CANDLE_WIDTH CANDLE_GAP; const totalWidth list.length * step; // 图表宽度不够时只画最后能装下的根数避免 K 线被挤扁 const startIndex totalWidth innerWidth ? list.length - Math.floor(innerWidth / step) : 0; const data list.slice(startIndex); const maColors [[#f59e0b, 5], [#3b82f6, 10], [#a855f7, 20]]; // 先画均线 maColors.forEach(([color, n]) { const maArr calcMA(data, n); ctx.strokeStyle color; ctx.lineWidth 1; ctx.beginPath(); maArr.forEach((v, i) { if (v null) return; const x makeX(i, step); const y makeY(v, allHigh, allLow); i 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }); ctx.stroke(); }); // 再画蜡烛影线和实体分开画 data.forEach((item, i) { const x makeX(i, step); const isUp item.close item.open; ctx.strokeStyle ctx.fillStyle isUp ? UP_COLOR : DOWN_COLOR; // 影线最高价到最低价的竖线 ctx.beginPath(); ctx.moveTo(x CANDLE_WIDTH / 2, makeY(item.high, allHigh, allLow)); ctx.lineTo(x CANDLE_WIDTH / 2, makeY(item.low, allHigh, allLow)); ctx.stroke(); // 实体open 到 close 的矩形 const yOpen makeY(item.open, allHigh, allLow); const yClose makeY(item.close, allHigh, allLow); const top Math.min(yOpen, yClose); const height Math.max(Math.abs(yOpen - yClose), 1); ctx.fillRect(x, top, CANDLE_WIDTH, height); }); }两点必须要说明。第一startIndex的逻辑是在屏幕宽度不足以画全部 120 根 K 线时只画最后能放下的那一段否则所有蜡烛会被压成一条线段数不够、蜡烛太密是这类图表最常见的观感问题。第二CANDLE_WIDTH 7是我在真机上试出来的一个舒适值iPhone 逻辑宽度 375px 时7px 实体加 2px 间距一屏能看清约 40 根缩放后不会糊改成 5 以下就太挤10 以上又太粗。3.3 分时图双区绘制价格线在上、成交量柱在下分时图跟 K 线最大的区别是它只有一个交易日的数据横轴是 240 分钟A 股 9:30-11:30、13:00-15:00 各 120 分钟纵轴价格要相对昨收计算。常见做法是把画布上下分成两个区域上方 70% 画价格折线下方 30% 画成交量柱这样一张 canvas 解决两个数据维度不用叠加两层。// minuteList: [{ time: 09:31, price: 10.22, volume: 1200 }, ...] // prevClose: 昨收价 function drawMinute(ctx, minuteList, layout, prevClose) { const { innerWidth, innerHeight, makeX, makeY } layout; const priceAreaHeight innerHeight * 0.7; const volAreaHeight innerHeight * 0.3; const maxVol Math.max(...minuteList.map((it) it.volume)); // 价格轴范围昨收上下 2%超出部分自动裁掉 const minPrice prevClose * 0.98; const maxPrice prevClose * 1.02; // 价格折线 ctx.strokeStyle #2563eb; ctx.lineWidth 1; ctx.beginPath(); minuteList.forEach((item, i) { const x makeX(i, innerWidth / 240); const y ((maxPrice - item.price) / (maxPrice - minPrice)) * priceAreaHeight; i 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }); ctx.stroke(); // 昨收基准线一条水平虚线 ctx.setLineDash([4, 4]); ctx.strokeStyle #94a3b8; const baseY ((maxPrice - prevClose) / (maxPrice - minPrice)) * priceAreaHeight; ctx.beginPath(); ctx.moveTo(0, baseY); ctx.lineTo(innerWidth, baseY); ctx.stroke(); ctx.setLineDash([]); // 成交量柱涨红跌绿宽度固定 1px minuteList.forEach((item, i) { const x makeX(i, innerWidth / 240); const h (item.volume / maxVol) * volAreaHeight; ctx.fillStyle item.price prevClose ? UP_COLOR : DOWN_COLOR; ctx.fillRect(x, priceAreaHeight volAreaHeight - h, 1, h); }); }分时图有个容易翻车的地方价格线和成交量柱不在同一个坐标系里但共用横轴。上面代码里横轴直接用innerWidth / 240做步长也就是 240 个点均匀铺满整个图表宽度这样成交量柱的 x 才能和价格折线严格对齐。成交量的高度是按最大值等比例缩放到下方 30% 区域如果某根量特别大其他柱会被压得很矮这是正常现象不必强行放大。4. 自选股和模拟交易虚拟盘也有真规则很多人觉得模拟盘的交易逻辑随便写写就行实际上虚拟盘比真实交易更需要规则——因为真实交易有交易所帮你结算模拟盘所有数字都要自己算。买入撮合、手续费、持仓成本、可卖数量每一处写错都会让页面上的数字对不上用户一旦发现“账户总资产不等于现金加市值”整个项目就废了。4.1 自选股列表的幂等增删别用对象存自选自选股最基础的坑是用“对象”存。很多初学者喜欢favorites[code] stock短时间没问题但页面里遍历Object.keys时顺序完全不可控还会出现 A 股代码 6 开头、0 开头混排后的诡异排序。我一般只用数组并且保证增删操作是幂等的——重复添加同一支股票不会产生两条记录。// 添加自选已存在则忽略新加的放最前面 function addFavorite(list, stock) { if (list.some((it) it.code stock.code)) return list; const next [stock, ...list]; wx.setStorageSync(favorites, next); return next; } // 删除自选过滤掉目标代码 function removeFavorite(list, code) { const next list.filter((it) it.code ! code); wx.setStorageSync(favorites, next); return next; }some判断是幂等的关键不加这一句用户在详情页反复点“加自选”就会攒出一串重复项。删除用filter而不是splice因为splice需要先findIndex两步操作在函数式写法里容易漏判“不存在的代码”filter一步到位。所有写操作都同步setStorageSync这样冷启动后自选列表才不会丢。4.2 买入计算器一手 100 股、最低佣金 5 元、T1模拟盘虽然没有真实资金但交易规则建议按 A 股来买入按 100 股一手取整佣金万 2.5 且最低 5 元卖出一方额外收印花税。把费用规则做成纯函数交易页、持仓页、资产统计页用同一套计算避免各算各的对不上。// 买入费用佣金 max(金额 * 费率, 最低佣金) function calcBuy(price, shares, feeRate 0.00025, minFee 5) { const amount price * shares; const fee Math.max(amount * feeRate, minFee); return { amount: round2(amount), fee: round2(fee), total: round2(amount fee) // 实际扣款 成交金额 佣金 }; } // 可买数量按手取整不足一手不能买 function maxBuyable(cash, price) { const lots Math.floor(cash / (price * 100)); return lots * 100; } // 执行买入更新现金、写入持仓 function executeBuy(positionList, stock, price, shares) { const { total, fee } calcBuy(price, shares); const app getApp(); if (app.globalData.cash total) return null; app.globalData.cash round2(app.globalData.cash - total); let pos positionList.find((it) it.code stock.code); if (pos) { // 加仓持仓成本按摊薄计算手续费计入成本 const oldTotal pos.costPrice * pos.shares; pos.shares shares; pos.costPrice round2((oldTotal total) / pos.shares); } else { positionList.push({ code: stock.code, name: stock.name, shares, sellable: 0, // T1当日买入不可卖 frozen: shares, costPrice: round2(total / shares), buyDate: formatToday() }); } wx.setStorageSync(positions, positionList); wx.setStorageSync(cash, app.globalData.cash); return { total, fee }; }逻辑说明加仓时旧持仓市值加上本次总花费含佣金再除以总股数得到摊薄后的持仓成本。这个成本价会比单纯按成交价算略高但更接近真实账户的“盈亏平衡价”。买入后sellable 0、frozen shares对应 A 股 T1 规则——当天买的股票当天不能卖出这个细节对模拟盘的体验感提升非常大。4.3 持仓的 sellable 与 frozen当日买入为什么不能马上卖持仓结构里区分“可卖数量”和“冻结数量”之后卖出校验就很简单了查看持仓时判断sellable 卖出数量而不是shares 卖出数量。很多新手直接把总股数当可卖数会出现“上午买入、下午卖出还成功”的假账。跨交易日后需要做解冻把前一天的frozen转进sellable。// 每日首次进入交易页时解冻前一天仓位 function unlockPosition(pos, today) { if (pos.buyDate ! today pos.frozen 0) { pos.sellable pos.frozen; pos.frozen 0; pos.buyDate today; // 避免重复解冻 } }解冻逻辑放进onShow每次调用一次即可注意用buyDate ! today做幂等判断。如果忘记更新buyDate同一笔冻结仓会在每次进页面时重复解冻持仓总股数不变但sellable会莫名其妙越加越多。这个字段是冻结状态的状态位也是整个交易模块最容易漏掉的一环。5. 图表与交易联调的 5 个坑每个都是真机才能复现的开发工具里一切正常一上真机就翻车这是小程序 canvas 和状态同步的老传统。下面 5 个坑我按“现象 → 原因 → 解决”整理全部来自实际调试经验按顺序过一遍能省下大量排查时间。5.1 最后一根 K 线暴露“未来数据”现象早上 10 点打开详情页K 线图最后一根却是一根完整的 3% 大阳线仿佛行情已经收盘。原因行情生成器把“当天”也生成了一根完整日 K但盘中时分时数据才走到一半两根数据的“当前价”对不上。解决生成日 K 时把当天排除K 线数据截至上一个交易日当天的最新价单独维护只在分时图和顶部价格上展示。改动很小但数据逻辑一下就自洽了。我习惯在生成器参数里加一个excludeToday: true开关调试时再打开看全量数据。5.2 canvas 在 scroll-view 里卡住滚动现象详情页上下滑动时手指划到 K 线图区域就卡死图表区域像一块钢板。原因老版 canvas 是原生组件手势事件优先被 canvas 消费而且原生组件层级最高盖住了滚动容器的触摸响应。解决优先把 canvas 迁到type2d新接口走同层渲染后滚动冲突基本消失如果还在用老接口临时方案是给 canvas 加catchtouchmovenoop阻止事件冒泡或者包一层disable-scroll{{true}}。5.3 iPhone 分时图顶部出现 1px 白边现象分时线最高点附近有一条细白边或者最高价刚好被画布上边缘裁掉。原因canvas 的 CSS 尺寸用了 rpx 转 px但换算结果有小数绘制时按小数像素计算边界出现亚像素渲染误差真机上还有 dpr像素比缩放不处理就会在边界处露白。解决初始化 canvas 时用wx.createSelectorQuery拿到节点实际像素宽度乘dpr后取整再ctx.scale(dpr, dpr)。这是 canvas 2d 的标准初始化姿势所有绘制代码都跑在缩放后的坐标系里一劳永逸。const query wx.createSelectorQuery(); query.select(#kline).fields({ node: true, size: true }).exec((res) { const canvas res[0].node; const ctx canvas.getContext(2d); const dpr wx.getWindowInfo().pixelRatio; // 取整是关键小数像素会引发边界渲染问题 canvas.width Math.floor(res[0].width * dpr); canvas.height Math.floor(res[0].height * dpr); ctx.scale(dpr, dpr); });5.4 自选股顺序每次进页面都变现象昨天添加的自选股今天打开页面顺序完全变了有时候还按代码大小升序排。原因八成是用了对象存自选比如{ [code]: stock }。JS 对象里整数类型的 key 会按数值自动升序排列字符串 key 按插入序而股票代码是数字字符串两种规则混在一起遍历顺序根本不可控。解决统一改用数组存储新加自选用unshift放头部删除用filter整个数组直接存 Storage顺序一定是稳定的。5.5 买入成功但持仓页纹丝不动现象交易页提示“买入成功”返回持仓页一看持仓列表还是旧的金额也没变。原因持仓页的数据在onLoad里只读了一次之后页面实例没有销毁onLoad不会重新执行Storage 里的数据明明变了页面没感知。解决交易页买入成功后调用一个全局事件通知持仓页在onShow里重新拉数据。事件通知不用引第三方库几行代码就能实现。// utils/event.js极简事件总线 const listeners {}; function on(event, fn) { (listeners[event] listeners[event] || []).push(fn); } function emit(event, data) { (listeners[event] || []).forEach((fn) fn(data)); }// 持仓页 onShow() { this.refreshPositions(); // 从 app.globalData 重读持仓并 setData } // 交易页买入成功后 emit(trade, { type: buy });强调一点onShow里刷新是兜底方案事件通知是即时方案两者一起用才能覆盖“页面已存在”和“页面被销毁重建”两种场景。只靠事件通知冷启动进持仓页时会漏掉之前的状态只靠onShow从交易页返回时会有几百毫秒的旧数据闪一下。6. 加分项把 Demo 做成面试作品前三章做完这个项目已经是一个能跑的模拟炒股小程序但离“拿得出手”还差三件事数据是死的交互是点的战绩是空的。下面三个方向按性价比排序时间不够就只做第一个。6.1 模拟 WebSocket 推送让分时线自己动真实行情是服务端不断推送 tick小程序项目里想体现这个能力可以用定时器模拟推送每两秒更新一次当前价重绘分时图的最后一个点。定时器要挂在页面实例上onUnload和onHide里都要清理否则页面返回列表后还在后台刷数据这是闭包清理的经典翻车点。真机演示时长按页面不退出分时线像真行情一样自己走说服力瞬间拉满。startPush() { this.timer setInterval(() { const tick this.data.lastPrice * (1 (Math.random() - 0.5) * 0.002); this.updateMinute(tick); // 追加一个点、重绘分时图 }, 2000); } onUnload() { if (this.timer) clearInterval(this.timer); }6.2 长按十字光标把交互精度做出来K 线图加十字光标是区分“会画图”和“会做图表交互”的分水岭。实现思路不复杂touchstart时记录触摸点通过(x - paddingLeft) / step反推出 K 线索引再在对应 x 坐标上画一条竖线和价格标签touchmove时更新索引重绘整张图。关键是要对索引做边界钳制Math.max(0, Math.min(len - 1, idx))否则手指划出图表范围时十字光标会飞到画布外面。几百根 K 线重绘一次在真机上完全流畅不需要做局部刷新优化。6.3 交易战绩页收益率、胜率、回撤模拟盘做完了总得有张成绩单。我建议加一个轻量统计页总资产 现金 各持仓市值累计收益率 总资产 / 初始资金 - 1每日收盘时把总资产快照存进数组收益曲线直接用第 3 章的折线绘图逻辑复用最大回撤 历史上从峰值到谷底的最大跌幅这是面试时能讲出细节的指标也是普通 Demo 项目里很难看到的函数。function calcMaxDrawdown(assetCurve) { let peak assetCurve[0], maxDd 0; assetCurve.forEach((v) { peak Math.max(peak, v); maxDd Math.max(maxDd, (peak - v) / peak); }); return round2(maxDd * 10000) / 100; // 返回百分比 }我的个人习惯是把这个统计页放在最后一个做因为它依赖交易记录、持仓快照、行情数据全部打通任何一环数据不一致都会直接暴露。做完它整个项目的闭环才算真正闭合。回想我自己做同类项目时最大的教训就是一开始急着铺五个页面结果每个页面都在处理数据不一致后来改成“先打通一根 K 线的数据链再把页面复制出去”效率至少高出一倍。这个顺序你也可以试试希望帮到你。本文还有配套的精品资源点击获取