Building A Multi-Account Walkthrough System That Supports MyLanguage and Pine Strategy Language Based on FMZ

RedHeartRedHeart
13 min read

Demand Scenarios of Walkthrough System

I was often asked this question in the FMZ platform community and in private communication with users:

"Why can strategies written in MyLanguage or Pine scripts always only control one account and one product?"

The essence of this problem lies in the design positioning of the language itself. My language and Pine language are highly encapsulated scripting languages, and their underlying implementation is based on JavaScript. In order to allow users to quickly get started and focus on strategy logic, both have done a lot of encapsulation and abstraction at the language level, but this has also sacrificed a certain degree of flexibility: by default, only single-account, single-product strategy execution models are supported.

When users want to run multiple accounts in live trading, they can only do so by running multiple Pine or MyLanguage live trading instances. This approach is acceptable when the number of accounts is small, but if multiple instances are deployed on the same docker, a large number of API requests will be generated, and the exchange may even restrict access due to excessive request frequency, bringing unnecessary live trading risks.

So, is there a more elegant way to copy the trading behavior of other accounts automatically by just running a Pine or MyLanguage script?

The answer is: Yes.

This article will guide you through building a cross-account, cross-product walkthrough system from scratch, which is compatible with My and Pine language strategies. Through the Leader-Subscriber architecture, it will implement an efficient, stable, and scalable multi-account synchronous trading framework to solve the various disadvantages you encounter in live trading deployment.

Strategy Design

The program is designed and written in JavaScript, and the program architecture uses the Leader-Subscriber model.

Strategy source code:

/*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] }) {
        // Account managed with trading signals configuration
        this.leaderCfg = leaderCfg
        // Cache the last position information for comparison
        this.lastPos = null
        // Record current position information
        this.currentPos = null
        // Record subscribers
        this.subscribers = []

        // According to the leaderCfg configuration, determine which monitoring solution to use: 1. Monitor the account managed with trading signals directly. 2. Monitor the data of the live trading strategy of the account managed with trading signals through the FMZ extended API. 3. Walkthrough through the message push mechanism. The default solution is 1.

        // initialization
        let ex = this.leaderCfg.apiClient
        let currency = ex.GetCurrency()
        let arrCurrency = currency.split("_")
        if (arrCurrency.length !== 2) {
            throw new Error("The account managed with trading signals configuration is wrong, it must be two currency pairs")
        }
        this.baseCurrency = arrCurrency[0]
        this.quoteCurrency = arrCurrency[1]
        // Get the total equity of the account managed with trading signals at initialization
        this.initEquity = _C(ex.GetAccount).Equity
        this.currentPos = _C(ex.GetPositions)
    }

    // Monitoring leader logic
    poll() {
        // Get the exchange object
        let ex = this.leaderCfg.apiClient

        // Get the leader's position and account asset data
        let pos = ex.GetPositions()
        if (!pos) {
            return
        }
        this.currentPos = pos

        // Call the judgment method to determine the position changes
        let { hasChanged, diff } = this._hasChanged(pos)
        if (hasChanged) {
            Log("Leader position changes, current position:", pos)
            Log("Position changes:", diff)
            // Notify Subscribers
            this.subscribers.forEach(subscriber => {
                subscriber.applyPosChanges(diff)
            })
        }

        // Synchronous positions
        this.subscribers.forEach(subscriber => {
            subscriber.syncPositions(pos)
        })
    }

    // Determine whether the position has changed
    _hasChanged(pos) {
        if (this.lastPos) {
            // Used to store the results of position differences
            let diff = {
                added: [],    // Newly added positions
                removed: [],  // Removed positions
                updated: []   // Updated positions (amount or price changes)
            }

            // Convert the last position and the current position into a Map, with the key being `symbol + direction` and the value being the position object
            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]))

            // Traverse the current positions and find new and updated positions
            for (let [key, current] of currentPosMap) {
                if (!lastPosMap.has(key)) {
                    // If the key does not exist in the last position, it is a new position.
                    diff.added.push({ symbol: current.Symbol, type: current.Type, deltaAmount: current.Amount })
                } else {
                    // If it exists, check if the amount or price has changed
                    let last = lastPosMap.get(key)
                    if (current.Amount !== last.Amount) {
                        diff.updated.push({ symbol: current.Symbol, type: current.Type, deltaAmount: current.Amount - last.Amount })
                    }
                    // Remove from lastPosMap, and what remains is the removed position
                    lastPosMap.delete(key)
                }
            }

            // The remaining keys in lastPosMap are the removed positions.
            for (let [key, last] of lastPosMap) {
                diff.removed.push({ symbol: last.Symbol, type: last.Type, deltaAmount: -last.Amount })
            }

            // Determine if there is a change
            let hasChanged = diff.added.length > 0 || diff.removed.length > 0 || diff.updated.length > 0

            // If there is a change, update lastPos
            if (hasChanged) {
                this.lastPos = pos
            }

            return { hasChanged: hasChanged, diff: diff }
        } else {
            // If there is no last position record, update the record and do not synchronize positions
            this.lastPos = pos
            return { hasChanged: false, diff: { added: [], removed: [], updated: [] } }

            /* Another solution: synchronize positions
            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: []}}
            }
            */
        }
    }

    // Subscriber registration
    subscribe(subscriber) {
        if (this.subscribers.indexOf(subscriber) === -1) {
            if (this.quoteCurrency !== subscriber.quoteCurrency) {
                throw new Error("Subscriber currency pair does not match, current leader currency pair: " + this.quoteCurrency + ", subscriber currency pair:" + subscriber.quoteCurrency)
            }
            if (subscriber.followStrategy.followMode === "equity_ratio") {
                // Set the ratio of account managed with trading signals
                let ex = this.leaderCfg.apiClient
                let equity = _C(ex.GetAccount).Equity
                subscriber.setEquityRatio(equity)
            }
            this.subscribers.push(subscriber)
            Log("Subscriber registration is successful, subscription configuration:", subscriber.getApiClientInfo())
        }
    }

    // Unsubscribe
    unsubscribe(subscriber) {
        const index = this.subscribers.indexOf(subscriber)
        if (index !== -1) {
            this.subscribers.splice(index, 1)
            Log("Subscriber unregistration successful, subscription configuration:", subscriber.getApiClientInfo())
        } else {
            Log("Subscriber unregistration failed, subscription configuration:", subscriber.getApiClientInfo())
        }
    }

    // Get UI information
    fetchLeaderUI() {
        // Information of the trade order issuer
        let tblLeaderInfo = { "type": "table", "title": "Leader Info", "cols": ["order trade plan", "denominated currency", "number of walkthrough followers", "initial total equity"], "rows": [] }
        tblLeaderInfo.rows.push([this.leaderCfg.type, this.quoteCurrency, this.subscribers.length, this.initEquity])

        // Construct the display information of the trade order issuer: position information
        let tblLeaderPos = { "type": "table", "title": "Leader pos", "cols": ["trading product", "direction", "amount", "price"], "rows": [] }
        this.currentPos.forEach(pos => {
            let row = [pos.Symbol, pos.Type == PD_LONG ? "long" : "short", pos.Amount, pos.Price]
            tblLeaderPos.rows.push(row)
        })

        // Construct the display information of the subscriber
        let strFollowerMsg = ""
        this.subscribers.forEach(subscriber => {
            let arrTbl = subscriber.fetchFollowerUI()
            strFollowerMsg += "`" + JSON.stringify(arrTbl) + "`\n"
        })

        return "`" + JSON.stringify([tblLeaderInfo, tblLeaderPos]) + "`\n" + strFollowerMsg
    }

    // Expand functions such as pausing walkthrough trading and removing subscriptions
}

