avatar of 发明者量化-小小梦 发明者量化-小小梦
Suivre Messages privés
4
Suivre
1271
Abonnés

Création d'un système de copy trading multi-comptes basé sur FMZ prenant en charge My Language et Pine Strategy Language

Créé le: 2025-05-22 16:28:53, Mis à jour le: 2025-05-23 10:08:21
comments   0
hits   546

[TOC]

Scénarios de demande pour les systèmes documentaires

Dans la communauté de la plateforme FMZ et dans les communications privées avec les utilisateurs, on me pose souvent cette question :

« Pourquoi les stratégies écrites dans les scripts My ou Pine ne peuvent-elles contrôler qu’un seul compte et un seul produit ? »

L’essence de ce problème réside dans le positionnement de conception du langage lui-même. Mon langage et le langage Pine sont des langages de script hautement encapsulés, et leur implémentation sous-jacente est basée sur JavaScript. Afin de permettre aux utilisateurs de démarrer rapidement et de se concentrer sur la logique stratégique, les deux ont fait beaucoup d’encapsulation et d’abstraction au niveau du langage, mais cela a également sacrifié un certain degré de flexibilité : par défaut, seul un modèle d’exécution de stratégie à compte unique et à produit unique est pris en charge.

Lorsqu’un utilisateur souhaite exécuter plusieurs comptes en temps réel, cela ne peut être réalisé qu’en exécutant plusieurs instances réelles Pine ou My. Cette approche est acceptable lorsque le nombre de comptes est faible, mais si plusieurs instances sont déployées sur le même hôte, un grand nombre de requêtes API seront générées et l’échange peut même restreindre l’accès en raison d’une fréquence de requête excessive, entraînant des risques inutiles en temps réel.

Alors, existe-t-il un moyen plus élégant d’exécuter simplement un script Pine ou My Language afin que d’autres comptes puissent copier automatiquement son comportement de trading ?

La réponse est : oui.

Cet article vous guidera dans la création d’un système de copy trading multi-comptes et multi-produits à partir de zéro, compatible avec les stratégies linguistiques My et Pine. Grâce à l’architecture Leader-Subscriber, il mettra en œuvre un cadre de trading synchrone multi-comptes efficace, stable et évolutif pour résoudre les différents problèmes que vous rencontrez lors du déploiement en temps réel.

Conception de la stratégie

Le programme est écrit et conçu en JavaScript, et l’architecture du programme utilise le modèle Leader-Abonné.

Code source de la stratégie :

/*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())
    }
}
  • Modèles de conception Nous avons précédemment conçu plusieurs stratégies de copy trading sur la plateforme, en utilisant une conception orientée processus. Cet article est une nouvelle tentative de conception, utilisant un style orienté objet et le modèle de conception d’observateur.

  • Solution de surveillance L’essence du soi-disant copy trading est une sorte de surveillance du comportement, de surveillance des actions de la cible et de leur réplication lorsque de nouvelles actions sont trouvées.

Cet article n’implémente qu’une seule solution : utiliser API KEY pour configurer les objets d’échange et surveiller les positions du compte cible. En fait, il existe deux autres solutions qui peuvent être plus compliquées à concevoir :

  • Grâce à l’API étendue de FMZ Surveillez les informations du journal et de la barre d’état du compte réel cible, effectuez des opérations et suivez les commandes en fonction des modifications. L’avantage d’utiliser cette solution est qu’elle permet de réduire efficacement les requêtes API.

  • Cela dépend du message push du disque réel cible Vous pouvez activer le message push du compte réel cible sur FMZ, et un message sera envoyé lorsque le compte réel cible aura une commande. La stratégie de copy trading peut recevoir ces messages et agir. Les avantages de l’utilisation de cette solution incluent : la réduction des requêtes API et le passage d’un mécanisme d’interrogation des requêtes à un mécanisme piloté par les événements.

  • Stratégie de copy trading

Il peut y avoir plusieurs exigences pour les stratégies de copy trading, et le cadre stratégique est conçu pour être facile à étendre.

  • Réplication de position : Les positions peuvent être répliquées 1:1 ou mises à l’échelle selon des paramètres de mise à l’échelle spécifiés.

  • Ratio de fonds propres Le ratio de fonds propres entre le compte avec commandes et le compte suivant les commandes peut être automatiquement utilisé comme paramètre de mise à l’échelle pour les commandes suivantes.

  • Synchronisation de position Dans la pratique, il peut y avoir diverses raisons qui font que les positions du preneur d’ordre et du suiveur d’ordre diffèrent. Le système peut être conçu pour détecter la différence entre le compte de copie et le compte porteur d’ordres lors du suivi des ordres et synchroniser automatiquement les positions.

  • Réessayer la commande Vous pouvez spécifier le nombre spécifique de tentatives pour les ordres échoués dans la stratégie de copy trading.

  • Backtesting Tests aléatoires Dans le codefunction randomTrade(symbol, amount)La fonction est utilisée pour un test d’ouverture aléatoire lors du backtesting afin de détecter l’effet du copy trading.

Backtesting et vérification de la stratégie

Création d’un système de copy trading multi-comptes basé sur FMZ prenant en charge My Language et Pine Strategy Language

Selon le premier objet d’échange ajouté (le preneur d’ordres), les objets d’échange ajoutés ultérieurement suivent les ordres (les suiveurs).

Création d’un système de copy trading multi-comptes basé sur FMZ prenant en charge My Language et Pine Strategy Language

Dans le cadre du test, trois types d’ordres ont été placés au hasard pour vérifier la demande de copy trading multi-variétés :

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

Partage de stratégie

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

Extension et optimisation

  • Développez les solutions de surveillance pour les données telles que les positions et les informations de compte.
  • Pour augmenter le contrôle sur les abonnés, vous pouvez ajouter des fonctions telles que la mise en pause et la désinscription des comptes de copie.
  • Mettre à jour dynamiquement les paramètres de la stratégie de copy trading.
  • Ajoutez et développez des données documentaires et des informations plus riches.

END

Vous êtes invités à laisser des messages et à discuter dans la bibliothèque et la communauté Inventor Quantitative (FMZ.COM). Vous pouvez formuler diverses demandes et idées. L’éditeur sélectionnera des plans de production de contenu, des explications et du matériel pédagogique plus utiles en fonction des messages.

Cet article n’est qu’un point de départ. Il utilise un style orienté objet et un mode observateur pour concevoir un cadre de stratégie de trading de copie préliminaire. J’espère que cela pourra servir de référence et d’inspiration aux lecteurs. Merci pour votre lecture et votre soutien.