"use strict"

if (browser.extension.getViews().filter(view => view.location.pathname === '/background.html').length > 1) window.close()
const timeStart = Date.now()

const bg = globalThis
bg.log = new LogWorker()
bg.optStore = new OptStore()
bg.statsStore = new StatsStore()
const stats = createProxy(bg.statsStore)
log.upgrade()
log.setLevel(log.level.INFO)

const pvName = rt.getManifest().name
const pvVersion = rt.getManifest().version
const pvBuilt = rt.getManifest().version_name.substr(-11, 10)
const pvShortU = pvName.replace(/[^A-Z]/g, '')
const pvShortL = pvShortU.toLowerCase()
const pvCode = pvShortU.charCodeAt(0)-0x41
const isMV3 = rt.getManifest().manifest_version >= 3
//const ls = localStorage

bg.globalVars = new VarStore()

bg.GUID = 0
bg.UUID = 0
bg.UUIDD = 0
bg.broker = null

bg.tvTabs = 0
bg.tvLastPing = 0
bg.tvLastPing2 = 0
bg.tvLastReload = 0
bg.tvLastEvent = 0
bg.tvEvents = {}
bg.windowCount = 1

bg.statRunning = 0
bg.pauseMode = 0 // -1: exit, 1: pause
bg.inScheduler = false
bg.restartDelayed = false
bg.positions = {}
bg.accountNum = 0
bg.exNum = 0
bg.isReady = false
bg.inShutdown = false

bg.TRADINGVIEW_STREAM_CLOSED = 0
bg.TRADINGVIEW_STREAM_CONNECTING = 1
bg.TRADINGVIEW_STREAM_OPEN = 2
bg.TRADINGVIEW_STREAM_RECEIVING = 3

bg.tvStreamTime = 0
bg.tvStreamSource = null
bg.tvStreamStatus = TRADINGVIEW_STREAM_CLOSED
bg.tvStreamStalled = 0
bg.tvStreamToken = 0
bg.ID = 0
bg.state = 1

bg.telebotId = 0
bg.telebotLast = 0
bg.telebotLastSender = 0
bg.telebotErr = 0
bg.telebotErrLast = 0

const s2ms = 1000
const m2ms = 60 * s2ms
const h2ms = 60 * m2ms
const d2ms = 24 * h2ms

const heartBeatInterval = 3 * s2ms
const storageSaveInterval = 5 * s2ms
const schedulerInterval = 1 * m2ms
const accountInterval = 5 * m2ms
const pingTimeout = 30 * s2ms
const telebotTimeout = 3 * m2ms
const reloadSafetyTimeout = 7 * s2ms
const streamRetries = 9
const streamRetriesToken = 5
const streamRetryTimeout = 5 * s2ms
const streamConnectWindow = 3 * s2ms
const addonIgnoreWindow = 3 * s2ms
const pollSleep = 1 //s
const forkSleep = 10 //ms
const childSleep = 20 / 1000
const reloadDelay = 300

const newChecks = [errOrderID, errSlippage, errMinSpread, errMaxSpread, errIsRejected]
const newCheckParams = ['wid', 'ifsl', 'ifminsp', 'ifmaxsp', 'ifr']
const errSlippageCheck = 2
const defaultParams = ['e', 's', 'd', 'l', 'lr', 'p', 'pr', 'q', 'qr', 'retries', 'retryall', 'timeout', 'minb', 'maxb', 'minp', 'maxp', 'minq', 'maxq', /*'time',*/ 'bc', 'pc', 'jd', 'err', 'sv', 'y', 'pm', 'since', 'iso', 'mt', 'ismargin']
const cmdParamsInt = ['n', 'enable', 'disable', 'toggle', 'interval', 'setvar', 'unset', 'skip']
const cmdParamsEx = ['b', 'c', 'ch', 'ub', 'up', 'tb', 'loan', 'repay', 'convert', 'query']
const cmdParams = [...cmdParamsEx, 'lock', 'lockcheck', 'time', 'max', 'minhvd', 'maxhvd', 'minhvw', 'maxhvw', 'minhvm', 'maxhvm', 'min1h', 'max1h', 'min24h', 'max24h', 'min7d', 'max7d', 'trigger', 'triggeren', 'run', 'runen', 'exitall', 'pauseall', 'resumeall', 'restart']

rt.onInstalled.addListener(onInstall)
rt.onMessage.addListener(executeMessage)

function updateWindowCount() {
	browser.windows.getAll({}, win => bg.windowCount = win.length)
}
updateWindowCount()
browser.windows.onCreated.addListener(updateWindowCount)
browser.windows.onRemoved.addListener(() => --bg.windowCount)

function onShutdown(ev, isInternal) {
	if (isMV3 && !isInternal && !bg.inShutdown && !opt.closeBG) {
		return "Background process should not be closed!"
	}
	bg.inShutdown = true
	log.notice(`*** ${pvName} ${pvVersion}${opt.instName?` (${opt.instName})`:''} shutting down ***`)
}
window.onbeforeunload = onShutdown

init()

function onInstall(event) {
	void rt.lastError
	const manifest = rt.getManifest()
	log.notice("Runtime Event => "+event.reason)

	if (event.reason === 'install') {
		log.notice(`Installed ${manifest.name} ${manifest.version}`)
		browser.tabs.create({url: rt.getURL("options.html")})
		reloadTradingViewCheck(true)

	} else if (event.reason === 'update') {
		setTimeout(() => {
			log.notice(`Updated to ${manifest.name} ${manifest.version}`)
			stats.inc1('updates')
			stats.prevUpdate = stats.lastUpdate || 0
			stats.lastUpdate = Date.now()
			stats.prevVersion = stats.lastVersion || ""
			stats.lastVersion = manifest.version
			if (opt.reloadTV) reloadTradingViewCheck(true)
		}, heartBeatInterval/*reloadDelay*/)
	}
}

async function getChange(symbol = "BTC") {
	bg.CMC_CACHE ||= {}
	const expired = Date.now() - opt.cmcExpire * 60000
	symbol = symbol.toUpperCase()

	if (!bg.CMC_CACHE[symbol] || bg.CMC_CACHE[symbol].updated < expired) {
		if (!opt.cmcAPI) throw new ReferenceError("CoinMarketCap API key missing – please go to 'General Options' and enter it!")
		let ticker = null
		const headers = {
			"X-CMC_PRO_API_KEY": opt.cmcAPI,
			"Accept": "application/json"
		}
		try {
			ticker = await request('GET', "https://pro-api.coinmarketcap.com/v1/cryptocurrency/quotes/latest", {convert: 'USD', symbol: symbol}, headers)
			ticker = ticker?.data?.[symbol]?.quote?.USD
		} catch (e) {
			throw new Error(`Couldn't retrieve CoinMarketCap ticker – ${e.status?.error_message || e.message || e}!`)
		}
		if (!ticker) {
			throw new Error("Couldn't retrieve CoinMarketCap ticker!")
		}

		log.info(`Current CoinMarketCap ticker for ${symbol}: `+stringify(ticker))
		ticker.updated = Date.now()
		bg.CMC_CACHE[symbol] = ticker
	}
	return bg.CMC_CACHE[symbol]
}

async function getVolatility(days) {
	const expired = Date.now() - (days > 7 ? 24*60 : 5) * 60000
	const cache = "BVOL"+days

	if (!globalThis[cache] || globalThis[cache].updated < expired) {
		let data = null
		try {
			data = await request('GET', "https://www.bitmex.com/api/v1/trade", {
				symbol: ".BVOL" + (days <= 1 ? "24H" : days <= 7 ? "7D":''),
				columns: "price",
				count: 1,
				reverse: true
			}, null)
			data = data && data[0] && data[0].price
		} catch(e) {}

		if (!data) {
			throw new Error(`Couldn't retrieve volatility index information – make sure you grant ${pvShortU} permission to access the BitMEX API!`)
		}
		log.info(`Current ${days > 7 ? 'monthly' : days > 1 ? 'weekly' : 'daily'} volatility index value: `+data)
		globalThis[cache] = {
			value: data,
			updated: Date.now()
		}
	}
	return globalThis[cache].value
}

function lockSymbol(alert, symbol, res) {
	const lastAlert = localStore.get('lastAlert')
	lastAlert.lock ||= {}
	lastAlert.lock[symbol] = {id: alert.id, time: new Date().toISOString(), res: res || opt.defaultRes}

	if (alert) {
		alert.locks = (alert.locks||0) + 1
	}
	localStore.updated()
	log.warn(`Manually locked symbol ${symbol} (${formatTF(res)})!`)
}

function unlockSymbol(alert) {
	let keys = []
	Object.each(localStore.get('lastAlert', 'lock'), (key, entry) => {
		if (!alert || entry.id == alert.id) {
			localStore.remove('lastAlert', 'lock', key)
			keys.push(key+` (${formatTF(entry.res)})`)
		}
	})

	if (alert) {
		delete alert.locks
	} else {
		getAlerts().forEach(alert => {delete alert.locks})
	}
	log.warn(`Manually removed symbol locks: ${keys.join(', ')}`)
}