class Subscriber {
    constructor(subscriberCfg, followStrategy = { followMode: "position_ratio", ratio: 1, maxReTries: 3 }) {
        this.subscriberCfg = subscriberCfg
        this.followStrategy = followStrategy

        // initialization
        let ex = this.subscriberCfg.apiClient
        let currency = ex.GetCurrency()
        let arrCurrency = currency.split("_")
        if (arrCurrency.length !== 2) {
            throw new Error("Subscriber configuration error, must be two currency pairs")
        }
        this.baseCurrency = arrCurrency[0]
        this.quoteCurrency = arrCurrency[1]

        // Initial acquisition of position data
        this.currentPos = _C(ex.GetPositions)
    }

    setEquityRatio(leaderEquity) {
        // {followMode: "equity_ratio"} Automatically follow orders based on account 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("Rights and interests of the trade order issuer:", leaderEquity, "Subscriber benefits:", equity)
            Log("Automatic setting, subscriber equity ratio:", ratio)
        }
    }

    // Get the API client information bound to the subscriber
    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("The subscriber is not bound to the API client")
        }
    }

    // Returns the transaction direction parameters according to the position type and position changes
    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) {
                // Traverse the positions and find the corresponding symbol and 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("The subscriber is not bound to the API client")
        }
    }

    // Retry order
    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
    }

    // Synchronous position changes
    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) {
                        // Calculate the walkthrough trading ratio
                        let ratio = this.followStrategy.ratio
                        let tradeAmount = Math.abs(item.deltaAmount) * ratio
                        if (side == "closebuy" || side == "closesell") {
                            // Get the number of positions to check
                            let posAmount = this.getSymbolPosAmount(item.symbol, item.type)
                            tradeAmount = Math.min(posAmount, tradeAmount)
                        }
                        // Order 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)
                        // Check the Order Id
                        if (orderId) {
                            Log("The subscriber successfully placed an order, order ID:", orderId, ", Order direction:", side, ", Order amount:", Math.abs(item.deltaAmount), ", walkthrough order ratio (times):", ratio)
                        } else {
                            Log("Subscriber order failed, order ID: ", orderId, ", order direction: ", side, ", order amount: ", Math.abs(item.deltaAmount), ", walkthrough order ratio (times): ", ratio)
                        }
                    }
                })
            })

            // Update current position
            this.currentPos = _C(ex.GetPositions)
        } else {
            throw new Error("The subscriber is not bound to the API client")
        }
    }

    // Synchronous positions
    syncPositions(leaderPos) {
        let ex = this.subscriberCfg.apiClient
        this.currentPos = _C(ex.GetPositions)

        // Used to store the results of position differences
        let diff = {
            added: [],    // Newly added positions
            removed: [],  // Removed positions
            updated: []   // Updated positions (amount or price changes)
        }
        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]))
        // Traverse the current positions and find new and updated positions
        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 })
        }
        // Determine if there is a change
        let hasChanged = diff.added.length > 0 || diff.removed.length > 0 || diff.updated.length > 0
        if (hasChanged) {
            // synchronous
            this.applyPosChanges(diff)
        }
    }

    // Get subscriber UI information
    fetchFollowerUI() {
        // Subscriber information
        let ex = this.subscriberCfg.apiClient
        let equity = _C(ex.GetAccount).Equity
        let exLabel = ex.GetLabel()
        let tblFollowerInfo = { "type": "table", "title": "Follower Info", "cols": ["exchange object index", "exchange object tag", "denominated currency", "walkthrough order mode", "walkthrough order ratio (times)", "maximum retry times", "total equity"], "rows": [] }
        tblFollowerInfo.rows.push([this.subscriberCfg.clientIdx, exLabel, this.quoteCurrency, this.followStrategy.followMode, this.followStrategy.ratio, this.followStrategy.maxReTries, equity])

        // Subscriber position information
        let tblFollowerPos = { "type": "table", "title": "Follower pos", "cols": ["trading product", "direction", "amount", "price"], "rows": [] }
        let pos = this.currentPos
        pos.forEach(p => {
            let row = [p.Symbol, p.Type == PD_LONG ? "long" : "short", p.Amount, p.Price]
            tblFollowerPos.rows.push(row)
        })

        return [tblFollowerInfo, tblFollowerPos]
    }
}

