[TOC]
Trong cộng đồng nền tảng FMZ và trong giao tiếp riêng với người dùng, tôi thường được hỏi câu hỏi này:
“Tại sao các chiến lược được viết bằng tập lệnh My hoặc Pine chỉ có thể kiểm soát một tài khoản và một sản phẩm?”
Bản chất của vấn đề này nằm ở vị trí thiết kế của chính ngôn ngữ đó. Ngôn ngữ của tôi và ngôn ngữ Pine là các ngôn ngữ kịch bản có tính đóng gói cao và việc triển khai cơ bản của chúng dựa trên JavaScript. Để cho phép người dùng bắt đầu nhanh chóng và tập trung vào logic chiến lược, cả hai đều đã thực hiện nhiều đóng gói và trừu tượng hóa ở cấp độ ngôn ngữ, nhưng điều này cũng làm mất đi một mức độ linh hoạt nhất định: theo mặc định, chỉ hỗ trợ mô hình thực thi chiến lược cho một tài khoản và một sản phẩm.
Khi người dùng muốn chạy nhiều tài khoản cùng lúc, điều này chỉ có thể thực hiện được bằng cách chạy nhiều phiên bản Pine hoặc My real. Cách tiếp cận này được chấp nhận khi số lượng tài khoản nhỏ, nhưng nếu triển khai nhiều phiên bản trên cùng một máy chủ, một số lượng lớn yêu cầu API sẽ được tạo ra và sàn giao dịch thậm chí có thể hạn chế quyền truy cập do tần suất yêu cầu quá mức, gây ra những rủi ro không cần thiết theo thời gian thực.
Vậy, có cách nào thanh lịch hơn để chạy tập lệnh Pine hoặc My language để các tài khoản khác có thể tự động sao chép hành vi giao dịch của nó không?
Câu trả lời là: Có.
Bài viết này sẽ hướng dẫn bạn cách xây dựng hệ thống giao dịch sao chép chéo tài khoản, chéo sản phẩm ngay từ đầu, tương thích với các chiến lược ngôn ngữ My và Pine. Thông qua kiến trúc Leader-Subscriber, nó sẽ triển khai một khuôn khổ giao dịch đồng bộ đa tài khoản hiệu quả, ổn định và có khả năng mở rộng để giải quyết nhiều vấn đề khó khăn mà bạn gặp phải khi triển khai theo thời gian thực.
Chương trình được viết và thiết kế bằng JavaScript và kiến trúc chương trình sử dụng mô hình Leader-Subscriber.
Mã nguồn chiến lược:
/*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())
}
}
Mẫu thiết kế Trước đây chúng tôi đã thiết kế nhiều chiến lược giao dịch sao chép trên nền tảng này, sử dụng thiết kế theo quy trình. Bài viết này là một nỗ lực thiết kế mới, sử dụng phong cách hướng đối tượng và mẫu thiết kế quan sát.
Giải pháp giám sát Bản chất của cái gọi là giao dịch sao chép là một loại hành vi giám sát, theo dõi các hành động của mục tiêu và sao chép chúng khi phát hiện ra hành động mới.
Bài viết này chỉ triển khai một giải pháp: sử dụng API KEY để cấu hình các đối tượng trao đổi và theo dõi vị trí của tài khoản mục tiêu. Trên thực tế, có hai giải pháp khác có thể phức tạp hơn về mặt thiết kế:
Thông qua API mở rộng của FMZ Theo dõi thông tin nhật ký và thanh trạng thái của tài khoản thực mục tiêu và thực hiện các hoạt động cũng như tuân theo các lệnh dựa trên những thay đổi. Ưu điểm của việc sử dụng giải pháp này là nó có thể giảm hiệu quả các yêu cầu API.
Phụ thuộc vào mục tiêu đẩy tin nhắn đĩa thực Bạn có thể bật tính năng đẩy tin nhắn của tài khoản thực mục tiêu trên FMZ và tin nhắn sẽ được đẩy khi tài khoản thực mục tiêu có lệnh. Chiến lược giao dịch sao chép có thể nhận được những thông điệp này và thực hiện hành động. Lợi ích của việc sử dụng giải pháp này bao gồm: giảm yêu cầu API và chuyển từ cơ chế thăm dò yêu cầu sang cơ chế theo sự kiện.
Chiến lược giao dịch sao chép
Có thể có nhiều yêu cầu đối với chiến lược giao dịch sao chép và khuôn khổ chiến lược được thiết kế để dễ dàng mở rộng.
Sao chép vị trí: Các vị trí có thể được sao chép theo tỷ lệ 1:1 hoặc có thể được chia tỷ lệ theo các tham số chia tỷ lệ đã chỉ định.
Tỷ lệ vốn chủ sở hữu Tỷ lệ vốn chủ sở hữu giữa tài khoản có lệnh và tài khoản theo lệnh có thể được tự động sử dụng làm tham số mở rộng cho các lệnh theo lệnh.
Đồng bộ vị trí Trong thực tế sử dụng, có thể có nhiều lý do khiến vị trí của người nhận lệnh và người theo lệnh khác nhau. Hệ thống có thể được thiết kế để phát hiện sự khác biệt giữa tài khoản sao chép và tài khoản thực hiện lệnh khi theo dõi lệnh và tự động đồng bộ hóa các vị thế.
Thử lại đơn hàng Bạn có thể chỉ định số lần thử lại cụ thể cho các lệnh không thành công trong chiến lược sao chép giao dịch.
Kiểm tra ngược Kiểm tra ngẫu nhiên
Trong mãfunction randomTrade(symbol, amount)Chức năng này được sử dụng để kiểm tra mở ngẫu nhiên trong quá trình kiểm tra ngược nhằm phát hiện hiệu ứng sao chép giao dịch.

Theo đối tượng trao đổi đầu tiên được thêm vào (người nhận lệnh), các đối tượng trao đổi được thêm vào sau đó sẽ tuân theo lệnh (người theo dõi).

Trong thử nghiệm, ba loại lệnh được đặt ngẫu nhiên để xác minh nhu cầu giao dịch sao chép đa dạng:
randomTrade("BTC_USDT.swap", 0.001)
randomTrade("ETH_USDT.swap", 0.02)
randomTrade("SOL_USDT.swap", 0.1)
Bạn có thể để lại tin nhắn và thảo luận trong thư viện và cộng đồng Inventor Quantitative (FMZ.COM). Bạn có thể đưa ra nhiều yêu cầu và ý tưởng khác nhau. Biên tập viên sẽ sàng lọc ra những thiết kế kế hoạch sản xuất nội dung, giải thích và tài liệu giảng dạy có giá trị hơn dựa trên các thông điệp.
Bài viết này chỉ là điểm khởi đầu. Nó sử dụng phong cách hướng đối tượng và chế độ quan sát để thiết kế một khuôn khổ chiến lược giao dịch sao chép sơ bộ. Tôi hy vọng nó có thể cung cấp tài liệu tham khảo và nguồn cảm hứng cho độc giả. Cảm ơn các bạn đã đọc và ủng hộ.