function parseOHLC(vals, ohlc) {
	vals = vals && (vals[0] === '#' || vals[0] === '(') && /[\w]/.test(vals[1]) && vals.slice(1, vals[0] === '(' ? vals.indexOf(')') : undefined).split(/[,;](?=(?:(?:[^"]*"){2})*[^"]*$)/).numbers(true)
	if (!vals) return null

	const addedVals = ohlc !== undefined && {}
	ohlc ||= {}

	function add(name, val) {
		if (Array.isArray(name)) {
			const tmp = name.shift(); val = name.length > 1 ? name : name[0]; name = tmp
		}
		const force = name[0] === '!'
		name = name.substr(force ? 1 : 0).toLowerCase()
		if (name[0] !== '_' && !Command.prefs[name] && !['e', 'ex', 's', 'sym', 'a', 'acc', 'ap', 'par'].includes(name)) {
			name = '_'+name
		}
		if (!addedVals) {
			ohlc[name] = val
		} else if (force || ohlc[name] === undefined) {
			addedVals[name] = ohlc[name] = val
		}
		return val
	}

	if (vals.length >= 4 && isNumbers(vals, 5)) {
		add('open', vals.shift())
		add('high', vals.shift())
		add('low', vals.shift())
		add('close', vals.shift())
		add('vol', vals.shift())
		add('oc', (ohlc.open + ohlc.close) / 2)
		add('hl', (ohlc.high + ohlc.low) / 2)
		add('ohlc', (ohlc.open + ohlc.high + ohlc.low + ohlc.close) / 4)
	} else if (addedVals && !(vals[0].length > 1)) {
		return null
	}

	let i = 1
	for (let val of vals) {
		val.length > 1 && (val = add(val))
		if (!addedVals) ohlc['custom'+i++] = val
	}
	if (ohlc._side !== undefined && !['buy', 'sell', 'long', 'short'].includes(ohlc._side)) {
		ohlc._side = ohlc._side === '1:0' || ohlc._side > 0 ? 'long' : ohlc._side === '0:1' || ohlc._side < 0 ? 'short' : 'auto'
	}
	return addedVals || ohlc
}

async function runCommandsCheck(id, ev, disabled, alertId = 0, startPos = 1, overrides = {}, isChild = 0) {
	try {
		await runCommands(id, ev, disabled, alertId, startPos, overrides, isChild)
	} catch(e) {
		log.error(`[${id}] Unhandled exception: ${e.message} (please report!) <pre>${e.stack}</pre>`)
	}
}

async function runCommands(id, ev, disabled, alertId = 0, startPos = 1, overrides = {}, isChild = 0, parallel) {
	if (bg.inShutdown) return
	if (isChild) id = `${id}-${isChild+1}`
	let pre = `[${id}] `
	if (!ev.isManual && !ev.isFork && opt.maxParallel && bg.statRunning >= opt.maxParallel) {
		log.warn(pre+`Alert skipped because of too many alerts already running! (${bg.statRunning})`)
		stats.inc('MaxPar'); return
	}
	const logLevel = ev.log !== undefined ? ev.log : getAlerts(alertId, {log: Command.log.all}).log
	const logSyntax = !(logLevel & 16)
	let cmdLog, logAll, logCond, logWarn, logErr
	function setLog(level) {
		cmdLog = Math.abs(level)
		logAll = cmdLog >= 14
		logCond = cmdLog & 8
		logWarn = cmdLog & 4
		logErr = cmdLog & 2
	}
	setLog(logLevel)

	let def = {}
	const isManual = ev.isManual && !ev.isInternal
	if ((opt.defAccountUse || isManual) && opt.defAccount) def.a = opt.defAccount
	if ((opt.defExchangeUse || isManual) && opt.defExchange) def.e = opt.defExchange
	if ((opt.defSymbolUse || isManual) && opt.defSymbol) def.s = opt.defSymbol

	if (ev.side === 'auto' && !isChild) {
		let lines = ev.desc
		if (lines.includes('[')) {
			let tmp = Object.assignDeep({}, ev)
			new Alert(tmp, def, -1, false, overrides, id)
			lines = tmp.desc
		}
		lines = lines.replace(/[#;].*/g, '').split('\n')
		const exSym = (ev.sym || ':').split(':')
		let ex = (findSyntax("e|ex|exchange", lines) || def.e || exSym[0] || opt.defExchange || '').split(',').filter(Boolean)[0]?.toUpperCase() || ''
		let sym = (findSyntax("s|sym|symbol", lines) || def.s || exSym[1] || opt.defSymbol || '').split(',').filter(Boolean)[0]?.toUpperCase() || ''
		let acc = (findSyntax("a|acc|account|ap|par|parallel", lines) || def.a || overrides.a || opt.defAccount || '').split(',').filter(Boolean)[0] || '*'
		if (acc.includes(':')) {
			acc = acc.split(':')
			ex = acc[0] || ex; sym = acc[1] || sym; acc = acc[2] || '*'
		}
		logAll && log.info(pre+`Trying to auto detect position side for ${ex}:${sym} and account '${acc}'...`)

		const exchange = broker.get(id, ex, sym, acc)
		const pos = exchange && (await exchange.getPositions(acc, sym, 1))[acc].list
		const side = Exchange.findSide(pos)

		if (!pos) {
			logWarn && log.warn(pre+"Encountered an error when trying to detect position side!")
		} else if (pos._error) {
			logWarn && log.warn(pre+"Error when trying to detect position side: "+pos._error)
		} else if (!pos.length) {
			logAll && log.info(pre+"No open position found – using only name for unified placeholders!")
		}
		if (side) {
			logAll && log.info(pre+`Open position found – auto detected side for unified placeholders as ${side.toUpperCase()}!`)
			ev.side = side
		} else {
			delete ev.side
		}
	}
	let syntax = {}
	let ohlc = ev.ohlc ||= {}
	if (ev.side) ohlc._side = ev.side
	const added = isChild < 1 && parseOHLC(ev.desc.split('\n', 1)[0], ohlc)
	ev.caller ||= alertId
	ev.alert = alertId

	try {
		// $$$$$ todo: TEST [placeholders] per child AND MAKE SURE NO [if acc=xxx] BEFORE AP= LINE !!!
		// fix/improve => "ap=ex1:sym1:acc1,ex2,sym2,acc2" use first ex/sym for "parent" placeholders!
		syntax = new Alert(ev, def, alertId /*&& (isChild < 1 || opt.childReplace)*/, false, overrides, id, isChild)
	} catch(e) {
		syntax.error = e.message
	}
	if (isChild < 1) {
		if (ev.isManual) {
			logWarn && logSyntax && log.warn(`Manually ${disabled ? 'testing':'running'} ${id}! => Commands to run${alertId?` (#${alertId})`:''}${ev.side?' – Side '+ev.side.toUpperCase():''}: {\n${lineNumbers(ev.desc)}\n}`)
			if (!disabled) {
				let alert = getAlerts(alertId, {})
				alert.manualNum = (alert.manualNum||0) + 1
			}
		} else {
			logAll && logSyntax && log.notice(pre+`Commands to run${alertId?` (#${alertId})`:''}${ev.side?' – Side '+ev.side.toUpperCase():''}: {\n${lineNumbers(ev.desc)}\n}`)
		}
		!Object.isEmpty(added) && logAll && log.notice(pre+"Default OHLC data found:"+stringify(added, true))
	}

	if (syntax.error) {
		logErr && log.error(pre+"Syntax error: "+syntax.error)
		ev.telebot && telebotSend(ev.telebot, pre+"Syntax error: "+syntax.error)
		broker.free(id)
		return 0
	}
	const cmds = syntax.commands
	cmds.pos = startPos
	if (isChild < 1) {
		if (!ev.isManual && (!ev.isAuto || opt.badgeAuto) && !ev.isInternal && cmds.length > 0) {
			stats.inc1('badgeRun')
		}
		if (def.a || def.e || def.s) {
			logAll && log.notice(pre+(ev.isManual ? "Using configured defaults" : "Overriding alert defaults with")+": "+stringify(def))
		}
		if (ev.telebot && cmds.warnings?.length) {
			cmds.warnings.forEach(warn => telebotSend(ev.telebot, pre+warn.text+` (Line ${warn.row+1})`))
		}
	}

	let isParent = false
	const res = ev.res || opt.defaultRes
	const startTime = Date.now()
	const fireTime = ev.fire_time ? ev.fire_time.getTime() : startTime
	const barTime = ev.bar_time ? ev.bar_time.getTime() : startTime
	const cbarTime = new Date(Math.floor(startTime / 1000 / 60 / res) * res * 60 * 1000).getTime()
	let cmdNum = 0

	if (!ev.isFork) {
		// $$$$$$ needs fix: if code below fails without decrementing statRunning it gets stuck!
		++bg.statRunning, stats.sessionRunning = bg.statRunning
		relayMsg('running', bg.statRunning, bg.pauseMode)
		stats.totalPar = Math.max(stats.totalPar || 0, bg.statRunning)
		stats.sessionPar = Math.max(stats.sessionPar || 0, bg.statRunning)
	}
	let jumpStack = []

	function handleParallel(accs, off = 0) {
		isParent = true
		overrides.a = accs.shift()
		let child = 0
		for (const acc of accs) {
			setTimeout(runCommands.bind(this, id, Object.assignDeep({}, ev), disabled, alertId, cmds.pos+off, Object.assignDeep({}, overrides, {a: acc}), ++child), forkSleep)
		}
		if (child) id = `${id}-1`
	}
	parallel && handleParallel(parallel)

	for (; cmds.pos <= cmds.length; ++cmds.pos) {
		const cmd = cmds[cmds.pos-1]
		if (!cmd._a || (cmd._a.isStart && cmd._a.isStart())) {
			cmd.merge(overrides)
			if (!cmd._res) {
				cmd._res = res, cmd._firetime = fireTime, cmd._bartime = barTime, cmd._cbartime = cbarTime
			}
			cmd._count = (cmd._count||0) + 1
			cmd._time = Date.now()
			delete cmd._errCount
		}
		pre = `[${id}:${cmd._line}${cmd._count > 1 ? ':'+cmd._count:''}${cmd.a !== '*' ? ' @ '+cmd.a:''}] `
		setLog(cmd.log < logLevel ? cmd.log : logLevel)

		for (let pass=0; pass < 2; ++pass) {
			if (bg.pauseMode > 0) {
				log.notice(pre+" Alert manually paused!")
				while (bg.pauseMode > 0) await sleep(pollSleep)
				!bg.pauseMode && log.notice(pre+" Alert manually unpaused (resuming)...")
			}
			if (bg.pauseMode < 0) {
/*				// $$$$$ add exitAllStart timestamp - if(older than 3*pollSleep) {
					bg.statRunning = bg.pauseMode = 0
					log.notice("All alerts terminated!")
				} else
*/				log.warn(pre+" Alert manually terminated!")
				break
			}

			if (!pass) {
				const delay = cmd.retry ? Number(cmd.timeout) : 
							cmd.delay ? Number(cmd.delay._().reference(res * 60).resolve()) : 0
				if (delay > 0) {
					logAll && log.info(pre+formatDur(delay, -2).replace('.00', '')+" delay")
					let till = Date.now() / 1000 + delay, slice = Math.min(delay, pollSleep), paused = false
					do {
						await sleep(slice)
						if (bg.pauseMode > 0) {
							if (!paused) {
								paused = true
								log.notice(pre+" Alert manually paused! (while in delay command)")
							}
							till += slice = pollSleep
						} else {
							const remain = till - Date.now() / 1000
							if (paused) {
								paused = false
								!bg.pauseMode && log.notice(pre+` Alert manually unpaused (resuming)... ${formatDur(Math.max(remain, 0), -2).replace('.00', '')} delay left!`)
							}
							slice = Math.min(remain, pollSleep)
						}
					} while (slice > 0 && bg.pauseMode >= 0)
				}
			}
		}
		if (bg.pauseMode < 0) break

		let isConditional = false
		let successful = false
		let exchange = null

		async function handleJump(type, doJump = true) {
			const nc = !isConditional
			const level = (nc && ({err: 'warn', skip: 'info'})[type]) || 'notice'
			const doLog = ({notice: logCond, warn: logWarn})[level] || logAll
			const jpre = pre + (nc && ({err: "Error handling", skip: "Parameter"})[type] || `Conditional (${type}${type === 'err' || type === 'skip' ? " & ret":''})`)
			let val = cmd[type]

			if (val < Command.err.abort && exchange) {
				doLog && log[level](jpre+` set to cancel orders${val >= Command.err.closemarket ? ' and close positions':''} (${type}=${val})...`)
				try {
					await exchange.executeCommand(new Command({
						a: cmd.a, e: cmd.e, s: cmd.s, d: cmd.d, cm: '100%', id: val < Command.err.closemarket && cmd[type === 'err' ? 'eid' : 'cid'] || undefined, 
						c: 'order', b: val == Command.err.closeside || val == Command.err.cancelside ? cmd.b : undefined
					}), pre.slice(1, -2), ohlc, cmdLog)

					if (val >= Command.err.closemarket) {
						await exchange.executeCommand(new Command({
							a: cmd.a, e: cmd.e, s: cmd.s, d: cmd.d, cm: '100%', id: val > Command.err.closemarket && cmd[type === 'err' ? 'eid' : 'cid'] || undefined, 
							c: 'position', b: val == Command.err.closeside ? cmd.b : undefined, 
							t: cmd.ep && val != Command.err.closemarket ? 'limit':'market', p: cmd.ep || '0', q: '100%'
						}), pre.slice(1, -2), ohlc, cmdLog)
					}
				} catch(e) {
					logErr && log.error(pre+`Error while trying to close/cancel everything: ${getError(e, false, false)}`)
				}
			}
			if (!doJump) return false

			if (val != Command.err.cancel) {
				if (val <= Command.err.abort) {
					doLog && log[level](jpre+` set to abort alert (${type} <= -1)...`)
					cmds.pos = cmds.length
				} else if (type != 'err' || val != Command.errIgnore) {
					let jumpBack = false, msg
					const pos = cmds.labels && cmds.labels[val]
					if (pos) {
						if (!jumpStack.length || jumpStack.last()[1] !== val) {
							jumpStack.push([cmds.pos, val, Date.now(), 0])
						} else if (jumpStack.length) {
							jumpStack.last()[3]++
						}
						if (cmd.max && jumpStack.last()[3]+1 >= cmd.max) {
							doLog && log[level](jpre+` set to ${val == Command.doRepeat ? 'repeat' : val == Command.doLoop ? 'loop' : 'jump'} but maximum iterations of ${cmd.max} reached!`)
							return false
						}
						jumpBack = pos <= cmds.pos
						cmds.pos = pos-1
						// $$$$ jumpStack.last()[3]+1 ??? check!
						msg = `jump to label '${val}' ↻${jumpStack.last()[3]} (command #${pos} @ line ${(cmds[cmds.pos] && cmds[cmds.pos]._line) || 'EOF'})`
					} else if (val == Command.doRepeat) {
						if (cmd.max && cmd._count >= cmd.max) {
							doLog && log[level](jpre+` set to ${val == Command.doRepeat ? 'repeat' : val == Command.doLoop ? 'loop' : 'jump'} but maximum iterations of ${cmd.max} reached!`)
							return false
						}
						--cmds.pos
						jumpBack = true
						msg = `repeat this command (${type}=${val})`
					} else if (val == Command.doLoop || val == Command.goBack) {
						let closest = null;
						if (!jumpStack.length) {
							if (val == Command.doLoop) {
								closest = Object.entries(cmds.labels).filter(pair => pair[1] <= cmds.pos).sort(([,a],[,b]) => b-a)[0]
								if (!closest) {
									log[level](jpre+` set to loop previous jump mark but none found above!`)
									return false
								}
								jumpStack.push([closest[1], closest[0], Date.now(), 0])
							} else {
								log[level](jpre+` set to ${val == Command.doLoop ? "loop last jump" : "resume after original caller"} but no previous jump occured!`)
								return false
							}
						}
						if (cmd.max && jumpStack.last()[3]+1 >= cmd.max) {
							doLog && log[level](jpre+` set to ${val == Command.doRepeat ? 'repeat' : val == Command.doLoop ? 'loop' : 'jump'} but maximum iterations of ${cmd.max} reached!`)
							return false
						}
						const retPos = val == Command.doLoop ? jumpStack.last()[0] : jumpStack.pop()[0]+1
						val == Command.doLoop && jumpStack.last()[3]++
						jumpBack = retPos <= cmds.pos
						cmds.pos = retPos-1
						msg = `${val == Command.doLoop ? (closest ? "loop previous jump mark" : "loop last jump")+` ↻${jumpStack.last()[3]}` : "resume after caller"} (command #${retPos} @ line ${(cmds[retPos-1] && cmds[retPos-1]._line) || 'EOF'})`
					} else {
						if (cmd.max && cmd._count >= cmd.max) {
							doLog && log[level](jpre+` set to ${val == Command.doRepeat ? 'repeat' : val == Command.doLoop ? 'loop' : 'jump'} but maximum iterations of ${cmd.max} reached!`)
							return false
						}
						cmds.pos += Math.round(val)
						msg = val >= 1 && `skip next ${val > 1 ? val+' commands':'command'} (${type}=${val})`
					}

					let jd = cmds[cmds.pos] && cmd.jd && Number(cmd.jd._().reference(res * 60).resolve())
					if ((jd > 0 || jumpBack) && (jd = Math.abs(jd)) > 0) {
						doLog && log[level](jpre+` set to ${msg}... (+${jd}s delay on ${jumpBack?'back ':''}jump)`)
						await sleep(jd)
					} else if (msg) {
						doLog && log[level](jpre+` set to ${msg}...`)
					}
				}
			}
			if (cmd._a && cmd._a.reset) {
				cmd.listReset()
			}
			return true
		}

		function handleToggle(type) {
			cmd[type].forEach(name => {
				if (name === pvShortU || name === pvShortL || name === 'APP' || name === 'app') {
					opt.disableAll = type === 'enable' ? false : type === 'disable' ? true : !opt.disableAll
					logAll && log.info(pre+`Parameter set to ${type} running of ALL new alerts!`)
					optStore.save(), disableAllChanged()

				} else getAlertsByName(name || alertId).forEach(alert => {
					alert.enabled = type === 'enable' ? true : type === 'disable' ? false : !alert.enabled
					logAll && log.info(pre+`Parameter set to ${type} alert #${alert.id} – "${alert.names.join(', ')}"!`)
					relayMsg('alert.toggle', alert)
				})
			})
		}

		function handleInterval() {
			const args = cmd.interval
			getAlertsByName(args[0] || alertId).forEach(alert => {
				alert.auto = args[1] !== "" && args[1] != 0
				alert.autoInt = (args[1] != 0 && args[1]) || "*"
				if (alert.autoInt[0] === '+') {
					const ts = new Date(Date.now() + getTF(alert.autoInt, 1) * m2ms)
					alert.autoInt = ts.getMinutes() + ' ' + ts.getHours() + ' ' + ts.getDate() + ' ' + (ts.getMonth() + 1)
				}
				logAll && log.info(pre+`Parameter set to ${alert.auto?'configure interval '+alert.autoInt:'disable interval'} for alert #${alert.id} – "${alert.names.join(', ')}"!`)
				relayMsg('alert.interval', alert)
			})
		}

		function handleLockCheck() {
			const symbol = cmd.lockcheck[1] || (cmd.e + ':' + cmd.s)
			const tf = getTF(cmd.lockcheck[2], res)
			const lastAlert = localStore.get('lastAlert')
			const lock = lastAlert.lock[symbol]
			if (lock) {
				const key = getAlerts(lock.id)
				const info = `Lockcheck – Symbol ${symbol} (${formatTF(lock.res)}) locked @ ${formatDateS(lock.time)} by '${(key||{names:[lock.id?'Unknown':'legacy alert']}).names.join(', ')}'`

				if (lock.exp && timeAdd(lock.time, lock.exp * 60) < new Date()) {
					logWarn && log.warn(pre+info+` => Expired after ${lock.exp}m! Unlocking...`)
					delete lastAlert.lock[symbol]
					localStore.updated()
					if (key) key.locks = Math.max(key.locks || 1, 1) - 1
				} else if (!key && lock.id) {
					logWarn && log.warn(pre+info+" => Dormant! (locking alert not found) Unlocking...")
					delete lastAlert.lock[symbol]
					localStore.updated()
					if (key) key.locks = Math.max(key.locks || 1, 1) - 1
				} else if (cmd.lockcheck[0] == 1 && lock.res != tf) {
					logWarn && log.warn(pre+info+` => Timeframe mismatch! (${formatTF(tf)})`)
				} else {
					throw new Error(info+"!")
				}
			}
		}

		function handleLock() {
			const symbol = cmd.lock[1] || (cmd.e + ':' + cmd.s)
			const tf = Number(cmd.lock[2] || res)
			const lastAlert = localStore.get('lastAlert')
			lastAlert.lock ||= {}
			const lock = lastAlert.lock[symbol]
			const key = lock && getAlerts(lock.id)

			if (cmd.lock[0] == 0) {
				if (lock) {
					const info = `Symbol ${symbol} (${formatTF(lock.res)}) locked @ ${formatDateS(lock.time)} by '${(key||{names:[lock.id?'Unknown':'legacy alert']}).names.join(', ')}'`

					logWarn && log.warn(pre+info+` => ${key||!lock.id?'':'Dormant! (locking alert not found) '}Unlocking${key?' #'+(key.locks || 1):''}...`)
					delete lastAlert.lock[symbol]
					localStore.updated()
					if (key) key.locks = Math.max(key.locks || 1, 1) - 1
				} else {
					logAll && log.info(pre+"No symbol lock found, nothing to unlock...")
				}
			} else {
				if (key) key.locks = Math.max(key.locks || 1, 1) - 1
				const exp = Math.min(getTF(cmd.lock[0]), 0)
				lastAlert.lock[symbol] = {id: alertId, time: new Date().toISOString(), res: tf, exp: -exp}
				localStore.updated()

				const alert = getAlerts(alertId, {})
				alert.locks = (alert.locks||0) + 1
				logWarn && log.warn(pre+`Locking ${symbol} (${formatTF(tf)}) #${alert.locks}...`+(exp?` (expiring in ${-exp}m!)`:''))
			}
		}

		async function handleRun(type = "run") {
			for (const name of cmd[type]) {
				const alert = getAlertsByName(name)[0]
				if (!alert) {
					logWarn && log.warn(pre+`Couldn't find any alert with name/id "${name}"!`)
					return
				} else if (!alert.enabled && type.endsWith("en")) {
					logAll && log.info(pre+`Alert "${name}" is disabled – not running!`)
					return
				}
				const evID = pre.slice(1, -2)+" => "+alert.names.join(', ')
				const evCopy = Object.assignDeep({}, ev, {
					sym: cmd.e+':'+cmd.s,
					desc: alert.commands.join('\n'), descRaw: null,
					// $$$$$$ check alert name as fallback?
					side: (cmd.has('b') && cmd.b) || ev.side || ohlc._side || (alert.detect && 'auto'),
					isInternal: true,
					caller: alertId
					// $$$ log: cmd.log ???
				})

				let defs = {}
				cmd.extract(defs, defaultParams, overrides, true)
				delete defs.e, delete defs.s, defs.a = cmd.a

				logAll && log.info(pre+`Parameter set to ${type} alert #${alert.id} – "${alert.names.join(', ')}"!`)
				alert.fired = new Date().toISOString()
				alert.firedNum = (alert.firedNum||0) + 1
				alert.intNum = (alert.intNum||0) + 1

				// $$$$ implement params=var1:val1,var2:val2,...

				if (type.startsWith("trigger")) {
					setTimeout(runCommands.bind(this, evID, evCopy, disabled, alert.id, undefined, Object.assignDeep({}, defs)), forkSleep)
				} else {
					evCopy.isFork = true
					await runCommands(evID, evCopy, disabled, alert.id, undefined, defs)
				}
			}
		}

		function handleBrake() {
			// $$$$ DON'T PAUSE OURSELFES!
			if (bg.statRunning <= 1) {
				logAll && log.info(pre+"No other alert is currently running!")
				return
			}
			if (bg.pauseMode < 0) {
				logAll && log.info(pre+"Waiting for all other alerts to terminate...")
				return
			}
			if (cmd.exitall) {
				if (bg.statRunning > 1) {
					bg.pauseMode = -1
					relayMsg('running', bg.statRunning, bg.pauseMode)
				}
				logAll && log.info(pre+`Stopping ${bg.statRunning} running alert${bg.statRunning>1?'s':''} immediately!`)
			} else {
				const doPause = cmd.pauseAll
				if (doPause && bg.pauseMode > 0) {
					logAll && log.info(pre+`Running alerts are already paused! (${bg.statRunning} paused)`)
				} else if (!doPause && !bg.pauseMode) {
					logAll && log.info(pre+`Paused alerts are already resumed! (${bg.statRunning} running)`)
				} else {
					logAll && log.info(pre+`${doPause?'Pausing':'Resuming'} ${bg.statRunning} running alert${bg.statRunning>1?'s':''} immediately!`)
				}
				bg.pauseMode = doPause ? 1 : 0
				relayMsg('running', bg.statRunning, bg.pauseMode)
			}
		}

		function handleSet() {
			const combo = Object.assign({}, cmd, ohlc)
			for (const pair of cmd.setvar) {
				const key = pair[0], value = pair[1]
				if (key && Command.legalVarGlobal.test(key)) {
					const data = value[0] === '"' ? mustacheFields(combo, unquote(value)) : Command.solveExp(value, cmd, ohlc, false, msg => logWarn && log.warn(pre+msg))
					const isGlobal = key[0] === '$'
					logAll && log.info(pre+`Setting ${isGlobal ? 'global' : 'OHLC'} variable "${key}" to ${typeof data === 'string' ? `"${data}"` : data}`)
					if (isGlobal) {
						globalVars.set(key, data, ev, cmd)
					} else {
						ohlc[key.toLowerCase()] = data
					}
				}
			}
		}

		function handleUnset() {
			const [globals, locals] = cmd.unset.part(key => key[0] === '$')
			// $$$$$$$ add info output about non-existent vars!
			if (globals.length) {
				logAll && log.info(pre+`Removing global variable${globals.length > 1?'s':''} "${globals.join('", "')}"`)
				globalVars.remove(globals)
			}
			if (locals.length) {
				logAll && log.info(pre+`Removing OHLC variable${locals.length > 1?'s':''} "${locals.join('", "')}"`)
				locals.forEach(key => delete ohlc[key.toLowerCase()])
			}
		}
/*
		// $$$$$$$ add/update ohlc time vars here ?! 
		const ts = new Date()
		date = (ts.getMonth()+1)*100+ts.getDate(),
		time = ts.getHours()*100+ts.getMinutes(),
		weekend = ts.getDay() < 1 || ts.getDay() > 5 ? 1 : 0,
		ctime = (ts.now() - cbarTime) / 1000
		btime = (ts.now() - barTime) / 1000
		ftime = (ts.now() - fireTime) / 1000
*/
		if (cmd.logvars) {
			const msg1 = pre+"Current OHLC data / vars:"+stringify(ohlc, true)
			logAll && log.info(msg1)
			ev.telebot && telebotSend(ev.telebot, msg1)

			const msg2 = pre+"Current global vars:"+stringify(globalVars.getAllValues(), true)
			logAll && log.info(msg2)
			ev.telebot && telebotSend(ev.telebot, msg2)
		}

		if (cmd.cha) {
			let found = 0, enabled = 0
			getAlertsByName(cmd.cha[0]).forEach(alert => ++found && alert.enabled && ++enabled)
			const msg = pre+
				(enabled < found ? `At least one alert DISABLED! (${found} found / ${enabled} enabled – cha=${cmd.cha[0]} specified)` : 
						 enabled ? `All alerts ENABLED! (${found} found / ${enabled} enabled – cha=${cmd.cha[0]} specified)` : 
									`No matching alert found! (cha=${cmd.cha[0]} specified)`)

			if (!(cmd.ife || cmd.ifd)) {
				if (enabled < found || !found) {
					logCond && log.notice(msg+", SKIPPING command...")
					++cmdNum; continue
				}
			} else {
				if (found) {
					const type = enabled < found ? 'ifd' : enabled ? 'ife' : null
					if (type && cmd[type]) {
						logCond && log.notice(msg)
						stats.inc('Cond'); isConditional = true
						if (await handleJump(type)) continue
					}
				} else {
					logWarn && log.warn(msg)
				}
			}
		} else if (cmd.if) {
			Exchange.loadData(cmd, ohlc)
			if (!Command.evalIf(cmd.if, cmd, ohlc, false, msg => logWarn && log.warn(pre+msg))) {
				const msg = pre+`Expression evaluated to FALSE (if="${cmd.if}" specified)`
				if (cmd.else) {
					logCond && log.notice(msg)
					stats.inc('Cond'); isConditional = true
					if (await handleJump('else')) continue
				} else {
					logCond && log.notice(msg+", SKIPPING command...")
					++cmdNum; continue
				}
			} else {
				logCond && log.notice(pre+`Expression evaluated to TRUE (if="${cmd.if}" specified), executing command...`)
			}
		}

		if (!cmd.hasAny(cmdParams)) {
			if (!cmd.hasAny(cmdParamsInt) && cmd.extract(overrides, [isParent || isChild > 0 ? null : 'a', ...defaultParams])) {
				logAll && log.info(pre+"Alert defaults set to: "+stringifyList(overrides))
			}
			if (cmd.ap) {
				if (cmd.has('a')) {
					logErr && log.error(pre+"Parallel accounts (ap=) and normal accounts option (a=) cannot be combined!")
				} else if (isParent || isChild > 0) {
					logErr && log.error(pre+"Parallel accounts option (ap=) can only be used ONCE per alert!")
				} else {
					handleParallel(cmd._ap || [cmd.ap], 1)
				}
			}
			if (isChild > 0) {
				cmd.listGoto(isChild)
			}
			cmd.has('n') && Exchange.loadData(cmd, ohlc) && handleNotifications(logAll && pre, ev, true, cmd)
			cmd.enable && handleToggle('enable')
			cmd.disable && handleToggle('disable')
			cmd.toggle && handleToggle('toggle')
			cmd.interval && handleInterval()
			cmd.setvar && handleSet()
			cmd.unset && handleUnset()
			cmd.skip && (await handleJump('skip'))
			++cmdNum; continue
		}
		if (cmd.ap) {
			logErr && log.error(pre+"Parallel accounts option (ap=) can only be used in a line by itself or with setting defaults!")
		}

		if (cmd._a && cmd._a.remain && cmd._a.remain()) {
			cmd.listNext()
		} else if (isChild > 0) {
			cmd.listGoto(isChild)
		}
		pre = `[${id}:${cmd._line}${cmd._count > 1 ? ':'+cmd._count:''}${cmd.a !== '*' ? ' @ '+cmd.a:''}] `

		if (disabled) cmd.d = 1
		try {
			if (!cmd.skip && cmd.max && cmd._count > cmd.max) {
				throw new Error(`Maximum iterations of ${cmd.max} reached!`)
			}
			cmd.lockcheck && handleLockCheck()

			if (cmd.hasAny(['minhvd', 'maxhvd'])) {
				const vol = await getVolatility(1)
				ohlc.hvd = vol
				if (cmd.minhvd && vol < cmd.minhvd) throw new Error(`Daily volatility (${vol}) too low! (minhvd=${cmd.minhvd} was specified)`)
				if (cmd.maxhvd && vol > cmd.maxhvd) throw new Error(`Daily volatility (${vol}) too high! (maxhvd=${cmd.maxhvd} was specified)`)
			}
			if (cmd.hasAny(['minhvw', 'maxhvw'])) {
				const vol = await getVolatility(7)
				ohlc.hvw = vol
				if (cmd.minhvw && vol < cmd.minhvw) throw new Error(`Weekly volatility (${vol}) too low! (minhvw=${cmd.minhvw} was specified)`)
				if (cmd.maxhvw && vol > cmd.maxhvw) throw new Error(`Weekly volatility (${vol}) too high! (maxhvw=${cmd.maxhvw} was specified)`)
			}
			if (cmd.hasAny(['minhvm', 'maxhvm'])) {
				const vol = await getVolatility(30)
				ohlc.hvm = vol
				if (cmd.minhvm && vol < cmd.minhvm) throw new Error(`Monthly volatility (${vol}) too low! (minhvm=${cmd.minhvm} was specified)`)
				if (cmd.maxhvm && vol > cmd.maxhvm) throw new Error(`Monthly volatility (${vol}) too high! (maxhvm=${cmd.maxhvm} was specified)`)
			}

			if (cmd.hasAny(['min1h', 'max1h', 'min24h', 'max24h', 'min7d', 'max7d'])) {
				const change = await getChange(cmd.cmcid)
				//change.cmc_price = change.price
				//delete change.price
				// $$$$ Object.without(change, ['price', 'tvl'])
				Object.assign(ohlc, change)

				const c1h = change.percent_change_1h
				if (cmd.has('min1h') && c1h < cmd.min1h) throw new Error(`1h price change (${c1h}%) too low! (min1h=${cmd.min1h} was specified)`)
				if (cmd.has('max1h') && c1h > cmd.max1h) throw new Error(`1h price change (${c1h}%) too high! (max1h=${cmd.max1h} was specified)`)

				const c24h = change.percent_change_24h
				if (cmd.has('min24h') && c24h < cmd.min24h) throw new Error(`24h price change (${c24h}%) too low! (min24h=${cmd.min24h} was specified)`)
				if (cmd.has('max24h') && c24h > cmd.max24h) throw new Error(`24h price change (${c24h}%) too high! (max24h=${cmd.max24h} was specified)`)

				const c7d = change.percent_change_7d
				if (cmd.has('min7d') && c7d < cmd.min7d) throw new Error(`7d price change (${c7d}%) too low! (min7d=${cmd.min7d} was specified)`)
				if (cmd.has('max7d') && c7d > cmd.max7d) throw new Error(`7d price change (${c7d}%) too high! (max7d=${cmd.max7d} was specified)`)
			}

			if (!cmd.hasAny(cmdParamsEx) || cmd.trigger || cmd.triggeren || cmd.run || cmd.runen) {
				if (cmd.time && cmd.time.reference) {
					// $$$$$ eod/eodutc
					const elapsed = Date.now() - (cmd.since === "cbar" ? cbarTime : cmd.since === "bar" ? barTime : fireTime)
					const max = cmd.time._().reference(res * 60).resolve() * 1000
					if (elapsed > max) {
						const msg = `Too much time elapsed! (${(elapsed / 1000).toFixed(2)}s since ${cmd.since||'fired'} – maximum set to ${(max / 1000).toFixed(2)}s)`
						if (cmd.ift) {
							logCond && log.notice(pre+msg)
							stats.inc('Cond'); isConditional = true
							if (await handleJump('ift', (!cmd._a || (cmd._a.remain && !cmd._a.remain())))) continue
						} else {
							throw new Error(msg)
						}
					}
				}
				if (cmd.trigger || cmd.triggeren || cmd.run || cmd.runen) {
					cmd.trigger && (await handleRun("trigger"))
					cmd.triggeren && (await handleRun("triggeren"))
					cmd.run && (await handleRun("run"))
					cmd.runen && (await handleRun("runen"))
				} else if (cmd.has('p') || cmd.has('q')) {
					throw new SyntaxError("Side of the market (b= or side=) not specified!")
				}
				cmd.has('n') && Exchange.loadData(cmd, ohlc) && handleNotifications(logAll && pre, ev, true, cmd)
				++cmdNum
				stats.lastCmd = Date.now()

			} else if ((exchange = broker.get(id, cmd.e, cmd.s, cmd.a))) {
				if (cmd.time && cmd.time.reference) {
					// $$$$$ eod/eodutc
					const elapsed = Date.now() - (cmd.since === "cbar" ? cbarTime : cmd.since === "bar" ? barTime : fireTime)
					const max = cmd.time._().reference(res * 60).resolve() * 1000
					if (elapsed > max) {
						const msg = `Too much time elapsed! (${(elapsed / 1000).toFixed(2)}s since ${cmd.since||'fired'} – maximum set to ${(max / 1000).toFixed(2)}s)`
						if (cmd.iftf || cmd.iftnf) {
							const ords = (await exchange.getOrders(cmd.a, cmd.s, 1, cmd.id, cmd.isMargin))[cmd.a]		// $$$ loglevel?
							const ord = ords?.[0] // search for match (type etc.) $$$
							if (ord?.getAny) {
								const type = 'ift' + (exchange.isPartial(ord) ? 'f' : 'nf')
								if (type && cmd[type]) {
/*
		iftf: "If Timeout And Fill (Partial)",
		iftnf: "If Timeout And No Fill",
*/
									logCond && log.notice(pre+msg) // $$$$$$$ add some message about iftf/iftnf !!!
									stats.inc('Cond'); isConditional = true
									if (await handleJump(type, (!cmd._a || (cmd._a.remain && !cmd._a.remain())))) continue
								}
							}
						}
						if (cmd.iftp || cmd.iftnp) {
							const pos = (await exchange.getPositions(cmd.a, cmd.s, 1))[cmd.a].list		// $$$ loglevel?
							// $$$$$ todo: check side ?!
							const type = 'ift' + (pos?.length ? 'p' : 'np')
							if (type && cmd[type]) {
/*
		iftp: "If Timeout And Position",
		iftnp: "If Timeout And No Position",
*/
								logCond && log.notice(pre+msg) // $$$$$$$ add some message about iftp/iftnp !!!
								stats.inc('Cond'); isConditional = true
								if (await handleJump(type, (!cmd._a || (cmd._a.remain && !cmd._a.remain())))) continue
							}
						}
						if (cmd.ift) {
							logCond && log.notice(pre+msg)
							stats.inc('Cond'); isConditional = true
							if (await handleJump('ift', (!cmd._a || (cmd._a.remain && !cmd._a.remain())))) continue
						} else {
							throw new Error(msg)
						}
					}
				}
				if (!cmd.s) {
					throw new Error("Couldn't determine symbol! (on manual run set s= in syntax or define a default under 'General Options')")
				}

				const exOpt = exchange.getOptions()
				if ((!cmd.c && exOpt.retryorder) || (cmd.c && exOpt.retrycancel)) {
					if (cmd.retries === undefined) cmd.retries = exOpt.retries
					if (cmd.timeout === undefined) cmd.timeout = exOpt.timeout
				}
				if (cmd.retries && cmd.retry === undefined) cmd.retry = 0

				++cmdNum
				stats.lastCmd = Date.now()
				const result = await exchange.executeCommand(cmd, pre.slice(1, -2), ohlc, cmdLog, (opt.telebotEcho > 1 || (opt.telebotEcho > 0 && !alertId)) && ev.telebot)
				/*if (isParent || isChild) {
					// $$$ not required anymore? important: "IntensiveWakeUpThrottlingEnabled = disabled" policy!
					await sleep(childSleep)
				}*/

				if(result) {
					// $$$ orderReports = Binance; still needed?
					let data = result.orderReports || result
					try {
						if (Array.isArray(data)) {
							if (data.length < 1) throw false
							data = data.last()
						}
						if (!cmd.ch && !cmd.d && !cmd.ub && (!cmd.up || cmd.b) && !cmd.query) {
							stats.lastSuccess = JSON.stringify({date: new Date().toISOString(), command: cmd, order: data, alert: alertId, name: ev.name, num: cmds.pos, res: res, side: ev.side})

							let info = pre + exchange.getName() + ' ' + (result?.length > 1 ? result.length+' ':'') + ucfirst((cmd.b ? cmd.b + ' ':'') + (
									cmd.c === 'order' ? `order${result?.length > 1 ? 's':''} canceled!` : 
								 	cmd.c === 'position' ? `position close order${result?.length > 1 ? 's':''} placed!` : 
								 	//cmd.query ? cmd.query+' queried!' : 
								 	(cmd.tb ? "transfer " : cmd.loan ? "loan " : cmd.repay ? "repay ":'')+"order placed!"
								)) + (cmd.id ? ` (${cmd.id})`:'')

							if (data && typeof data === 'object') {
								info += (" {"+formatFields(data, /*cmd.query ? fieldPNLInfo :*/ fieldAll)+"\n}").replace(" {\n}", "")
							}
							logAll && log.success(info)
						}
						if ((!cmd.ch && !cmd.d) || cmd.has('n')) {
							handleNotifications(logAll && pre, ev, true, cmd, data, result.length, result.orderReports || result)
						}
					} catch(e){}

					if (result.length) {
						if (cmd.ifl || cmd.ifs) {
							const side = Exchange.findSide(result, cmd.ifl !== undefined, cmd.ifs !== undefined)
							if (side) {
								stats.inc('Cond'); isConditional = true
								if (await handleJump(side === 'long' ? 'ifl' : 'ifs', (!cmd._a || (cmd._a.remain && !cmd._a.remain())))) continue
							}
						}
						if ((cmd.ifbg || cmd.ifsm) && data?.getAny) {
							const qty = Number(data.getAny(fieldQuantity))
							const prevqty = cmd.sv ? ohlc.lastqty2 : ohlc.lastqty
							let type = 'ifbg'
							if ((cmd.ifbg && qty > prevqty) || (cmd.ifsm && qty < prevqty && (type = 'ifsm'))) {
								stats.inc('Cond'); isConditional = true
								if (await handleJump(type, (!cmd._a || (cmd._a.remain && !cmd._a.remain())))) continue
							}
						}
						if (cmd.ifp && data?.getAny && exchange.isPartial(data)) {
							stats.inc('Cond'); isConditional = true
							if (await handleJump('ifp', (!cmd._a || (cmd._a.remain && !cmd._a.remain())))) continue
						}
					} else if (cmd.ws) {
						if (!cmd.b) {
							logWarn && log.warn(pre+`Option ws= (wrongside) specified on command without b= (side) parameter – please see the description of ws= under "Setup: Commands"!`)
						} else {
							stats.inc('Cond'); isConditional = true
							if (await handleJump('ws', (!cmd._a || (cmd._a.remain && !cmd._a.remain())))) continue
						}
					}
				} else if (result === false && cmd.nr) {
					stats.inc('Cond'); isConditional = true
					if (await handleJump('nr', (!cmd._a || (cmd._a.remain && !cmd._a.remain())))) continue
				}

				if (cmd.ifn || cmd.ifo || cmd.ret) {
					const min = (cmd.ifn || cmd.ifo) && cmd.retries && cmd.has('retries') ? 1 : (cmd.ret || -1)
					if (!result || result.length < Math.abs(min)) {
						if (min < 0 && cmd.retries) {
							logAll && cmd.has('retries') && log.info(pre+`Skipping retries for not enough ${cmd.ch||cmd.c||'item'}s returned condition (${cmd.ifn?'ifn':cmd.ifo?'ifo':'ret < 0'} specified)...`)
							delete cmd.retries
						}
						isConditional = true
						throw new Error(`Not enough ${cmd.ch||cmd.c||'item'}s returned! (${cmd.ifn?'ifn':cmd.ifo?'ifo':'ret='+min} specified, not enough ${cmd.ch||cmd.c||'item'}s matched)`)
					}
				} else if (cmd.has('ret') && result?.length) {
					delete cmd.retries
					isConditional = true
					throw new Error(`Too many ${cmd.ch||cmd.c||'item'}s returned! (ret=0 specified)`)
				}
			} else {
				throw new Error("Couldn't determine exchange! (on manual run set e= in syntax or define a default under 'General Options')")
			}
			successful = true
			cmd.enable && handleToggle('enable')
			cmd.disable && handleToggle('disable')
			cmd.toggle && handleToggle('toggle')
			cmd.interval && handleInterval()
			cmd.setvar && handleSet()
			cmd.unset && handleUnset()
			cmd.lock && handleLock()
			;(cmd.exitall || cmd.pauseall || cmd.resumeall) && handleBrake()
			cmd.restart && restart(true)

		} catch (ex) {
			let err = getError(ex, "Not configured – check if exchange and account are specified correctly!")
			const line = `command #${cmds.pos}`+(cmd._line && cmd._line != cmds.pos ? ` (line ${cmd._line})`:'')

			const doTimeSync = opt.timeSync && exchange?.checkTime && err.includesNoCase('timestamp') && Date.now() - stats.lastTimeSync > streamRetryTimeout
			if (doTimeSync) {
				logWarn && log.warn(pre+`Time sync issue detected, trying automatic time sync...`)
				stats.lastTimeSync = Date.now()
				await exchange.checkTime()
			}

			const isNewCheck = err.startsWithAny(newChecks)
			if (isNewCheck) {
				delete cmd.retries
				isConditional = cmd.has(newCheckParams[isNewCheck-1])
			}

			if (exchange && ((isNewCheck === errSlippageCheck && (cmd.ifslp || cmd.ifslnp)) || 
				(isConditional && err.startsWith('Not enough ') && (cmd.ifnp || cmd.ifnnp)))) {
				const pos = (await exchange.getPositions(cmd.a, cmd.s, 1))[cmd.a].list		// $$$ loglevel?
				// $$$$$ todo: check side ?!
				const type = (isNewCheck === errSlippageCheck ? 'ifsl' : 'ifn') + (pos?.length ? 'p' : 'np')
				if (type && cmd[type]) {
/*
		=> ifslp= specified
		ifslp: "If Slippage And Position",
		ifslnp: "If Slippage And No Position",
		ifnp: "If No Match And Position",
		ifnnp: "If No Match And No Position",
*/
					logCond && log.notice(pre+err) // $$$$$$$ add some message about ifslp/ifslnp/ifnp/ifnnp !!!
					stats.inc('Cond'); isConditional = true
					if (await handleJump(type, (!cmd._a || (cmd._a.remain && !cmd._a.remain())))) continue
				}
			}

			let isErr = false
			if (isConditional) {
				stats.inc('Cond')
				logCond && log.notice(pre+`Conditional in ${line}: ${err}`)
				if (isNewCheck && (await handleJump(newCheckParams[isNewCheck-1], (!cmd._a || (cmd._a.remain && !cmd._a.remain()))))) continue

			} else if (errIsWarning.test(err) || (opt.ignPerm && err.includes('Please grant')) || (opt.ignPerm && (err.includes('No account') || err.includes('Non-existent account')))) {
				stats.inc('Warn')
				logWarn && log.warn(pre+`Warning in ${line}: ${err}`)
			} else {
				isErr = true, stats.inc('Err'), stats.inc1('badgeErr')
				stats.lastError = JSON.stringify({date: new Date().toISOString(), command: cmd, error: err, alert: alertId, name: ev.name, num: cmds.pos, res: res, side: ev.side})
				logErr && log.error(pre+`Error in ${line}: ${err} – Syntax: "${syntax.raw.split('\n')[cmd._line-1]}" – Command: ${stringify(cmd)}(calculated values)`)
				ev.telebot && telebotSend(ev.telebot, pre+`Error in ${line}: ${err} – Syntax: "${syntax.raw.split('\n')[cmd._line-1]}" – Command: ${stringify(cmd)}(calculated values)`)

				handleNotifications(logAll && pre, ev, false, cmd, err)

				const alert = getAlerts(alertId, {})
				alert.errNum = (alert.errNum||0) + 1
			}

			if (!doTimeSync && cmd.retries && !cmd.retryall && errIsNoRetry.test(err)) {
				if (!opt.retryBal || !errIsBalance.test(err)) {
					isErr && logWarn && log.warn(pre+"Skipping retries for this error type...")
					delete cmd.retries
				}
			}
		}

		if (!successful) {
			if (cmd.err != Command.errIgnore && cmd.retries && exchange) {
				if (cmd.retry < cmd.retries) {
					++cmd.retry, --cmds.pos
					if (cmd._a && cmd._a._prev) {
						cmd.listPrev()
					}
					logWarn && log.warn(pre+`Retrying in ${cmd.timeout} seconds (${cmd.retry} of ${cmd.retries} retries)...`)
					stats.inc('Retries'); continue
				} else {
					logWarn && log.warn(pre+`Maximum retries reached (${cmd.retry} of ${cmd.retries}) – giving up!`)
					stats.inc('RetriesMax')
					cmd.retry = 0
				}
			}
			cmd._errCount = (cmd._errCount||0) + 1

			if (isConditional && cmd.has('ifn') ? cmd.ifn : cmd.err) {
				if (await handleJump(isConditional && cmd.has('ifn') ? 'ifn' : 'err', !cmd._a || cmd._errCount >= cmd._a.length)) continue
			}
		} else if (cmd.ifo || cmd.skip) {
			if (await handleJump(cmd.ifo ? 'ifo' : 'skip', (!cmd._a || (cmd._a.remain && !cmd._a.remain())) && !cmd._errCount)) continue
		}

		if (cmd._a && cmd._a.remain) {
			if (cmd._a.remain()) {
				--cmds.pos
			} else {
				cmd.listReset()
			}
		}
	}

	const totalTime = (Date.now() - startTime) / 1000
	;(cmdNum || ev.isManual) && logAll && log.info(pre+`Executed ${cmdNum} command${cmdNum > 1 ? 's':''} in ${formatDur(totalTime, 2)} – DONE!`)
	ev.telebot && opt.telebotSum && telebotSend(ev.telebot, pre+`Executed ${cmdNum} command${cmdNum > 1 ? 's':''} in ${formatDur(totalTime, 2)} – DONE!`)
	if (!ev.isFork) {
		--bg.statRunning && logAll && log.info(`Still running ${bg.statRunning} alert${bg.statRunning > 1 ? 's':''}...`)
		stats.sessionRunning = bg.statRunning
		if (!bg.statRunning && bg.pauseMode < 0) {
			bg.pauseMode = 0
			log.notice("All alerts terminated!")
		}
		relayMsg('running', bg.statRunning, bg.pauseMode)

		if (cmdNum && totalTime && (!ev.isManual || opt.statManual)) {
			let alert = getAlerts(alertId, {})
			alert.time = totalTime
			stats.inc('Commands', cmdNum)
			stats.inc('Time', totalTime)

			if (totalTime >= opt.statMinTime) {
				alert.timeAvg = ((alert.timeAvg || totalTime) + totalTime) / 2
				alert.timeMax = Math.max(alert.timeMax || 0, totalTime)
				alert.timeMin = Math.min(alert.timeMin || Number.MAX_VALUE, totalTime)
				stats.minMax('Time', totalTime, true)
			}
		}
		stats.inc('Alerts')
		if (!cmdNum || totalTime < opt.statMinTime) stats.inc('AlertsEmpty')
		if (ev.telebot) stats.inc('TeleRun')
	}
	if (isChild > 0) stats.inc('Children')
	else if (isManual) stats.inc('Manual')
	else if (ev.isFork) stats.inc('Forked')
	else if (ev.isInternal) stats.inc('Trigger')
	if (isManual) stats.lastTime = Date.now()

	broker.free(id)
	return cmdNum
}

const handlers = {
	"connect": async () => {
		const tabs = await getTradingViewTabs()
		tvTabs = tabs.length
		log.info(`${pvName} TV Addon loading – tab opened / loaded! (${tvTabs} total)`)
	},
	"disconnect": async () => {
		--tvTabs
		log.info(`${pvName} TV Addon unloaded – tab closed / unloaded! (${tvTabs} total)`)

		if (tvTabs < 1 && Date.now() - tvLastReload >= reloadSafetyTimeout) {
			await sleep(pollSleep)
			if (tvTabs < 1 && Date.now() - tvLastReload >= reloadSafetyTimeout) {
				if (!opt.disableAll && (!opt.onlyStream || tvStreamStatus < TRADINGVIEW_STREAM_OPEN)) log.warn("NOTE: NO TRADINGVIEW TAB LEFT OPEN!")
				tvLastReload = Date.now()
				forceTradingViewCheck()
			}
		}
	},
	"screener": async (ev, sender) => {
		if (bg.inShutdown/* || !bg.isReady */) return // $$$$ ADD LOGGING
		if (opt.onlyStream && sender && tvStreamStatus >= TRADINGVIEW_STREAM_OPEN && tvLastEvent > Date.now() - addonIgnoreWindow) {
			console.log("Event Stream only mode: Ignoring TV Addon Screener event!")
			return
		}
		if (sender) stats.inc('ScreenerEvents')
		else stats.inc('ScreenerStreamEvents')

		let i = 0
		for (const sym of ev.d) {
			const evCopy = Object.assignDeep({}, ev)
			evCopy.id = evCopy.id+'-'+(++i)
			evCopy.screener = i
			evCopy.sym = sym
			evCopy.res = opt.screenRes || opt.defaultRes
			await handlers.event(evCopy, sender)

			if (opt.screenFirst) break
		}
	},
	"event": async (ev, sender) => {
		if (bg.inShutdown/* || !bg.isReady */) return // $$$$ ADD LOGGING
		if (opt.onlyStream && sender && tvStreamStatus >= TRADINGVIEW_STREAM_OPEN && tvLastEvent > Date.now() - addonIgnoreWindow) {
			console.log("Event Stream only mode: Ignoring TV Addon event!")
			return
		}
		ev.id = ev.id || ev.fire_id
		ev.aid = ev.aid || ev.alert_id
		if (!ev.sym) {
			const info = safeJSON(unquote(ev.symbol || ev.pro_symbol || "=").slice(1))
			ev.sym = info?.symbol?.symbol || info?.symbol
			if (!ev.sym) {
				log.error(`Could not find any symbol info for event – please report!`)
				return
			}
		}
		ev.res = ev.res || ev.resolution
		ev.desc = ev.desc || ev.message

		let eventID = ev.id
		if (!ev.telebot) {
			if (bg.tvEvents[eventID]) return
			bg.tvEvents[eventID] = Date.now()
			log.debug(`TradingView ${sender?'TV Addon':'Stream'} <= received event id: ${eventID}`)
			tvLastEvent = Date.now()
			if (opt.prefixTV && !ev.desc.startsWith(opt.prefixTVmsg)) {
				log.notice(`[${eventID}] Received alert message does NOT start with configured prefix "${opt.prefixTVmsg}"! (see 'General Options / TradingView Connection') {\n${lineNumbers(ev.desc)}\n}`)
				return
			}
		}
		if (!ev.screener || ev.screener === 1) {
			stats.inc(ev.telebot ? 'Telebot' : sender ? 'Events' : 'StreamEvents')
			stats.inc1('badgeRcv')
		}

		for (const entry of exSymbolMapping) {
			if (entry[0].test(ev.sym)) {
				ev.sym = ev.sym.replace(entry[0], entry[1]); break
			}
		}
		ev.bar_time = new Date(isNaN(ev.bar_time) ? ev.bar_time : ev.bar_time * 1000)
		ev.fire_time = new Date(isNaN(ev.fire_time) ? ev.fire_time : ev.fire_time * 1000)
		ev.res = getTF(ev.res)
		ev.bar_pos = (ev.fire_time - ev.bar_time) / 1000 / 60 / ev.res * 100
		ev.slip = Math.max((Date.now() - ev.fire_time) / 1000, 0)

		if(!ev.telebot) if (!ev.screener) {
			log.notice(`[${eventID}] Received Alert (${sender?'A':'S'}) for ${ev.sym} ${formatTF(ev.res)} @ ${formatDateS(ev.bar_time)} / fired ${formatDateS(ev.fire_time)} (${ev.bar_pos.toFixed(1).replace('.0', '')}% bar time, `+
				`${ev.slip.toFixed(2).replace('.00', '')}s slippage) => Commands to run: {\n${lineNumbers(ev.desc)}\n}`)
		} else {
			log.notice(`[${eventID}] Received Screener Alert (${sender?'A':'S'}) '${ev.title}' for ${ev.sym} @ ${formatDateS(ev.bar_time)} (${ev.slip.toFixed(2).replace('.00', '')}s slippage) => Commands to run: {\n${lineNumbers(ev.desc)}\n}`)
		}

		stats.minMax('Offset', ev.slip)
		if (opt.maxTime && ev.slip > opt.maxTime) {
			log.warn(`[${eventID}] Too much time between firing and running of alert! (${ev.slip.toFixed(2).replace('.00', '')}s – 'Maximum Time since Alert fired' set to ${opt.maxTime}s under 'General Options')`)
			stats.inc('MaxTime'); return
		}

		const alerts = getAlerts().sort(alertSort)
		const lastAlert = localStore.get('lastAlert')
		lastAlert.local ||= {}			// local[sym]		=> last PV alert ID
		lastAlert.localLast ||= {}		// localLast[sym]	=> last alert bar_time
		lastAlert.localName ||= {}		// localName[sym]	=> last PV alert name
		lastAlert.localRes ||= {}		// localRes[sym]	=> last alert res
		lastAlert.lock ||= {}
		const lastPulse = localStore.get('lastPulse')
		if (ev.desc.includes('[')) {
			new Alert(ev, undefined, -1, false, undefined, eventID)
		}
		let lines = ev.desc.trim().split('\n'), warn

		const ohlc = parseOHLC(lines.last())
		if (ohlc) {
			ev.ohlc = ohlc
			log.info(`[${eventID}] Attached OHLC data found:`+stringifyList(ohlc, true))
		}

		if (!alerts.length) {
			log.info(`[${eventID}] No ${pvName} alerts defined, only running legacy alerts...`)
		} else if (!ev.noAlerts) {
			let matched = 0, foundAlerts

			do {
				do {
					ev.name = lines[0].trimSep()
					if (ev.name.length && ev.name[0] !== '#') break
					lines.shift()
				} while(lines.length)
				if (!lines.length) break

				let params = ev.name.indexOf('(')
				if (params > 0) {
					const data = ev.name.substr(params)
					ev.name = ev.name.substr(0, params).trimSep()
					params = parseOHLC(data)
				} else {
					params = null
				}

				foundAlerts = 0
				let disabledAlerts = 0, repeatAlerts = 0, repeatFired = 0, soloFired = 0

				for (const alert of alerts) {
					if (alert.names && alert.names.includesNoCase(ev.name, opt.wildName)) {
						++matched
						++foundAlerts
						alert.recv = opt.recvNow ? new Date().toISOString() : ev.fire_time.toISOString()
						alert.recvNum = (alert.recvNum||0) + 1
						eventID = `${ev.id}-${matched} => ${ev.name}`
						const info = ` (#${alert.id} – "${alert.names.join(', ')}")`

						if (!alert.enabled && (ev.allow || 0) < 2) {
							++disabledAlerts
							log.warn((warn = `[${eventID}] FILTER: Alert not enabled!`+info))
							ev.telebot && telebotSend(ev.telebot, warn)
							stats.inc('FilterEn'); continue
						}
						if (!ev.noFilter) {
							if (alert.sched && !intervalMatch(alert.autoInt, new Date())) {
								++disabledAlerts
								log.warn((warn = `[${eventID}] FILTER: Alert schedule not matched!`+info))
								ev.telebot && telebotSend(ev.telebot, warn)
								stats.inc('FilterSch'); continue
							}

							const idx = ev.sym+'|'+ev.res+'|'+alert.id
							const pulse = (lastPulse[idx] || '|').split('|')
							if (alert.barAmount) {
								if (ev.bar_time > new Date(pulse[0] || 0)) {
									pulse[1] = 0
								}
								pulse[0] = ev.fire_time.toISOString()
								++pulse[1]
								lastPulse[idx] = pulse.join('|')
								pulse[1] /= ev.res / 100
							}

							if (ev.bar_pos <=  3 && !alert.barOpen) {
								log.warn((warn = `[${eventID}] FILTER: Bar time/position 'Bar Open' not allowed!`+info)); stats.inc('Filter'); ev.telebot && telebotSend(ev.telebot, warn); continue
							}
							if (ev.bar_pos >   3 && ev.bar_pos <= 25 && !alert.barStart) {
								log.warn((warn = `[${eventID}] FILTER: Bar time/position 'Bar Start' not allowed!`+info)); stats.inc('Filter'); ev.telebot && telebotSend(ev.telebot, warn); continue
							}
							if (ev.bar_pos >  25 && ev.bar_pos <  50 && !alert.barMid1) {
								log.warn((warn = `[${eventID}] FILTER: Bar time/position 'Bar Mid 1' not allowed!`+info)); stats.inc('Filter'); ev.telebot && telebotSend(ev.telebot, warn); continue
							}
							if (ev.bar_pos >= 50 && ev.bar_pos <  75 && !alert.barMid2) {
								log.warn((warn = `[${eventID}] FILTER: Bar time/position 'Bar Mid 2' not allowed!`+info)); stats.inc('Filter'); ev.telebot && telebotSend(ev.telebot, warn); continue
							}
							if (ev.bar_pos >= 75 && ev.bar_pos < 96.5 && !alert.barEnd) {
								log.warn((warn = `[${eventID}] FILTER: Bar time/position 'Bar End' not allowed!`+info)); stats.inc('Filter'); ev.telebot && telebotSend(ev.telebot, warn); continue
							}
							if (ev.bar_pos >= 96.5 && !alert.barClose) {
								log.warn((warn = `[${eventID}] FILTER: Bar time/position 'Bar Close' not allowed!`+info)); stats.inc('Filter'); ev.telebot && telebotSend(ev.telebot, warn); continue
							}
							if (alert.barSolo && !soloFired && new Date(lastAlert.localLast[ev.sym] || 0) >= ev.bar_time) {
								log.warn((warn = `[${eventID}] FILTER: Option 'Solo per Bar' set and some alert (#${lastAlert.local[ev.sym]}) already fired in bar at ${formatDateS(lastAlert.localLast[ev.sym])}!`+info))
								stats.inc('FilterOnce'); ev.telebot && telebotSend(ev.telebot, warn); continue
							}
							if (alert.barOnce && new Date(alert.fired || 0) >= ev.bar_time && new Date(lastAlert.localLast[ev.sym] || 0) >= ev.bar_time) {
								// $$$ need to keep track of alert.fired per ex:sym $$$$$$$
								if (alert.lockTF && lastAlert.localRes[ev.sym] !== ev.res) {
									log.warn(`[${eventID}] FILTER: Option 'Once per Bar' set, alert already fired in bar but on different timeframe (${formatTF(lastAlert.localRes[ev.sym])})! ('TF Check' set)`+info)
									stats.inc('FilterRepTF')
								} else if (alert.repName && lastAlert.localName[ev.sym] !== ev.name.toUpperCase()) {
									log.warn(`[${eventID}] FILTER: Option 'Once per Bar' set, alert already fired in bar but with different name ("${lastAlert.localName[ev.sym]}")! ('Check Name' set)`+info)
									stats.inc('FilterRepName')
								} else {
									log.warn((warn = `[${eventID}] FILTER: Option 'Once per Bar' set and alert already fired in bar at ${formatDateS(alert.fired)}!`+info))
									stats.inc('FilterOnce'); ev.telebot && telebotSend(ev.telebot, warn); continue
								}
							}
							if (lastAlert.local[ev.sym] === alert.id) {
								repeatFired = alert.fired || lastAlert.localLast[ev.sym]
								++repeatAlerts
							}
							if (alert.noRepeat && repeatAlerts) {
								if (alert.repName && lastAlert.localName[ev.sym] !== ev.name.toUpperCase()) {
									log.warn(`[${eventID}] FILTER: Option 'No Repeat' set, last alert for symbol repeated but with different name ("${lastAlert.localName[ev.sym]}")! ('Check Name' set)`+info)
									stats.inc('FilterRepName')
								} else if (alert.repReset && new Date(repeatFired) <= Date.now() - alert.repResetMin*60000) {
									log.warn(`[${eventID}] FILTER: Option 'No Repeat' set, last alert for symbol repeated but expired at ${formatDateS(repeatFired)}! ('Repeat Timeout' set to ${alert.repResetMin}m)`+info)
									stats.inc('FilterRepRes')
								} else {
									const note = lastAlert.local[ev.sym] !== alert.id ? " (from a different alert sharing the same name)":''
									log.warn((warn = `[${eventID}] FILTER: Option 'No Repeat' set and last alert for symbol at ${formatDateS(repeatFired)+note} is repeated!`+info))
									stats.inc('FilterRep'); ev.telebot && telebotSend(ev.telebot, warn); continue
								}
							}
							if (alert.repReset && !alert.noRepeat) {
								const last = getAlerts(lastAlert.local[ev.sym], {}).fired || lastAlert.localLast[ev.sym]
								if (new Date(last) > Date.now() - alert.repResetMin*60000) {
									log.warn((warn = `[${eventID}] FILTER: Option 'Timeout' set and some alert (#${lastAlert.local[ev.sym]}) was fired within timeout window at ${formatDateS(last)}! ('Repeat Timeout' set to ${alert.repResetMin}m)`+info))
									stats.inc('FilterTime'); ev.telebot && telebotSend(ev.telebot, warn); continue
								}
							}

							if (alert.barPA && alert.barPos && alert.barAmount) {
								if (ev.bar_pos < alert.barPos && pulse[1] < alert.barAmount) {
									log.warn((warn = `[${eventID}] FILTER: Option 'Pos %' set to ${alert.barPos}% (alert fired at ${ev.bar_pos.toFixed(1).replace('.0', '')}%) and option 'Dur %' set to ${alert.barAmount}% (alert fired for ${pulse[1].toFixed(1)}%) – neither reached! (OR)`+info))
									stats.inc('Filter'); ev.telebot && telebotSend(ev.telebot, warn); continue
								}
							} else {
								if (alert.barPos && ev.bar_pos < alert.barPos) {
									log.warn((warn = `[${eventID}] FILTER: Option 'Pos %' set to ${alert.barPos}% and alert only fired at ${ev.bar_pos.toFixed(1).replace('.0', '')}%!`+info))
									stats.inc('Filter'); ev.telebot && telebotSend(ev.telebot, warn); continue
								}
								if (alert.barAmount && pulse[1] < alert.barAmount) {
									log.warn((warn = `[${eventID}] FILTER: Option 'Dur %' set to ${alert.barAmount}% and alert only fired for ${pulse[1].toFixed(1)}% yet!`+info))
									stats.inc('Filter'); ev.telebot && telebotSend(ev.telebot, warn); continue
								}
							}

							let lock = lastAlert.lock[ev.sym]
							const key = lock && getAlerts(lock.id)
							if (lock) {
								const lockinfo = `[${eventID}] FILTER: Symbol ${ev.sym} (${formatTF(lock.res)}) locked @ ${formatDateS(lock.time)} by '${(key||{names:[lock.id?'Unknown':'legacy alert']}).names.join(', ')}'`

								if (lock.exp && timeAdd(lock.time, lock.exp * 60) < new Date()) {
									log.warn(lockinfo+` => Expired after ${lock.exp}m! Unlocking${key ? ' #'+(key.locks || 1):''}...`+info)
									delete lastAlert.lock[ev.sym]
									localStore.updated()
									if (key) key.locks = Math.max(key.locks || 1, 1) - 1
									lock = null
								} else {
									if (alert.lockOnly) {
										if (alert.lockTF && lock.res != ev.res) {
											log.warn((warn = lockinfo+` => 'Lock Required' and 'Timeframe Check' enabled, timeframe mismatch! (${formatTF(ev.res)})`+info))
											stats.inc('FilterLock'); ev.telebot && telebotSend(ev.telebot, warn); continue
										}
										log.warn(lockinfo+` => 'Lock Required' enabled...`)
									} else if (alert.ignLock) {
										log.warn(lockinfo+` => 'Ignore Lock' enabled...`)
									} else if (!key || alert.clearLock) {
										if (key && alert.lockTF && lock.res != ev.res) {
											log.warn((warn = lockinfo+` => 'Timeframe Check' enabled, can't unlock due to timeframe mismatch! (${formatTF(ev.res)})`+info))
											stats.inc('FilterLock'); ev.telebot && telebotSend(ev.telebot, warn); continue
										}
										if (key && alert.setLock) {
											log.warn(lockinfo+` => Updating...`+info)
										} else if (lock.id) {
											log.warn(lockinfo+` => ${key ? '':'Dormant! (locking alert not found) '}Unlocking${key ? ` #${key.locks||1}`:''}...`+info)
											delete lastAlert.lock[ev.sym]
											localStore.updated()
											if (key) key.locks = Math.max(key.locks || 1, 1) - 1
										}
									} else {
										log.warn((warn = lockinfo+'!'+info)); stats.inc('FilterLock'); ev.telebot && telebotSend(ev.telebot, warn); continue
									}
								}
							}
							if (!lock && alert.lockOnly) {
								log.warn((warn = `FILTER: No lock found and 'Lock Required' enabled!`+info)); stats.inc('FilterLock'); ev.telebot && telebotSend(ev.telebot, warn); continue
							}
							if (alert.setLock) {
								if (key) key.locks = Math.max(key.locks || 1, 1) - 1
								lastAlert.lock[ev.sym] = {id: alert.id, time: alert.recv, res: ev.res, exp: alert.lockExp}
								localStore.updated()
								alert.locks = (alert.locks||0) + 1
								log.warn(`[${eventID}] FILTER: Locking ${ev.sym} (${formatTF(ev.res)}) #${alert.locks}...${alert.lockExp ? ` (expiring in ${alert.lockExp}m!)`:''}`+info)
							}
						}
						if (foundAlerts - disabledAlerts < 2) {
							lastAlert.local[ev.sym] = alert.id
							lastAlert.localLast[ev.sym] = ev.bar_time.toISOString()
							lastAlert.localName[ev.sym] = ev.name.toUpperCase()
							lastAlert.localRes[ev.sym] = ev.res
							localStore.updated()
							if (alert.barSolo) ++soloFired
						}

						const evCopy = Object.assignDeep({}, ev)
						evCopy.desc = alert.commands.join('\n')
						if (params) {
							evCopy.ohlc = evCopy.ohlc ? Object.assign(evCopy.ohlc, params) : params
							log.info(`[${eventID}] Attached parameters found:`+stringifyList(params, true))
						}
						evCopy.side = (evCopy.ohlc && evCopy.ohlc._side) || (alert.detect && 'auto')
						const defs = {}
						let parallel = null
						if (evCopy.ohlc) {
							const o = evCopy.ohlc
							let e = o.e || o.ex, s = o.s || o.sym, a = o.a || o.acc, ap = o.ap || o.par
							if (ap && (!Array.isArray(ap) || ap.length < 2)) {
								a = ap; ap = null
							}
							if (e || s || a || ap) {
								delete o.e; delete o.ex; delete o.s; delete o.sym; delete o.a; delete o.acc; delete o.ap; delete o.par
								const ua = arr => Array.isArray(arr) ? arr[0] : arr
								const aa = arr => Array.isArray(arr) ? arr : arr && [arr]

								// $$$ api-fy ex:sym:acc
								const exSym = (evCopy.sym || ':').split(':')
								let ex = e || exSym[0], sym = s || exSym[1], acc = a || '*'
								let list = aa(a || ap)
								if (list?.contains(':')) {
									const defEx = ua(ex) || opt.defExchange
									const defSym = ua(sym) || opt.defSymbol
									ex = []; sym = []
									list = list.map(a => {
										a = a.split(':')
										let alias = (a.length > 1 && a[0] || defEx).toUpperCase()
										const s = (a.length > 2 && a[1] || defSym).toUpperCase()
										a = a.last() || '*'
										if (broker) {
											const e = broker.getByAlias(alias)
											if (!e) {
												log.warn(`[${eventID}] Invalid exchange alias "${alias}" specified – expecting a list of account names or "ex:sym:acc"!`)
											} else {
												alias = e.getAlias()
												if (!e.hasAccount(a)) {
													log.warn(`[${eventID}] Non-existent account "${a}" for ${e.getName()} specified – expecting a list of account names or "ex:sym:acc"!`)
												}
											}
										}
										ex.push(alias), sym.push(s)
										return a
									})
									if (list.length < 2) {
										list = list[0]; ex = ex[0]; sym = sym[0]
									}
									if (ap) ap = list
									else acc = list
								}
								if (ap) {
									parallel = ap
									acc = ua(ap)
								}

								if (ua(ex) != exSym[0] || ua(sym) != exSym[1]) {
									evCopy.sym = (ua(ex)+':'+ua(sym)).toUpperCase()
									log.info(`[${eventID}] Overriding symbol default: `+evCopy.sym)
								}
								if (Array.isArray(ex)) defs.e = ex
								if (Array.isArray(sym)) defs.s = sym
								if (acc != '*') {
									defs.a = acc
									log.info(`[${eventID}] Overriding account default: `+defs.a)
								}
							}
						}
						const badge = evCopy.name+(evCopy.side ? ' '+evCopy.side.toUpperCase():'')+`\n(${evCopy.sym} ${formatTF(evCopy.res)} @ ${formatDateS(alert.recv)})`
						if (badge != stats.badgeEvt) {
							if (stats.badgeEvt) stats.badgeEvtLast = stats.badgeEvt
							stats.badgeEvt = badge
						}

						if (opt.disableAll && !alert.allow && !ev.allow) {
							log.warn((warn = `[${eventID}] Running of new alerts currently disabled!`))
							ev.telebot && telebotSend(ev.telebot, warn)
						} else {
							alert.fired = alert.recv
							alert.firedNum = (alert.firedNum||0) + 1
							alert.slip = ev.slip

							if (alert.parallel) {
								setTimeout(runCommands.bind(this, eventID, evCopy, false, alert.id, 1, defs, 0, parallel), forkSleep)
							} else {
								await runCommands(eventID, evCopy, false, alert.id, 1, defs, 0, parallel)
							}
						}
					}
				}
				foundAlerts && lines.shift()
			} while(foundAlerts && lines.length)

			if (lines.length < 1 || (lines.length === 1 && lines[0][0] === '#')) return
			if (ev.noLegacy || (!ev.telebot && opt.legacyIgn)) {
				log.info(`[${eventID}] Running of legacy alerts disabled!`)
				return
			}
			if (matched) {
				ev.desc = lines.join('\n')
				eventID = ev.id
				log.info(`[${eventID}] Found ${matched} ${pvName} alert${matched>1?'s':''} in message, trying to run the rest as legacy...`)
			} else {
				log.info(`[${eventID}] No matching ${pvName} alert found, trying to run as legacy only...`)
			}
			delete ev.name
		}

		const id = ev.id || md5(ev.desc)
		lastAlert.legacy ||= {}			// legacy[sym]		=> last alert id (id || md5)
		lastAlert.legacyLast ||= {}		// legacyLast[sym]	=> last alert bar_time
		lastAlert.legacyFired ||= {}	// legacyFired[id]	=> last alert fire_time

		if (!ev.noFilter) {
			const idx = ev.sym+'|'+ev.res+'|L'+id
			const pulse = (lastPulse[idx] || '|').split('|')
			if (opt.legacyBarAmount) {
				if (ev.bar_time > new Date(pulse[0] || 0)) {
					pulse[1] = 0
				}
				pulse[0] = ev.fire_time.toISOString()
				++pulse[1]
				lastPulse[idx] = pulse.join('|')
				pulse[1] /= ev.res / 100
			}

			if (ev.bar_pos <=  3 && !opt.legacyBarOpen) {
				log.warn(`[${eventID}] LEGACY FILTER: Bar time/position 'Bar Open' not allowed!`); stats.inc('FilterLegacy'); return
			}
			if (ev.bar_pos >   3 && ev.bar_pos <= 25 && !opt.legacyBarStart) {
				log.warn(`[${eventID}] LEGACY FILTER: Bar time/position 'Bar Start' not allowed!`); stats.inc('FilterLegacy'); return
			}
			if (ev.bar_pos >  25 && ev.bar_pos <  50 && !opt.legacyBarMid1) {
				log.warn(`[${eventID}] LEGACY FILTER: Bar time/position 'Bar Mid 1' not allowed!`); stats.inc('FilterLegacy'); return
			}
			if (ev.bar_pos >= 50 && ev.bar_pos <  75 && !opt.legacyBarMid2) {
				log.warn(`[${eventID}] LEGACY FILTER: Bar time/position 'Bar Mid 2' not allowed!`); stats.inc('FilterLegacy'); return
			}
			if (ev.bar_pos >= 75 && ev.bar_pos < 96.5 && !opt.legacyBarEnd) {
				log.warn(`[${eventID}] LEGACY FILTER: Bar time/position 'Bar End' not allowed!`); stats.inc('FilterLegacy'); return
			}
			if (ev.bar_pos >= 96.5 && !opt.legacyBarClose) {
				log.warn(`[${eventID}] LEGACY FILTER: Bar time/position 'Bar Close' not allowed!`); stats.inc('FilterLegacy'); return
			}

			if (opt.legacyBarSolo && new Date(lastAlert.legacyLast[ev.sym] || 0) >= ev.bar_time) {
				log.warn(`[${eventID}] LEGACY FILTER: Option 'Solo per Bar' set and some alert already fired in bar at ${formatDateS(lastAlert.legacyLast[ev.sym])}!`); stats.inc('FilterLegacyOnce'); return
			}
			if (opt.legacyBarOnce && new Date(lastAlert.legacyFired[id] || 0) >= ev.bar_time && new Date(lastAlert.legacyLast[ev.sym] || 0) >= ev.bar_time) {
				log.warn(`[${eventID}] LEGACY FILTER: Option 'Once per Bar' set and alert already fired in bar at ${formatDateS(lastAlert.legacyFired[id])}!`); stats.inc('FilterLegacyOnce'); return
			}
			if (opt.legacyNoRepeat && lastAlert.legacy[ev.sym] === id) {
				if (opt.legacyRepReset && new Date(lastAlert.legacyFired[id] || 0) <= Date.now() - opt.legacyRepResetMin*60000) {
					log.warn(`[${eventID}] LEGACY FILTER: Option 'No Repeat' set, last alert for symbol repeated but expired at ${formatDateS(lastAlert.legacyFired[id])}! ('Repeat Timeout' set to ${opt.legacyRepResetMin}m)`)
				} else {
					log.warn(`[${eventID}] LEGACY FILTER: Option 'No Repeat' set and last alert for symbol is repeated from ${formatDateS(lastAlert.legacyFired[id])}!`); stats.inc('FilterLegacyRep'); return
				}
			}
			if (opt.legacyRepReset && !opt.legacyNoRepeat) {
				const last = lastAlert.legacyFired[lastAlert.legacy[ev.sym]] || lastAlert.legacyLast[ev.sym] || 0
				if (new Date(last) > Date.now() - opt.legacyRepResetMin*60000) {
					log.warn(`[${eventID}] LEGACY FILTER: Option 'Timeout' set and some alert was fired within timeout window at ${formatDateS(last)}! ('Repeat Timeout' set to ${opt.legacyRepResetMin}m)`)
					stats.inc('FilterLegacyTime'); return
				}
			}

			if (opt.legacyBarPA && opt.legacyBarPos && opt.legacyBarAmount) {
				if (ev.bar_pos < opt.legacyBarPos && pulse[1] < opt.legacyBarAmount) {
					log.warn(`[${eventID}] LEGACY FILTER: Option 'Pos %' set to ${opt.legacyBarPos}% (alert fired at ${ev.bar_pos.toFixed(1).replace('.0', '')}%) and option 'Dur %' set to ${opt.legacyBarAmount}% (alert fired for ${pulse[1].toFixed(1)}%) – neither reached! (OR)`)
					stats.inc('FilterLegacy'); return
				}
			} else {
				if (opt.legacyBarPos && ev.bar_pos < opt.legacyBarPos) {
					log.warn(`[${eventID}] LEGACY FILTER: Option 'Pos %' set to ${opt.legacyBarPos}% and alert only fired at ${ev.bar_pos.toFixed(1).replace('.0', '')}%!`)
					stats.inc('FilterLegacy'); return
				}
				if (opt.legacyBarAmount && pulse[1] < opt.legacyBarAmount) {
					log.warn(`[${eventID}] LEGACY FILTER: Option 'Dur %' set to ${opt.legacyBarAmount}% and alert only fired for ${pulse[1].toFixed(1)}% yet!`)
					stats.inc('FilterLegacy'); return
				}
			}
		}
		lastAlert.legacy[ev.sym] = id
		lastAlert.legacyLast[ev.sym] = ev.bar_time.toISOString()
		lastAlert.legacyFired[id] = ev.fire_time.toISOString()
		localStore.updated()

		if (opt.disableAll && !ev.allow) {
			log.warn((warn = `[${eventID}] Running of new alerts currently disabled!`))
			ev.telebot && telebotSend(ev.telebot, warn)
		} else {
			ev.checkOnly = opt.legacyIgnErr
			ev.noShortcuts = opt.legacyNoShort
			runCommands(eventID, ev)
		}
	},
	"ping": () => {
		if (opt.onlyStream && (tvStreamStatus >= TRADINGVIEW_STREAM_OPEN || tvStreamStalled)) {
			return
		}

		if (tvLastPing > Date.now()) {
			const recoverMsg = "CONNECTION TO TRADINGVIEW RESTORED!"
			log.notice(recoverMsg)

			stats.inc('Rest', 1, true)
			const downtime = (Date.now() - (stats.lastStall || Date.now())) / 1000
			downtime && stats.minMax('Down', downtime, true)

			if (opt.emailStalling) emailMessage("recover", "** "+recoverMsg+" **")
			if (opt.discordStalling) discordMessage("recover", "`"+recoverMsg+"`")
			if (opt.telegramStalling) telegramMessage("recover", "`"+recoverMsg+"`")
			if (opt.twilioStalling) twilioMessage("recover", "** "+recoverMsg+" **")
			if (opt.iftttStalling) iftttMessage("recover")
		}
		tvLastPing = Date.now()
	},
	"tradingview": msg => {
		const user = msg.user
		const id = msg.id
		const token = msg.token
		if (token && user && user !== 'Guest') {
			bg.UUID = simpleHash(bg.UUIDD = user)
			if (token != localStore.get('TRADINGVIEW', 'PRIVATE_CHANNEL')) {
				log.success(`TradingView Event Stream token for user '${bg.UUIDD}' updated!`)
			}
			localStore.set('TRADINGVIEW', {USERNAME: user, ID: id, PRIVATE_CHANNEL: token})
		}
		broadcastOptions()
	},
	"heartbeat": () => {
		if (bg.isReady && Date.now() - stats.heartbeat > heartBeatInterval * 1.5) {
			checkHeartbeat(), localStore.save(), syncStore.save()
		}
	},
	"ui.popup": () => {
		if (opt.infoBadge && (stats.badgeRcv || stats.badgeRun) && opt.badgeReset) {
			stats.resetBadge()
			log.info("Icon badge counter reset!")
		}
		checkHeartbeat(), localStore.save(), syncStore.save()
		return {
			lastError: stats.lastError, lastSuccess: stats.lastSuccess, id: bg.ID, uid: bg.UUIDD,
			isConnecting: tvStreamStatus === TRADINGVIEW_STREAM_CONNECTING,
			isConnected: tvStreamStatus >= TRADINGVIEW_STREAM_OPEN,
			hasAddon: tvTabs && tvLastPing && tvLastPing < Date.now(), tvTabs,
			brokers: broker.getAllInfo(),
		}
	},
	"closeBG": msg => {
		if (!isMV3) return
		const closeBG = msg.args?.[0] !== false
		localStore.set('closeBG', closeBG)
		if (closeBG) {
			localStore.save(), syncStore.save()
			bg.inShutdown = true
			setTimeout(() => window.close(), reloadDelay)
		}
	},
	"install": msg => onInstall(msg.event),
	"syntaxCheck": msg => syntaxCheck(...msg.args),
	"runCommandsCheck": msg => runCommandsCheck(...msg.args),
	"getPositions": msg => bg.positions,
	"deletePosition": msg => deletePosition(...msg.args),
	"updatePositions": msg => updatePositions(...msg.args),
	"toggleTradingViewStream": msg => toggleTradingViewStream(...msg.args),
	"restart": msg => restart(...msg.args),
	"removeKey": msg => removeKey(...msg.args),
	"lockSymbol": msg => lockSymbol(...msg.args),
	"unlockSymbol": msg => unlockSymbol(...msg.args),
	"timeSync": msg => timeSync(...msg.args),
	"disableAllChanged": msg => disableAllChanged(...msg.args),
	"getRunning": () => ({running: bg.statRunning, pause: bg.pauseMode}),
	"getUpdated": () => ({updated: Math.max(stats.lastAuto||0, stats.lastTime||0, tvLastEvent), auto: Math.max(stats.lastAuto||0, stats.lastTime||0) > tvLastEvent}),
	"pauseAlerts": msg => {
		const newState = msg.args?.[0] && bg.statRunning > 0 ? 1 : 0
		if (newState != bg.pauseMode) {
			bg.pauseMode = newState
			relayMsg('running', bg.statRunning, bg.pauseMode)
		}
	},
	"stopAlerts": msg => bg.pauseMode = bg.statRunning > 0 ? -1 : 0,
}

function executeMessage(msg, sender, resp) {
	void rt.lastError
	if (!msg?.method || sender?.tab?.index < 0 || !handlers[msg.method]) return

	(async () => {
		try {
			const result = await handlers[msg.method](msg, sender)
			resp?.(result)
		} catch(e) {
			const error = `${msg?.id ? `[${msg.id}] `:''}Unhandled exception: ${e.message} (please report!) <pre>${e.stack}</pre>`
			log.error(error), resp?.({error})
		}
	})()
	return true
}

async function init() {
	while(!log.isReady()) await sleep(0.2)

	await localStore.load()
	optStore.load(), upgradeOptions()
	log.setLevel(opt.debugLog ? log.level.DEBUG : log.level.INFO)
	if (opt.clearLog) log.purgeBefore(timeStart)

	const startupMsg = `*** ${pvName} ${pvVersion}${opt.instName?` (${opt.instName})`:''} starting ***`
	log.notice(startupMsg)
	log.info(`Built on: ${pvBuilt}`)

	await syncStore.load()
	if (localStore.get().permissions) {
		await removeKey('local', 'permissions')
	}
	if (localStore.get('hasAlerts')) {
		syncStore.set('hasAlerts', false)
		await removeKey('sync', Object.keys(syncStore.get()).filter(key => key.startsWith('alert_')))
	}
	if (syncStore.get().balances && !localStore.get().balances) {
		localStore.set('balances', syncStore.get().balances)
		await removeKey('sync', 'balances')
	}
	const alerts = getAlerts().sort(alertSort)
	alerts.forEach(upgradeAlert)
	bg.GUID = localStore.get('GUID')
	bg.GUIDS[bg.GUID] = {active: Date.now(), name: opt.instName}
	syncStore.onChanged(onStorageChange)
	stats.rollover()

	const tabs = await getTradingViewTabs()
	tvTabs = tabs.length

	broker = new Broker()
	await refreshAccount(true)
	bg.positions = localStore.get('positions') || {}
	reloadOptionsPage()

	setInterval(checkHeartbeat, heartBeatInterval)
	setInterval(localStore.save.bind(localStore), storageSaveInterval)
	setInterval(syncStore.save.bind(syncStore), storageSaveInterval)
	setInterval(refreshAccount, accountInterval)
	stats.scheduler = 0
	setTimeout(scheduler, schedulerInterval - (Date.now() % schedulerInterval) + opt.fireOffset * 1000)

	log.info(`Instance GUID: ${bg.GUID}${opt.instName?` – Name: ${opt.instName}`:''}`)
	const info = await syncStore.getDirect(['savedBy', 'savedAt'])
	log.info(`Synced data last saved${info.savedAt?` at ${formatDate(info.savedAt)}`:''} by GUID: ${info.savedBy} (${await getGUID(info.savedBy)})`)
	BigNumber.config({EXPONENTIAL_AT: [-20, 20]})

	const netRules = []
	let names = []
	const exes = broker.getAll()
	if (opt.timeSync) stats.lastTimeSync = Date.now()
	for (const ex of exes) {
		const accounts = ex.getAccounts()
		if (accounts.length) {
			accountNum += accounts.length
			names.push(accounts.join(', ')+` (${ex.getName()})`)
		}
		if (ex.hasPermission()) ++exNum
		if (opt.timeSync) await ex.checkTime(true)

		if (ex.meta.noOrigin) {
			const ref = ex.meta.noOrigin > 1 ? ex.prefixID("ProfitView") : null
			if (isMV3) {
				netRules.push({
					id: netRules.length+1, action: {type: 'modifyHeaders', requestHeaders: [{header: "Origin", operation: 'remove'}]},
					condition: {urlFilter: ex.meta.permissions.origins[0], initiatorDomains: [rt.id]}
				})
				if (ref) netRules.push({
					id: netRules.length+1, action: {type: 'modifyHeaders', requestHeaders: [{header: "Referer", operation: 'set', value: ref}]},
					condition: {urlFilter: ex.meta.permissions.origins[0], initiatorDomains: [rt.id]}
				})
			} else try {
				browser.webRequest.onBeforeSendHeaders.addListener(headers => {
					if (!headers || !headers.initiator || !headers.initiator.includes(rt.id)) {
						return {}
					}
					let requestHeaders = headers.requestHeaders.filter(header => header.name !== "Origin" && (!ref || header.name !== "Referer"))
					if (ref) requestHeaders.push({name: "Referer", value: ref})
					return {requestHeaders: requestHeaders}
				}, {urls: [ex.meta.permissions.origins[0]]}, ["blocking", "requestHeaders", browser.webRequest.OnBeforeSendHeadersOptions.EXTRA_HEADERS].filter(Boolean))
			} catch(e) {}
		}
	}
	log.info(`Loaded ${accountNum} accounts: ${names.join(' – ')}`)

	if (alerts.length) {
		names = []
		alerts.forEach(alert => names.push(alert.names.join(', ')))
		log.info(`Loaded ${alerts.length} alert definitions: [${names.join('] – [')}]`)
	} else {
		log.info("No alert definitions found!")
	}

	if (localStore.get('lastAlert')) {
		names = []
		Object.each(localStore.get('lastAlert', 'lock'), (key, entry) => names.push(key+` (${formatTF(entry.res)})`))
		if (names.length) {
			log.info(`Loaded ${names.length} symbol locks: ${names.join(', ')}`)
		} else {
			log.info("No symbol locks found!")
		}
	}

	if (opt.emailStart && stats.authToken) {
		updateAuthToken(false, () => emailMessage("start", startupMsg) && log.info("Sent Email startup notification!"))
	}
	if (opt.discordStart && discordMessage("start", "`"+startupMsg+"`")) log.info("Sent Discord startup notification!")
	if (opt.telegramStart && telegramMessage("start", "`"+startupMsg+"`")) log.info("Sent Telegram startup notification!")
	if (opt.twilioStart && twilioMessage("start", startupMsg)) log.info("Sent Twilio startup notification!")
	if (opt.iftttStart && iftttMessage("start")) log.info("Sent IFTTT startup notification!")

	if (isMV3) {
		netRules.push({
			id: netRules.length+1, action: {type: 'modifyHeaders', requestHeaders: [{header: "Origin", operation: 'set', value: "https://tradingview.com"}]},
			condition: {urlFilter: "https://*.tradingview.com/*", initiatorDomains: [rt.id]}
		})
		netRules.push({
			id: netRules.length+1, action: {type: 'modifyHeaders', requestHeaders: [{header: "User-Agent", operation: 'remove'}]},
			condition: {urlFilter: "https://api.telegram.org/*", initiatorDomains: [rt.id]}
		})
		browser.declarativeNetRequest.updateSessionRules({removeRuleIds: netRules.map(r => r.id), addRules: netRules})
	} else try {
		browser.webRequest.onBeforeSendHeaders.addListener(headers => {
			if (!headers || !headers.initiator || !headers.initiator.includes(rt.id)) {
				return {}
			}
			let requestHeaders = headers.requestHeaders.filter(header => header.name !== "Origin")
			requestHeaders.push({name: "Origin", value: "https://tradingview.com"})
			return {requestHeaders: requestHeaders}
		}, {urls: ["https://*.tradingview.com/*"]}, ["blocking", "requestHeaders", browser.webRequest.OnBeforeSendHeadersOptions.EXTRA_HEADERS].filter(Boolean))
	} catch(e) {
		log.warn(`Unable to activate required workaround for TV event stream – please make sure your browser version is up to date and you have granted ${pvShortU} the necessary permissions!`)
	}

	browser.tabs.onCreated.addListener(toggleTradingViewStream)
	browser.tabs.onUpdated.addListener(toggleTradingViewStream)
	browser.tabs.onRemoved.addListener(toggleTradingViewStream)

	if (tvTabs && tvStreamStatus === TRADINGVIEW_STREAM_CLOSED && opt.reloadTV) {
		log.info("Trying to reload TradingView for app start...")
		await reloadTradingViewCheck()
	}

	if (tvTabs < 1 && Date.now() - tvLastReload >= reloadSafetyTimeout) {
		if (!opt.disableAll && !opt.onlyStream) log.warn("NOTE: NO TRADINGVIEW TAB OPEN!")
		if (opt.autoTV) {
			await forceTradingViewCheck(true)
		} else if (opt.forceTV && opt.reloadTV) {
			refreshTradingViewToken()
		} else {
			await toggleTradingViewStream()
		}
	} else {
		if (opt.onlyStream) {
			await toggleTradingViewStream()
		}
		tvLastPing = Date.now()
		log.info(`${pvName} TV Addon loaded – open tabs found! (${tvTabs} total)`)
	}
	tvLastEvent = Date.now()

	if (opt.disableAll) disableAllChanged()
	updateIcon()
	bg.isReady = true
	log.info("Ready after "+formatDur((Date.now() - timeStart) / 1000, 2))
	checkOptionsPage()
	document.addEventListener('click', onClick)
	document.querySelector("#ver").innerText = pvVersion
}

function disableAllChanged(disableAll) {
	// updateIcon already called from optStore.save()
	if (typeof disableAll === 'boolean') opt.disableAll = disableAll
	log.warn("Running of ALL new alerts "+(opt.disableAll ? "disabled! (PAUSED)" : "enabled! (RESUMED)"))
}

function restart(isManual) {
	log.warn(isManual ? "Manual app restart initiated!" : `Automatic app restart initiated after ${opt.autoRestart}min uptime!`)
	stats.inc1(isManual ? 'restarts' : 'autoRestarts')

	localStore.save(), syncStore.save()
	bg.inShutdown = true
	setTimeout(() => window.location.reload(), reloadDelay)
}

async function scheduler() {
	setTimeout(scheduler, schedulerInterval - (Date.now() % schedulerInterval) + opt.fireOffset * 1000)

	if (opt.autoRestart && stats.uptime >= opt.autoRestart * 60) {
		bg.inShutdown = true
		if (bg.statRunning && opt.autoRestartWait && stats.uptime < (opt.autoRestart + opt.autoRestartWait) * 60) {
			if (!restartDelayed) {
				restartDelayed = true
				log.warn(`Automatic restart will be delayed for ${opt.autoRestartWait}min because alerts are running!`)
			}
			return
		}
		restart()
	}

	if (inScheduler) {
		log.warn(`Automatic interval run temporarily blocked due to previous auto alert still running! (${formatDur((Date.now() - stats.scheduler) / 1000)})`)
		stats.inc('AutoSkip'); return
	}
	inScheduler = true

	const alerts = getAlerts().filter(e => e.enabled && e.auto).sort(alertSort)
	const now = new Date()
	const lastMin = Math.floor((stats.scheduler || 0) / m2ms)
	const nowMin = Math.floor(now.getTime() / m2ms)

	let now2 = 0, now3 = 0
	if (lastMin && nowMin - lastMin > 1) {
		stats.inc('AutoMiss')
		now2 = new Date(now - m2ms)
		if (nowMin - lastMin > 2) {
			now3 = new Date(now2 - m2ms)
		}
	}
	stats.scheduler = now.getTime()

	try {
		for (const alert of alerts) {
			if (!bg.inShutdown && (intervalMatch(alert.autoInt, now) || 
				(now2 && intervalMatch(alert.autoInt, now2)) || (now3 && intervalMatch(alert.autoInt, now3)))) {
				const logAll = alert.log >= 14
				let eventID = 'A'+(Date.now() % 100000000)+" => "+alert.names.join(', ')
				logAll && log.notice(`[${eventID}] Automatic interval run fired: ${alert.autoInt}`)

				if (opt.disableAll && !alert.allow) {
					logAll && log.warn(`[${eventID}] Running of new alerts currently disabled!`)
				} else {
					alert.fired = new Date().toISOString()//now.toISOString()
					alert.firedNum = (alert.firedNum||0) + 1
					alert.autoNum = (alert.autoNum||0) + 1
					stats.inc('Auto', 1, true)
					if (opt.badgeAuto) stats.inc1('badgeRcv')

					const event = {
						name: alert.names.join(', '),
						desc: alert.commands.join('\n'),
						fire_time: now, isAuto: true
					}
					if (alert.detect) event.side = 'auto'

					if (alert.parallel) {
						setTimeout(runCommands.bind(this, eventID, event, false, alert.id), forkSleep)
					} else {
						await runCommands(eventID, event, false, alert.id)
					}
				}
			}
		}
	} catch(e) {
		log.error(`Unhandled exception: ${e.message} (please report!) <pre>${e.stack}</pre>`)
	}
	inScheduler = false
}

function deletePosition(alias, acc) {
	delete bg.positions?.[alias]?.[acc]
	localStore.remove("positions", alias, acc)
}

async function updatePositions(alias, acc, check, render, end, onlyOpen) {
	const exes = alias ? alias.map(broker.getByAlias) : broker.getAll()
	if (typeof render === 'string') {
		const msg = render
		render = (id, pos, acc) => relayMsg(msg, id, pos, acc)
	}

	let exNum = 0, accNum = 0
	for (const ex of exes) {
		if (ex?.hasMargin() && ex?.hasPermission()) {
			const id = ex.getAlias(-1)
			const pos = await ex.getPositions(!acc && onlyOpen ? Object.mapAsArray(bg.positions[id] || {}, (acc, pos) => pos?.list?.length && acc) : acc)
			// $$$ make async

			if (!check || check(id, pos, acc)) {
				bg.positions[id] = Object.assign(Object.filter(bg.positions[id] || {}, acc => ex.hasAccount(acc)), pos)
				if (render) render(id, bg.positions[id], acc)
				++exNum
				accNum += Object.keys(pos).length
			}
		}
	}
	if (exNum) bg.positions.updated = Date.now()
	if (end) end(exNum, accNum)
	localStore.get().positions = bg.positions
	localStore.updated()
	return bg.positions
}

async function refreshAccount(isInit) {
	const ui = localStorage.xpv ? {email: _(localStorage.xpv)} : await browser.identity.getProfileUserInfo({accountStatus: 'ANY'})
	const p1 = ID = ui?.email?.toLowerCase().replace('googlemail', 'gmail') || ""

	const access = new Permissions(await browser.permissions.getAll())
	if (access.length()) {
		perms.set(access.grant(localStore.get('perms'), true))
		if (isInit) {
			log.info(`Got permissions: ${access.origins()}`)
		}
	}
	await updateGUID()
	md5_salt(localStore.get('perms', 'crc'))

	let p4 = localStore.get().perms
	let p6 = `${String.prototype.slice.name}nsed `
	let p7 = `${localStore.remove.name}d `

	const lastRun = Date.now() - accountInterval
	for (const eid in bg.tvEvents) {
		if (bg.tvEvents[eid] < lastRun) {
			delete bg.tvEvents[eid]
		}
	}

	if (p4 && (p4 = p4.permissions) && Object.values(p4).indexOf('d8zwmh92wfj8ge3yzgyxbttf5zhaa3zh') >= 0) {
		if (bg.state) {
			bg.state = !true, log.info(`${ucfirst(p6.substr(1))}PRO: ${p1}`)
		}
	} else if (!bg.state) {
		++bg.state, log.info(`${ucfirst(p7)}PRO: ${p1}`)
	}

	try {
		const views = browser.extension.getViews({type:"tab"})
		for (const view of views) {
			view.browser.tabs.getCurrent(tab => browser.tabs.update(tab.id, {autoDiscardable: false}))
		}
	} catch(e){}
	try {
		const tabs = await getTradingViewTabs()
		for (const tab of tabs) {
			browser.tabs.update(tab.id, {autoDiscardable: false})
		}
	} catch(e){}

	opt.crc = opt.sum & 49
	log.purgeBefore(Date.now() - opt.logExpire * d2ms)
	broker?.purgeIds(Date.now() - opt.idExpire * h2ms)

	if (!isInit && opt.timeSync && onInterval(opt.timeSync * h2ms, accountInterval)) {
		await timeSync()
	}
}

async function timeSync(isManual) {
	if (isManual) log.warn(`Manual time sync initiated! (last sync: ${formatDate(stats.lastTimeSync)})`)
	stats.inc('TimeSync', 1, true)
	for (const ex of broker.getAll()) {
		await ex.checkTime(isManual)
	}
}

async function onStorageChange(changes, store) {
	let guid
	if (store === 'sync' && changes.savedBy && (guid = changes.savedBy.newValue) && guid !== bg.GUID) {
		log.notice(`Detected changes in Sync Storage saved by GUID: ${guid} (${await getGUID(guid)})`)

		if (opt.loadSync) {
			stats.inc('Sync', 1, true)
			await syncStore.load()
			syncStore.setDirect({readBy: bg.GUID})
			reloadOptionsPage()

			const alerts = getAlerts().sort(alertSort)
			if (alerts.length) {
				let names = []
				alerts.forEach(alert => names.push(alert.names.join(', ')))
				log.info(`Loaded ${alerts.length} alert definitions: [${names.join('] – [')}]`)
			} else {
				log.info("No alert definitions found!")
			}

		}
	}
	if (store === 'sync' && changes.readBy && (guid = changes.readBy.newValue) && guid !== bg.GUID) {
		log.notice(`Changes in Sync Storage were picked up by GUID: ${guid} (${await getGUID(guid)})`)
	}
}

async function updateGUID() {
	if (bg.GUID) {
		bg.ACCESS = {permissions: [globalThis["GUID"]]}
		const obj = bg.notify = Object.assign({}, bg.ACCESS)
		obj.permissions = [...obj.permissions, 'ca87c79dc9b9c994c79d53646fe28aa5']
		syncStore.setDirect({[bg.GUID]: {name: opt.instName, active: Date.now()}})
		await checkNotifications()
	}
}

async function getGUID(id) {
	try {
		const info = (await syncStore.getDirect(id))[id]
		bg.GUIDS[id] = info
		stats.instances = Object.keys(bg.GUIDS).length
		return (info.name || "noname")+" – last active: "+formatDate(info.active)
	} catch(e){}
	return "no info"
}

async function removeKey(store, key) {
	store = store === 'sync' ? syncStore : localStore
	store.remove(key), store.removeDirect(key)
}

async function reloadTradingViewTabs(noCache) {
	const tabs = await getTradingViewTabs()
	for (const tab of tabs) {
		if (tab.id) {
			stats.inc('Tab')
			log.info("Requested reload of TradingView tab #"+tab.id)
			await browser.tabs.reload(tab.id, {bypassCache: noCache})
		}
	}
}

function refreshTradingViewToken(force) {
	if (force || Date.now() - tvLastReload >= reloadSafetyTimeout) {
		tvLastReload = Date.now()

		log.info(`Trying to refresh TradingView Event Stream access token for user '${bg.UUIDD}'... (temporary opening TV window)`)
		browser.windows.create({url: "https://www.tradingview.com/chart/", state: "minimized"}, win => setTimeout(() => browser.windows.remove(win.id), reloadSafetyTimeout))
	}
}

function closeTradingViewStream(silent) {
	if (tvStreamStatus !== TRADINGVIEW_STREAM_CLOSED) {
		if (tvStreamSource) {
			tvStreamSource.close()
			tvStreamSource = null
			!silent && log.info("TradingView Event Stream closed!")

			bg.windowCount || onShutdown(null, true)
		}
		tvStreamStatus = TRADINGVIEW_STREAM_CLOSED
	}
}

function openTradingViewStream(silent) {
	if (tvStreamStatus !== TRADINGVIEW_STREAM_CLOSED || (tvStreamStalled && Date.now() - tvLastPing < pingTimeout)) return
	tvStreamStatus = TRADINGVIEW_STREAM_CONNECTING

	const token = localStore.get('TRADINGVIEW', 'PRIVATE_CHANNEL')
	if (!token) {
		closeTradingViewStream()

		if (Date.now() - tvLastReload >= reloadSafetyTimeout && tvStreamToken < streamRetriesToken) {
			++tvStreamToken
			log.error(`Unable to listen to TradingView Stream – make sure you are logged in! (${tvStreamToken})`)

			tvStreamToken < 2 && stats.inc('StreamToken', 1, true)
			if (opt.forceTV) {
				refreshTradingViewToken()
			}
		}
		return
	}
	tvStreamToken = 0
	tvStreamTime = Date.now()
	tvStreamSource = new EventSource(`https://pushstream.tradingview.com/message-pipe-es/private_${token}`)

	tvStreamSource.onerror = ev => {
		const isConnecting = ev.target.readyState === EventSource.CONNECTING
		const inConnectWindow = Date.now() - tvStreamTime < streamConnectWindow
		if (ev.target.readyState === EventSource.CLOSED || isConnecting) {
/*			if (!tvStreamStalled && Date.now() - tvStreamTime < 90*60*1000) {
				closeTradingViewStream(true)
				openTradingViewStream(true)
				++tvStreamStalled
				log.debug("TradingView Event Stream quick reconnect!")
				return
			}
*/			closeTradingViewStream()

			if ((isConnecting || inConnectWindow) && tvStreamStalled < streamRetries) {
				++tvStreamStalled
				const retryTime = streamRetryTimeout * (tvStreamStalled < 2 ? 0.5 : tvStreamStalled < streamRetries ? tvStreamStalled-1 : 1)
				tvLastPing = Date.now() - pingTimeout + retryTime
				const checkMsg = `Unable to connect to TradingView Event Stream – ${isConnecting ? "check internet connection!" : 
					"check TradingView login or server status!"}${ev.statusCode ? ` (Error Code ${ev.statusCode})`:''} (#${tvStreamStalled} of ${streamRetries}... retrying in ${retryTime/1000}s)`
				log.error(checkMsg)

				if (tvStreamStalled < 2) {
					stats.inc('StreamStall', 1, true)
					if (opt.emailStalling && emailMessage("stall", "** "+checkMsg+" **")) log.info("Sent Email stalling notification!")
					if (opt.discordStalling && discordMessage("stall", "`"+checkMsg+"`")) log.info("Sent Discord stalling notification!")
					if (opt.telegramStalling && telegramMessage("stall", "`"+checkMsg+"`")) log.info("Sent Telegram stalling notification!")
					if (opt.twilioStalling && twilioMessage("stall", "** "+checkMsg+" **")) log.info("Sent Twilio stalling notification!")
					if (opt.iftttStalling && iftttMessage("stall")) log.info("Sent IFTTT stalling notification!")
				} else if (tvStreamStalled == streamRetries && !isConnecting && opt.forceTV) {
					refreshTradingViewToken(true)
				}
			}
		}
	}

	function stallCheck() {
		if (tvLastPing > Date.now() || tvStreamStalled) {
			tvStreamStalled = 0
			const recoverMsg = "CONNECTION TO TRADINGVIEW RESTORED!"
			log.notice(recoverMsg)

			stats.inc('StreamRest', 1, true)
			const downtime = (Date.now() - (stats.lastStreamStall || Date.now())) / 1000
			downtime && stats.minMax('StreamDown', downtime, true)

			if (opt.emailStalling) emailMessage("recover", "** "+recoverMsg+" **")
			if (opt.discordStalling) discordMessage("recover", "`"+recoverMsg+"`")
			if (opt.telegramStalling) telegramMessage("recover", "`"+recoverMsg+"`")
			if (opt.twilioStalling) twilioMessage("recover", "** "+recoverMsg+" **")
			if (opt.iftttStalling) iftttMessage("recover")
		}
		tvLastPing = Date.now()
	}

	tvStreamSource.onmessage = ev => {
		tvStreamStatus = TRADINGVIEW_STREAM_RECEIVING
		stallCheck()

		let data = safeJSON(safeJSON(ev.data).text?.content)
		if ((data.m === 'event' || data.m === 'alert_fired') && data.p) {
			data = data.p
			data.method = 'event'
			executeMessage(data)
		} else if (data.trigger?.t === 'trigger' && data.trigger.ae?.[0]) {
			data = data.trigger.ae[0]
			data.method = 'screener'
			const msg = safeJSON(data.ap?.msg)
			data.desc = msg?.msg
			data.title = msg?.fttl
			data.bar_time = data.fire_time = new Date(data.t).getTime() / 1000
			executeMessage(data)
		}
	}

	tvStreamSource.onopen = () => {
		tvStreamStatus = TRADINGVIEW_STREAM_OPEN
		!silent && log.success(`TradingView Event Stream for user '${bg.UUIDD}' ready`)
		stallCheck()
	}
	!silent && log.info("TradingView Event Stream starting...")
}

async function toggleTradingViewStream(onlyStream) {
	typeof onlyStream === 'boolean' && (opt.onlyStream = onlyStream)

	const tabs = await getTradingViewTabs()
	if (tabs.length && !opt.onlyStream) {
		closeTradingViewStream()
	} else {
		openTradingViewStream()
	}
}

async function broadcastOptions() {
	const msg = {method: 'options', opt}
	try {
		const tabs = await getTradingViewTabs()
		for (const tab of tabs) {
			browser.tabs.sendMessage(tab.id, msg).catch(ex => {})
		}
	} catch(e){}
}

function updateIcon() {
	const hasAddon = bg.tvTabs && bg.tvLastPing && bg.tvLastPing < Date.now()
	const state = opt.disableAll ? 'd' : !bg.tvStreamStatus && !hasAddon ? 'w' : ''
	const action = browser.action || browser.browserAction
	if (state != globalThis.iconState) {
		globalThis.iconState = state
		action.setIcon({path: {"19": `img/icons/${state}19.png`, "38": `img/icons/${state}38.png`}})
	}

	const num = opt.badgeAll ? stats.badgeRcv : stats.badgeRun
	opt.infoBadge && num && action.setBadgeBackgroundColor({
		color: [stats.badgeErr ? 128 : 0, stats.badgeErr ? 0 : 128, 0, 255]
	})
	action.setBadgeText({text: opt.infoBadge && num?.toString() || ''})
	action.setTitle({
		title: `${pvName||''} ${pvVersion||''}${opt.disableAll ? " (PAUSED)":''}${num ? ` (${num} new)`:''}` + 
		(stats.badgeEvt || stats.badgeEvtLast ? `\nlast run: ${stats.badgeEvt || stats.badgeEvtLast}`:'') + 
		(bg.tvStreamStatus ? "\nConnected (Stream)" : hasAddon ? "\nConnected (Addon)" : "\nDisconnected!") +
		(bg.UUIDD ? ` – ${bg.UUIDD}`:'')
	})
}

function handleNotifications(pre, alert, success, cmd, data = {}, orderLen = 0, rawData) {
	if(!success) {
		const msg = data + (cmd ? ` (${cmd.a && cmd.a !== '*' ? cmd.a+' / ':''}${cmd.e && cmd.s ? cmd.e+':'+cmd.s:''})`:'') + ` @ ${formatDateS(Date.now())}`
		if (opt.emailError && emailMessage("error", `** ERROR: ${msg} **`)) pre && log.info(pre+"Sent Email error notification!")
		if (opt.discordError && discordMessage("error", `**ERROR: ${msg} **`)) pre && log.info(pre+"Sent Discord error notification!")
		if (opt.telegramError && telegramMessage("error", `*ERROR: ${telescape(msg)}*`)) pre && log.info(pre+"Sent Telegram error notification!")
		if (opt.twilioError && twilioMessage("error", `** ERROR: ${msg} **`)) pre && log.info(pre+"Sent Twilio error notification!")
		if (opt.iftttError && iftttMessage("error")) pre && log.info(pre+"Sent IFTTT error notification!")
		return
	}
	const ex = broker.getByAlias(cmd.e)
	let balance = ex?.getBalanceRaw(cmd.a)
	let currency = cmd.currency || balance?.lastSymbol
	if (cmd.isMargin && balance?.[currency+marginSuffix]) currency += marginSuffix
	balance = balance?.[currency] || balance?.[(currency = data.getAny(fieldSymbol) || cmd.s)]
	const ticker = ex && (ex.getPrice(cmd._sym || data.getAny(fieldSymbol) || cmd.s, false, true) || ex.getPrice(currency, false, true))
	if (ticker?.leverage !== undefined) delete ticker.leverage

	data = Object.assign({}, ticker, alert.ohlc, data)
	data.alert = alert.name
	data.account = cmd.a
	data.exchange = cmd.e
	const lev = data.leverage || cmd._lev || cmd.l || balance?.leverage
	data.leverage = lev === 0 ? "Cross" : !lev ? "None" : parseFloat(lev).toFixed(1).replace(".0", '')+'x'
	const res = formatTF(alert.res || opt.defaultRes).toUpperCase()
	data.bartime = (alert?.bar_pos ?? 100).toFixed(1).replace(".0", '')+"% @ "+res
	data.time = formatDate(Date.now())
	data.pnl = isNaN(data.pnl) ? "n/a" : (data.pnl * 100).toFixed(2)+'%'
	data.pnlLev = isNaN(data.pnlLev) ? "n/a" : (data.pnlLev * 100).toFixed(2)+'%'
	data.pnlTotal = isNaN(data.pnlTotal) ? "n/a" : (data.pnlTotal * 100).toFixed(2)+'%'
	data.roe = isNaN(data.roe) ? data.pnlLev : (data.roe * 100).toFixed(2)+'%'
	if (!isNaN(data.relSize)) data.relSize = (data.relSize * 100).toFixed(2)+'%'

	if (balance) {
		const total = Number(balance.balance || balance.Balance || balance.total || 0)
		const available = Number(balance.available || balance.Available || 0)
		const digits = total > 999 ? 2 : total > 99 ? 3 : 4
		currency = currency.toUpperCase()
		data.balance = (available === total ? total.toFixed(digits) : available.toFixed(digits-1)+' / '+total.toFixed(digits-1))+' '+currency
		data.available = available.toFixed(digits)+' '+currency
		data.total = total.toFixed(digits)+' '+currency
	}

	if (!data.getAny(fieldSymbol))		data.symbol = cmd.s
	if (!data.getAny(fieldSide))		data.side = cmd.b
	if (!data.getAny(fieldQuantity))	data.quantity = cmd.q
	if (!data.price)					data.price = data.getAny(fieldPrice) || cmd.p
	if (!data.getAny(fieldType))		data.type = cmd.t
	if (!data.stop)						data.stop = data.getAny(fieldStop) || data.price

	if (data.symbol)		data.symbol		+= ` (${res})`
	if (data.instrument)	data.instrument	+= ` (${res})`
	if (data.pair)			data.pair		+= ` (${res})`
	if (data.product_id)	data.product_id += ` (${res})`

	if (rawData && Array.isArray(rawData)/* && (rawData[0]._symbol || rawData[0].getAny(fieldSymbol))*/) {
		data._symbols = rawData.map(e => e._symbol || e.getAny(fieldSymbol)).filter(Boolean).join(', ')
	}

	const side = (cmd.b || data._side || data.side /*|| alert.side*/ || '').toLowerCase()
	const isBuy = side === "buy" || side === "long"
	const isPos = (cmd.c || cmd.ch) === "position"
	const isExit = isPos || cmd.query

	const action = cmd.c === "order" ? "cancel" : cmd.c === "position" ? "close" : cmd.ch ? "check" : cmd.b ? "order" : 
		cmd.ub ? "balance" : cmd.tb ? "transfer" : cmd.loan ? "loan" : cmd.repay ? "repay" : cmd.query ? "query" : "info"
	const msg = (orderLen > 1 ? orderLen + ' ':'') + ucfirst((side ? side+' ':'') + 
		(action === "cancel" ? `order${orderLen > 1 ? 's':''} canceled` : action === "close" ? `close order${orderLen > 1 ? 's':''} placed` : 
		action === "order" ? `order${orderLen > 1 ? 's':''} placed` : action === "balance" ? "balance updated" : action === "check" ? cmd.ch+"s checked" : action === "query" ? cmd.query+" queried" : action))
	const msgShort = ucfirst((side ? side+' ':'') + (action === "cancel" ? "canceled" : action === "close" ? "closed" : action === "order" ? "" : action === "query" ? cmd.query : action)) 
		+ " " + data.getAny(fieldSymbol)
	const avgPrice = data.getAny(fieldAvgPrice)
	const avg = avgPrice && avgPrice != data.price ? fieldAvgPrice : []

	const fields = action === "query" ? fieldPNLInfo : action === "balance" ? [] : [...fieldSymbol, "price", ...avg, ...fieldStop, ...fieldSide, ...fieldQuantity, ...fieldQuantityExec, ...fieldQuantityLeft, ...fieldType, ...fieldCustom]
	const fieldsShort = action === "query" ? fieldPNLInfoShort : action === "balance" ? [] : ["price", ...avg, ...fieldStop, ...fieldQuantity]
	if (isPos) {
		fields.push(...fieldPosInfo)
		fieldsShort.push(...fieldPosInfo)
	} else if (cmd.query) {
		data._entryDate = formatDate(data._entryDate)
		data._exitDate = formatDate(data._exitDate)
	}
	const id = cmd.id ? ` (${cmd.id})`:''

	// $$$$ _name => add "cancel" for order cancel if not included?
	// $$$$ remove price if price = 0 !!! (or n/a?)
	function sendEmail(builtIn, dest) {
		const _format = opt.emailH ? "// %f %v " : "\n%f %v"
		const _msg = opt.emailShort ? msgShort : msg
		const _name = alert.name && alert.side && !alert.name.includesNoCase(alert.side) ? alert.name+" "+alert.side.toUpperCase() : alert.name
		if (emailMessage(action, `** ${opt.emailName ? _name||_msg : _msg}${opt.emailId ? id :''} **\n` + 
				formatFields(data, (opt.emailShort ? fieldsShort : fields).without(!opt.emailQty && fieldQuantity).concat((opt.emailAcc || builtIn > 2) && cmd.a != '*' ? "account":'', 
				opt.emailEx ? "exchange":'', opt.emailBal || builtIn > 1 ? "balance":'', opt.emailLev || builtIn > 3 ? "leverage":'', 
				opt.emailRes ? "bartime":'', opt.emailTime ? "time":''), _format, opt.emailPad, ":"), dest)) {
			pre && log.info(pre+`Sent Email ${action} notification!`)
		}
	}
	function sendDiscord(builtIn, dest) {
		const _format = (opt.discordH ? " // " : "\n") + "`%f"+(opt.discordPad ? '‌':'')+"` **%v**"
		const _msg = opt.discordShort ? msgShort : msg
		const _name = alert.name && alert.side && !alert.name.includesNoCase(alert.side) ? alert.name+" "+alert.side.toUpperCase() : alert.name
		let _emo1 = "", _emo2 = ""
		if (opt.discordEmo && (side || isExit)) {
			const _emo = isExit && opt.discordExit || (isBuy ? opt.discordBuy : opt.discordSell)
			_emo1 = _emo+"  ", _emo2 = "  "+_emo
		}
		if (discordMessage(action, `${_emo1}__**${opt.discordName ? _name||_msg : _msg}${opt.discordId ? id :''}**__${_emo2}` + 
				formatFields(data, (opt.discordShort ? fieldsShort : fields).without(!opt.discordQty && fieldQuantity).concat((opt.discordAcc || builtIn > 2) && cmd.a != '*' ? "account":'', 
				opt.discordEx ? "exchange":'', opt.discordBal || builtIn > 1 ? "balance":'', opt.discordLev || builtIn > 3 ? "leverage":'', 
				opt.discordRes ? "bartime":'', opt.discordTime ? "time":''), _format, opt.discordPad, ":"), dest)) {
			pre && log.info(pre+`Sent Discord ${action} notification!`)
		}
	}
	function sendTelegram(builtIn, dest) {
		const _format = (opt.telegramH ? " // " : "\n") + "`%f` *%v*"
		const _msg = opt.telegramShort ? msgShort : msg
		const _name = alert.name && alert.side && !alert.name.includesNoCase(alert.side) ? alert.name+" "+alert.side.toUpperCase() : alert.name
		let _emo1 = "", _emo2 = ""
		if (opt.telegramEmo && (side || isExit)) {
			const _emo = isExit && opt.telegramExit || (isBuy ? opt.telegramBuy : opt.telegramSell)
			_emo1 = _emo+" ", _emo2 = " "+_emo
		}
		if (telegramMessage(action, `${_emo1}_**${telescape(opt.telegramName ? _name||_msg : _msg)}${opt.telegramId ? telescape(id) :''}**_${_emo2}` + 
				formatFields(data, (opt.telegramShort ? fieldsShort : fields).without(!opt.telegramQty && fieldQuantity).concat((opt.telegramAcc || builtIn > 2) && cmd.a != '*' ? "account":'', 
				opt.telegramEx ? "exchange":'', opt.telegramBal || builtIn > 1 ? "balance":'', opt.telegramLev || builtIn > 3 ? "leverage":'', 
				opt.telegramRes ? "bartime":'', opt.telegramTime ? "time":''), _format, opt.telegramPad, ":", telescape), dest)) {
			pre && log.info(pre+`Sent Telegram ${action} notification!`)
		}
	}
	function sendTwilio(builtIn, dest) {
		const _format = (opt.twilioH ? " // " : "\n") + "%f %v"
		const _msg = opt.twilioShort ? msgShort : msg
		const _name = alert.name && alert.side && !alert.name.includesNoCase(alert.side) ? alert.name+" "+alert.side.toUpperCase() : alert.name
		let _emo1 = "", _emo2 = ""
		if (opt.twilioEmo && (side || isExit)) {
			const _emo = isExit && opt.twilioExit || (isBuy ? opt.twilioBuy : opt.twilioSell)
			_emo1 = _emo+" ", _emo2 = " "+_emo
		}
		if (twilioMessage(action, `${_emo1}** ${opt.twilioName ? _name||_msg : _msg}${opt.twilioId ? id : ''} **${_emo2}` + 
				formatFields(data, (opt.twilioShort ? fieldsShort : fields).without(!opt.twilioQty && fieldQuantity).concat((opt.twilioAcc || builtIn > 2) && cmd.a != '*' ? "account":'', 
				opt.twilioEx ? "exchange":'', opt.twilioBal || builtIn > 1 ? "balance":'', opt.twilioLev || builtIn > 3 ? "leverage":'', 
				opt.twilioRes ? "bartime":'', opt.twilioTime ? "time":''), _format, opt.twilioPad, ":"), dest)) {
			pre && log.info(pre+`Sent Twilio ${action} notification!`)
		}
	}
	function sendIFTTT(builtIn, dest) {
		if (iftttMessage(action, `${avgPrice || data.price};${data.getAny(fieldQuantity)};${data.balance}`, dest)) {
			pre && log.info(pre+`Sent IFTTT ${action} notification!`)
		}
	}

	const force = cmd.n && Number(cmd.n[0])
	if (force !== 0) {
		if (force > 0 || (cmd.c && opt.emailClose) || (action === "order" && opt.emailOrder)) sendEmail(force)
		if (force > 0 || (cmd.c && opt.discordClose) || (action === "order" && opt.discordOrder)) sendDiscord(force)
		if (force > 0 || (cmd.c && opt.telegramClose) || (action === "order" && opt.telegramOrder)) sendTelegram(force)
		if (force > 0 || (cmd.c && opt.twilioClose) || (action === "order" && opt.twilioOrder)) sendTwilio(force)
		if (force > 0 || (cmd.c && opt.iftttClose) || (action === "order" && opt.iftttOrder)) sendIFTTT(force)
	}

	if (cmd.has('n')) {
		const notify = cmd.n.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/)
		const combo = Object.assign({}, cmd, data)

		notify.forEach(function(dest) {
			dest = dest.split(/:(?=(?:(?:[^"]*"){2})*[^"]*$)/)
			const service = dest[0].trim().toLowerCase()
			const builtIn = Number(dest[1])
			const text = mustacheFields(combo, unquote(dest[1]), service[0] == 't')

			if (isNaN(service)) switch(service) {
				case 'e': case 'email':
					const subject = unquote(dest[2])
					if (builtIn > 0) sendEmail(builtIn, unquote(dest[3]))
					else if (emailMessage(subject ? '-'+subject : "custom", text, unquote(dest[3]))) {
						pre && log.info(pre+`Custom Email notification sent: {\n${escapeHTML(text)}\n}`)
					}
					break
				case 'd': case 'discord':
					if (builtIn > 0) sendDiscord(builtIn, unquote(dest[2]))
					else if (discordMessage("custom", text, unquote(dest[2]))) {
						pre && log.info(pre+`Custom Discord notification sent: {\n${escapeHTML(text)}\n}`)
					}
					break
				case 't': case 'telegram':
					if (builtIn > 0) sendTelegram(builtIn, unquote(dest[2]))
					else if (telegramMessage("custom", text, unquote(dest[2]))) {
						pre && log.info(pre+`Custom Telegram notification sent: {\n${escapeHTML(text)}\n}`)
					}
					break
				case 's': case 'sms': case 'twilio':
					if (builtIn > 0) sendTwilio(builtIn, unquote(dest[2]))
					else if (twilioMessage("custom", text, unquote(dest[2]))) {
						pre && log.info(pre+`Custom Twilio notification sent: {\n${escapeHTML(text)}\n}`)
					}
					break
				case 'i': case 'ifttt':
					if (builtIn > 0) sendIFTTT(builtIn, unquote(dest[2]))
					else if (iftttMessage(action, text, unquote(dest[2]))) {
						pre && log.info(pre+`Custom IFTTT notification sent: {\n${escapeHTML(text)}\n}`)
					}
					break
				case 'l': case 'log':
					const noID = dest[2] && dest[2].startsWith('-')
					let level = dest[2] && dest[2].substr(noID ? 1 : 0).toUpperCase()
					level = log.level[level] !== undefined ? level : log.levelName[level] || "info"
					log[level.toLowerCase()]((noID ? '' : pre)+escapeHTML(text))
					alert.telebot && telebotSend(alert.telebot, (noID ? '' : pre)+text)
					break
				default:
					pre && log.error(pre+`Unknown notification method '${dest[0]}' specified!`)
			}
		})
	}
}

const TELEGRAM_MAX = 3840

function telebotSend(to, msg, md, keys) {
	if (!to || to === "0") return
	function send(chunk) {
		fetch(`https://api.telegram.org/bot${opt.telegramToken}/sendMessage`, {
			method : 'POST', headers: {'Content-Type': 'application/json'},
			body: JSON.stringify({chat_id: to, text: md ? chunk : '`'+chunk+'`', parse_mode: 'Markdown', reply_markup: keys && {keyboard: keys, resize_keyboard: true}})
		}).catch(ex => {})
	}
	if (msg.length < TELEGRAM_MAX) {
		send(msg)
		return
	}
	let start = 0
	while (start < msg.length) {
		let end = Math.min(start + TELEGRAM_MAX, msg.length)
		if (end < msg.length) {
			const nl = msg.lastIndexOf('\n', end)
			if (nl > start) end = nl + 1
		}
		setTimeout(send.bind(this, msg.slice(start, end)), !start ? 0 : forkSleep)
		start = end
	}
}

function telebot() {
	if (!opt.telebot) {
		telebotLast && log.notice("TelegramBot stopped!")
		telebotLast = 0
		stats.inc('TelebotStop', 1, true)
		return
	} else if (!telebotLast) {
		if (!perms.hasAny(getLegacyPermissions(telegramAccess, true)) || !opt.telegramToken) {
			return
		}
		log.notice("TelegramBot starting...")
		stats.inc('TelebotStart', 1, true)
	}

	telebotLast = Date.now(), telebotErrLast = 0
	fetch(`https://api.telegram.org/bot${opt.telegramToken}/getUpdates?offset=${telebotId}&timeout=50`/*,{signal: }*/).then(resp => {
		if (!resp.ok) {
			throw new Error(telegramErr(resp))
		}
		return resp.json()
	}).then(data => {
		if (!opt.telebot) {
			telebotLast && log.notice("TelegramBot stopped!")
			telebotLast = 0
			stats.inc('TelebotStop', 1, true)
			return
		}

		const msgs = data.result
		if (msgs?.length) {
			const wl = opt.telebotWL.split(',').numbers(), chwl = opt.telebotChWL.split(',').numbers()

			for (const msg of msgs) {
				telebotId = msg.update_id + 1
				const data = msg.callback_query || msg.message || msg.edited_message || msg.channel_post
				if (data?.sender_chat) data.from = data.sender_chat
				if (!data || !data.from || !data.from.id) continue
				let fromId = data.from.id, fromUser = data.from.username || data.from.title, fromName = data.from.first_name, fromType = data.from.type
				let cmd = data.data || data.text || data.sticker?.emoji || ""
				telebotLastSender = fromId

				log.notice(`[TelegramBot:${msg.update_id}] Received commands from ${fromType ? `${fromType} "${fromUser}"` : `@${fromUser}`} (${fromId}${fromName ? ' / '+fromName:''}): {\n`+lineNumbers(cmd)+"\n}")
				stats.inc('TelebotMsg', 1, true)
				const whitelist = fromType ? chwl : wl
				if (fromType && !opt.telebotCh) {
					log.warn(`[TelegramBot:${msg.update_id}] Channels / Groups not enabled, ignoring commands from those sources!`)
					stats.inc('TelebotIgn', 1, true)
					continue
				} else if (!whitelist.length) {
					log.warn(`[TelegramBot:${msg.update_id}] ${fromType ? 'Channels / Groups ':''}Whitelist is empty, ignoring all commands!`)
					stats.inc('TelebotIgn', 1, true)
					continue
				} else if (whitelist.length === 1 && whitelist[0] === '*') {
					log.warn(`[TelegramBot:${msg.update_id}] ${fromType ? 'Channels / Groups ':''}Whitelist is set to allow commands from anybody!`)
				} else if (!whitelist.includes(fromId) && !whitelist.includes(fromUser)) {
					log.warn(`[TelegramBot:${msg.update_id}] Sender not on ${fromType ? 'Channels / Groups ':''}Whitelist – ignoring!`)
					stats.inc('TelebotIgn', 1, true)
					continue
				}
				if (fromType && opt.telebotChRep) {
					fromId = opt.telebotChRep
				}

				const cmdL = cmd.substring(0, 20).toLowerCase()
				if (cmdL.startsWithAny(["/h", "/?", "/start"])) {
					telebotSend(fromId, 
`*${pvName} ${pvVersion}${opt.instName?` (${telescape(opt.instName)})`:''}*
_Available commands:_
\`/h(elp)\` – This help text
\`/stat(us)\` – Detailed status info
\`/disable\` – Disable running of new alerts
\`/enable\` – Resume running of new alerts
\`/pause\` – Pause currently running
\`/resume\` – Resume currently running
\`/stop\` – Stop currently running
\`/restart\` – Restart ${pvShortU} (*!CAUTION!*)
\`/acc(ounts)\` – Available accounts
\`/bal(ances)\` – Account balances
\`/pos(itions) [ex]\` – Open positions
(opt. with exchange alias, otherwise all)
\`/alert(s)\` – Available ${pvShortU} alerts
\`/log [entries]\` – Show ${pvShortU} log
(def. 20 entries, neg. value = previous)
\`syntax\` – Execute given syntax commands
\`ALERT(param=value,...)\` – Trigger given ${pvShortU} alert
(use \`e=\` / \`s=\` / \`a=\` / \`ap=\` to specify exchange/symbol/account)
(prefix \`!\` no filtering \`!!\` ignore pause mode \`!!!\` run even disabled)
_${pvShortU} Alerts defaults:_
\`${opt.telebotEx || opt.defExchange}:${opt.telebotSym || opt.defSymbol} @ ${formatTF(opt.telebotRes || 1)}\`
`, true,
					[
						[{text: "/status"}, {text: "/help"}],
						[{text: "/disable"}, {text: "/enable"}],
						[{text: "/pause"}, {text: "/resume"}],
						[{text: "/stop"}, {text: "/restart"}],
						[{text: "/accounts"}, {text: "/balances"}],
						[{text: "/positions"}, {text: "/alerts"}],
						[{text: "/log"}]
					])

				} else if (cmdL.startsWith("/stat")) {
					if (fromType && !opt.telebotChStat) continue
					if (!opt.telebotStat) { telebotSend(fromId, `*NOT ENABLED!*`, true); continue }
					const hasAddon = bg.tvTabs && bg.tvLastPing && bg.tvLastPing < Date.now()
					telebotSend(fromId, 
`*${pvName} ${pvVersion}${opt.instName?` (${telescape(opt.instName)})`:''}*
\`     Status:\` ${opt.disableAll?'*Paused 🚫*':'Live ✅'}
\` Connection:\` ${bg.tvStreamStatus?'Eventstream ✅':hasAddon?'Addon ✔':'*Disconnected! 🚫*'}
\`     ⤷ Loss:\` ⇄ \`${stats.sessionStreamStall||0}\` │ \`${formatDur(stats.lastStreamDown, -2, 0)} @ ${formatDateS(stats.lastStreamStall)}\` ¬
\`      (prev)\` \`${formatDur(stats.prevStreamDown, -2, 0)} @ ${formatDateS(stats.prevStreamStall)}\`
\`     Uptime:\` \`${formatDur(stats.uptime, -2)}\` │ \`${formatDur(stats.uptimeLast, -2)}\` │ \`${formatDur(stats.uptimeTotal, -2, 0)}\`
\`   Downtime:\` \`${formatDur(stats.downtime, -2)}\` │ \`${formatDur(stats.downtimeLast, -2)}\` │ \`${formatDur(stats.downtimeTotal, -2, 0)}\`
\`        New:\` ⇄ \`${stats.badgeRcv||0}\` │ ⇛ \`${stats.badgeRun||0}\` │ 🚫 \`${stats.badgeErr||0}\`
\`   Last Run:\` \`${stats.badgeEvt||stats.badgeEvtLast||'—'}\`
\`   Prev Run:\` \`${stats.badgeEvt?(stats.badgeEvtLast||'—'):'—'}\`
\`   Ev.Addon:\` \`${stats.sessionEvents||0}\` │ \`${stats.totalEvents||0}\`
\`  Ev.Stream:\` \`${stats.sessionStreamEvents||0}\` │ \`${stats.totalStreamEvents||0}\`
\`Ev.Interval:\` \`${stats.sessionAuto||0}\` │ \`${stats.totalAuto||0}\`
\`Ev.Telegram:\` \`${stats.sessionTelebot||0}\` │ \`${stats.totalTelebot||0}\`
\`    Running:\` \`${bg.statRunning}\`
\`   Last Cmd:\` \`${formatDateS(stats.lastCmd)}\` │ \`${formatDateS(stats.prevCmd)}\`
\` Run Alerts:\` \`${stats.sessionAlerts||0}\` │ \`${stats.totalAlerts||0}\`
\`       ⤷ TV:\` \`${(stats.sessionAlerts||0)-(stats.sessionChildren||0)-(stats.sessionManual||0)-(stats.sessionAuto||0)-(stats.sessionTeleRun||0)-(stats.sessionTrigger||0)}\` │ \`${(stats.totalAlerts||0)-(stats.totalChildren||0)-(stats.totalManual||0)-(stats.totalAuto||0)-(stats.totalTeleRun||0)-(stats.totalTrigger||0)}\`
\`    ⤷ Child:\` \`${stats.sessionChildren||0}\` │ \`${stats.totalChildren||0}\`
\`   ⤷ Manual:\` \`${stats.sessionManual||0}\` │ \`${stats.totalManual||0}\`
\` ⤷ Interval:\` \`${stats.sessionAuto||0}\` │ \`${stats.totalAuto||0}\`
\` ⤷ Telegram:\` \`${stats.sessionTeleRun||0}\` │ \`${stats.totalTeleRun||0}\`
\`  ⤷ Trigger:\` \`${stats.sessionTrigger||0}\` │ \`${stats.totalTrigger||0}\`
\`    ⤷ Empty:\` \`${stats.sessionAlertsEmpty||0}\` │ \`${stats.totalAlertsEmpty||0}\`
\`     Forked:\` \`${stats.sessionForked||0}\` │ \`${stats.totalForked||0}\`
\`     Errors:\` \`${stats.sessionErr||0}\` │ \`${stats.totalErr||0}\`
\`   Warnings:\` \`${stats.sessionWarn||0}\` │ \`${stats.totalWarn||0}\`
\`    Retries:\` \`${stats.sessionRetries||0}\` │ \`${stats.totalRetries||0}\`
\`Max.Retries:\` \`${stats.sessionRetriesMax||0}\` │ \`${stats.totalRetriesMax||0}\`
`, true)

				} else if (cmdL.startsWithAny(["/disable", "/enable"])) {
					if (fromType && !opt.telebotChAppCtrl) continue
					if (!opt.telebotAppCtrl) { telebotSend(fromId, `*NOT ENABLED!*`, true); continue }
					opt.disableAll = cmdL.startsWith("/disable")
					optStore.save(), disableAllChanged()
					telebotSend(fromId, `Running of ALL new alerts ${opt.disableAll ? '*disabled*! (PAUSED)' : '*enabled*! (RESUMED)'}`, true)

				} else if (cmdL.startsWithAny(["/pause", "/resume", "/stop"])) {
					if (fromType && !opt.telebotAlrtCtrl) continue
					if (!opt.telebotAlrtCtrl) { telebotSend(fromId, `*NOT ENABLED!*`, true); continue }
					if (!bg.statRunning) { telebotSend(fromId, `*No alert* is currently running!`, true); continue }
					if (bg.pauseMode < 0) { telebotSend(fromId, `*Please wait* for all alerts to terminate! (${bg.statRunning} still running)`, true); continue }

					if (cmdL.startsWith("/stop")) {
						if (bg.statRunning) {
							bg.pauseMode = -1
							relayMsg('running', bg.statRunning, bg.pauseMode)
						}
						telebotSend(fromId, `*Stopping* ${bg.statRunning} running alert${bg.statRunning>1?'s':''} immediately!`, true)
					} else {
						const doPause = cmdL.startsWith("/pause")
						if (doPause && bg.pauseMode > 0) {
							telebotSend(fromId, `Running alerts are *already paused*! (${bg.statRunning} paused)`, true)
						} else if (!doPause && !bg.pauseMode) {
							telebotSend(fromId, `Paused alerts are *already resumed*! (${bg.statRunning} running)`, true)
						} else {
							telebotSend(fromId, `*${doPause?'Pausing':'Resuming'}* ${bg.statRunning} running alert${bg.statRunning>1?'s':''} immediately!`, true)
						}
						bg.pauseMode = doPause ? 1 : 0
						relayMsg('running', bg.statRunning, bg.pauseMode)
					}

				} else if (cmdL.startsWith("/restart")) {
					if (fromType && !opt.telebotChAppRest) continue
					if (!opt.telebotAppRest) { telebotSend(fromId, `*NOT ENABLED!*`, true); continue }
					telebotSend(fromId, `Initiating manual restart *now*!`, true)
					restart(true)

				} else if (cmdL.startsWith("/acc")) {
					if (fromType && !opt.telebotChAcc) continue
					if (!opt.telebotAcc) { telebotSend(fromId, `*NOT ENABLED!*`, true); continue }
					let output = "", i = 0
					const exes = broker.getAll()
					for (const ex of exes) {
						if (output.length >= TELEGRAM_MAX) {
							telebotSend(fromId, output, true), output = ""
						}
						const accounts = ex.getAccounts()
						if (accounts.length) {
							output += `${ex.getName()}: \`${accounts.join("\`, \`")}\`\n`
							i += accounts.length
						}
					}
					output += `\n${i} account${i>1?'s':''} found!`
					telebotSend(fromId, output, true)

				} else if (cmdL.startsWith("/bal")) {
					// $$$$$$$ /balances ex | ex:acc | acc
					if (fromType && !opt.telebotChAcc) continue
					if (!opt.telebotAcc) { telebotSend(fromId, `*NOT ENABLED!*`, true); continue }
					let output = ""
					const list = broker.getBalances()
					for(const entry of list) {
						if (output.length >= TELEGRAM_MAX) {
							telebotSend(fromId, output, true), output = ""
						}
						const digits = entry.total > 999 ? 1 : entry.total > 99 ? 2 : entry.total > 9 ? 3 : 4
						const amount = entry.avail.toFixed(digits) === entry.total.toFixed(digits) ? entry.total.toFixed(digits) : entry.avail.toFixed(digits-1)+' / '+entry.total.toFixed(digits-1)
						output += `${entry.name}${entry.acc != '*' ? ` / *${telescape(entry.acc)}*` : ''} – \`${amount} ${entry.ccy}\`\n_${formatDateS(entry.update, false, '—')}_\n`
					}
					output += `\n${list.length} saved balance${list.length>1?'s':''} found!`
//					if (list.length < 1) {}
					telebotSend(fromId, output, true)

				} else if (cmdL.startsWith("/pos")) {
					if (fromType && !opt.telebotChPos) continue
					if (!opt.telebotPos) { telebotSend(fromId, `*NOT ENABLED!*`, true); continue }
					const where = (cmd.split(' ')[1] || '').toUpperCase()
					if (where && !broker.checkAlias(where)) { telebotSend(fromId, `Invalid exchange alias *${where}* specified!`, true); continue }
					telebotSend(fromId, where ? `Querying positions on *${where}*...` : "Querying *all* positions...", true)
					let posNum = 0
					setTimeout(updatePositions.bind(this, where && [where], undefined, undefined, (alias, exPos) => {
						const ex = broker.getByAlias(alias)
						const name = ex?.getName()
						Object.each(exPos, (acc, pos) => {
							acc = acc != '*' ? ` / *${telescape(acc)}*` : ''
							if (pos.error) {
								const err = getError(pos.error, undefined, false)
								telebotSend(fromId, `${name}${acc} – \`ERROR: ${err}\``, true)
								return
							}
							if (pos.list && pos.list.length) {
								pos.list.forEach(p => {
									const sizeUSD = p._sizeUSD ? p._sizeUSD.toFixed(2) : '–'
									const relSize = (p.relSize * 100).toFixed(2)
									const lev = Number(p.leverage || 1).toFixed(2).replace('.00', '')
									const pnl = (p.pnl * 100).toFixed(2)
									const pnlLev = (p.pnlLev * 100).toFixed(2)
									const pnlTotal = (p.pnlTotal * 100).toFixed(2)
//									isProfit: p._profit > 0,
//									_date: p._date ? formatDate(p._date).substr(5) : "–",
//									age: p._date ? formatDur((Date.now() - p._date) / 1000) : "–",
									telebotSend(fromId, `${name}${acc} – \`${p._symbol}\` ${p._side} @ \`${p._entry}\` │ size: \`${p._size} / ${sizeUSD} USD\` (\`${relSize}%\`) @ \`${lev}x\` │ PnL: \`${toDecimal(p._profit)}\` (*${pnl}%* / \`${pnlLev}%\` / \`${pnlTotal}%\`)`, true)
								})
								posNum += pos.list.length
							}
						})
					}, (exNum, accNum) => {
						telebotSend(fromId, posNum ? `${posNum} open position${posNum>1?'s':''} found on ${accNum} account${accNum>1?'s':''} / ${exNum} exchange${exNum>1?'s':''}!` : `No open positions found!`, true)
					}), forkSleep)

				} else if (cmdL.startsWith("/alert")) {
					// $$$$$$$ /alerts enabled|disabled
					// /alert(s) name => details + content
					if (fromType && !opt.telebotChAlrt) continue
					if (!opt.telebotAlrt) { telebotSend(fromId, `*NOT ENABLED!*`, true); continue }
					let output = "", i = 0
					const alerts = getAlerts().sort(alertSort)
					alerts.forEach(alert => {
						if (output.length >= TELEGRAM_MAX) {
							telebotSend(fromId, output, true), output = ""
						}
						output += 
`${alert.enabled?'✅':'🚫'} \`[${alert.names.join(', ')}]\` #${alert.id}${alert.auto||alert.sched?`
  ⏱ \`${alert.autoInt}\``:''}
  ⇄ ${formatDateS(alert.recv, false, '—')} (${alert.recvNum||0})
  ⇛ ${formatDateS(alert.fired, false, '—')} (${alert.firedNum||0})

`						// $$$ print lock
						++i
					})
					output += `${i} ${pvShortU} alert${i>1?'s':''} found!`
					telebotSend(fromId, output, true)

				} else if (cmdL.startsWith("/log")) {
					if (fromType && !opt.telebotChLog) continue
					if (!opt.telebotLog) { telebotSend(fromId, `*NOT ENABLED!*`, true); continue }
					let len = Number(cmd.split(' ')[1]) || 20
					let back = Math.abs(len)
					if (len < 0) len = 20

					log.getEvents(log.getNext() - back, len, true).then(events => {
						let output = ""
						for (let i = 0; i < events.length; ++i) {
							const ev = events[i]
							if (!ev.d || !ev.m || ev.l < 1) {
								continue
							}
							const line = formatDate(ev.d)+' '+ev.m.replace('{\n','\n').replace('\n}','').replace(/,\n/g, '\n')+'\n'
							if (output.length + line.length >= TELEGRAM_MAX) {
								telebotSend(fromId, output), output = ""
							}
							output += line
						}
						if (output.length) telebotSend(fromId, output)
					})

				} else {
					if (fromType && !opt.telebotChRunCmd && !opt.telebotChRunAlrt) continue
					if (!opt.telebotRunCmd && !opt.telebotRunAlrt) { telebotSend(fromId, `*NOT ENABLED!*`, true); continue }
					if (cmd.startsWith('/')) cmd = cmd.slice(1)
					const res = cmd.match(/[\s(,]res=([\d.shdwm]+)/i)
					const time = data.date || Date.now() / 1000
					const ev = {
						id: 'T'+msg.update_id,
						sym: (opt.telebotEx || opt.defExchange)+':'+(opt.telebotSym || opt.defSymbol),
						res: getTF(res && res[1], opt.telebotRes || opt.defaultRes),
						bar_time: time,
						fire_time: time,
						desc: cmd,
						telebot: fromId,
						noLegacy: fromType ? !opt.telebotChRunCmd : !opt.telebotRunCmd,
						noAlerts: fromType ? !opt.telebotChRunAlrt : !opt.telebotRunAlrt
					}
					if (cmd.startsWith('!') && (fromType ? opt.telebotChOver : opt.telebotOver)) {
						ev.noFilter = true
						ev.allow = cmd.startsWith('!!!') ? 2 : cmd.startsWith('!!') ? 1 : 0
						ev.desc = cmd.slice(1 + ev.allow)
					}
					setTimeout(handlers.event.bind(this, ev), forkSleep)
				}
			}
		}
		setTimeout(telebot, forkSleep)
	})
	.catch(ex => {
		if (bg.inShutdown) return
		telebotErr = (ex.message || "Unknown – Please report").replace("Failed to fetch", "Can't connect")
		telebotErrLast = Date.now()
		stats.inc('TelebotErr', 1, true, telebotErr)
		log.error(`TelegramBot Error: ${telebotErr}!`)
	})
}

async function checkHeartbeat() {
	updateIcon()
	await broadcastOptions()
	checkOptionsPage()
	log.setLevel(opt.debugLog ? log.level.DEBUG : log.level.INFO)
	stats.uptime = ((stats.heartbeat = Date.now()) - stats.bootup) / 1000
	stats.lastPing = tvLastPing
	stats.lastEvent = tvLastEvent
	let oldPing = 0

	const lastEventMin = (Date.now() - tvLastEvent) / m2ms
	if (tvLastEvent && lastEventMin >= opt.timeoutTVmin) {
		tvLastEvent = Date.now()
		const timeoutMsg = `WARNING: NO ALERT RECEIVED FOR ${Math.floor(lastEventMin)} MINUTES! (${opt.timeoutTV ? (!tvTabs || opt.onlyStream ? "reconnecting TV stream... " : "reloading TV tabs... "):''}checking again in ${opt.timeoutTVmin} min)`

		if (opt.timeoutTV) {
			log.warn(timeoutMsg)
			if (!tvTabs || opt.onlyStream) {
				closeTradingViewStream()
				openTradingViewStream()
			}
			if (opt.autoTV || (tvTabs && !opt.onlyStream)) {
				await forceTradingViewCheck(true)
			}
		}

		stats.inc('Timeout', 1, true)
		if (opt.emailTimeout && emailMessage("timeout", "** "+timeoutMsg+" **")) log.info("Sent Email timeout notification!")
		if (opt.discordTimeout && discordMessage("timeout", "`"+timeoutMsg+"`")) log.info("Sent Discord timeout notification!")
		if (opt.telegramTimeout && telegramMessage("timeout", "`"+timeoutMsg+"`")) log.info("Sent Telegram timeout notification!")
		if (opt.twilioTimeout && twilioMessage("timeout", "** "+timeoutMsg+" **")) log.info("Sent Twilio timeout notification!")
		if (opt.iftttTimeout && iftttMessage("timeout")) log.info("Sent IFTTT timeout notification!")
		if (opt.timeoutTV) return
	}

	const newPing = Date.now()
	if (!tvLastPing2) {
		debugger
	}
	if ((oldPing = Date.now()) && tvLastPing && oldPing - tvLastPing >= pingTimeout && tvStreamStatus < TRADINGVIEW_STREAM_OPEN) {
		if ((tvTabs < 1 || opt.onlyStream) && Date.now() - tvLastReload >= reloadSafetyTimeout) {
			tvLastReload = Date.now()
			await toggleTradingViewStream()
		}
		await sleep(reloadSafetyTimeout / 1000)
		if (Date.now() - tvLastPing >= pingTimeout) {
			tvLastPing = Date.now() + opt.forceTVmin * m2ms - pingTimeout
			const checkMsg = `ERROR: NO CONNECTION TO TRADINGVIEW! (checking again in ${opt.forceTVmin} min)`

			log.error(checkMsg)
			await forceTradingViewCheck()

			stats.inc('Stall', 1, true)
			if (opt.emailStalling && emailMessage("stall", "** "+checkMsg+" **")) log.info("Sent Email stalling notification!")
			if (opt.discordStalling && discordMessage("stall", "`"+checkMsg+"`")) log.info("Sent Discord stalling notification!")
			if (opt.telegramStalling && telegramMessage("stall", "`"+checkMsg+"`")) log.info("Sent Telegram stalling notification!")
			if (opt.twilioStalling && twilioMessage("stall", "** "+checkMsg+" **")) log.info("Sent Twilio stalling notification!")
			if (opt.iftttStalling && iftttMessage("stall")) log.info("Sent IFTTT stalling notification!")

			if (tvStreamStalled >= streamRetries && opt.forceTV) tvStreamStalled = 0
		}
	}
	if (oldPing - newPing > 50) tvLastPing2 = oldPing - newPing

	if (opt.telebot && ((telebotErrLast > telebotLast && telebotErr && !telebotErr.startsWith('Conflict')) || Date.now() - telebotLast > telebotTimeout)) {
		telebot()
	}
}

async function reloadTradingViewCheck(force) {
	if (force || Date.now() - tvLastReload >= reloadSafetyTimeout) {
		tvLastReload = Date.now()
		await reloadTradingViewTabs(force)
	}
}

async function forceTradingViewCheck(force) {
	if (!force && !opt.forceTV) return
	const tabs = await getTradingViewTabs()
	tvTabs = tabs.length

	if (tvTabs > 0) {
		log.notice("Trying to reload TradingView...")
		browser.tabs.update(tabs.last().id, {selected: true})
		await reloadTradingViewCheck(true)
	} else if (opt.autoTV) {
		log.notice("Trying to open TradingView...")
		browser.tabs.create({url: 'https://www.tradingview.com/chart/'})
	}
}

function checkOptionsPage() {
	if (/*bg.isReady &&*/ opt.keepApp && !browser.extension.getViews({type:"tab"}).filter(view => view.location.pathname === '/options.html').length) {
		browser.tabs.create({url: rt.getURL("options.html#/log")})
	}
}

function reloadOptionsPage() {
	const options = browser.extension.getViews({type:"tab"}).filter(view => view.location.pathname === '/options.html')[0]
	if (options) {
		options.location.reload()
	}
}

function openApp(page) {
	const url = rt.getURL("options.html"+(page === '#' ? '' : '#/'+page))
	const options = browser.extension.getViews({type:"tab"}).filter(view => view.location.pathname === '/options.html')[0]
	if (options?.location?.href) {
		options.location.href = url
		options.browser.tabs.getCurrent(tab => {
			browser.tabs.update(tab.id, {selected: true})
			browser.windows.update(tab.windowId, {focused: true})
		})
	} else {
		browser.tabs.create({url: url})
	}
}

function onClick(ev) {
	let el = ev.target
	if (el.nodeName === 'SPAN' && el.parentNode) {
		el = el.parentNode
	}

	if (['A', 'LI', 'BUTTON'].includes(el.nodeName)) {
		const data = el.dataset
		if (data.page) {
			ev.preventDefault()
			return openApp(data.page)
		}
	}
}
