"use strict"

const fieldSymbol = ["symbol", "instrument", "pair", "product_id", "marketSymbol", "instrument_name", "instId"]
const fieldAvgPrice = ["avgPrice", "avgPx", "avg_execution_price", "avgFillPrice", "price_avg", "average_filled_price"]
const fieldPrice = ["price", ...fieldAvgPrice, "limitPrice", "orderPrice", "px", "limit_price"]
const fieldTP = ["take_profit", "takeProfit", "tpTriggerPx", "tpLimitPrice", "take_profit_price"]
const fieldSL = ["stop_loss", "stopLoss", "slTriggerPx", "slLimitPrice", "stop_loss_price"]
const fieldStop = ["stopPx", "stop_px", "stopprice", "activationPrice", "triggerPrice", "trigger_price", ...fieldTP, ...fieldSL, "trailing_stop", "stop_trigger_price"]
const fieldSide = ["side", "direction"]
const fieldHedge = ["positionSide", "posSide", "position_idx", "positionIdx"]
const fieldQuantityExec = ["executedQty", "cum_exec_qty", "cumExecQty", "cumQty", "fillQuantity", "filled_amount", "filledSize", "filled_size", "filled", "accFillSz", "fillSz", "vol_exec"]
const fieldQuantityLeft = ["leavesQty", "leaves_qty", "remainingSize", "remaining_amount"/*, "amount"*/, "unfilledSize"]
const fieldQuantity = ["orderQty", "quantity", "units", "qty", "size", "original_amount", "startingAmount", "origQty", "volume", "vol", "sz", "amount", "orderQtyRq", "sizeRq", "base_size"]
const fieldType = ["ordType", "type", "order_type", "orderType", "ordertype"]	//	ordertype	orderType ???
const fieldTIF = ["timeInForce", "time_in_force"]
const fieldStatus = ["ordStatus", "status", "order_status", "orderStatus"]
const fieldId = ["orderID", "orderId", "order_id", "ordId", "id", "txid", "algoId", "uid", "comment"]
const fieldCustom = ["algoClOrdId", "clOrdId", "clOrdID"/*, "uuid"*/, "newClientOrderId", "clientOrderId", "order_link_id", "orderLinkId", "clientOid", "client_oid", "label", "clientId", "cliOrdID", "client_order_id"]	//	clOrdId	clOrdID ???
const fieldAll = [...fieldSymbol, ...fieldPrice, ...fieldStop, ...fieldSide, ...fieldHedge, ...fieldQuantity, ...fieldQuantityExec, ...fieldQuantityLeft, ...fieldType, ...fieldTIF, ...fieldStatus, ...fieldId, ...fieldCustom]

const fieldPosSide = [...fieldSide, "type", "positionSide", "posSide"]
const fieldPosHedge = fieldHedge
const fieldPosQuantity = ["quantity", /*"size_currency",$$$*/ "size", "volume", "vol", "amount", "available", /*"availPos",*/ "pos"]
const fieldPosPrice = ["avgEntryPrice", "recentAverageOpenPrice", "entryPrice", "entry_price", "average_price", "averagePrice", "avgPrice", "openPrice", "price_avg", "basePrice", "price", "avgPx", "base"]
const fieldPosPriceEven = ["breakEvenPrice", "recentBreakEvenPrice"]
const fieldPosPriceMark = ["markPrice", "mark_price", "mark", "last"]
const fieldPosPriceLiq = ["liquidationPrice", "liq_price", "liqPrice", "estimatedLiquidationPrice", "estimated_liquidation_price", "liqPx"]
const fieldPosProfit = ["unrealisedPnl", "unRealizedProfit", "unrealised_pnl", "unrealizedPL", "recentPnl", "unrealizedPnl", "floating_profit_loss", "profit", "net", "pl", "upl"]
const fieldPosDate = ["createdAt", "openingTimestamp", "updated_at", "updateTime", "updatedTime", "create_date", "createTime", "createdTime", "openTime", "cTime", "time", "transactTimeNs", "fillTime", "created_time"]
const fieldPosInfo = ["relSize", "pnl", "pnlTotal"]

const fieldClosedSize = ["closedSize"]
const fieldClosedPrice = ["avgExitPrice"]
const fieldClosedProfit = ["closedPnl"]
const fieldPNLInfo = ["_symbol", "_side", "_closed", "_sizeUSD", "relSize", "_entry", "_entryDate", "_exit", "_exitDate", "_profit", "pnl", "pnlTotal"]
const fieldPNLInfoShort = ["relSize", "_entry", "_entryDate", "_exit", "_exitDate", "_profit", "pnlTotal"]

