ThinkPHP二开微盘:K线采集与时间盘聚合实战
2026/9/16 15:42:05 网站建设 项目流程

简介:基于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、XAUUSD
periodvarchar(10)周期标识,M1/M5/M15/M30/H1/H4/D1
tsint(11) unsigned周期起始时间的UTC时间戳
opendecimal(12,5)开票价
highdecimal(12,5)最高价
lowdecimal(12,5)最低价
closedecimal(12,5)收盘价
volumeint(11)该周期内成交量,可为0
create_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 --symbol=EURUSD。到了ThinkPHP 5.1或6,命令行的写法是php think crawler:run --symbol=EURUSD,但在二说到底层逻辑时,采集和业务代码尽量不要耦合进控制器。常见做法是写一个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删除这几天的数据。历史回补最怕中断,删除操作别放循环里,先按symbol+period+时间范围删除,再插入,整体一致性更好。

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 --symbol=EURUSD --once

CrawlControllerindex方法里,判断是否为--once,是则调用一次tick里的核心逻辑,否则进入循环。这个思路也适合迁移到ThinkPHP 5.1的命令行Task,用php think kline:crawl --symbol EURUSD --once,逻辑完全一样。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询