
简介基于ThinkPHP的二次开发福汇微盘源码是一套带时间盘与K线采集的微盘交易系统实现适合有PHP基础、希望深入金融交易平台开发的程序员学习参考。压缩包约34.23MB目前已有583人学习。源码在原生ThinkPHP框架上进行了定制化改进包括交易规则调整、用户界面优化与后台管理增强可帮助开发者理解MVC分层下业务模块的扩展方式。时间盘模块涉及实时行情展示与前端交互K线采集则涵盖接口对接、数据请求与图表渲染等关键技术点针对K线失效场景也给出了自行寻找API接口的排错思路。通过阅读这套源码能同时接触PHP后端逻辑、前端图表呈现与行情数据流处理对构建类似微盘或模拟交易系统有直接参考价值。1. 拿到ThinkPHP二开福汇微盘源码先别急着改下单逻辑我的建议是先把“行情能不能出”这条链路理清。这类二手源码表面上是PHP写的一套微盘系统包含用户、订单、账户资金流水但交易盘面依赖的数据却来自外部行情源。如果源码作者只留了一个静态JSON或者一行假循环你就算把前端的图表库换掉也没用。真正需要二开的是三个部分K线采集、时间盘聚合、行情数据与业务订单的关联。这篇文章按这个顺序展开每一节都给出能直接落进ThinkPHP项目的代码和数据表设计。适合正在接手这类“半成品”项目、不得不自己补行情链路的PHP开发者。2. 二开前先把行情链路和数据表对齐2.1 一条交易请求背后要经过哪些模块微盘源码里的下单流程往往很简单前端选一个品种点“买涨”或“买跌”后端把订单金额和目标方向写进数据库等到期时间到了拿一个“最近价格”和“开仓价格”对比判断盈亏。这里最容易出问题的就是那个“最近价格”和“到期价格”到底从哪来。如果K线采集没有真正跑起来订单结算就会落到一个固定值上盘面看起来像演示实际不能通。因此二开的第一步不是改订单控制器而是确认三件事。第一行情源能不能稳定给出某个品种的最新价和到达时间第二这些价格怎么聚合成1分钟、5分钟、15分钟、1小时等“时间盘”周期第三结算时读的是哪一张表是实时缓存还是Kline表中的最新收盘价。这三点对应三个模块采集器Crawler、聚合器Aggregator、行情查询API。它们是兄弟模块必须在同一套ThinkPHP项目里共享数据库或Redis。2.2 K线表结构一张表还是按周期分表老源码常见的做法是每个周期建一张表比如kline_1m、kline_5m、kline_1h。这样做在数据量不大时查询简单但二开时会发现很痛苦聚合逻辑写死了一堆SQL新增一个周期就要建一张新表而且用ThinkPHP的模型查询时还得临时拼表名。我一般建议用一张表加period字段配合联合唯一索引。字段类型说明idint(11) unsigned自增主键symbolvarchar(20)品种代码如EURUSD、XAUUSDperiodvarchar(10)周期标识M1/M5/M15/M30/H1/H4/D1tsint(11) unsigned周期起始时间的UTC时间戳opendecimal(12,5)开票价highdecimal(12,5)最高价lowdecimal(12,5)最低价closedecimal(12,5)收盘价volumeint(11)该周期内成交量可为0create_timeint(11)写入时间这张表的唯一索引是(symbol, period, ts)。为什么必须加唯一索引因为采集器可能会因网络抖动而重复写入同一根K线没有唯一索引后面做幂等重试会非常麻烦。实际查询中一条K线的定位就是“品种周期起始时间”这个三元组足够唯一。除了K线表微盘业务自己还有用户表、订单表、资金流水表。二开时最需要留意的是订单表里是否冗余了open_price和close_price字段。如果冗余了那数据采集的延迟会直接影响订单结算因此K线采集脚本的稳定性优先级要高于缓存优化。2.3 代码目录ThinkPHP模块怎么摆才不打架ThinkPHP 3.2的老项目最头疼的是所有控制器混在一起。二开这种带行情采集的项目我的习惯是开启CLI入口把采集和聚合单独放一个模块避免HTTP请求超时或进程被Web服务回收。目录结构大致这样Application ├─ Home │ ├─ Controller │ │ ├─ KlineController.class.php // 行情K线接口 │ │ └─ QuoteController.class.php // 最新报价接口 │ └─ Model │ └─ KlineModel.class.php ├─ Admin │ └─ Controller │ └─ SymbolController.class.php // 后台维护品种和周期 ├─ Cli │ └─ Controller │ ├─ CrawlController.class.php // 采集 │ └─ AggController.class.php // 聚合时间盘 └─ Common └─ Service ├─ MarketService.class.php // 统一行情服务 └─ KlineCacheService.class.php // Redis缓存在ThinkPHP 3.2里CLI入口一般放在项目根目录的think.php调用方式类似php think.php Cli/crawl --symbolEURUSD。到了ThinkPHP 5.1或6命令行的写法是php think crawler:run --symbolEURUSD但在二说到底层逻辑时采集和业务代码尽量不要耦合进控制器。常见做法是写一个MarketService采集器只把价格和窗口时间传进去由服务完成Redis更新、K线落库、触发订单结算。这样以后从CLI换到Swoole常驻进程改动也只在入口层。3. K线采集从Tick到落地K线的完整实现3.1 行情源接入选择HTTP轮询比想象中更可靠做K线采集首先要决定用哪种方式接行情源。常见选择有WebSocket长连接、HTTP轮询、文件导入。WebSocket延迟最低但老ThinkPHP项目要常驻一个进程还得处理断线重连和心跳。文件导入适合做历史数据回补不实时。绝大多数我见过的二开项目最终都用HTTP轮询每秒或每两秒请求一次最新报价代价小出问题易排查对模拟盘来说完全够用。接入方式实时性实现难度适配场景HTTP轮询1-2秒延迟低一行循环模拟盘、非高频策略WebSocket毫秒级高需常驻进程实时性要求高的交易文件导入分钟级低一次性历史K线回补我一般这样设计采集脚本里设置一个循环每次请求一个行情源URL返回JSON里面包含最新价、品种代码和时间戳。为了兼容不同行情源的返回结构写一个适配器方法统一输出[symbol,price,time]。这样后续换行情源只改适配器不动聚合逻辑。3.2 用Redis聚合当前1分钟K线拿到Tick价格后不能直接写数据库否则每秒一行数据表很快就膨胀。常规做法是先在内存或Redis里聚合出当前1分钟K线。这里的关键是时间窗口一分钟K线的ts必须对齐到那个分钟的开始秒上例如12:30:45的价格ts应该是12:30:00。// 用ThinkPHP的缓存服务操作Redis class CrawlController { public function tick() { set_time_limit(0); $symbol I(get.symbol, EURUSD); while (true) { $quote $this-fetchQuote($symbol); if ($quote) { $this-updateMinuteKline($quote[symbol], $quote[price], $quote[time]); } sleep(1); } } private function updateMinuteKline($symbol, $price, $time) { $window $time - ($time % 60); // 对齐到分钟开始 $key kline:m1:{$symbol}:{$window}; $cache S(array(type redis)); $kline $cache-get($key); if (!$kline) { $kline array( o $price, h $price, l $price, c $price, ); } else { // 更新最高价、最低价、收盘价 $kline[h] max($kline[h], $price); $kline[l] min($kline[l], $price); $kline[c] $price; } $cache-set($key, $kline, 120); // 保存两分钟给落库脚本充足时间 } }这段代码的核心是按60取模对齐时间戳。$time % 60得到的是当前秒数减去它就到分钟开头。注意这里缓存过期时间设置成120秒意味着如果落库脚本每分钟执行一次上一根K线还有机会被读取到。如果一次轮询周期是1秒实际上一分钟会产生60次更新但都只写同一把Redis key。3.3 落库与幂等insertAll前先删旧窗口每分钟到了下一分钟需要把已经成型的上一分钟K线从Redis取出来写入数据库。这里我通常是再跑一个flush方法但更靠谱的是在采集循环里判断窗口是否变化一旦变化立即落库。private function flushKline($symbol, $window) { $cache S(array(type redis)); $key kline:m1:{$symbol}:{$window}; $kline $cache-get($key); if (!$kline) { return; } $data array( symbol $symbol, period M1, ts $window, open $kline[o], high $kline[h], low $kline[l], close $kline[c], ); // 先删除同窗口可能存在的脏数据再插入 M(kline)-where(array(symbol $symbol, period M1, ts $window))-delete(); M(kline)-add($data); $cache-rm($key); }先删再插是依赖了前面说的联合唯一索引。如果采集脚本在整分钟边界发生重入可能同一秒落下两个同样窗口的写入。先删再插虽然可能造成一瞬间没有数据但对整个行情链路来说可以接受最少保证最终只有一条。3.4 历史K线回补一次性任务怎么写上线前一定要回补历史K线否则前端图表打开是空的。常见做法是写一个命令行方法按天往前拉1分钟数据源然后交给同一套聚合逻辑。因为历史数据量不会太大可以直接每秒拉一次用insertAll批量写入。这里有个技巧不要循环单条插入而是攒够1000根再insertAll。public function history($symbol EURUSD, $days 30) { $list array(); for ($i $days; $i 0; $i--) { $day date(Y-m-d, strtotime(-$i day)); $items $this-fetchHistoryKlines($symbol, $day); foreach ($items as $it) { $list[] array( symbol $symbol, period M1, ts $it[ts], open $it[open], high $it[high], low $it[low], close $it[close], volume $it[volume], ); } } // 分批写入配合REPLACE处理重复 if ($list) { $model M(kline); foreach (array_chunk($list, 1000) as $chunk) { $model-addAll($chunk); } } }addAll在ThinkPHP 3.2里是批量插入但默认不会处理重复所以建议在插入前用where删除这几天的数据。历史回补最怕中断删除操作别放循环里先按symbolperiod时间范围删除再插入整体一致性更好。4. 时间盘多周期K线聚合与查询接口4.1 周期定义一个映射表决定前端能切哪些周期前端所谓的时间盘就是让用户能切换M1、M5、M15、M30、H1、D1这些周期。后端要有一个清晰的定义告诉聚合脚本每个周期对应多少秒以及前端参数名如何映射。我用一个静态配置数组来管理。period秒数显示名称M1601分钟M53005分钟M1590015分钟M30180030分钟H136001小时H4144004小时D186400日线在ThinkPHP里这些配置可以放在Common/Conf/timeperiod.php下聚合脚本和前端接口共用同一份配置。二开时最怕的是后端把M5写成5m前端又写成5min三个地方对不上。统一用大写M加数字这种格式约定一处就没有所谓“时间盘切不动”的问题。4.2 从1分钟K线聚合成5分钟K线思路是取1分钟K线列表按目标周期的窗口重新开高低收。例如5分钟K线的窗口就是ts - ts % 300。聚合用SQL也能做但老版本MySQL没有窗口函数写起来绕而且聚合脚本本身也不是特别高频直接用PHP遍历更稳妥。public function build($symbol, $targetPeriod) { $conf C(time_periods); if (!isset($conf[$targetPeriod])) { return false; } $step $conf[$targetPeriod]; // 只读取最近24小时的1分钟K线 $startTime time() - 86400; $rows M(kline) -where(array( symbol $symbol, period M1, ts array(egt, $startTime), )) -order(ts asc) -select(); $map array(); foreach ($rows as $row) { $window $row[ts] - $row[ts] % $step; if (!isset($map[$window])) { $map[$window] array( open $row[open], high $row[high], low $row[low], close $row[close], ); } else { $map[$window][high] max($map[$window][high], $row[high]); $map[$window][low] min($map[$window][low], $row[low]); $map[$window][close] $row[close]; } } // 写入目标周期表先删后插 M(kline)-where(array(symbol$symbol, period$targetPeriod))-delete(); $data array(); foreach ($map as $window $line) { $data[] array( symbol $symbol, period $targetPeriod, ts $window, open $line[open], high $line[high], low $line[low], close $line[close], ); } M(kline)-addAll($data); }这个脚本可以放进Cli/AggController里定时执行。聚合的范围要控制好我上面的示例只聚合最近24小时因为历史周期线通常在第一次部署时已经回补过实时聚合只要覆盖最近一段时间就够。如果你在意更完整的日线可以在构建D1时额外从数据库里读取全部M1数据。4.3 前端时间盘查询接口从缓存读还是实时查查询接口的思路很简单前端传symbol和period后端从数据库按时间倒序取200根K线返回给图表库。但每次都查询数据库在秒推级别的前端轮询下压力会很大。我一般会用ThinkPHP的S缓存把symbol period组合缓存60秒。// Home/Controller/KlineController.class.php public function chart() { $symbol I(get.symbol, EURUSD); $period I(get.period, M1); $limit I(get.limit, 500); $cacheKey chart:{$symbol}:{$period}:{$limit}; $data S($cacheKey); if (false $data) { $where array( symbol $symbol, period $period, ); $data M(kline) -where($where) -order(ts desc) -limit($limit) -select(); // 反转成时间正序前端图表通常按时间从左到右 $data array_reverse($data); S($cacheKey, $data, 60); } $this-ajaxReturn(array(code 0, data $data)); }用S缓存60秒的代价是周期K线最新一根可能不是最新的。前端通常在切换完周期后再请求一次QuoteController的最新报价把最后一根K线的close值临时替换成实时价。这样既享受到缓存性能又不让用户感到盘面“卡住”。4.4 时间盘必须避免的坑二开中最常见的问题是跨周期分钟数对不齐。例如你从9点30分开始记录第一根5分钟K线时间戳应该是9:30:00但如果聚合脚本直接用当前时间戳对300取整一小时内的窗口会变成9:25、9:30、9:35……对齐基准不一致K线图上的时间标签就是乱的。解决办法是给时间盘聚合一个基准偏移配置比如凌晨0点开始也就是0秒对齐。上面的代码已经这么做了ts - ts % $step天然从1970-01-01 00:00:00对齐保证M1的整点窗口和M5、M15是同步的。另一个坑是聚合脚本重复执行时把目标周期的表全删了再全量重算。如果行情量太大脚本执行时间长这段时间前端就查不到数据。改为按最近时间范围增量更新或者先插入再删掉更早的数据能明显降低查询空洞。5. 二开里的三个必要细节时区、补数、高频写入5.1 时间戳统一用UTC前端显示再转时区很多微盘源码在数据库里存的是本地时间比如东八区的时间字符串。这在K线采集上会造成灾难同一根K线在不同服务器时区下聚合的窗口会不一样。只要换成国内服务器本地时间和UTC差8小时日线K线在换日时会出现偏差。我强烈建议所有行情相关字段都用UTC时间戳前端拿到ts后通过JavaScript的toLocaleTimeString转成用户本地时区。数据库里保留一个ts字段不要额外再加字符串时间列。5.2 校验K线连续性的一个SQL采集脚本跑了一阵子怎么知道有没有漏数据可以统计两个相邻时间戳之间是否多于一个周期。比如M1数据理论上相邻ts应该相差60秒。用一条SQL就能找出缺口SELECT a.ts AS prev_ts, b.ts AS current_ts FROM kline a INNER JOIN kline b ON b.symbol a.symbol AND b.period a.period AND b.ts a.ts WHERE a.symbol EURUSD AND a.period M1 AND b.ts - a.ts 60 ORDER BY a.ts DESC LIMIT 20;这个检查可以放到定时任务中每晚跑一次发现缺口就触发历史回补。用一条JOIN就能快速定位是哪一段缺失不用写复杂的循环判断。5.3 高频写入优化合并Redis触发落库如果行情源每秒推送一次每分钟会产生60次更新。如果你没有做上面的Redis聚合而是直接写数据库一个月后Kline表会以每天86400行的速度膨胀。用Redis聚合后每分钟只有一条M1写入再产生时间盘聚合数据量小很多。这里的关键是别在采集循环内部直接调用聚合脚本而是用一个标志位判断当前分钟窗口是否已经变化private $lastFlushWindow 0;每次tick时如果$window ! $this-lastFlushWindow就说明到了新的一分钟先冲掉上一分钟K线再更新当前窗口。这样可以避免在循环中重复调用flushKline也防止Redis key被提前删除。5.4 混乱时用--once参数做手工重放上线调试时最怕采集脚本写成了while(true)想跑一次测试却停不下来。我会在CLI控制器里设计一个--once参数当传入该参数时只跑一次拉取和聚合然后直接退出。这样手动补数、验证接口响应都只用一条命令php think.php Cli/crawl --symbolEURUSD --once在CrawlController的index方法里判断是否为--once是则调用一次tick里的核心逻辑否则进入循环。这个思路也适合迁移到ThinkPHP 5.1的命令行Task用php think kline:crawl --symbol EURUSD --once逻辑完全一样。本文还有配套的精品资源点击获取