
自己带小团队跑交易的时候最烦的事情之一就是复制订单。信号账户下了单剩下的账户都要跟着操作开仓、调止损、平仓一个都不能漏。手动操作不仅慢而且人一多就乱漏一单止损可能把整天的利润吐回去。后来我干脆写了一套网络跟单系统把多账户同步跟单这件事彻底交给程序处理。这套系统上线跑了几个月稳定跟了几千笔信号今天把整个项目的设计思路、核心逻辑和踩坑记录完整梳理一遍给打算自建跟单系统的朋友一个参考。先说清楚这套系统解决什么问题一个信号源账户的交易行为通过网络实时同步到多个跟单账户。适合谁带资管账户的操盘手、需要同时管理多个自有账户的个人交易者以及对交易系统自动化的程序员。核心价值就两个词高效、可复制。人工做的事情越少出错的概率就越低。1. 项目拆解跟单系统到底要做什么1.1 一套完整的跟单链路由哪几部分组成不管用什么技术方案跟单系统的本质都是一条数据链路从信号账户产生订单到跟单账户完成订单中间经过采集、传输、执行三个环节。信号采集监听信号源账户的订单变化捕捉开仓、平仓、修改止损止盈这些动作。信号传输把捕捉到的动作封装成结构化数据JSON通过网络通道推送到跟单端。跟单执行跟单端收到信号后在目标账户上提交对应的交易指令。看着简单实际做起来要处理的问题很多信号重复推送了怎么办跟单账户资金不够怎么办断线重连后漏掉的信号怎么补偿这些问题放到后面专门说先看架构选型。1.2 三种主流架构为什么我选了自建服务端市面上实现多账户同步跟单的路子大致有三种各有各的适用场景。方案部署方式延迟扩展性可控性适用场景本地终端插件所有账户装在同一个交易终端用插件同步低差受单机性能限制高个人几台账户临时用自建服务端Agent信号Agent和跟单Agent跑在云服务器上低网络好可毫秒级好账户多了加进程最高资管团队、量化交易团队第三方跟单SaaS用平台自带的跟单功能中受平台限制低策略会被平台看到不在乎策略隐私的散户我选择的是第二种自己搭服务端Agent。原因很实际本地插件方案要求所有账户在同一台电脑上只要电脑关机或者网络抖动整条同步链路就断了第三方SaaS功能是省事但品种映射、手数比例、风控过滤这些自定义逻辑做不了而且信号会经过平台方策略细节有泄露风险。自建服务端虽然开发工作量最大但账户数量上来之后稳定性和灵活性是最好的。1.3 技术选型背后的考量我用的是信号端事件推送 Redis消息分发 跟单端订阅执行的组合。信号端用交易终端的事件机制比如MT5的OnTradeTransaction实时捕获订单变化转成JSON后推到消息网关。传输层Redis。轻量、部署简单、支持发布订阅和消息队列两种模式。团队规模小不需要上Kafka这种重型消息中间件。跟单端Python服务每个跟单账户一个独立进程订阅对应消息通道调用交易API执行订单。为什么不直接用数据库轮询轮询的问题是实时性差而且每次都要全表扫描有没有新订单在高频场景下浪费资源。事件驱动的方式是有变化才推一次延迟通常在几百毫秒以内轮询至少需要1秒的间隔才能保证不频繁占用数据库。1.4 设定明确的技术指标项目启动前我先定了一组可量化的指标后面所有技术决策都围绕这些指标展开。延迟从信号账户产生订单到跟单账户提交指令目标小于500毫秒。幂等性同一条信号在任何情况下都不能被执行两次。容量至少支持50个跟单账户同时在线。容错消息通道断线后自动重连恢复后对账补偿漏掉的信号。这组指标不夸张却足以淘汰掉一大半能跑就行的方案。没有指标的开发后面验收的时候一定会扯皮。2. 核心细节怎么设计才能不错单、不重单、不死循环2.1 账户映射与仓位数量的计算规则多账户同步跟单最基础的问题就是信号账户下单0.5手跟单账户要下多少手三种常见模式固定手数不管信号账户下多少跟单账户永远固定一个手数。适合净值相近的账户。比例手数按净值比例缩放。这是我最常用的模式。自定义区间设置上限和下限超出范围自动截断或跳过。比例手数计算公式跟单手数 信号手数 × (跟单账户净值 / 信号账户净值) × 调节系数举个例子信号账户净值是10000美元跟单账户净值是2000美元信号账户开了0.5手调节系数为1那么跟单手数 0.5 × (2000 / 10000) 0.1手。需要提醒的是算出来的手数必须做合规校验不能低于品种的最小交易量比如0.01手不能高于账户的最大持仓限制还要估算保证金是否充足。计算后不足最小手数的直接跳过并发出告警不要硬下。2.2 订单全生命周期只同步开仓会出大问题一个完整的订单生命周期包含开仓、平仓、修改止损止盈、删除挂单。如果只做开仓同步就是半残废系统。我按照下面的事件类型设计了同步协议事件类型同步内容跟单端动作open品种、方向、手数、止损、止盈提交市价单带上SL/TPclose品种、手数按相同手数平掉对应方向的持仓modify品种、止损、止盈找到对应持仓修改SL/TPdelete挂单编号删除对应挂单这一步看起来容易实际容易踩坑的是平仓的对应关系。信号账户可能开了两笔同方向订单第一笔平了一半第二笔全部平掉。跟单端必须严格对应手数来平不然就会出现想平第一笔结果平了第二笔的错乱。解决办法是给每一笔原始信号分配一个唯一的source_order_id跟单端记录这个ID和本地订单号的映射关系。平仓信号里带着source_order_id就能准确找到该平哪一笔。2.3 幂等性网络重复推送是常态消息在网络上传输可能因为超时重发、消费端处理慢等原因被重复投递。如果没有幂等机制跟单账户就会重复下单这是跟单系统里最严重的故障之一。我的做法是给每个信号一个全局唯一ID用Redis的SETNX操作做去重# signal_id 来自信号端的全局唯一标识 import redis r redis.Redis(hostlocalhost, port6379, decode_responsesTrue) def is_duplicate(signal_id): # 如果signal_id不存在则设置成功返回True如果已存在则返回False return not r.set(fdedup:signal:{signal_id}, 1, nxTrue, ex86400)这段逻辑保证了即使同一条信号被推了十次也只有第一次能通过去重检查。需要注意这个去重标记的过期时间要足够长至少要覆盖整个交易日的处理周期我设置的是一天。2.4 数据同步的可靠性发布订阅 vs 消息队列Redis的发布订阅Pub/Sub实现起来最简单但有一个致命短板消费者掉线期间的消息会直接丢失。这对交易系统来说不可接受。实际项目中我改用了Redis Streams它本质上是一个支持消费者组和ACK确认的消息队列。消息发出去之后会一直存在消费者处理完某一笔才标记为已确认ACK如果处理失败挂掉消息会留在Pending列表里重启后还能重新拉取处理。用Streams的基本流程信号端往Stream里写入消息XADD trade-signals * json payload每个跟单账户的消费进程用XREADGROUP读取属于自己的消息流处理成功后XACK确认# 生产者写入 XADD trade-signals * signal_id 1001 symbol XAUUSD direction buy volume 0.1 # 消费者读取 XREADGROUP GROUP account-1 consumer-1 COUNT 1 STREAMS trade-signals 这种至少一次投递 消费端幂等的组合是分布式系统里非常经典的可靠性模型。投递可能重复但处理结果不会重复。3. 实操过程从零跑通一个最小可用版本3.1 环境准备与架构预览先放一张我实际使用的部署架构图文字版信号源MT5终端挂EA ↓ HTTP/Socket推送订单事件 本地消息网关轻量HTTP服务 ↓ 校验、格式化 Redis Streams消息持久化 ↓ 消费者组分发 跟单Worker进程每个账户一个 ↓ 调用交易API 跟单账户终端/账户API硬件和环境清单一台云服务器2核4GB内存起步操作系统用Ubuntu 22.04 Server。Redis 6.2以上开启AOF持久化。Python 3.10安装redis-py、MetaTrader5包。信号账户和至少两个跟单测试账户建议都用模拟盘。这一步有个心得不要一上来就接实盘先用模拟盘跑两周把各种故障场景逼出来比什么都重要。3.2 信号采集端用EA把交易事件变成消息信号端最核心的代码是交易终端里的EA脚本。MT5平台提供了OnTradeTransaction事件回调任何成交行为都会触发。我在这里捕获交易事务组装成JSON后推送给消息网关。简化版代码结构// 信号采集EA核心逻辑 void OnTradeTransaction(const MqlTradeTransaction trans, const MqlTradeRequest request, const MqlTradeResult result) { // 只关心已成交的交易 if(trans.type ! TRADE_TRANSACTION_DEAL_ADD) return; // 跳过平仓单的重复处理只保留有效方向 string direction (trans.deal_entry DEAL_ENTRY_IN) ? buy : ; // 组装JSON报文 string json StringFormat( {\symbol\:\%s\,\volume\:%f,\type\:\%s\,\sl\:%f,\tp\:%f}, trans.symbol, trans.volume, direction, trans.sl, trans.tp ); // 通过HTTP POST发送到本地消息网关 // 注意MT5的WebRequest需要在MetaEditor里把网关URL加入白名单 string headers Content-Type: application/json\r\n; char post_data[]; int len StringToCharArray(json, post_data, 0, StringLen(json)); if(len 0) WebRequest(POST, http://127.0.0.1:9000/ingest, headers, 5000, post_data, result); }这里有一个容易忽略的细节MT5的WebRequest有URL白名单机制如果URL没有加入MetaEditor的白名单请求会被直接拒绝。初次配置时需要在MetaEditor的工具选项EA交易里把http://127.0.0.1:9000添加到允许列表。网关服务负责接收这个POST请求做基本校验后写入Redis Streams。网关本身是一个简单的Flask或FastAPI应用几十行代码就能搞定这里不展开。3.3 消息传输层Redis Streams搭建安装Redissudo apt update sudo apt install redis-server sudo systemctl enable redis-server sudo systemctl start redis-server然后修改配置开启AOF持久化。编辑/etc/redis/redis.confappendonly yes appendfsync everysecAOF的作用是防止服务器重启后Redis内存里的消息全部丢失。appendfsync everysec的意思是每秒钟同步一次磁盘兼顾性能和可靠性。验证Redis连通redis-cli ping # 返回PONG生产环境建议给Redis设置访问密码至少bind到内网地址不要在公网裸奔。3.4 跟单执行端多账户并行的实现方式这里是多账户同步跟单的关键。我的做法是每个跟单账户跑一个独立的Worker进程进程用Supervisor统一管理。单个Worker的核心逻辑长这样import json import time import MetaTrader5 as mt5 import redis # 账户配置 ACCOUNT_ID 123456 PASSWORD your_password SERVER YourBroker-Server # 品种映射信号源品种 - 本账户品种 SYMBOL_MAP { XAUUSD: XAUUSD, EURUSD: EURUSD, } def init_mt5(): mt5.initialize(loginACCOUNT_ID, passwordPASSWORD, serverSERVER) def execute_signal(signal): symbol SYMBOL_MAP.get(signal[symbol]) if not symbol: print(f未映射品种: {signal[symbol]}) return if signal[type] open: tick mt5.symbol_info_tick(symbol) symbol_info mt5.symbol_info(symbol) volume calc_volume(signal[volume]) order_type mt5.ORDER_TYPE_BUY if signal[direction] buy else mt5.ORDER_TYPE_SELL request { action: mt5.TRADE_ACTION_DEAL, symbol: symbol, volume: volume, type: order_type, price: tick.ask if order_type mt5.ORDER_TYPE_BUY else tick.bid, sl: normalize_price(symbol, signal.get(sl)), tp: normalize_price(symbol, signal.get(tp)), deviation: 20, # 最大滑点单位是点数 magic: 20240101, comment: copy-order, } result mt5.order_send(request) if result.retcode ! mt5.TRADE_RETCODE_DONE: raise Exception(f开仓失败: {result.comment}) print(f开仓成功: {symbol} {signal[direction]} {volume}) def main(): r redis.Redis(hostlocalhost, port6379, decode_responsesTrue) stream_key trade-signals group_name faccount-{ACCOUNT_ID} init_mt5() # 创建消费组如果已存在则忽略错误 try: r.xgroup_create(stream_key, group_name, id0) except Exception: pass while True: # 读取当前账户的新消息 messages r.xreadgroup(group_name, fworker-{ACCOUNT_ID}, {stream_key: }, count10, block5000) for stream, items in messages: for msg_id, fields in items: signal json.loads(fields[payload]) if is_duplicate(signal[signal_id]): # 重复消息确认并跳过 r.xack(stream_key, group_name, msg_id) continue try: execute_signal(signal) r.xack(stream_key, group_name, msg_id) except Exception as e: print(f处理失败不确认消息等待重试: {e}) if __name__ __main__: main()关于MT5的Python包需要提醒一句MetaTrader5库的initialize方法在一个进程中只能持有一个账户连接。所以多账户不能在一个进程里频繁切换连接正确方式是用多进程方案一个进程管一个账户这也是我在代码里把Worker独立出来的原因。Supervisor配置示例放在/etc/supervisor/conf.d/copy-worker.conf[program:copy-account-123456] commandpython3 worker_account_123456.py directory/opt/copy-trading/ autostarttrue autorestarttrue redirect_stderrtrue stdout_logfile/var/log/copy-trading/account_123456.log部署多个账户就复制多个配置文件把账号ID换掉。3.5 仓位计算与风控校验仓位计算的代码逻辑我在前面已经给了公式。这里补充一个完整的校验链条按比例计算出目标手数。检查是否大于0、是否满足最小手数。四舍五入到最小步进单位比如0.01。检查最大持仓限制超过就按上限截断。估算所需保证金不足就跳过并告警。信号账户开0.5手跟单账户净值小算出0.1手结果品种最小手数是0.01精度步进是0.010.1手合规没问题。但如果算出0.005手就必须向上或向下取整到0.01或者干脆跳过。取整方向要根据策略来定如果想要严格跟随信号向上取整如果偏保守向下取整并发送提示。风控这块最容易出事故的是单品种持仓集中度。信号源可以同时开10个品种跟单账户资金小10个品种同时开仓可能导致保证金占用过大。所以我额外加了一个总保证金占用率上限默认50%超过就拒绝新开仓信号。3.6 上线前的最后检查和监控上线不是把代码丢服务器上跑起来就完了必须有监控。我加了三个层级的监控进程级Supervisor自带的autorestart保证进程挂了自动拉起。数据级每笔消息在Redis里有生产时间和消费时间我写了一个定时任务统计最近5分钟的平均消费延迟延迟超过2秒就告警。业务级每5分钟做一次对账对比信号账户的持仓列表和跟单账户的持仓列表差异超过阈值就推送告警。对账任务也是Python写的逻辑不复杂核心就是拉取两边的持仓按照品种和方向做差集比对。这一步是发现隐性掉单的最有效手段很多问题靠日志看不出一对账就原形毕露。4. 常见问题与排查技巧实录4.1 订单重复执行跟单账户多了一倍的仓位这是项目上线后遇到的第一个严重故障。现象是某个跟单账户突然多了一笔完全相同的订单排查后发现是消息队列在网络抖动期间发生了超时重试生产端把同一条信号写入了Stream两次消费端第一次处理成功后因为网络问题没有及时返回ACK第二次又把它读出来了。解决方案分两步生产端生成signal_id时要保证全局唯一我直接用时间戳随机数的方案。消费端在执行任何订单前先做幂等检查用signal_id作为Redis的key做SETNX去重。第一版代码偷懒去重只做了内存层面的进程重启就失效了。后来改到Redis层面问题才彻底解决。教训就是分布式系统的去重必须用共享存储不能依赖单机内存。4.2 跟单账户的成交价和信号账户差太多滑点问题在数据公布时段特别明显。有一次非农数据公布信号账户在黄金上以1982.5开了多单跟单账户因为流动性差实际成交在1986.8差了43个点持仓成本直接高了0.35%。跟单端的deviation参数就是用来限制滑点的。我设置为20点超过这个点数订单会被拒绝同时触发告警。这样虽然可能会漏单但至少不会在极差的价格成交。这里有个取舍deviation设太小容易导致大量漏单设太大又会在行情剧烈时吃到最差价格。我实测下来的经验是黄金这种高波动品种设40-50点主流货币对设20点要根据品种特征分别调整。4.3 跟单账户保证金不足导致大量失败小账户跟大账户是常见组合信号账户开1手黄金跟单账户只有500美元按净值比例计算出来连0.01手都开不了还硬下回报错误代码RETCODE_NO_MONEY。后来我加了两层保护下单前用订单计算API预先估算所需保证金不够就直接跳过。对单笔信号的手数设置硬性上限比如最大不超过账户净值的5%。加了这个风控后资金不足导致的订单失败率降到了零。与其让系统不停报错不如事前就理性拒绝。4.4 Redis或进程断线重启后信号丢失Redis本身配置了AOF持久化服务器重启不会丢数据。但消费进程如果挂掉它在Pending列表里的消息要正确处理否则重启后会重复消费或者永久卡在Pending里。处理Pending消息的标准做法是每次启动时先读取Pending列表XREADGROUP指定消息ID为0逐条检查是否处理成功过。如果处理成功就直接XACK确认如果没有成功就重新处理。这个过程我封装成了一个函数def recover_pending(): pending r.xpending_range(stream_key, group_name, min-, max, count100) for item in pending: msg_id item[message_id] messages r.xrange(stream_key, minmsg_id, maxmsg_id) for _, fields in messages: signal json.loads(fields[payload]) if is_duplicate(signal[signal_id]): r.xack(stream_key, group_name, msg_id) else: print(f发现未处理消息: {msg_id}) # 这里可以选择重新处理或人工介入4.5 品种后缀不匹配导致下单全部失败不同交易商对品种的命名不一样信号源账户用的是XAUUSD跟单账户的平台可能叫XAUUSD.pro或者GOLD.standard。如果直接拿信号源的品种名去下单必然报unknown symbol。解决这个问题要让每个账户有自己的品种映射表。我没有用简单的Dict写死而是做了一个动态映射启动时遍历MT5的symbols通过对比基础货币和计价货币猜测对应关系再人工确认一遍。这个方法在账户数量多的时候效率高很多。写在最后的经验整个系统从开发到稳定运行最大的收获不是自动化替代了人工而是把交易执行过程中的情绪干扰去掉了。以前手动跟单看到信号账户浮亏跟单的时候会犹豫会想要不要等等再进现在程序严格执行反而更符合策略本来的逻辑。给所有想自建跟单系统的朋友三个建议第一先跑模拟盘至少连续运行两周把断线、重连、对账、告警都验证一遍再考虑实盘第二任何一次代码变更都要经过模拟盘验证不要直接改实盘配置第三系统必须保留手动干预的开关程序再可靠也要允许人随时接管这是最后的底线。这套系统的架构并不复杂核心就是事件捕获、可靠传输、幂等消费三个点。把这三个点想清楚多账户同步跟单其实没有想象中那么难。