[TOC]
FMZ 플랫폼 커뮤니티와 사용자와의 개인적인 소통에서 저는 종종 다음과 같은 질문을 받습니다.
“My 또는 Pine 스크립트로 작성된 전략은 왜 하나의 계정과 하나의 제품만 제어할 수 있나요?”
이 문제의 본질은 언어 자체의 디자인적 위치에 있습니다. 제가 사용하는 언어와 Pine 언어는 고도로 캡슐화된 스크립팅 언어이며, 기본 구현은 JavaScript에 기반합니다. 사용자가 빠르게 시작하고 전략 논리에 집중할 수 있도록 두 언어 모두 언어 수준에서 캡슐화와 추상화를 많이 수행했지만, 이로 인해 어느 정도 유연성이 희생되었습니다. 기본적으로 단일 계정, 단일 제품 전략 실행 모델만 지원됩니다.
사용자가 실시간으로 여러 계정을 실행하려는 경우, 여러 Pine 또는 My 실제 인스턴스를 실행해야만 가능합니다. 계정 수가 적을 때는 이런 방식이 적합하지만, 동일한 호스트에 여러 인스턴스가 배포되면 API 요청이 대량으로 발생하고, 거래소에서 요청 빈도가 너무 높아 접근을 제한할 수도 있어 불필요한 실시간 리스크가 발생할 수 있습니다.
그렇다면 다른 계정에서 자동으로 거래 동작을 복사할 수 있도록 Pine이나 My 언어 스크립트를 실행하는 더 우아한 방법이 있을까요?
대답은 ‘예’입니다.
이 글에서는 My와 Pine 언어 전략과 호환되는 교차 계정, 교차 제품 복사 트레이딩 시스템을 처음부터 구축하는 방법을 안내합니다. 리더-구독자 아키텍처를 통해 효율적이고 안정적이며 확장 가능한 다중 계정 동기식 거래 프레임워크를 구현하여 실시간 배포에서 발생할 수 있는 다양한 문제점을 해결합니다.
이 프로그램은 JavaScript로 작성되고 설계되었으며, 프로그램 아키텍처는 Leader-Subscriber 모델을 사용합니다.
전략 소스 코드:
/*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 키를 사용하여 거래소 객체를 구성하고 대상 계정의 위치를 모니터링하는 하나의 솔루션만 구현합니다. 사실, 설계상 더 복잡할 수 있는 두 가지 다른 솔루션이 있습니다.
FMZ의 확장된 API를 통해 대상 실제 계정의 로그 및 상태 표시줄 정보를 모니터링하고, 변경 사항에 따라 작업을 수행하고 명령을 따릅니다. 이 솔루션을 사용하면 API 요청을 효과적으로 줄일 수 있다는 장점이 있습니다.
대상 실제 디스크 메시지 푸시에 따라 다릅니다. FMZ에서 타겟 실제 계좌의 메시지 푸시를 켜면 타겟 실제 계좌에 주문이 있을 때 메시지가 푸시됩니다. 복사 트레이딩 전략은 이러한 메시지를 수신하고 조치를 취할 수 있습니다. 이 솔루션을 사용하면 API 요청이 줄어들고 요청 폴링 메커니즘에서 이벤트 기반 메커니즘으로 변경되는 등의 이점이 있습니다.
카피 트레이딩 전략
복제 트레이딩 전략에는 여러 가지 요구 사항이 있을 수 있으며, 전략 프레임워크는 확장하기 쉽도록 설계되었습니다.
위치 복제: 위치는 1:1로 복제될 수 있으며, 지정된 스케일링 매개변수에 따라 크기가 조정될 수 있습니다.
자기자본비율 주문이 있는 계정과 주문이 있는 계정 간의 자기자본 비율은 자동으로 주문이 있는 계정의 스케일링 매개변수로 사용될 수 있습니다.
위치 동기화 실제로는 주문 접수자와 주문 처리자의 입장이 달라지는 데에는 다양한 이유가 있을 수 있습니다. 이 시스템은 주문을 따를 때 복사 계정과 주문 수행 계정의 차이를 감지하고 자동으로 위치를 동기화하도록 설계될 수 있습니다.
주문 재시도 복사 트레이딩 전략에서는 실패한 주문에 대한 재시도 횟수를 구체적으로 지정할 수 있습니다.
백테스팅 랜덤 테스팅
코드에서function randomTrade(symbol, amount)이 기능은 복사 트레이딩 효과를 감지하기 위해 백테스팅 중에 무작위 개방형 테스트에 사용됩니다.

첫 번째로 추가된 교환 객체(주문 수신자)에 따라, 그 후에 추가된 교환 객체는 주문을 따릅니다(추종자).

테스트에서는 다양한 종류의 복사 거래에 대한 수요를 검증하기 위해 세 가지 종류의 주문을 무작위로 배치했습니다.
randomTrade("BTC_USDT.swap", 0.001)
randomTrade("ETH_USDT.swap", 0.02)
randomTrade("SOL_USDT.swap", 0.1)
Inventor Quantitative(FMZ.COM) 라이브러리와 커뮤니티에서 메시지를 남기고 토론해 보세요. 다양한 요구와 아이디어를 제시할 수 있습니다. 편집자는 전달된 메시지를 토대로 더욱 가치 있는 콘텐츠 제작 계획 설계, 설명, 교육 자료를 걸러냅니다.
이 기사는 단지 시작점일 뿐입니다. 객체 지향 스타일과 관찰자 모드를 사용하여 예비적인 복사 거래 전략 프레임워크를 설계합니다. 독자들에게 참고 자료와 영감을 제공했으면 좋겠습니다. 여러분의 읽어주시고 응원해주셔서 감사합니다.