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

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
Thefunction 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
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.
Subscribe to my newsletter
Read articles from RedHeart directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