// $$$ amount => positive (Bitfinex)
const errIsNoRetry = /(account|Invalid|Illegal|allowed|restricted|verification|not configured|not provided|not defined|not found|not available|not required|not authorized|not normal|not match|not valid|not within|not supported|unexpected|wouldNot|authentication|forbidden|denied|not logged|not exist|doesn't exist|doesn&#039;t exist|validation|amount|cannot set|cannot read|cannot be|does not match|undefined|missing|expire|Please grant|ProfitView|AlphaShifter|balance available|volatility|price change|already exists|immediately|Too much time|INSUFFICIENT|bind error|parameter error|Unknown market|Unknown order|Minimum size|min_notional|duplicate|orderCost|MARKET_HALTED|TRADING_NO_MONEY|less than|too small|too_small|not enough|order value|enough margin|collateral|no volume|max_future|error sign|should be between|truncated|not a function|exceed|too low|too_low|too high|too_high|INCONSISTENT|mismatch|TRIGGER|NO_POSITION|ABORT|ccy|tdMode)/i
const errIsWarning = /(balance available|volatility|price change|locked|too much time elapsed|maximum iterations|unknown order)/i
const errIsBalance = /(INSUFFICIENT|not enough|balance)/i
const errIsRejected = /reject|WouldExecute/i
// BinanceFT: "Due to the order could not be executed as maker, the Post Only order will be rejected. The order will not be recorded in the order history (Error Code -5022)"
// Deribit: "post_only_reject (Error Code 11054)"
// KrakenFT: "postWouldExecute"

const errOrderID = "Order ID check failed!"
const errSlippage = "Price difference too high (slippage)"
const errMinSpread = "Bid/Ask difference too low (min spread)"
const errMaxSpread = "Bid/Ask difference too high (max spread)"
const errMinBalance = "Not enough balance available! (minb was specified)"
const errMaxBalance = "Too much balance available! (maxb was specified)"
const errRejected = "Post-only order could not be executed as maker and was rejected!"

const exSymbolMapping = [
	[/BINANCE:(.*)(PERP|\.P)$/, "BINANCEFT:$1"],
	[/BINANCE:(.*\d\d\d\d)$/, "BINANCEFT:$1"],
	[/BITMEX:(.*)(PERP|\.P)$/, "BITMEX:$1"],
	[/BITFINEX:(.*)(UST|USDT)\.P$/, "BITFINEX:$1F0$2F0"],
	[/BYBIT:(.*)(PERP|\.P)$/, "BYBIT:$1"],
	[/BYBIT:(.*)\d\d(\d\d)$/, "BYBIT:$1$2"],
	[/BYBIT:(.*)/, "BYBIT:$1S"],
	[/DERIBIT:(.*)USD\.P$/, "$1PERPETUAL"],
	[/DERIBIT:(.*)USD(.)\.P$/, "$1USD$2PERPETUAL"],
	[/DERIBIT:(.*)(USDT|BTC)$/, "$1$2"],
	[/KRAKEN:BTCUSD(|.)$/, "KRAKEN:XBTUSD$1"],
	[/KRAKEN:BTCUSD\.PM$/, "KRAKENFT:PF_XBTUSD"],
	[/KRAKEN:BTCUSD(PERP|\.P)$/, "KRAKENFT:PI_XBTUSD"],
	[/KRAKEN:(.*)\.PM$/, "KRAKENFT:PF_$1"],
	[/KRAKEN:(.*)(PERP|\.P)$/, "KRAKENFT:PI_$1"],
	[/KRAKEN:BTCUSD(.*\d\d\d\d)$/, "KRAKENFT:XBTUSD$1"],
	[/KRAKEN:(.*\d\d\d\d)$/, "KRAKENFT:$1"],
	[/OKX:(.*)\.P$/, "OKX:$1PERP"],
	[/PHEMEX:BTCUSD\.P$/, "PHEMEX:UBTCUSD"],
	[/PHEMEX:ETHUSD\.PI$/, "PHEMEX:CETHUSD"],
	[/PHEMEX:(.*)(PERP|\.P|\.PI)$/, "PHEMEX:$1"],
	[/PHEMEX:(.*)/, "PHEMEX:S$1"],
]

class Exchange {
	constructor(meta) {
		!meta.nonceName && (meta.nonceName = meta.aliases[0] + "_NONCE")
		!meta.symbolCacheName && (meta.symbolCacheName = meta.aliases[0] + "_SYMBOLS")
		!meta.tickerCacheName && (meta.tickerCacheName = meta.aliases[0] + "_TICKERS")
		!meta.stateCacheName && (meta.stateCacheName = meta.aliases[0] + "_STATE")
		!meta.offsetName && (meta.offsetName = meta.aliases[0] + "_OFFSET")
		this.meta = meta
		this.cmd = {logLevel: 15}
	}

	hasSpot() {
		return this.meta.spot || !this.meta.margin
	}
	hasMargin() {
		return this.meta.margin
	}
	canListPos() {
		return this.meta.margin && this.meta.listPos !== false
	}
	hasStopsOnFill() {
		return this.meta.stopsOnFill
	}
	hasReducePosOnly() {
		return this.meta.reducePosOnly
	}
	hasLeavesQuantity() {
		return this.meta.leavesQuantity
	}
	getAlias(index = 0) {
		return this.meta.aliases && (index < 0 ? this.meta.aliases.last() : this.meta.aliases[index])
	}
	getAliases() {
		return this.meta.aliases || (() => {throw new ReferenceError("Exchange aliases are not configured!")})()
	}
	getFields() {
		return this.meta.fields || (() => {throw new ReferenceError("Exchange fields are not configured!")})()
	}
	getName() {
		return this.meta.name || (() => {throw new ReferenceError("Exchange name is not configured!")})()
	}
	getPermissions() {
		return this.meta.permissions || (() => {throw new ReferenceError("Exchange permissions are not configured!")})()
	}
	getWebsite() {
		return this.meta.website || (() => {throw new ReferenceError("Exchange website is not configured!")})()
	}
	getDescription() {
		return this.meta.desc || (() => {throw new ReferenceError("Exchange description is not configured!")})()
	}
	getDefaultSymbol() {
		return this.meta.defaultSymbol || "BTC"
	}
	getTestSymbol() {
		return this.getAlias() + ':' + (this.meta.testSymbol || "BTCUSD")
	}
	hasAccount(acc) {
		return this.getAccounts().includesNoCase(acc)
	}
	hasCredentials() {
		return this.getAccounts().length
	}
	getAccount() {
		return this.cmd.a
	}
	setAccount(acc) {
		this.cmd.a = acc
	}
	setEventID(id) {
		this.cmd.id = id
	}
	setLogLevel(level) {
		this.cmd.logLevel = level
	}
	isTest() {
		return this.cmd.id && this.cmd.id.startsWith('TEST @ ')
	}
	debug(msg) {
		if (this.cmd.logLevel & 1) log.debug((this.cmd.id ? `[${this.cmd.id}] ` : '')+this.getName()+" "+msg)
	}
	info(msg) {
		if (this.cmd.logLevel >= 14) {
			log.info((this.cmd.id ? `[${this.cmd.id}] ` : '')+this.getName()+" "+msg)
			this.cmd.telebot && telebotSend(this.cmd.telebot, (this.cmd.id ? `[${this.cmd.id}] ` : '')+this.getName()+" "+msg)
		}
	}
	warn(msg) {
		if (this.cmd.logLevel & 4) {
			log.warn((this.cmd.id ? `[${this.cmd.id}] ` : '')+this.getName()+" "+msg)
			this.cmd.telebot && telebotSend(this.cmd.telebot, (this.cmd.id ? `[${this.cmd.id}] ` : '')+this.getName()+" "+msg)
		}
	}
	notice(msg) {
		if (this.cmd.logLevel & 8) {
			log.notice((this.cmd.id ? `[${this.cmd.id}] ` : '')+this.getName()+" "+msg)
			this.cmd.telebot && telebotSend(this.cmd.telebot, (this.cmd.id ? `[${this.cmd.id}] ` : '')+this.getName()+" "+msg)
		}
	}

	static cleanSym = /[-_/\\: ]/g
	static d = ['Testnet', 'Sandbox', 'Demo']

	sym(name) {
		return name?.replace(Exchange.cleanSym, '').toUpperCase()
	}

	getSubscriptions(key) {
		this.meta.subscriptions || (() => {throw new ReferenceError("Exchange subscriptions are not configured!")})()
		if (key) {
			return this.meta.subscriptions[key] || (() => {throw new ReferenceError("Invalid subscription key "+key)})()
		}
		return this.meta.subscriptions
	}

	getCredentials(field = 'public') {
		const acc = this.getAccount() || (() => {throw new Error("No account provided!")})()
		this.hasAccess()
		const accs = (localStore.get('hasAccs') ? localStore : syncStore).get('exchanges', this.getAlias())
		const cred = (accs || {}).getProp(acc)
		if (!accs || !cred || !cred[field]) {
			const names = Object.keys(accs || {})
			names.length || (() => {throw new ReferenceError(`No accounts configured for ${this.getName()} – please check "Options / Accounts"!`)})()
			throw new ReferenceError(`Non-existent account ${acc} specified for ${this.getName()} (available: ${names.join(', ')}) – please check "Options / Accounts"!`)
		}
		const p2 = localStore.gets('perms.permissions')
		if ((this.getSubscriptions().active.length || this.getSubscriptions().inactive.length) && Object.values(p2||0).indexOf('ca8de1b4a7ca87c4b1c99f6fc9b9d480') < 0) {
			throw new ReferenceError(`${this.getName()}: Unfortunately this is only supported in ${pvName} PRO!`)
		}
		return cred
	}

	setCredentials(acc, input) {
		if (!acc.length || !acc.trim()) {
			throw new SyntaxError("Account name was not provided!")
		}
		if (isIllegalAcc(acc)) {
			throw new SyntaxError("Account name contains reserved or illegal characters!")
		}
		let data = {account: acc}
		for (const field in this.meta.fields) {
			let val = input[field]
			if (typeof val === 'number') {
				// $$$$ if(field === 'cache' && val === 0) clearState?
				data[field] = val
			} else if (val) {
				if (typeof val !== 'string') {
					throw new TypeError(`Field was not a string! (${typeof val})`)
				}
				val = val.replace(/[^\x00-\x7F]/g, '').trim()
				if (!val.length && !this.meta.fields[field].optional) {
					throw new SyntaxError(`Field '${this.meta.fields[field].label}' was not provided!`)
				}
				data[field] = val
			} else if (!this.meta.fields[field].optional) {
				throw new SyntaxError(`Field '${this.meta.fields[field].label}' was not provided!`)
			}
		}
		return data
	}

	getOptions() {
		return syncStore.get("options_"+this.getAlias().toLowerCase()) || defExchangeOpt
	}

	getAccounts() {
		return Object.keys((localStore.get('hasAccs') ? localStore : syncStore).get('exchanges', this.getAlias()) || {})
	}

	getAccountRaw(acc) {
		return (localStore.get('hasAccs') ? localStore : syncStore).get('exchanges', this.getAlias(), acc)
	}

	removeAccount(acc) {
		localStore.remove('exchanges', this.getAlias(), acc)
		syncStore.remove('exchanges', this.getAlias(), acc)
		// $$$$ clear caches?
	}
	removeAccounts() {
		localStore.remove('exchanges', this.getAlias())
		syncStore.remove('exchanges', this.getAlias())
		// $$$$ clear caches?
	}

	hasPermission() {
		const exPerms = this.getPermissions()
		const legacyPerms = getLegacyPermissions(exPerms)
		return perms.hasAll(exPerms) || perms.hasAll(legacyPerms[0]) || perms.hasAll(legacyPerms[1])
	}

	hasSubscription() {
		const subs = this.getSubscriptions()
		if (!subs.active.length && this.meta.name.includesAny(Exchange.d)) {
			return subs.inactive
		}
		return perms.hasAll(globalThis.ACCESS) && perms.hasAny({permissions: subs.active})
	}

	hasAccess() {
		this.hasPermission() || (() => {throw new ReferenceError(`${this.getName()}: Please grant necessary permissions under "Setup: Permissions"!`)})()
		this.hasSubscription() || (() => {throw new ReferenceError(`${this.getName()}: Unfortunately this is only supported in ${pvName} PRO!`)})()
	}

	async executeCommand(cmd, id, ohlc = {}, logLevel = 15, telebot) {
		this.setAccount(cmd.a)
		this.setEventID(id)
		this.setLogLevel(logLevel)
		telebot && (this.cmd.telebot = telebot)
		this.loadOrderData(cmd, ohlc)

		if ((cmd.ch || cmd.c) === 'order') {
			return this.saveOrderData(cmd, ohlc, await this.ordersCancel(cmd, ohlc))
		} else if ((cmd.ch || cmd.c) === 'position') {
			if (cmd.c && cmd.cid) {
				const pos = (await this.getPositions(cmd.a, cmd.s, 1))[cmd.a].list
				if (!pos || !pos.length) {
					this.info("no positions open!")
					return false
				}
				const ord = (await this.getOrders(cmd.a, cmd.s, logLevel, cmd.cid, true))[cmd.a]
				if (!ord || !ord.length) {
					throw new Error(errOrderID+` (cid=${cmd.cid} specified)`)
				}
				return this.saveOrderData(cmd, ohlc, await this.positionsCloseAll(cmd.clone({c: 'position', ch: undefined}), ohlc, pos))
			}
			return this.saveOrderData(cmd, ohlc, await this.positionsCloseAll(cmd, ohlc))
		} else if (cmd.ch || cmd.c) {
			throw new SyntaxError(`Invalid cancel/close/check option "${cmd.ch||cmd.c}" (${cmd.ch?'ch':'c'}=) specified!`)
		}

		if (cmd.tb) {
			if (!this.transfer) throw new SyntaxError(`Command "transfer balance" (tb=) not supported on this exchange!`)
			return await this.transfer(cmd, ohlc)
		} else if (cmd.loan) {
			if (!this.loan) throw new SyntaxError(`Command "loan margin" (loan=) not supported on this exchange!`)
			return await this.loan(cmd, ohlc)
		} else if (cmd.repay) {
			if (!this.repay) throw new SyntaxError(`Command "repay margin" (repay=) not supported on this exchange!`)
			return await this.repay(cmd, ohlc)
		} else if (cmd.convert) {
			if (!this.convert) throw new SyntaxError(`Command "convert balance" (convert=) not supported on this exchange!`)
			return await this.convert(cmd, ohlc)
		} else if (cmd.withdraw) {
			if (!this.withdraw) throw new SyntaxError(`Command "withdraw balance" (withdraw=) not supported on this exchange!`)
			return await this.withdraw(cmd, ohlc)
		} else if (cmd.query) {
			if (!this.query) throw new SyntaxError(`Command "query trades" (query=) not supported on this exchange!`)
			return this.saveOrderData(cmd, ohlc, await this.query(cmd, ohlc))
		}

		if (cmd.b || cmd.ub || cmd.up) {
			if (['limit', 'market', 'fok', 'ioc', 'post', 'day', 'settle'].includes(cmd.t)) {
				if (['pos', 'even', 'liq'].includes(cmd.pr) || cmd.y === 'possize' || cmd.yp) {
					let pos = null
					try {
						pos = (await this.getPositions(cmd.a, cmd.s, logLevel/*, cmd.b ??? */))[cmd.a].list
					} catch(ex) {}
					if (!pos || !pos.length) {
						this.warn("position based price reference (pr=pos/even/liq) or trade size (y=pos / yp) specified but no positions open!")
						if (cmd.nr) return false
					} else {
						ohlc.possize = 0
						for (const entry of pos) {
							ohlc.pos = entry._entry
							ohlc.even = entry._even
							ohlc.liq = entry._liq
							ohlc.possize += entry._size
						}
					}
				}
				return this.saveOrderData(cmd, ohlc, await this.trade(cmd, ohlc))
			}
			throw new SyntaxError(`Invalid order type "${cmd.t}" (t=) specified!`)
		}
		!cmd.lock && this.warn("Empty command executed!")
	}

	getBalance(symbol, isMargin, noInfo) {
		const acc = this.getAccount()
		const balances = this.getBalanceRaw()
		symbol ||= balances?.lastSymbol || this.getDefaultSymbol()
		if (isMargin && !symbol.endsWith(marginSuffix)) {
			symbol += marginSuffix
		}
		if (!noInfo) if (!balances) {
			this.warn(`does not have any cached balance info yet for account ${acc}, querying current balance! (use ub=1 or non-bc somewhere before bc=1)`)
		} else if (!balances[symbol]) {
			this.warn(`does not have cached balance info yet for account ${acc} and asset ${symbol}, querying current balance! (use ub=1 or non-bc somewhere before bc=1)`)
		} else {
			this.info(`using cached balance info for account ${acc} and asset ${symbol}! (bc=1 specified)`)
		}
		return balances && balances[symbol]
	}

	updateBalance(balance, symbol, isMargin, noInfo) {
		if (!balance) return
		symbol ||= this.getDefaultSymbol()
		if (balance.balance || balance.Balance || balance.total || balance.Total) {
			balance = {[symbol]: balance}
		}
		if (!noInfo) this.notice(`available ${isMargin?'margin ':''}balance(s): `+stringify(balance))

		if (isMargin) {
			let margin = {}
			for (const asset in balance) {
				margin[typeof balance[asset] === 'object' && !asset.endsWith(marginSuffix) ? asset+marginSuffix : asset] = balance[asset]
			}
			balance = margin
			if (!symbol.endsWith(marginSuffix)) {
				symbol += marginSuffix
			}
		}
		balance.lastSymbol = symbol
		balance.updated = new Date().toISOString()
		localStore.set('balances', this.getAlias(), this.getAccount().toUpperCase(), balance)
	}

	getBalanceRaw(acc) {
		return localStore.get('balances', this.getAlias(), (acc || this.getAccount() || '*').toUpperCase()) || {}
	}

	setBalanceRaw(key, val, acc) {
		localStore.set('balances', this.getAlias(), (acc || this.getAccount() || '*').toUpperCase(), key, val)
	}

	removeBalance(acc) {
		localStore.remove('balances', this.getAlias(), (acc || this.getAccount() || '*').toUpperCase())
	}

	checkBalance(cmd, balance, currency, type) {
		if (!balance) {
			throw new ReferenceError(`Account balance ${currency||''} not available!${type ? ` (${type})`:''}`)
		}
		if (cmd.minb && balance.available < cmd.minb._().reference(balance.balance).resolve()) {
			throw new Error(errMinBalance+` – ${balance.available} << lower than ${cmd.minb.toDecimal()} (${cmd.minb.toString()}${!cmd.minb.isFixed() ? ` from ${balance.balance}`:''})!`)
		} else if (cmd.maxb && balance.available > cmd.maxb._().reference(balance.balance).resolve()) {
			throw new Error(errMaxBalance+` – ${balance.available} >> higher than ${cmd.maxb.toDecimal()} (${cmd.maxb.toString()}${!cmd.maxb.isFixed() ? ` from ${balance.balance}`:''})!`)
		}
	}

	getPrice(symbol, isMargin, noInfo) {
		if (isMargin) {
			symbol += typeof isMargin === "string" ? `[${isMargin}]` : marginSuffix
		}
		const ex = localStore.get('tickers', this.getAlias())
		if (!noInfo) if (!ex) {
			this.warn("does not have any cached price data yet, using current price! (use up=1 somewhere before pc=1)")
		} else if (!ex[symbol]) {
			this.warn(`does not have cached price data for ${symbol} yet, using current price! (use up=1 somewhere before pc=1)`)
		} else {
			this.info(`using cached price data for ${symbol}! (pc=1 specified)`)
		}
		return ex && ex[symbol]
	}

	updatePrice(symbol, ticker, isMargin) {
		if (isMargin) {
			symbol += typeof isMargin === "string" ? `[${isMargin}]` : marginSuffix
		}
		this.notice(`caching ticker for ${symbol}: `+stringify(ticker))
		ticker.updated = new Date().toISOString()
		localStore.set('tickers', this.getAlias(), symbol, ticker)
	}

	uniqueID(id, suffix1, suffix2) {
		let event = (this.cmd.id || '').toString()
		const subs = event.split('=>')
		const subnum = subs.length - (event.charCodeAt(0) >= 48 && event.charCodeAt(0) <= 57 ? 2 : 1)
		let line = ((subs.last().match(/:([\d:]+)/)||[])[1] || '').replace(':' , '-')
		event = (event.match(/^[\d\-]+/)||[])[0] || this.cmd.randomID || (this.cmd.randomID = (Date.now() % 100000000).toString())
		return (id ? `${id}-` : '') + event + (subnum > 0 ? `-${subnum}` : '') + (line ? `-${line}` : '') + (suffix1 ? `-${suffix1}` : '') + (suffix2 ? `-${suffix2}` : '')
	}

	uniqueIDA(len, id, suffix1, suffix2) {
		let event = (this.cmd.id || '').toString()
		const subs = event.split('=>')
		const subnum = subs.length - (event.charCodeAt(0) >= 48 && event.charCodeAt(0) <= 57 ? 2 : 1)
		let line = ((subs.last().match(/:([\d:]+)/)||[])[1] || '').replace(':' , 'R')
		event = (event.match(/^[\d\-]+/)||[])[0] || this.cmd.randomID || (this.cmd.randomID = (Date.now() % 100000000).toString())
		return (alnum(id) + alnum(event/*, 'C'*/).substr(len > 16 ? -10 : -6) + (subnum > 0 ? `S${subnum}` : '') + (line ? `L${line}` : '') + (suffix1 || '') + (suffix2 || '')).substr(0, len)
	}

	prefixID(prefix) {
		return globalThis['\x70'] && pvCode && opt[this.getAlias(-1)] || prefix
	}

	getNonce() {
		let nonce = Math.max(Date.now(), globalThis[this.meta.nonceName] || 0)
		if (nonce == globalThis[this.meta.nonceName]) {
			++nonce
		}
		return globalThis[this.meta.nonceName] = nonce
	}

	getIdCache(order) {
		const idcache = localStore.get().idcache
		const alias = this.getAlias()
		const cache = idcache[alias] = idcache[alias] || {}
		//if (order !== undefined) this.debug(`retrieving id '${cache[order] && cache[order][0]}' for order '${order}'`)
		return order !== undefined ? cache[order] && cache[order][0] : cache
	}

	cacheId(order, ids) {
		const cache = this.getIdCache()
		cache[order] = [ids, Date.now()]
		localStore.updated()
		//this.debug(`caching id '${ids}' for order '${order}'`)
	}

	removeId(order) {
		const cache = this.getIdCache()
		delete cache[order]
		localStore.updated()
	}

	checkId(order, ids) {
		const cache = this.getIdCache()
		return cache[order] && cache[order][0].startsWithAny(ids, true)
	}

	getSymbolCache() {
		const symbolCache = globalThis[this.meta.symbolCacheName] = globalThis[this.meta.symbolCacheName] || {}
		const expired = Date.now() - opt.infoExpire * 60 * 60 * 1000
		symbolCache._refresh = !symbolCache._updated || (opt.infoExpire && symbolCache._updated < expired)
		return symbolCache
	}

	purgeSymbolCache() {
		delete globalThis[this.meta.symbolCacheName]
	}

	updatedSymbolCache(newCache) {
		if (newCache) globalThis[this.meta.symbolCacheName] = newCache
		const symbolCache = globalThis[this.meta.symbolCacheName] = globalThis[this.meta.symbolCacheName] || {}
		delete symbolCache._refresh
		symbolCache._updated = Date.now()
	}

	async updateSymbols() {
		this.setAccount(this.getAccount() || this.getAccounts()[0])
		await this.symbolInfo()
	}

	getTickerCache(symbol) {
		const tickerCache = globalThis[this.meta.tickerCacheName] = globalThis[this.meta.tickerCacheName] || {}
		const ticker = tickerCache[symbol] = tickerCache[symbol] || {}
		return ticker
	}

	async symbolInfo(symbol) {
		const cache = this.getSymbolCache()
		if (cache._mutex) {
			this.debug('waiting for previous market symbol info request to finish...')
			await cache._mutex
		} else if (cache._refresh) {
			this.info('updating market symbol info...')
			cache._mutex = this.symbolInfoImpl(cache)
			try {
				this.updatedSymbolCache(await cache._mutex)
			} catch(ex) {
				delete cache._mutex
				throw(ex)
			}
			delete cache._mutex
		}
		if (!symbol) return cache

		const info = cache[this.sym(symbol)]
		if (!info) throw new Error(`Unknown market symbol: ${symbol} – Use the 'Symbols' button in the exchange settings or alert editor to browse for valid symbols!`)
		return info
	}

	async symbolTicker(up, symbol, param) {
		const cache = opt.priceExpire && this.getTickerCache(symbol)
		if (cache && !(opt.priceUpNoCache && up)) {
			if (cache._mutex) {
				this.debug(`waiting for previous ${symbol} ticker request to finish...`)
				await cache._mutex
			}
			if (cache._cached >= Date.now() - opt.priceExpire * 1000) {
				this.debug(`using cached ticker for ${symbol} from ${formatDateS(cache._cached)}`)
				return cache
			}
		}
		try {
			const apiCall = this.symbolTickerImpl(symbol, param)
			if (cache) cache._mutex = apiCall
			const ticker = await apiCall
			if (cache) {
				Object.assign(cache, ticker, {_cached: Date.now()})
				delete cache._mutex
			}
			return ticker
		} catch(ex) {
			if (cache) delete cache._mutex
			throw(ex)
		}
	}

	getStateCache(acc, sym, readOnly) {
		if (readOnly && !globalThis[this.meta.stateCacheName]) return null
		const stateCache = globalThis[this.meta.stateCacheName] = globalThis[this.meta.stateCacheName] || {}
		if (readOnly && !stateCache[acc]) return null
		const stateAcc = stateCache[acc] = stateCache[acc] || {}
		if (readOnly && !stateAcc[sym]) return null
		const state = stateAcc[sym] = stateAcc[sym] || {}
		return state
	}

	async cacheState(market, getState) {
		const acc = (this.getAccount() || '*').toUpperCase()
		let useCache = true
		try {
			if (this.getCredentials().cache === 0) {
				this.debug(`state caching disabled for ${acc}, querying directly...`)
				useCache = false
			}
		} catch (ex) {
			this.debug(`couldn't determine state cache setting! (${ex.message})`)
			useCache = false
		}

		const sym = market.symbol
		const cache = useCache && this.getStateCache(acc, sym)
		if (cache?.updated) {
			this.debug(`using cached state for ${acc}:${sym} from ${formatDateS(cache.updated)}`)
			return cache
		}
		this.debug(`no state cached yet for ${acc}:${sym}, querying...`)

		const state = await getState.call(this, market)
		if (cache) {
			Object.assign(cache, state)
			cache.updated = Date.now()
			this.debug(`cached state for ${acc}:${sym}!`)
		}
		return state
	}

	clearState(market) {
		const stateCache = globalThis[this.meta.stateCacheName]
		if (!stateCache) return
		const acc = (this.getAccount() || '*').toUpperCase()
		const stateAcc = stateCache[acc]
		if (!stateAcc) return

		if (!market) {
			delete stateCache[acc]
			this.debug(`cleared all state cache for ${acc}`)
		} else {
			const sym = market.symbol
			delete stateAcc[sym]
			this.debug(`cleared state cache for ${acc}:${sym}`)
		}
	}

	updateState(market, state) {
		const acc = (this.getAccount() || '*').toUpperCase()
		const sym = market.symbol
		const cache = this.getStateCache(acc, sym, true)
		if (cache) {
			Object.assign(cache, state)
			cache.updated = Date.now()
			this.debug(`updated cached state for ${acc}:${sym}!`)
		}
		return state
	}

	async checkTime(isInit = false) {
		if (!this.hasPermission()) return false
		let localTime = Date.now()
		let serverTime
		try {
			serverTime = await this.time()
		} catch(ex) {
			return false
		}
		localTime = Math.round((localTime + Date.now()) / 2)
		const offset = globalThis[this.meta.offsetName] = localTime - serverTime

		if (isInit) {
			log.info(this.getName() + ` time: ${formatDate(serverTime, true)} – Local time: ${formatDate(localTime, true)} – Offset: ${(offset / 1000).toFixed(3)} seconds`)

			if (this.meta.recvWindow && Math.abs(offset) > this.meta.recvWindow) {
				log.warn(`Your computer's clock is quite far ${offset < 0 ? 'behind' : 'ahead'} for ${this.getName()} `+
					`(> ${this.meta.recvWindow / 1000}s)! (trying to auto correct; in case of errors see Wiki / FAQ for clock syncing tools)`)
			}
		}
		return offset
	}

	getTimestamp(iso = false) {
		const ts = Date.now() - (globalThis[this.meta.offsetName] || 0)
		return iso ? new Date(ts).toISOString() : ts
	}

	async filterPosStart(cmd, ohlc, tickers, positions) {
		await this.filterOrderStart(cmd, ohlc, tickers, positions)
	}

	filterPos(cmd, ohlc, tickers, pos, calcTotal, precision = 0, hasSpot = false) {
		pos._symbol = pos.getAny(fieldSymbol) || this.sym(cmd.s)
		pos._side = ({long: "long", buy: "long", short: "short", sell: "short"}[pos.getAny(fieldPosSide, "").toLowerCase()])
		pos._hedge = ({LONG: "long", Long: "long", long: "long", 1: "long", SHORT: "short", Short: "short", short: "short", 2: "short", BOTH: "both", Merged: "both", 0: "both"}[pos.getAny(fieldPosHedge)]) || ""
		pos._entry = pos.getAny(fieldPosPrice)
		pos._sl = pos.getAny(fieldSL)
		pos._tp = pos.getAny(fieldTP)
		pos._even = pos.getAny(fieldPosPriceEven)
		pos._mark = pos.getAny(fieldPosPriceMark)
		pos._liq = pos.getAny(fieldPosPriceLiq)
		pos._size = pos.getAny(fieldPosQuantity)
		pos._profit = pos.getAny(fieldPosProfit)
		pos._date = pos.getAny(fieldPosDate)
		if (pos._date) try {
			pos._date = new Date(Number(pos._date) || pos._date).toISOString()
		} catch(ex) {}

		const balance = this.getBalance(cmd.hasOwnProperty('currency') ? cmd.currency : cmd.s, cmd.isMargin && hasSpot, true) || {}
		let total = cmd.has('y') && cmd.y === "balance" ? Number(balance.available || balance.Available || 0) : Number(balance.balance || balance.Balance || balance.total || 0)
		if (calcTotal) total = calcTotal(total)
		pos.relSize = total && (pos._size / total)

		const lev = pos.leverage === 0 ? 100 : (parseFloat(pos.leverage) || 1)
		pos.cross = pos.leverage === 0 || pos.isolated === false || pos.is_isolated === false || pos.marginType === 'cross' || pos.crossMargin === true
		if (pos.isolated === undefined) pos.isolated = pos.is_isolated || pos.marginType === 'isolated' /*|| pos.crossMargin === false*/ || undefined
		pos.pnlLev = pos.pnl * lev
		pos.pnlTotal = pos.pnlLev * pos.relSize
		// $$$$$$ add sum pnl over multiple list entries!!!

		const info = this.cmd.logLevel >= 14 ? `${pos._symbol} ${pos._side ? pos._side+' ' : ''}position`+
			` – entry: ${pos._entry} – size: ${toDecimal(pos._size)}${pos._sizeUSD ? ` / ${Number(pos._sizeUSD).toFixed(2)} USD` : ''}${total ? ` (${(pos.relSize * 100).toFixed(2)}%)` : ''}`+
			` @ ${lev.toFixed(1).replace('.0','')}x${pos.cross ? " (cross)" : pos.isolated ? " (isolated)" : ''} – PnL: ${toDecimal(pos._profit)}`+
			` (pos: ${isNaN(pos.pnl) ? 'n/a' : (pos.pnl * 100).toFixed(2)+'%'}${lev > 1 ? ` / lev: ${(pos.pnlLev * 100).toFixed(2)}%` : ''}${total ? ` / total: ${(pos.pnlTotal * 100).toFixed(2)}%` : ''})`+
			(pos._date ? ` – opened/updated ${formatDateS(pos._date)} (${formatDur(tillNow(pos._date))} ago)` : '') : null

		return this.filterPosMain(cmd, ohlc, tickers, pos, total, precision, info)
	}

	filterPosMain(cmd, ohlc, tickers, pos, total, precision, info) {
		// $$$$$$ add sum pnl over multiple list entries!!!
		function get(name) {
			const result = cmd[name]?._(), ref = cmd[name+'r']
			if (result && ref) {
				const refVal = globalVars.ohlc(ref, null, ohlc)
				const msg = `using custom '${name}' reference: ${refVal || "NOT FOUND!"} (${ref})`
				if (refVal) {
					result.reference(refVal)
					this.info(msg)
				} else {
					this.warn(msg)
				}
			}
			return result
		}

		const minpl = get('minpl')
		if (minpl?.compare(minpl.isPercent() ? {pos: pos.pnl, total: pos.pnlTotal}[cmd.plref] || pos.pnlLev : pos._profit) < 0) {
			info && this.info(info + ` –> ${minpl.isPercent() ? {pos: 'position', total: 'account'}[cmd.plref] || 'leveraged' : 'absolute'} PnL << lower than ${minpl.toString()}!`)
			return false
		}
		const maxpl = get('maxpl')
		if (maxpl?.compare(maxpl.isPercent() ? {pos: pos.pnl, total: pos.pnlTotal}[cmd.plref] || pos.pnlLev : pos._profit) > 0) {
			info && this.info(info + ` –> ${maxpl.isPercent() ? {pos: 'position', total: 'account'}[cmd.plref] || 'leveraged' : 'absolute'} PnL >> higher than ${maxpl.toString()}!`)
			return false
		}

		const mins = get('mins')
		const maxs = get('maxs')
		if (mins?.isPercent() || maxs?.isPercent()) {
			if (total) {
				mins?.reference(total), maxs?.reference(total)
			} else {
				this.warn("does not have balance info yet to calculate relative position size! (use ub=1 or similar before mins=/maxs= with percent value)")
			}
		}
		if (mins?.isPercent() === false && mins?.compare(cmd.u === "currency" && pos._sizeUSD || pos._size) < 0) {
			info && this.info(info + ` –> size << smaller than ${mins.toDecimal(precision)} (${mins.toString()}${mins.wasPercent() && total ? ` from ${total}`:''})!`)
			return false
		}
		if (maxs?.isPercent() === false && maxs?.compare(cmd.u === "currency" && pos._sizeUSD || pos._size) > 0) {
			info && this.info(info + ` –> size >> bigger than ${maxs.toDecimal(precision)} (${maxs.toString()}${maxs.wasPercent() && total ? ` from ${total}`:''})!`)
			return false
		}

		if (!this.filterOrder(cmd, ohlc, tickers, pos, info)) {
			return false
		}
		info && this.info(info)
		return true
	}

	async filterPNLStart(cmd, ohlc, tickers, pnls) {
		await this.filterOrderStart(cmd, ohlc, tickers, pnls)
	}

	filterPNL(cmd, ohlc, tickers, pnl, preBalance, precision = 0, hasSpot = false) {
		pnl._symbol = pnl.getAny(fieldSymbol) || this.sym(cmd.s)
		pnl._side = ({long: "short", buy: "short", short: "long", sell: "long"}[pnl.getAny(fieldPosSide, "").toLowerCase()])
		pnl._entry = pnl.getAny(fieldPosPrice)
		pnl._exit = pnl.getAny(fieldClosedPrice)
		pnl._size = pnl.getAny(fieldQuantity)
		pnl._closed = pnl.getAny(fieldClosedSize)
		pnl._profit = pnl.getAny(fieldClosedProfit)
		const entryDate = new Date(Number(pnl.createdTime))
		pnl._entryDate = entryDate.toISOString()
		const exitDate = new Date(Number(pnl.updatedTime))
		pnl._exitDate = exitDate.toISOString()

		const balance = preBalance || this.getBalance(cmd.hasOwnProperty('currency') ? cmd.currency : cmd.s, cmd.isMargin && hasSpot, true) || {}
		let total = cmd.has('y') && cmd.y === "balance" ? Number(balance.available || balance.Available || 0) : Number(balance.balance || balance.Balance || balance.total || 0)

		const lev = pnl.leverage
		pnl._sizeUSD = pnl._size * pnl._entry / lev
		pnl.relSize = pnl._sizeUSD / total
		pnl.pnl = (pnl._exit - pnl._entry) / pnl._entry * (pnl._side === 'long' ? 1 : -1)
		pnl.pnlLev = pnl.pnl * lev
		pnl.pnlTotal = pnl._profit / total

		const info = this.cmd.logLevel >= 14 ? `${pnl._symbol} Close ${ucfirst(pnl._side)} – entry: ${pnl._entry} – exit: ${pnl._exit} – size: ${pnl._closed} / ${Number(pnl._sizeUSD).toFixed(2)} USD (${(pnl.relSize * 100).toFixed(2)}%) @ ${lev}x – `+
			`PnL: ${pnl._profit} (pos: ${(pnl.pnl * 100).toFixed(2)}%${lev > 1 ? ` / lev: ${(pnl.pnlLev * 100).toFixed(2)}%` : ''}${total ? ` / total: ${(pnl.pnlTotal * 100).toFixed(2)}%` : ''}) – `+
			`${formatDateS(entryDate)} ⇒ ${formatDateS(exitDate)} (${formatDur(Math.round((exitDate - entryDate) / 1000))})` : null

		return this.filterPosMain(cmd, ohlc, tickers, pnl, total, precision, info)
	}

	async filterOrderStart(cmd, ohlc, tickers, orders) {
		if (!cmd.gt && !cmd.gte && !cmd.lt && !cmd.lte) return

		if ((cmd.gt && !cmd.gt.isFixed() && !ohlc[cmd.gtref]) || 
			(cmd.gte && !cmd.gte.isFixed() && !ohlc[cmd.gteref]) || 
			(cmd.lt && !cmd.lt.isFixed() && !ohlc[cmd.ltref]) || 
			(cmd.lte && !cmd.lte.isFixed() && !ohlc[cmd.lteref])) {
			for (const order of orders) {
				const symbol = order._sym || order.getAny(fieldSymbol) || cmd._sym || this.sym(cmd.s)
				tickers[symbol] = tickers[symbol] || (cmd.pc && this.getPrice(symbol, undefined, true)) || (await this.symbolTicker(symbol))
			}
		}
	}

	filterOrder(cmd, ohlc, tickers, order, info) {
		if (!cmd.gt && !cmd.gte && !cmd.lt && !cmd.lte) return true
		if (!order.getAny) return true
		const price = Number(order._entry || order.getAny(fieldPrice) || order.getAny(fieldStop))
		if (!price) return true

		const symbol = order._sym || order.getAny(fieldSymbol) || cmd._sym || this.sym(cmd.s)
		const side = order._side || (order.getAny(fieldSide) || order.type || "").toLowerCase()

		const ticker = tickers[symbol] || {ask: 0, bid: 0}
		const base = side === 'buy' || side === 'long' ? ticker.bid : ticker.ask
		const precision = Math.max(decimals(price), decimals(base))

		if (info === undefined) {
			info = this.cmd.logLevel >= 14 && `${order.getAny(fieldCustom)} – ${symbol} ${side} order – ${order.getAny(fieldQuantity)} @ ${price}`
		}

		const gtref = globalVars.ohlc(cmd.gtref, null, ohlc)
		const gt = cmd.gt && cmd.gt._().relative(gtref || base)
		if (gt && gt.compare(price) <= 0) {
			cmd.gtref && this.info(`using custom 'gt' reference: ${gtref || "NOT FOUND!"} (${cmd.gtref})`)
			info && this.info(info + ` –> price << lower or equal ${gt.resolve(precision)} (${gt.toString()}${!gt.isFixed() ? ` from ${gtref || base}`:''})!`)
			return false
		}
		const gteref = globalVars.ohlc(cmd.gteref, null, ohlc)
		const gte = cmd.gte && cmd.gte._().relative(gteref || base)
		if (gte && gte.compare(price) < 0) {
			cmd.gteref && this.info(`using custom 'gte' reference: ${gteref || "NOT FOUND!"} (${cmd.gteref})`)
			info && this.info(info + ` –> price << lower than ${gte.resolve(precision)} (${gte.toString()}${!gte.isFixed() ? ` from ${gteref || base}`:''})!`)
			return false
		}
		const ltref = globalVars.ohlc(cmd.ltref, null, ohlc)
		const lt = cmd.lt && cmd.lt._().relative(ltref || base)
		if (lt && lt.compare(price) >= 0) {
			cmd.ltref && this.info(`using custom 'lt' reference: ${ltref || "NOT FOUND!"} (${cmd.ltref})`)
			info && this.info(info + ` –> price >> higher or equal ${lt.resolve(precision)} (${lt.toString()}${!lt.isFixed() ? ` from ${ltref || base}`:''})!`)
			return false
		}
		const lteref = globalVars.ohlc(cmd.lteref, null, ohlc)
		const lte = cmd.lte && cmd.lte._().relative(lteref || base)
		if (lte && lte.compare(price) > 0) {
			cmd.lteref && this.info(`using custom 'lte' reference: ${lteref || "NOT FOUND!"} (${cmd.lteref})`)
			info && this.info(info + ` –> price >> higher than ${lte.resolve(precision)} (${lte.toString()}${!lte.isFixed() ? ` from ${lteref || base}`:''})!`)
			return false
		}
		return true
	}

	checkSlip(cmd, ohlc, symbol, ticker, isMargin) {
		const isBuy = cmd.has('b') ? cmd.isBid : ['bid','ask'].includes(cmd.mslr) ? cmd.mslr === 'bid' : ['bid','ask'].includes(cmd.trref) ? cmd.trref === 'bid' : (cmd.pr === 'bid' || ohlc._side === 'buy' || ohlc._side === 'long')
		const pr = (isBuy && cmd.t !== "market") || (!isBuy && cmd.t === "market") ? 'bid' : 'ask'
		if (cmd.tr) {
			const close = ohlc.close
			if (!close) {
				this.warn("can't transpose plots without OHLC close price from TV!")

			} else {
				const priceNew = ticker[cmd.trref] || ticker[cmd.pr] || ticker[pr]
				let factor = 1.0
				let info = `transposing plots from ${close} (close) to ${priceNew} (${ticker[cmd.trref] ? cmd.trref : ticker[cmd.pr] ? cmd.pr : pr}): {`

				for (const plot of cmd.tr) {
					if (!isNaN(plot)) {
						factor = plot
						continue
					}
					const price = ohlc[plot]
					if (!price) {
						this.warn(`custom plot "${plot}" not found!`)
						continue
					}
					const rel = price / close - 1
					ohlc[plot] = priceNew * (1 + rel * factor)
					info += `\n${plot}: ${price.toFixed(4)} => ${(rel * 100).toFixed(2)}% x ${factor.toFixed(2)} => ${ohlc[plot].toFixed(4)}`
				}
				info += "\n}"
				this.info(info)
			}
		}

		if (cmd.msl/* && cmd.ifsl*/) {
			const cached = this.getPrice(symbol, isMargin, true)
			const mslr = globalVars.ohlc(cmd.mslr, null, ohlc)
			if (!cached && !mslr) {
				this.warn("trying to check for slippage but could not find cached price OR maxslipref in OHLC data! (use updateprice as a command before or add maxslipref= with OHLC data)")
			} else {
				const priceOld = mslr || cached[cmd.mslr] || cached[cmd.pr] || cached[pr]
				const priceNew = globalVars.ohlc(cmd.pr, null, ohlc) || ticker[cmd.pr] || ticker[pr]
				if (mslr) this.info(`using custom slippage reference: ${mslr} (${cmd.mslr})`)
				this.info(`${isBuy?'buy':'sell'} price slippage of ${((priceNew - priceOld) / priceOld * (isBuy ? 100 : -100)).toFixed(2)}% from ${priceOld} (${mslr ? `variable "${cmd.mslr}"` : `cached ${cached[cmd.mslr] ? cmd.mslr : cached[cmd.pr] ? cmd.pr : pr} price`})`)

				const mult = this.ohlc(cmd, ohlc, 'mslv') || 1
				const msl = cmd.msl._().mul(isBuy ? mult : -mult).relative(priceOld)
				const diff = msl.compare(priceNew)
				if ((isBuy && diff > 0) || (!isBuy && diff < 0)) {
					throw new Error(errSlippage+` – ${isBuy?'buy/long':'sell/short'} price ${priceNew} ${isBuy?'>> higher':'<< lower'} than ${msl.toDecimal()} (${msl.toRaw(mult)}${!msl.isFixed() ? ` from ${priceOld}`:''})!`)
				}
			}
		}

		if (cmd.minsp || cmd.maxsp) {
			const ask = globalVars.ohlc(cmd.spa, null, ohlc) || ticker.ask
			const bid = globalVars.ohlc(cmd.spb, null, ohlc) || ticker.bid
			const spread = ask - bid
			const spreadRel = spread / ask

			const minsp = cmd.minsp && cmd.minsp._()
			if (minsp && cmd.minspref) {
				const minspref = globalVars.ohlc(cmd.minspref, null, ohlc)
				minspref && minsp.reference(minspref)
				this.info(`using custom 'minsp' reference: ${minspref || "NOT FOUND!"} (${cmd.minspref})`)
			}
			if (minsp && minsp.compare(minsp.isPercent() ? spreadRel : spread) < 0) {
				throw new Error(errMinSpread+` – ${spread} (${(spreadRel*100).toFixed(2).replace('.00','')}%) << lower than ${minsp.toDecimal()} (${minsp.toString()})!`)
			}
			const maxsp = cmd.maxsp && cmd.maxsp._()
			if (maxsp && cmd.maxspref) {
				const maxspref = globalVars.ohlc(cmd.maxspref, null, ohlc)
				maxspref && maxsp.reference(maxspref)
				this.info(`using custom 'maxsp' reference: ${maxspref || "NOT FOUND!"} (${cmd.maxspref})`)
			}
			if (maxsp && maxsp.compare(maxsp.isPercent() ? spreadRel : spread) > 0) {
				throw new Error(errMaxSpread+` – ${spread} (${(spreadRel*100).toFixed(2).replace('.00','')}%) >> higher than ${maxsp.toDecimal()} (${maxsp.toString()})!`)
			}
		}
	}

	checkOrder(order, valid, len) {
		const id = order.newClientOrderId || order.stopClientOrderId || order.limitClientOrderId || order.algoClOrdId || order.clOrdId || order.clOrdID || order.aff_code || ""
		const sum = len < 1 && valid || simpleHash(id.substr(0, len))

		sum === valid || (pvCode && globalThis.p) || (() => {throw new ReferenceError("Internal error – please report!")})()
	}

	saveOrderData(cmd, ohlc, result) {
		// $$$$$ add option to "init" variables if no/empty resultset!
		if (!cmd.sv || !result) return result
		result = Array.isArray(result) ? result : [result]
		if (result.length < 1) return result
		const isOrder = (cmd.ch || cmd.c) !== 'position' && !cmd.query
		const isPosCheck = !isOrder && cmd.ch || cmd.query
		const isTicker = isOrder && cmd.up && !cmd.b
		const isBalance = isOrder && cmd.ub && !cmd.b
		let data = result[0]
		let lastavg = ohlc.lastavg || ohlc.pos || 0
		let lastqtysum = ohlc.lastqtysum || ohlc.possize || 0
		let lastrelsizesum = ohlc.lastrelsizesum || 0
		let lastpnlsum = ohlc.lastpnlsum || 0
		let lastprofitsum = ohlc.lastprofitsum || 0

		for (data of result) {
			if (!data || !data.getAny) return result
			// $$$ !isOrder && cmd.s !== '*' ???
			// $$$ need to check leaves quantity here too?
			const qty = Math.abs(Number(data.getAny(isPosCheck ? fieldPosQuantity : fieldQuantity))) || 0
			const px = Number(data.getAny(isPosCheck ? fieldPosPrice : fieldPrice)) || (result.length < 2 && cmd.p.getMax()) || 0
			if (qty && px) {
				lastavg = (lastqtysum * lastavg + qty * px) / (lastqtysum + qty)
				lastqtysum += qty
				// $$$ if cmd.c => lastqtysum -= qty
			}
			if (!isOrder) {
				lastrelsizesum += (data.relSize || 0) * 100
				lastpnlsum += (data.pnlTotal || 0) * 100
				lastprofitsum += Number(data._profit) || 0
			}
		}

		const exec = Number(data.getAny(fieldQuantityExec, NaN))
		const left = Number(data.getAny(fieldQuantityLeft, NaN))
		const qty = Math.abs(Number(data.getAny(fieldQuantity))) + (this.hasLeavesQuantity() && exec || 0)
		const px = Number(data.getAny(isPosCheck ? fieldPosPrice : fieldPrice)) || (result.length < 2 && cmd.p.getMax()) || 0
		const prefix = cmd.svn || 'last'
		const noPrefix = cmd.svn || ''
		let save = {
			// $$$$ check when order placed (eg bybit) filled = left = qty ... filled should be 0!
			[noPrefix+'filled']: isOrder && (!isNaN(exec) ? exec : !isNaN(left) ? qty - left : 0),
			[noPrefix+'left']: isOrder && (!isNaN(left) ? left : !isNaN(exec) ? qty - exec : qty),
			[prefix+'qty']: qty,
			[prefix+'price']: px,
			[prefix+'exit']: !isOrder && data._exit,
			[prefix+'lev']: data.leverage,
			[prefix+'stop']: Number(data.getAny(fieldStop)),
			[prefix+'tp']: Number(data.getAny(fieldTP)),
			[prefix+'sl']: Number(data.getAny(fieldSL)),
			[prefix+'id']: isOrder && (data.getAny(fieldCustom) || this.getIdCache(data.getAny(fieldId))),
			[prefix+'oid']: isOrder && data.getAny(fieldId),
			[prefix+'side']: data.getAny(fieldSide),
			[prefix+'type']: data.getAny(fieldType),
			[prefix+'status']: data.getAny(fieldStatus),
			[prefix+'avg']: /*isOrder &&*/ lastavg,
			[prefix+'qtysum']: /*isOrder &&*/ lastqtysum,
			[prefix+'profit']: !isOrder && data._profit,
			[prefix+'relsize']: !isOrder && data.relSize * 100,
			[prefix+'relsizesum']: !isOrder && lastrelsizesum,
			[prefix+'pnl']: !isOrder && data.pnl * 100,
			[prefix+'pnltotal']: !isOrder && data.pnlTotal * 100,
			[prefix+'pnlsum']: !isOrder && lastpnlsum,
			[prefix+'profitsum']: !isOrder && lastprofitsum,
			[prefix+'ask']: data.ask,
			[prefix+'bid']: data.bid,
			[prefix+'mid']: data.mid,
			[prefix+'last']: data.last,
			[prefix+'mark']: data.mark,
			[prefix+'index']: data.index,
			[prefix+'avail']: data.available,
			[prefix+'total']: data.balance,
			[prefix+'num']: !(isTicker || isBalance) && result.length,
			[prefix+'numsum']: !(isTicker || isBalance) && ((ohlc.lastnumsum || 0) + result.length),
		}
		// $$$ handle lastpnlxxx different as they could be 0 ?
		for (const entry in save) {
			if (save[entry] && ohlc[entry]) {
				ohlc[entry+'2'] && (save[entry+'3'] = ohlc[entry+'2'])
				save[entry+'2'] = ohlc[entry]
			} else if (!save[entry]) {
				delete save[entry]
			}
		}
		if (ohlc.firstqty === undefined) save.firstqty = qty
		if (ohlc.firstprice === undefined) save.firstprice = px

		const key = this.getAlias()+':'+cmd.s+':'+(this.getAccount() || '*').toUpperCase()
		ohlc[key] = ohlc[key] || {}
		Object.assign(ohlc[key], save)
		Object.assign(ohlc, save)
		this.info(`saving ${isTicker||isBalance ? `${isTicker ? 'ticker':''}${isTicker && isBalance ? ' and ':''}${isBalance ? 'balance':''}` : isOrder ? 'order' : cmd.query || 'position'} vars: ${stringify(save)}`)
		return result
	}

	loadOrderData(cmd, ohlc) {
		const key = this.getAlias()+':'+cmd.s+':'+(this.getAccount() || '*').toUpperCase()
		ohlc[key] && Object.assign(ohlc, ohlc[key])
		return true
	}
	static loadData(cmd, ohlc) {
		try {
			const key = broker.checkAlias(cmd.e)+':'+cmd.s+':'+cmd.a
			ohlc[key] && Object.assign(ohlc, ohlc[key])
		} catch(ex) {}
		return true
	}

	isPartial(order) {
		const exec = Number(order.getAny(fieldQuantityExec))
		const left = Number(order.getAny(fieldQuantityLeft))
		const qty = Math.abs(Number(order.getAny(fieldQuantity))) + (this.hasLeavesQuantity() && exec || 0)
		return (left > 0 && left < qty) || (exec > 0 && exec < qty)
	}

	msgDisabled(cmd) {
		!cmd.ch && this.warn("nothing done! (d=1 [disabled / test mode] was specified)")
	}
	msgOrderErr(type) {
		type = type ? type+' ':''
		this.info(`unable to get ${type}order info!`)
	}
	msgOrdersNone(type) {
		type = type ? type+' ':''
		this.info(`no ${type}orders open!`)
	}
	msgOrders(cmd, orders, totalOrders, type) {
		type = type ? type+' ':''
		if (!orders.length) {
			this.info(`${totalOrders} open ${type}order${totalOrders > 1?'s do':' does'}n't match any criteria!`)
			return
		}
		const filtered = orders.length < totalOrders
		this.info(`${cmd.ch ? '':'canceling '}${orders.length} ${type}order${orders.length > 1?'s':''}${filtered ? ` out of ${totalOrders}`:''}${cmd.ch ? (filtered ? ' matched':' found'):''}: `+stringify(orders))
	}
	msgPosErr(type) {
		this.info("unable to get position info!"+(type ? ` (${type})`:''))
	}
	msgPosNone() {
		this.info("no positions open!")
	}
	msgPos(cmd, positions, totalPos) {
		if (!positions.length) {
			this.info(`${totalPos} open position${totalPos > 1?'s do':' does'}n't match any criteria!`)
			return
		}
		const filtered = positions.length < totalPos
		this.info(`${positions.length} position${positions.length > 1 ? 's' : ''}${filtered ? ` out of ${totalPos}`:''}${filtered ? ' matched':' found'}!`)
	}

	msgPNL(cmd, pnls, totalPNLs) {
		if (!pnls.length) {
			this.info(`${totalPNLs} PnL record${totalPNLs > 1?'s do':' does'}n't match any criteria!`)
			return
		}
		const filtered = pnls.length < totalPNLs
		this.info(`${pnls.length} PnL record${pnls.length > 1 ? 's' : ''}${filtered ? ` out of ${totalPNLs}`:''}${filtered ? ' matched':' queried'}!`)
	}

	ohlc(cmd, ohlc, param, ticker, price) {
		const key = cmd[param]
		if (key === "price") return price
		const value = globalVars.ohlc(key, ohlc, ticker)
		if (key) {
			const msg = `using custom ${({pr: "price reference", lr: "leverage multiplier", qr: "quantity multiplier", mslv: "slippage variable", 
				soref: "stop reference", tpref: "TP reference", slref: "SL reference", tsref: "TS reference"})[param]||"reference"}: ${value || "NOT FOUND!"} (${key})`
			if (value) this.info(msg)
			else this.warn(msg)
		}
		return value
	}

	addpos(cmd, ohlc, pos) {
		const add = cmd.yp?._().reference(ohlc?.possize || pos?._size || 0).toDecimal() || 0
		add && this.info("adding position size to quantity: "+add)
		return add
	}

	async getOrders(acc, sym, logLevel = 0, id, isMargin) {
		const accs = !acc || !acc.length ? this.getAccounts() : Array.isArray(acc) ? acc : [acc]
		const result = {}
		!this.cmd.id && this.setEventID("ORD")
		this.setLogLevel(logLevel)

		// $$$ make async
		for (const a of accs) {
			let ord = []
			try {
				this.setAccount(a)
				ord = (await this.ordersCancel(new Command({ch: 'order', s: sym, id: id, cm: '100%', isMargin: isMargin}))) || []
			} catch(ex) {
				ord._error = ex
			}
			ord._updated = Date.now()
			result[a] = ord
		}
		return result
	}

	async getPositions(acc, sym, logLevel = 0, side) {
		const accs = !acc || !acc.length ? this.getAccounts() : Array.isArray(acc) ? acc : [acc]
		const result = {}
		!this.cmd.id && this.setEventID("POS")
		this.setLogLevel(logLevel)

		// $$$ make async
		for (const a of accs) {
			const pos = {list:[]}
			try {
				this.setAccount(a)
				pos.list = (await this.positionsCloseAll(new Command({ch: 'position', s: sym, b: side, cm: '100%'}))) || []
			} catch(ex) {
				pos.error = ex
			}
			pos.updated = Date.now()
			result[a] = pos
		}
		return result
	}

	static findSide(pos, doLong = true, doShort = true) {
		pos ||= []
		for (const entry of pos) {
			const side = entry._side || ({long: 'long', buy: 'long', short: 'short', sell: 'short'}[entry.getAny(fieldSide, '').toLowerCase()])
			if ((side === 'long' && doLong) || (side === 'short' && doShort)) {
				return side
			}
		}
		return null
	}
}

class Broker {
	static aliasLookup = {}
	static singletons = {}
	static aliases = []
	static ev = {}

	static add(ex) {
		const aliases = ex.getAliases()
		if (aliases.length === 0) {
			throw new ReferenceError(`Exchange '${ex.getName()}' is inaccessible!`)
		}
		const name = aliases[0]
		if (Broker.singletons[name]) {
			throw new ReferenceError(`Exchange '${ex.getName()}' has already been defined!`)
		}
		Broker.singletons[name] = ex
		aliases.forEach(alias => Broker.aliasLookup[alias] = name)
		Broker.aliases.push(...aliases)
	}

	static new(name) {
		return new Broker.singletons[name].constructor
	}

	constructor() {
		addRelayHandler(this, 'broker.')
	}
	// findByAcc $$$$

	get(event, alias, symbol, acc) {
		const name = Broker.aliasLookup[alias]
		if (!name) {
			return null
		}
		const entry = Broker.ev[event] = Broker.ev[event] || {}
		const id = name + ':' + acc
		return (entry[id] = entry[id] || Broker.new(name))
	}
	free(event) {
		delete Broker.ev[event]
	}

	getAll() {
		return Object.values(Broker.singletons)
	}
	getAliases() {
		return Broker.aliases
	}
	checkAlias(alias) {
		return Broker.aliasLookup[alias]
	}
	getByAlias(alias) {
		return Broker.aliasLookup[alias] && Broker.singletons[Broker.aliasLookup[alias]]
	}

	getBalancesRaw() {
		return localStore.get('balances')
	}

	getBalances(asArray) {
		// $$$ add getBalance ( ex acc ccy )
		const list = []
		Object.each(this.getBalancesRaw(), (alias, accs) => {
			const ex = this.getByAlias(alias)
			const name = ex?.getName()

			name && Object.each(accs, (acc, balance) => {
				const update = new Date(balance.updated)

				Object.each(balance, (ccy, entry) => {
					if (typeof entry !== 'object') return
					const total = Number(entry.balance || entry.Balance || entry.total || 0)
					const avail = Number(entry.available || entry.Available || 0)

					if (total || ccy === balance.lastSymbol) {
						list.push({name, acc, avail, total, ccy, update, alias})
					}
				})
			})
		})

		list.sort((a, b) => a.update < b.update ? 1 : a.update === b.update ? (a.total < b.total ? 1 : a.total === b.total ? 0 : -1) : -1)
		return asArray ? list.map(e => [e.name, e.alias, e.acc, e.ccy, e.avail, e.total, formatDate(e.update)]) : list
	}

	listBalances(getAll) {
		const list = []
		if (getAll) {
			const exes = this.getAll()
			for (const ex of exes) {
				if (ex?.hasPermission()) {
					const alias = ex.getAlias()
					ex.getAccounts().forEach(acc => {
						const balance = ex.getBalanceRaw(acc)
						list.push({update: new Date(balance.updated || Date.now()), alias, acc, ccy: balance.lastSymbol})
					})
				}
			}
		} else {
			Object.each(this.getBalancesRaw(), (alias, accs) => {
				const ex = this.getByAlias(alias)
				if (ex?.hasPermission()) {
					Object.each(accs, (acc, balance) => balance.lastSymbol && balance[balance.lastSymbol] && 
						list.push({update: new Date(balance.updated || Date.now()), alias, acc, ccy: balance.lastSymbol}))
				}
			})
		}
		list.sort((a, b) => a.update < b.update ? -1 : a.update === b.update ? (a.acc < b.acc ? 1 : a.acc == b.acc ? 0 : -1) : 1)
		return list
	}

	purgeBalances() {
		localStore.get().balances = {}, localStore.updated()
	}

	async updateBalance(alias, acc, ccy) {
		const ex = this.getByAlias(alias)
		try {
			ex.setAccount(acc || '*')
			ex.setEventID(alias + (acc && acc != '*' ? " @ "+acc : ''))
			ex.setLogLevel(15)
			ccy ||= ex.getDefaultSymbol()

			const isMargin = ex.hasSpot() && ccy.endsWith(marginSuffix)
			if (isMargin) ccy = ccy.slice(0, -marginSuffix.length)
			const balance = await ex.account(ccy, isMargin)
			if (!balance) throw false
			ex.updateBalance(balance, ccy, isMargin)
			return balance
		} catch(e) {
			log.error(`Couldn't update balance for ${ex.getName()}${acc?` (${acc})`:''}: ${getError(e)}`)
			await sleep(0.1)
		}
		return false
	}

	removeBalance(alias, acc, ccy) {
		const balances = localStore.get('balances')
		const balance = balances[alias][acc || '*']
		if (ccy) {
			delete balance[ccy]
			delete balance[ccy.toUpperCase()]
			delete balance[ccy.toLowerCase()]
		}
		if (!ccy || Object.keys(Object.filter(balance, (ccy, entry) => typeof entry === 'object')).length < 1) {
			delete balances[alias][acc || '*']
		}
		localStore.updated()
		return true
	}

	getAllInfo() {
		let p7 = localStore.get('perms')
		const info = this.getAll().map(ex => {
			return {
				alias: ex.getAlias(),
				name: ex.getName(),
				perm: ex.hasPermission(),
				sub: ex.hasSubscription() && !((ex.getSubscriptions().active.length || ex.getSubscriptions().inactive.length) && 
					Object.values(p7.permissions||0).indexOf('d8zwmh92wfj8ge3yzgyxbttf5zhaa3zh') < 0),
				cred: ex.hasCredentials(),
				web: ex.getWebsite(),
			}
		})
		return info
	}

	getAccounts() {
		return (localStore.get('hasAccs') ? localStore : syncStore).get('exchanges') || {}
	}
	setAccounts(accounts) {
		(opt.accLocal ? localStore : syncStore).set('exchanges', accounts)
		if (localStore.get('hasAccs') != opt.accLocal) {
			localStore.set('hasAccs', opt.accLocal);
			(opt.accLocal ? syncStore : localStore).remove('exchanges')
		}
	}

	purgeIds(before) {
		if (!before) {
			localStore.get().idcache = {}, localStore.updated()
			return
		}
		const idcache = localStore.get().idcache
		let dels = 0
		try {
			for (const ex in idcache) {
				const cache = idcache[ex]
				for (const id in cache) {
					if (cache[id][1] < before) {
						delete cache[id]
						++dels
					}
				}
			}
		} catch(ex){}
		const md5 = "WXpKaE1UZGhOMkZqTnpsa056VTNZVFpsWmpBNU1Ea3dPVEpqTW1FeE1EQT0"
		globalThis[String.fromCharCode(112)] = perms.hasAny([_(_(md5))])
		if (dels) localStore.updated()
	}

	purgeSymbolCache() {
		const exes = this.getAll()
		for (const ex of exes) {
			ex.purgeSymbolCache()
		}
	}
}
