Type/to search
8
Follow
1364
Followers
基于FMZ构建一个支持My语言和Pine策略语言的多账户跟单系统
Discussions
Created 2025-05-22 16:28:53  Updated 2025-05-23 10:08:21
 0
 830

img

跟单系统的需求场景

在 FMZ 平台社区以及与用户的私信交流中,我经常被问到这样一个问题:

“为什么用麦语言(My)或 Pine 脚本编写的策略,总是只能控制一个账户、一个品种?”

这个问题的本质在于语言本身的设计定位。My 语言和 Pine 语言作为高度封装的脚本语言,其底层均基于 JavaScript 实现。为了让用户能快速上手并专注于策略逻辑,两者在语言层面做了大量封装与抽象,但也因此牺牲了一定的灵活性:默认仅支持单账户、单品种的策略执行模型。

当用户希望实盘运行多个账户时,只能通过运行多个 Pine 或 My 实盘实例来实现。这种做法在账户数量较少时尚可接受,但如果将多个实例部署在同一个托管器上,就会产生大量的 API 请求,甚至可能因请求频率过高而被交易所限制访问,带来不必要的实盘风险。

那么,有没有一种更优雅的方式,只需运行一个 Pine 或 My 语言脚本,其他账户就能自动复制其交易行为呢?

答案是:可以的。

本篇文章将带你从零构建一个跨账户、跨品种的跟单系统,兼容 My 和 Pine 语言策略,通过 Leader-Subscriber 架构,实现一个高效、稳定、可扩展的多账户同步交易框架,解决你在实盘部署中遇到的种种痛点。

策略设计

程序使用JavaScript语言编写设计,程序架构使用Leader-Subscriber模型。

策略源码:

javascript
/*backtest start: 2024-05-21 00:00:00 end: 2025-05-20 00:00:00 period: 1d basePeriod: 1d exchanges: [{"eid":"Futures_Binance","currency":"ETH_USDT"},{"eid":"Futures_Binance","currency":"ETH_USDT"},{"eid":"Futures_Binance","currency":"ETH_USDT","balance":10000}] args: [["isBacktest",true]] */ class Leader { constructor(leaderCfg = { type: "exchange", apiClient: exchanges[0] }) { // 带单账户配置 this.leaderCfg = leaderCfg // 缓存上次持仓信息,用于对比 this.lastPos = null // 记录当前持仓信息 this.currentPos = null // 记录订阅者 this.subscribers = [] // 根据 leaderCfg 配置,确定使用哪种监控方案:1、直接监控带单账户。2、通过FMZ 扩展API监控带单策略实盘的数据。3、消息推送机制跟单。默认使用方案1。 // 初始化 let ex = this.leaderCfg.apiClient let currency = ex.GetCurrency() let arrCurrency = currency.split("_") if (arrCurrency.length !== 2) { throw new Error("带单账户配置错误,必须是两种币对") } this.baseCurrency = arrCurrency[0] this.quoteCurrency = arrCurrency[1] // 获取初始化时,带单账户的总权益 this.initEquity = _C(ex.GetAccount).Equity this.currentPos = _C(ex.GetPositions) } // 监控leader的逻辑 poll() { // 获取交易所对象 let ex = this.leaderCfg.apiClient // 获取leader的持仓、账户资产数据 let pos = ex.GetPositions() if (!pos) { return } this.currentPos = pos // 调用判断方法,判断持仓变化 let { hasChanged, diff } = this._hasChanged(pos) if (hasChanged) { Log("Leader持仓变化,当前持仓:", pos) Log("持仓变动:", diff) // 通知订阅者 this.subscribers.forEach(subscriber => { subscriber.applyPosChanges(diff) }) } // 同步持仓 this.subscribers.forEach(subscriber => { subscriber.syncPositions(pos) }) } // 判断持仓是否变化 _hasChanged(pos) { if (this.lastPos) { // 用于存储持仓差异的结果 let diff = { added: [], // 新增的持仓 removed: [], // 移除的持仓 updated: [] // 更新的持仓(数量或价格变化) } // 将上次持仓和当前持仓转换为 Map,键为 `symbol + direction`,值为持仓对象 let lastPosMap = new Map(this.lastPos.map(p => [`${p.Symbol}|${p.Type}`, p])) let currentPosMap = new Map(pos.map(p => [`${p.Symbol}|${p.Type}`, p])) // 遍历当前持仓,找出新增和更新的持仓 for (let [key, current] of currentPosMap) { if (!lastPosMap.has(key)) { // 如果上次持仓中没有该 key,则为新增持仓 diff.added.push({ symbol: current.Symbol, type: current.Type, deltaAmount: current.Amount }) } else { // 如果存在,则检查数量或价格是否变化 let last = lastPosMap.get(key) if (current.Amount !== last.Amount) { diff.updated.push({ symbol: current.Symbol, type: current.Type, deltaAmount: current.Amount - last.Amount }) } // 从 lastPosMap 中移除,剩下的就是被移除的持仓 lastPosMap.delete(key) } } // 剩余在 lastPosMap 中的 key 是被移除的持仓 for (let [key, last] of lastPosMap) { diff.removed.push({ symbol: last.Symbol, type: last.Type, deltaAmount: -last.Amount }) } // 判断是否有变化 let hasChanged = diff.added.length > 0 || diff.removed.length > 0 || diff.updated.length > 0 // 如果有变化,更新 lastPos if (hasChanged) { this.lastPos = pos } return { hasChanged: hasChanged, diff: diff } } else { // 如果没有上次持仓记录,更新记录,不做持仓同步 this.lastPos = pos return { hasChanged: false, diff: { added: [], removed: [], updated: [] } } /* 另一种方案:同步仓位 if (pos.length > 0) { let diff = { added: pos.map(p => ({symbol: p.Symbol, type: p.Type, deltaAmount: p.Amount})), removed: [], updated: [] } return {hasChanged: true, diff: diff} } else { return {hasChanged: false, diff: {added: [], removed: [], updated: []}} } */ } } // 订阅者注册 subscribe(subscriber) { if (this.subscribers.indexOf(subscriber) === -1) { if (this.quoteCurrency !== subscriber.quoteCurrency) { throw new Error("订阅者币对不匹配,当前leader币对:" + this.quoteCurrency + ",订阅者币对:" + subscriber.quoteCurrency) } if (subscriber.followStrategy.followMode === "equity_ratio") { // 设置跟单比例 let ex = this.leaderCfg.apiClient let equity = _C(ex.GetAccount).Equity subscriber.setEquityRatio(equity) } this.subscribers.push(subscriber) Log("订阅者注册成功,订阅配置:", subscriber.getApiClientInfo()) } } // 取消订阅 unsubscribe(subscriber) { const index = this.subscribers.indexOf(subscriber) if (index !== -1) { this.subscribers.splice(index, 1) Log("订阅者取消注册成功,订阅配置:", subscriber.getApiClientInfo()) } else { Log("订阅者取消注册失败,订阅配置:", subscriber.getApiClientInfo()) } } // 获取UI信息 fetchLeaderUI() { // 带单者信息 let tblLeaderInfo = { "type": "table", "title": "Leader Info", "cols": ["带单方案", "计价币种", "跟单者数量", "初始总权益"], "rows": [] } tblLeaderInfo.rows.push([this.leaderCfg.type, this.quoteCurrency, this.subscribers.length, this.initEquity]) // 构造带单者的显示信息:持仓信息 let tblLeaderPos = { "type": "table", "title": "Leader pos", "cols": ["交易品种", "方向", "数量", "价格"], "rows": [] } this.currentPos.forEach(pos => { let row = [pos.Symbol, pos.Type == PD_LONG ? "多" : "空", pos.Amount, pos.Price] tblLeaderPos.rows.push(row) }) // 构造订阅者的显示信息 let strFollowerMsg = "" this.subscribers.forEach(subscriber => { let arrTbl = subscriber.fetchFollowerUI() strFollowerMsg += "`" + JSON.stringify(arrTbl) + "`\n" }) return "`" + JSON.stringify([tblLeaderInfo, tblLeaderPos]) + "`\n" + strFollowerMsg } // 扩展暂停跟单、移除订阅等功能 } class Subscriber { constructor(subscriberCfg, followStrategy = { followMode: "position_ratio", ratio: 1, maxReTries: 3 }) { this.subscriberCfg = subscriberCfg this.followStrategy = followStrategy // 初始化 let ex = this.subscriberCfg.apiClient let currency = ex.GetCurrency() let arrCurrency = currency.split("_") if (arrCurrency.length !== 2) { throw new Error("订阅者配置错误,必须是两种币对") } this.baseCurrency = arrCurrency[0] this.quoteCurrency = arrCurrency[1] // 初始获取持仓数据 this.currentPos = _C(ex.GetPositions) } setEquityRatio(leaderEquity) { // {followMode: "equity_ratio"} 自动根据账户权益比例跟单 if (this.followStrategy.followMode === "equity_ratio") { let ex = this.subscriberCfg.apiClient let equity = _C(ex.GetAccount).Equity let ratio = equity / leaderEquity this.followStrategy.ratio = ratio Log("带单者权益:", leaderEquity, "订阅者权益:", equity) Log("自动设置,订阅者权益比例:", ratio) } } // 获取订阅者绑定的API客户端信息 getApiClientInfo() { let ex = this.subscriberCfg.apiClient let idx = this.subscriberCfg.clientIdx if (ex) { return { exName: ex.GetName(), exLabel: ex.GetLabel(), exIdx: idx, followStrategy: this.followStrategy } } else { throw new Error("订阅者没有绑定API客户端") } } // 根据持仓类型、仓位变化,返回交易方向参数 getTradeSide(type, deltaAmount) { if (type == PD_LONG && deltaAmount > 0) { return "buy" } else if (type == PD_LONG && deltaAmount < 0) { return "closebuy" } else if (type == PD_SHORT && deltaAmount > 0) { return "sell" } else if (type == PD_SHORT && deltaAmount < 0) { return "closesell" } return null } getSymbolPosAmount(symbol, type) { let ex = this.subscriberCfg.apiClient if (ex) { let pos = _C(ex.GetPositions, symbol) if (pos.length > 0) { // 遍历持仓,查找对应的symbol和type for (let i = 0; i < pos.length; i++) { if (pos[i].Symbol === symbol && pos[i].Type === type) { return pos[i].Amount } } } return 0 } else { throw new Error("订阅者没有绑定API客户端") } } // 下单重试 tryCreateOrder(ex, symbol, side, price, amount, label, maxReTries) { for (let i = 0; i < Math.max(maxReTries, 1); i++) { let orderId = ex.CreateOrder(symbol, side, price, amount, label) if (orderId) { return orderId } Sleep(1000) } return null } // 同步持仓变化 applyPosChanges(diff) { let ex = this.subscriberCfg.apiClient if (ex) { ["added", "removed", "updated"].forEach(key => { diff[key].forEach(item => { let side = this.getTradeSide(item.type, item.deltaAmount) if (side) { // 计算跟单比例 let ratio = this.followStrategy.ratio let tradeAmount = Math.abs(item.deltaAmount) * ratio if (side == "closebuy" || side == "closesell") { // 获取持仓数量检查 let posAmount = this.getSymbolPosAmount(item.symbol, item.type) tradeAmount = Math.min(posAmount, tradeAmount) } // 订单Id // let orderId = ex.CreateOrder(item.symbol, side, -1, tradeAmount, ex.GetLabel()) let orderId = this.tryCreateOrder(ex, item.symbol, side, -1, tradeAmount, ex.GetLabel(), this.followStrategy.maxReTries) // 检测订单Id if (orderId) { Log("订阅者下单成功,订单Id:", orderId, ",下单方向:", side, ",下单数量:", Math.abs(item.deltaAmount), ",跟单比例(倍):", ratio) } else { Log("订阅者下单失败,订单Id:", orderId, ",下单方向:", side, ",下单数量:", Math.abs(item.deltaAmount), ",跟单比例(倍):", ratio) } } }) }) // 更新当前持仓 this.currentPos = _C(ex.GetPositions) } else { throw new Error("订阅者没有绑定API客户端") } } // 同步持仓 syncPositions(leaderPos) { let ex = this.subscriberCfg.apiClient this.currentPos = _C(ex.GetPositions) // 用于存储持仓差异的结果 let diff = { added: [], // 新增的持仓 removed: [], // 移除的持仓 updated: [] // 更新的持仓(数量或价格变化) } let leaderPosMap = new Map(leaderPos.map(p => [`${p.Symbol}|${p.Type}`, p])) let currentPosMap = new Map(this.currentPos.map(p => [`${p.Symbol}|${p.Type}`, p])) // 遍历当前持仓,找出新增和更新的持仓 for (let [key, leader] of leaderPosMap) { if (!currentPosMap.has(key)) { diff.added.push({ symbol: leader.Symbol, type: leader.Type, deltaAmount: leader.Amount }) } else { let current = currentPosMap.get(key) if (leader.Amount !== current.Amount) { diff.updated.push({ symbol: leader.Symbol, type: leader.Type, deltaAmount: leader.Amount - current.Amount * this.followStrategy.ratio }) } currentPosMap.delete(key) } } for (let [key, current] of currentPosMap) { diff.removed.push({ symbol: current.Symbol, type: current.Type, deltaAmount: -current.Amount * this.followStrategy.ratio }) } // 判断是否有变化 let hasChanged = diff.added.length > 0 || diff.removed.length > 0 || diff.updated.length > 0 if (hasChanged) { // 同步 this.applyPosChanges(diff) } } // 获取订阅者UI信息 fetchFollowerUI() { // 订阅者信息 let ex = this.subscriberCfg.apiClient let equity = _C(ex.GetAccount).Equity let exLabel = ex.GetLabel() let tblFollowerInfo = { "type": "table", "title": "Follower Info", "cols": ["交易所对象索引", "交易所对象标签", "计价币种", "跟单模式", "跟单比例(倍)", "最大重试次数", "总权益"], "rows": [] } tblFollowerInfo.rows.push([this.subscriberCfg.clientIdx, exLabel, this.quoteCurrency, this.followStrategy.followMode, this.followStrategy.ratio, this.followStrategy.maxReTries, equity]) // 订阅者持仓信息 let tblFollowerPos = { "type": "table", "title": "Follower pos", "cols": ["交易品种", "方向", "数量", "价格"], "rows": [] } let pos = this.currentPos pos.forEach(p => { let row = [p.Symbol, p.Type == PD_LONG ? "多" : "空", p.Amount, p.Price] tblFollowerPos.rows.push(row) }) return [tblFollowerInfo, tblFollowerPos] } } // 测试函数,模拟随机开仓,模拟leader仓位变动 function randomTrade(symbol, amount) { let randomNum = Math.random() if (randomNum < 0.0001) { Log("模拟带单交易", "#FF0000") let ex = exchanges[0] let pos = _C(ex.GetPositions) if (pos.length > 0) { // 随机平仓 let randomPos = pos[Math.floor(Math.random() * pos.length)] let tradeAmount = Math.random() > 0.7 ? Math.abs(randomPos.Amount * 0.5) : Math.abs(randomPos.Amount) ex.CreateOrder(randomPos.Symbol, randomPos.Type === PD_LONG ? "closebuy" : "closesell", -1, tradeAmount, ex.GetLabel()) } else { let tradeAmount = Math.random() * amount let side = Math.random() > 0.5 ? "buy" : "sell" if (side === "buy") { ex.CreateOrder(symbol, side, -1, tradeAmount, ex.GetLabel()) } else { ex.CreateOrder(symbol, side, -1, tradeAmount, ex.GetLabel()) } } } } // 策略主循环 function main() { let leader = new Leader() let followStrategyArr = JSON.parse(strFollowStrategyArr) if (followStrategyArr.length > 0 && followStrategyArr.length !== exchanges.length - 1) { throw new Error("跟单策略配置错误,跟单策略数量和交易所数量不匹配") } for (let i = 1; i < exchanges.length; i++) { let subscriber = null if (followStrategyArr.length == 0) { subscriber = new Subscriber({ apiClient: exchanges[i], clientIdx: i }) } else { let followStrategy = followStrategyArr[i - 1] subscriber = new Subscriber({ apiClient: exchanges[i], clientIdx: i }, followStrategy) } leader.subscribe(subscriber) } // 启动监控 while (true) { leader.poll() Sleep(1000 * pollInterval) // 模拟随机交易 if (IsVirtual() && isBacktest) { randomTrade("BTC_USDT.swap", 0.001) randomTrade("ETH_USDT.swap", 0.02) randomTrade("SOL_USDT.swap", 0.1) } LogStatus(_D(), "\n", leader.fetchLeaderUI()) } }
  • 设计模式
    之前在平台上也设计了多个跟单策略,使用的是面向过程的设计。本篇作为一种新的设计尝试,使用面向对象的风格,使用观察者设计模式。

  • 监控方案
    所谓跟单本质就是一种监控行为,监控目标的动作,发现有新动作就进行复刻操作。

    本篇文章中只实现了一种方案:通过API KEY,配置交易所对象,监控目标账户的持仓,其实还可以使用另外两种方案,在设计上可能会更加复杂一些:

    • 通过FMZ的扩展API
      监控目标实盘的日志、状态栏信息,根据变化进行操作、跟单。使用该方案的好处就是,可以有效减少API请求。

    • 依赖目标实盘的消息推送
      可以在FMZ上把目标实盘的消息推送打开,当目标实盘有下单操作时会推送消息。跟单策略接收这些消息做出操作即可。使用该方案的好处有:减少API请求、从请求轮询机制改为了事件驱动机制。

  • 跟单策略

    对于跟单策略来说可能有多种需求,策略框架也尽量设计的容易扩展一些。

    • 持仓复刻:
      可以1:1复刻持仓,也可以根据指定的缩放参数对持仓缩放。
    • 权益比
      可以根据带单账户、跟单账户的权益比值,自动作为缩放参数,进行跟单。
  • 持仓同步
    在实际使用中,可能有各种原因导致带单者与跟单者持仓有差异。可以设计跟单时检测跟单账户与带单账户的差异,自动同步持仓。

  • 下单重试
    可以在跟单策略中指定具体的下单失败重试次数。

  • 回测随机测试
    代码中function randomTrade(symbol, amount)函数用于回测时的随机开仓测试,用来检测跟单效果。

策略回测与验证

img

根据添加的第一个交易所对象(带单者),后续添加的交易所对象跟单(跟单者)。

img

测试中,使用三个品种随机下单,以检验多品种跟单的需求:

javascript
randomTrade("BTC_USDT.swap", 0.001) randomTrade("ETH_USDT.swap", 0.02) randomTrade("SOL_USDT.swap", 0.1)

策略分享

https://www.fmz.com/strategy/494950

扩展与优化

  • 扩展仓位、账户信息等数据的监控方案。
  • 增加对于订阅者的控制,可以增加跟单账户的暂停、取消订阅等功能。
  • 动态更新跟单策略参数。
  • 增加扩展更丰富的跟单数据、信息展示。

END

欢迎在发明者量化(FMZ.COM)文库、社区留言、讨论,可以提出各种需求以及思路,小编这里会根据留言,筛选出比较有价值的内容制作方案设计、讲解、教学资料。

本篇抛砖引玉,使用面向对象风格、观察者模式,架构设计了一个初步的跟单策略框架,希望可以给读者带来参考和思路启发,感谢阅读与支持。

Comment
All comments (0)
No data
No data
  • 1
iPhone Download
Forums
PINE Language
© 2015 - ∞ INVENTOR PTE LTD (SG)