// Test function, simulate random opening, simulate leader position change
function randomTrade(symbol, amount) {
    let randomNum = Math.random()
    if (randomNum < 0.0001) {
        Log("Simulate order managed with trading signals trading", "#FF0000")
        let ex = exchanges[0]
        let pos = _C(ex.GetPositions)

        if (pos.length > 0) {
            // Random close positions
            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())
            }
        }
    }
}

// Strategy main loop
function main() {
    let leader = new Leader()

    let followStrategyArr = JSON.parse(strFollowStrategyArr)
    if (followStrategyArr.length > 0 && followStrategyArr.length !== exchanges.length - 1) {
        throw new Error("Walkthrough trading strategy configuration error, walkthrough trading strategy amount and exchange amount do not match")
    }

    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)
    }

    // Start monitoring
    while (true) {
        leader.poll()
        Sleep(1000 * pollInterval)

        // Simulate random transactions
        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())
    }
}
  • Design pattern
    Previously, we have designed several walkthrough trading strategies on the platform, using process-oriented design. This article is a new design attempt, using object-oriented style and observer design pattern.

  • Monitoring plan
    The essence of walkthrough trading is a monitoring behavior, monitoring the target's actions and replicating them when new actions are found.

In this article, only one solution is implemented: configure the exchange object through API KEY, and monitor the position of the target account. In fact, there are two other solutions that can be used, which may be more complicated in design:

Extended API through FMZ
Monitor the log and status bar information of the target live trading, and operate and walkthrough orders according to the changes. The advantage of using this solution is that it can reduce API requests effectively.

Rely on the message push of the target live trading
You can turn on the message push of the target live trading on FMZ, and a message will be pushed when the target live trading has an order operation. The walkthrough trading strategy receives these messages and takes action. The advantages of using this solution are: reducing API requests and changing from a request polling mechanism to an event-driven mechanism.

  • Walkthrough trading strategy

There may be multiple requirements for walkthrough trading strategies, and the strategy framework is designed to be as easy to expand as possible.

Position replication:
Positions can be replicated 1:1, or they can be scaled according to specified scaling parameters.

Equity ratio
The equity ratio of the account managed with trading signals and the walkthrough account can be automatically used as a scaling parameter for walkthrough trading.

  • Position synchronization
    In actual use, there may be various reasons that cause the positions of the trade order issuer and the walkthrough trading follower to differ. You can design a system to detect the difference between the walkthrough trading account and the trade order issuer account when walkthrough orders, and synchronize the positions automatically.

  • Order retry
    You can specify the specific number of failed order retries in the walkthrough trading strategy.

  • Backtest random test
    The function randomTrade(symbol, amount) function in the code is used for random position opening test during backtesting to detect the walkthrough trading effect.

Strategy Backtesting and Verification

img

According to the first exchange object added (trade order issuer), the subsequent exchange objects added walkthrough order (walkthrough order follower).

In the test, three products are used to place orders randomly to verify the demand for multi-product order following:

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

Strategy Sharing

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

Extension and Optimization

  • Expand the monitoring scheme for data such as positions and account information.

  • Increase control over subscribers, and add functions such as pausing and unsubscribing of walkthrough trading accounts.

  • Dynamically update walkthrough trading strategy parameters.

  • Increase and expand richer walkthrough trading data and information display.

END

Welcome to leave messages and discuss in the FMZ Quant (FMZ.COM) Digest and Community. You can put forward various demands and ideas. The editor will select more valuable content production plan design, explanation, and teaching materials based on the messages.

This article is just a starting point. It uses object-oriented style and observer mode to design a preliminary walkthrough trading strategy framework. I hope it can provide reference and inspiration for readers. Thank you for your reading and support.

From: Building A Multi-Account Walkthrough System That Supports MyLanguage and Pine Strategy Language Based on FMZ

0
Subscribe to my newsletter

Read articles from RedHeart directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

RedHeart
RedHeart