"use strict"

const timeStart = Date.now()
const pvVersion = rt.getManifest().version
const pvName = rt.getManifest().name
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 ls = localStorage
const bg = browser.extension.getViews().filter(view => view.location.pathname === '/background.html')[0]
const log = new LogClient()
const optStore = new OptRelay()
const statsStore = new StatsRelay()
const stats = createProxy(statsStore)
/* move to relay >>>>> */
StorageInternal.connect(bg)
Permissions.connect(bg)
window.GUID = bg?.GUID
window.broker = bg?.broker
/* <<<<< move to relay */
window.globalVars = new RelayInterface(VarStore)

const q = sel => document.querySelector(sel)
const qa = sel => document.querySelectorAll(sel)
const qi = sel => document.getElementById(sel)

const firstAlertId = 1540353600000
const resetProps = ["recv", "fired", "locks", "recvNum", "firedNum", "autoNum", "intNum", "manualNum", "errNum", "time", "timeAvg", "timeMin", "timeMax", "slip"]
let state = {}
const canListPos = {}

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

const autoInterval = 1 * m2ms
const autoPosUpdate = 5 * m2ms
const heartBeatInterval = 3 * s2ms

navigator?.keyboard?.getLayoutMap().then(m => window.qwertz = m.get('KeyZ') === 'y')
document.addEventListener('DOMContentLoaded', init)

rt.onMessage.addListener((msg, sender, resp) => {
	void rt.lastError
	if (!msg?.method || sender?.tab?.index < 0) return

	const alert = msg.method.startsWith('alert.') && msg.args?.[0]
	switch(msg.method) {
		case 'ui.positions':
			const ex = canListPos[msg.args?.[0]?.ex]
			if (ex) {
				opt.posEx = ex, optStore.save(), filterPosChanged()
				msg.args[0].ex = ex
				updatePos(undefined, undefined, msg.args[0])
				resp(true)
			} else {
				resp(false)
			}
			break

		case 'ui.renderPositions':
			renderPositions(...msg.args); break

		case 'running':
			runningChanged(...msg.args); break

		case 'refresh':
			initLicense(), initExchanges(); break

		case 'alert.toggle':
			const form = q(`#alert-${alert?.id}`)
			if (form) {
				form.classList[alert.enabled ? 'remove' : 'add']('disabled')
				q(`#alert-${alert.id}-enabled`).checked = alert.enabled
			}
			break

		case 'alert.interval':
			const interval = q(`#alert-${alert?.id}-autoInt`)
			if (interval) {
				interval.value = alert.autoInt
				q(`#alert-${alert.id}-auto`).checked = alert.auto
			}
			break
	}
})

window.onbeforeunload = ev => {
	if (Array.prototype.reduce.call(qa('#main-links [data-page=alerts], #exchange-links a'), (res, el) => res || el.classList.contains('unsaved'), false)) {
		return "Unsaved changes!"
	}
}

function createAccount() {
	exchangeAccount.call(this)
}

function saveAccounts() {
	const forms = getState(this).content.querySelectorAll("form")
	let newAccounts = []
	let newCreds = {}
	let success = true

	for (const form of forms) {
		let creds = formData(form)
		if (!creds.account) {
			continue
		}
		creds.account = creds.account.trim()
		newAccounts.push(creds.account)

		try {
			if (newCreds.getProp(creds.account)) {
				throw new ReferenceError("Account already exists: "+creds.account)
			}
			creds = this.setCredentials(creds.account, creds)
			newCreds[creds.account] = creds
		} catch (err) {
			error(err.message)
			success = false
		}
	}
	if (!success) return false

	// $$$$ api-fy
	const alias = this.getAlias()
	let stored = broker.getAccounts()
	if (stored[alias]) {
		for (const account in stored[alias]) {
			--bg.accountNum
			if (!newAccounts.getProp(account)) {
				this.removeAccount(account)
				this.removeBalance(account)
			} else if (!newCreds.getProp(account)) {
				// $$$$ should never reach? (only if sync update inbetween UI load and save?)
				newCreds[account] = stored[alias][account]
			}
		}
	}
	bg.accountNum += newAccounts.length
	stored[alias] = newCreds
	broker.setAccounts(stored)
	q(`[data-page=exchange-${alias.toLowerCase()}] span`).classList.value = `glyphicon glyphicon-ok text-success`
	let posEx = q(`#posEx+ul [data-value=${this.getAlias(-1)}]`)
	posEx = posEx?.parentElement
	posEx?.classList?.remove('disabled')

	log.info(`Saved ${newAccounts.length} API keys for ${this.getName()}: `+newAccounts.join(', '))
	toastr.success(`Saved ${newAccounts.length} ${this.getName()} accounts!`)
	q(`[data-page=exchange-${this.getAlias().toLowerCase()}]`).classList.remove('unsaved')
	return true
}

async function testAccount(el) {
	if (!saveAccounts.call(this)) {
		return false
	}
	const data = formData(el.closest("form"))

	log.info(`Testing API key ${data.account} on ${this.getName()} ...`);
	try {
		// $$$$ api-fy
		const cmd = new Alert({desc: "ub", sym: this.getTestSymbol()}, {a: data.account}).commands[0]
		await this.executeCommand(cmd, `TEST @ ${data.account}`)

		ok(`The API key for ${data.account} was successfully tested!<br/>
			<br/>
			<b><u>NOTE:</u> PLEASE MAKE SURE TRADING PRIVILIGES FOR API KEY ARE SET CORRECTLY!`)

		log.success(`API key ${data.account} successfully tested!`)
	} catch (ex) {
		// $$$ err.includes timestamp => sync & try one more time
/*			if (opt.timeSync && exchange?.checkTime && err.includesNoCase('timestamp') && Date.now() - stats.lastTimeSync > streamRetryTimeout) {
				logWarn && log.warn(pre+`Time sync issue detected, trying automatic time sync...`)
				stats.lastTimeSync = Date.now()
				await exchange.checkTime()
			}
*/
		const err = getError(ex)
		error(`The following error occured testing account ${data.account}<br/>
			<br/>
			<b>${err}</b><br/>
			<br/>
			<u>Note:</u> Please check our <a href="https://wiki.profitview.app/faq" target="_blank">FAQ <span class="glyphicon glyphicon-new-window" aria-hidden="true"></span></a> for common errors!`)

		log.error("Error: "+err)
	}
}

async function removeAccount(el, ev) {
	const alias = this.getAlias()
	const form = el.closest("form")
	if (!form) return
	const curr = form.querySelector("[name='account']").value
	const account = !this.hasAccount(curr) && el.dataset.account || curr
	const onlyBalance = ev.shiftKey
	if (await yesnoA(`Do you really want to <b>remove</b> ${onlyBalance?'all stored balance info for ':''}the account <b>${account}</b> from ${this.getName()}?<br/>
		<br/>
		<u>Note:</u> This action cannot be undone!`)) {

		!onlyBalance && el.closest("form").remove()
		// $$$$ api-fy
		if (account && this.hasAccount(account)) {
			this.removeBalance(account)
			if (onlyBalance) return
			this.removeAccount(account)
			const alias2 = this.getAlias(-1)
			relayMsg('deletePosition', alias2, account)
			delete positions?.[alias2]?.[account]
			--bg.accountNum

			const saved = this.getAccounts()
			q(`[data-page=exchange-${alias.toLowerCase()}] span`).classList.value = `glyphicon glyphicon-ok text-${!saved.length ? 'muted' : 'success'}`
			if (!saved.length) {
				let posEx = q(`#posEx+ul [data-value=${this.getAlias(-1)}]`)
				posEx = posEx?.parentElement
				posEx?.classList?.add('disabled')
			}
		}
		if (!qa(`#exchange-${alias.toLowerCase()} form[data-account]`).length) {
			exchangeAccount.call(this)
		}
	}
}

async function cloneAccount(el, ev) {
	const creds = formData(el.closest('form'))
	const name = `${creds.account}-copy`
	const account = ev?.ctrlKey ? name : (await askA("Create a copy with the following name:<br/><br/>", `Clone account "${creds.account}"`, name))
	if (account) {
		exchangeAccount.call(this, account, creds)
		q(`[data-page=exchange-${this.getAlias().toLowerCase()}]`).classList.add('unsaved')
	}
}

async function copyAccount(el, ev) {
	const creds = formData(el.closest('form'))
	if (ev?.ctrlKey || (await yesnoA(`Do you really want to <b>copy</b> the credentials of the ${this.getName()} account <b>${creds.account}</b> to the clipboard?`))) {
		clipboardWrite("text/plain", stringifyPretty(creds))
	}
}

async function pasteAccount(el, ev) {
	let text = await navigator.clipboard.readText()
	let creds = null
	try {
		creds = JSON.parse(text)
	} catch(ex) {}

	if (!creds || !creds.account || !creds.public) {
		return error(`Clipboard data is not a valid ${pvName} API account!`)
	}
	const form = el.closest('form')
	const oldCreds = form && formData(form)
	const action = !oldCreds || ev?.shiftKey ? 3 : ev?.ctrlKey ? 1 : (await onetwoA(`Do you want to overwrite the ${this.getName()} account <b>${oldCreds.account}</b> with the credentials of the account <b>${creds.account}</b> – <u>including</u> the account name or only its key/secret?`, "Overwrite w/Name", "Only Key/Secret"))

	if (action) {
		if (action === 3) {
			exchangeAccount.call(this, creds.account, creds)
		} else {
			for (const field in creds) {
				if (oldCreds[field] === undefined || action === 2 && field === "account") continue
				form[field].value = creds[field]
			}
		}
		q(`[data-page=exchange-${this.getAlias().toLowerCase()}]`).classList.add('unsaved')
		toastr.success(`Pasted credentials ${action === 3 ? 'for':'into'} account ${action === 2 && oldCreds.account || creds.account}`)
	}
}

function onClick(ev) {
	let el = ev.target
	if (['SPAN', 'INPUT', 'TD'].includes(el.nodeName) && el.parentNode && !el.dataset.action) {
		el = el.parentNode
		if (el.nodeName === 'TD' && !el.dataset.action) el = el.parentNode
	}
	if (!['A', 'BUTTON', 'SPAN', 'LABEL', 'TR'].includes(el.nodeName) || el.classList.contains('disabled')) return
	if (el.target === "_blank") return
	if (el.dataset.href) window.open(el.dataset.href, el.dataset.target)

	const data = el.dataset
	if (data.page) {
		ev.preventDefault()
		return openPage(data.page)
	} else if (data.action) {
		if (typeof window[data.action] !== "function") {
			throw new ReferenceError("Action not found: "+data.action)
		}

		let cb = window[data.action]
		if (data.exchange) {
			const ex = broker.getByAlias(data.exchange)
			if (!ex) {
				throw new ReferenceError("Exchange Alias was not found: "+data.exchange)
			}
			cb = cb.bind(ex)
		}
		ev.preventDefault()
		return cb(el, ev)
	}
}

function exchangeAccount(account, creds) {
	const isNew = account === undefined || creds
	const state = getState(this)

	if (isNew && !creds) {
		account = "*"
		state.content.querySelectorAll("input[name='account']").forEach(e => e.value.trim() === '*' && (account = ''))
	}
	const access = new Template("exchange-access", state.content, state.data, {
		"access.#": (state.content.getElementsByTagName("form") || []).length,
		"access.account": account,
		"access.order": this.canListPos(),
	}).render()

	creds ||= this.getAccountRaw(account) || {}
	Object.each(this.getFields(), (key, field) => {
		new Template(`exchange-access-${field.type === 'switch' ? 'switch' : 'field'}`, access, {
			"field.label": field.label,
			"field.message": field.message || '',
			"field.tooltip": field.tooltip || '',
			"field.type": field.type || 'password',
			"field.key": key,
			"field.value": (creds[key] === undefined ? field.default : creds[key]) || ''
		}).render()
	})
	$("input[name='account']", state.content).on('change.account', function(ev){
		const name = ev.target.value.replace(regIllegalAcc, '-').trim()
		ev.target.value = name === '' ? ev.target.defaultValue : name
	})
	$("form[data-account] input", state.content).on('input.unsaved change.unsaved', function(ev){
		q(`[data-page=exchange-${state.data["exchange.id"]}]`).classList.add('unsaved')
	})

	if (isNew) {
		const scroll = $(".scroll-container", state.content)
		scroll.animate({
			scrollTop: scroll[0].scrollHeight - scroll[0].clientHeight
		}, 200)
		$("input[name='account']", state.content).focus()
	}
}

function initExchanges() {
	qa("#exchange-links li").forEach(el => el.remove())
	qa("article.exchange-page").forEach(el => el.remove())
	if (!broker) return

	const exes = broker.getAll()
	for (const ex of exes) {
		const id = ex.getAlias().toLowerCase()
		const state = getState(ex)

		state.content = qi("exchange-" + id)
		if (!state.content) {
			new Template("#exchange-page", null, state.data).render()

			// $$$$ api-fy
			let options = syncStore.get("options_"+id) || defExchangeOpt
			$(`#exchange-${id}-options input`).each(function(i, el){
				if (el.name && options.hasOwnProperty(el.name)) {
					el[el.type === 'checkbox' ? 'checked' : 'value'] = options[el.name]
				}
				$(el).off('change.exopt').on('change.exopt', function(ev){
					if (this.type === 'number') {
						this.value = Math.min(Math.max(this.value, this.min !== "" ? this.min : -Number.MAX_VALUE), this.max !== "" ? this.max : Number.MAX_VALUE)
					}
					syncStore.set("options_"+id, formData(ev.target.closest('form')))
				})
			})
			state.content = qi("exchange-"+id)

			new Template("#exchange-link", null, state.data).render()
		}

		let icon = "remove text-danger"
		const perm = ex.hasPermission()
		const sub = ex.hasSubscription()
		state.permissions.granted = perm && sub

		if (!perm || !sub) {
			new Template(!perm ? "#alert-permissions-missing" : "#alert-license-missing", null, state.data).render(state.content.querySelector("h1"))
		} else {
			const p9 = localStore.get('perms')
			if ((ex.getSubscriptions().active.length || ex.getSubscriptions().inactive.length) && 
				(!p9 || !p9.permissions || Object.values(p9.permissions).indexOf('d8zwmh92wfj8ge3yzgyxbttf5zhaa3zh') < 0)) {
				new Template('#alert-license-missing', null, state.data).render(state.content.querySelector("h1"))
				state.permissions.granted = !true
			}
		}

		const pos = ex.canListPos() && q(`[name=positions][data-id=${ex.getAlias(-1)}]`)
		let posEx = pos && q(`#posEx+ul [data-value=${ex.getAlias(-1)}]`)
		posEx = posEx?.parentElement
		const btns = state.content.querySelectorAll(".btn")
		for (const btn of btns) {
			btn.classList.toggle("hide", !state.permissions.granted)
		}
		if (state.permissions.granted) {
			const accounts = ex.getAccounts()
			icon = "ok text-success"
			if (!accounts.length) {
				icon = "ok text-muted"
				exchangeAccount.call(ex)
				pos?.classList?.add('noacc')
				posEx?.classList?.add('disabled')
			} else {
				for (const account of accounts) {
					exchangeAccount.call(ex, account)
					pos?.classList?.remove('noacc')
				}
				posEx?.classList?.remove('disabled')
			}
			pos?.classList?.remove('noperm')
		} else {
			state.content.querySelectorAll("form").forEach(el => el.remove())
			pos?.classList?.add('noperm')
			posEx?.classList?.add('disabled')
		}
		q(`[data-page=exchange-${id}] span`).classList.value = "glyphicon glyphicon-"+icon
	}

	if (currentPage.startsWith('exchange-')) {
		q(`article[id=${currentPage}]`).classList.remove('hide')
		q(`.nav [data-page=${currentPage}]`).parentNode.classList.add('active')
	}
}

function getState(ex) {
	const alias = ex.getAlias()
	if (!state[alias]) {
		state[alias] = {
			content: null,
			permissions: {connected: false, granted: false},
			data: {
				"exchange.alias": alias,
				"exchange.syntax": ex.getAlias(-1),
				"exchange.id": alias.toLowerCase(),
				"exchange.name": ex.getName(),
				"exchange.attr": ex.hasMargin() ? (ex.hasSpot() ? "MS" : "M") : "",
				"exchange.attrtitle": ex.hasMargin() ? (ex.hasSpot() ? "Margin and Spot trading" : "Margin trading") : "",
				"exchange.url": ex.getWebsite(),
			}
		}
	}
	return state[alias]
}

function scrollLogChanged() {
	if (opt.scrollLog) {
		const scroll = $('#log-container')
		scroll.stop(true).animate({
			scrollTop: scroll[0].scrollHeight - scroll[0].clientHeight
		}, 200)
	}
}

function onlyOneAlertChanged() {
	if (opt.onlyOneAlert) {
		minAlert("min")
		currentAlert && minAlert(q(`[data-alert='${currentAlert}'][data-action='minAlert']`))
	}
}

function debugLogChanged() {
	log.setLevel(opt.debugLog ? log.level.DEBUG : log.level.INFO)
}

function disableAll(val = !opt.disableAll) {
	if (opt.disableAll != val) {
		opt.disableAll = val
		optStore.save(), disableAllChanged()
	}
}

function disableAllChanged() {
	relayMsg('disableAllChanged', opt.disableAll)
	closeBGChanged()
}

function closeBGChanged() {
	if (opt.closeBG && opt.disableAll) {
		browser.storage.local.set({closeBG: true})
		relayMsg('closeBG', true)
	} else {
		if (!browser.extension.getViews().filter(view => view.location.pathname === '/background.html').length) {
			optStore.saveDirect()
			browser.tabs.create({url: rt.getURL("background.html"), active: false, selected: false})
		}
		browser.storage.local.set({closeBG: false})
		relayMsg('closeBG', false)
	}
}

function onlyStreamChanged() {
	relayMsg('toggleTradingViewStream', opt.onlyStream)
}

function showFilterToggle() {
	opt.showFilter = !opt.showFilter
	optStore.save(), showFilterChanged()
}

function showFilterChanged() {
	q("#alertlist").classList[opt.showFilter ? "remove" : "add"]("hideFilter")
}

function filterChanged(name) {
	const forms = qa("#alertlist form")
	if (!forms || !forms.length) return

	if (["searchName", "searchSyntax", "onlyActAlert", "onlyAutoAlert"].includes(name)) {
		let shown = 0
		let first = 0
		let current = currentAlert
		const searchName = wildcardExp(opt.searchName, true, true)// RegExp(opt.searchName, 'i')
		const searchSyntax = wildcardExp(opt.searchSyntax, true, true)// RegExp(opt.searchSyntax, 'i')

		for (const form of forms) {
			const visible = (!opt.onlyActAlert || !form.classList.contains('disabled')) && 
				(!opt.onlyAutoAlert || form.auto.checked) && 
				(!opt.searchName || searchName.test(form.names.value) || searchName.test(form.id.value)) && 
				(!opt.searchSyntax || searchSyntax.test(form.commands.value))

			form.classList.add(visible ? 'visible' : 'hidden')
			form.classList.remove(visible ? 'hidden' : 'visible')

			if (visible) {
				++shown
				if (!first) {
					first = form.dataset.alert
				}
			} else if (form.dataset.alert == current) {
				current = 0
			}
		}
		currentAlert = current || first
		q("#filterInfo").innerHTML = `Showing ${shown} of ${forms.length}`

		if (!current) {
			qa("#alertlist form.selected").forEach(el => el.classList.remove('selected'))
			const curr = q(`[data-alert='${currentAlert}']`)
			curr?.classList?.add('selected')
		}
	} else {
		const alerts = getAllAlerts()
		const prop = (form, first, second, def) => {
			const alert = alerts['alert_'+form.dataset.alert]
			def = def ? '0'+form[def].value : (-alert?.sort || 0)
			return (alert && (alert[first] || (second && alert[second]))) || def
		}

		const up = opt.sortRev ? -1 : 1
		const down = opt.sortRev ? 1 : -1
		const by = {
			"custom": (a, b) => Number(a.sort.value) > Number(b.sort.value) ? up : down,
			"name": (a, b) => a.names.value > b.names.value ? up : down,
			"created": (a, b) => Number(a.id.value) < Number(b.id.value) ? up : down,
			"recv": (a, b) => prop(a, 'recv', 'fired', 'names') < prop(b, 'recv', 'fired', 'names') ? up : down,
			"fired": (a, b) => prop(a, 'fired', 'recv', 'names') < prop(b, 'fired', 'recv', 'names') ? up : down,
			"recvnum": (a, b) => prop(a, 'recvNum') < prop(b, 'recvNum') ? up : down,
			"firednum": (a, b) => prop(a, 'firedNum') < prop(b, 'firedNum') ? up : down,
			"autonum": (a, b) => prop(a, 'autoNum') < prop(b, 'autoNum') ? up : down,
			"intnum": (a, b) => prop(a, 'intNum') < prop(b, 'intNum') ? up : down,
			"manualnum": (a, b) => prop(a, 'manualNum') < prop(b, 'manualNum') ? up : down,
			"errnum": (a, b) => prop(a, 'errNum') < prop(b, 'errNum') ? up : down,
			"timelast": (a, b) => prop(a, 'time') < prop(b, 'time') ? up : down,
			"timeavg": (a, b) => prop(a, 'timeAvg') < prop(b, 'timeAvg') ? up : down,
			"timemin": (a, b) => prop(a, 'timeMin') < prop(b, 'timeMin') ? up : down,
			"timemax": (a, b) => prop(a, 'timeMax') < prop(b, 'timeMax') ? up : down
		}[opt.sort || "custom"]

		const list = forms[0].parentElement
		Array.prototype.slice.call(forms).sort(by).forEach(form => list.appendChild(form))

		if (currentAlert) {
			const el = isAlertMin(currentAlert) ? q(`#alert-${currentAlert}-names`) : 
				q(`#alert-${currentAlert}-commands`).nextSibling.firstChild
			el?.focus()
		}
	}
}

let optionsInterval = 0

function initOptions(isUpdate) {
	$('[data-opt]').each(function(i, el){
		const name = el.dataset.opt
		if (name && opt.hasOwnProperty(name)) {
			if (el.type === 'checkbox') {
				el.checked = opt[name]
			} else if (el.type === 'button') {
				const item = el.nextElementSibling.querySelector(`[data-value='${opt[name]}' i]`)
				el.innerHTML = (item?.innerHTML || opt[name])+'<span class="caret"></span>'
			} else {
				el.value = opt[name]
			}
		}

		!isUpdate && el.type !== 'button' && $(el).off('change.opt').on('change.opt', function(ev){
			if (this.dataset.warn && this.checked) {
				ok(this.dataset.warn)
			}
			if (this.type === 'number') {
				this.value = Math.min(Math.max(this.value, this.min !== "" ? this.min : -Number.MAX_VALUE), this.max !== "" ? this.max : Number.MAX_VALUE)
			} else if (this.type === 'text' && this.dataset.default && !this.value.trim()) {
				this.value = this.defaultValue
			}
			opt[name] = 
				this.type === 'checkbox' ? this.checked : 
				this.type === 'number' ? (this.step?.includes('.') ? parseFloat(this.value) || 0 : parseInt(this.value) || 0) : 
				this.value.trim()
			optStore.save()

			if (!this.dataset.live) {
				const changed = (this.dataset.change || name)+'Changed'
				window[changed] && window[changed](name)
			}
		})

		!isUpdate && el.type === 'button' && $(el.nextElementSibling).find('a').off('click.opt').on('click.opt', function(ev){
			if (this.parentElement.classList.contains('disabled')) return false
			const btn = this.parentElement.parentElement.previousElementSibling
			btn.innerHTML = this.innerHTML+'<span class="caret"></span>'
			$(btn).dropdown("toggle")
			opt[name] = this.dataset.value
			optStore.save()

			const changed = (btn.dataset.change || name)+'Changed'
			window[changed] && window[changed](name)
			return false
		})

		!isUpdate && el.type === 'text' && el.dataset.live && $(el).off('keyup.opt').on('keyup.opt', function(ev){
			opt[name] = this.value.trim()

			const changed = (this.dataset.change || name)+'Changed'
			window[changed] && window[changed](name)
		})
	})

	if (!isUpdate) {
		opt._ = Date.now()
		clearInterval(optionsInterval)
		optionsInterval = setInterval(async () => {
			// $POLLING$
			if (await optStore.load()) {
				initOptions(true)
				opt.expConfSave && doConfigExport("Options changed")
			}
		}, livePageRate)
	}
}

function renderPositions(alias, exPos) {
	const table = q(`tbody[data-id=${alias}]`)
	if (!table) return

	const list = new Template("position")
	let updated = 0
	let posNum = 0

	Object.each(exPos, (acc, pos) => {
		const accS = smartTrim(acc, 31)
		if (pos.error) {
			const err = getError(pos.error, undefined, false)
			list.renderBuffer(`<tr class="error"><td>${accS}</td><td colspan="15">ERROR: ${err}</td></tr>`)
			return
		}
		if (pos.list?.length) {
			pos.list.forEach(p => list.renderBuffer(Object.assign({cross: false, isolated: false}, p, {
				isProfit: p._profit > 0,
				isHidden: opt.posSym && !p._symbol.includesNoCase(opt.posSym),
				acc: acc, accS: accS,
				_hedge: p._hedge === "both" ? '' : p._hedge,
				_oneway: p._hedge === "both" ? p._hedge : '',
				_sl: p._sl || '–', _tp: p._tp || '–',
				_mark: toDecimal(Number(p._mark)) || '–',
				_liq: toDecimal(Number(p._liq)) || '–',
				_size: toDecimal(p._size),
				_sizeUSD: p._sizeUSD ? Number(p._sizeUSD).toFixed(2) : '–',
				_sizeCoin: p._sizeCoin ? Number(p._sizeCoin).toFixed(4) : '–',
				relSize: (p.relSize * 100).toFixed(2),
				leverage: `${Number(p.leverage || 1).toFixed(2).replace('.00', '')}x`,
				_profit: toDecimal(p._profit),
				pnl: (p.pnl * 100).toFixed(2),
				pnlLev: (p.pnlLev * 100).toFixed(2),
				pnlTotal: (p.pnlTotal * 100).toFixed(2),
				_date: p._date ? formatDateSP(p._date) : '–',
				age: p._date ? formatDurP(tillNow(p._date)) : '–',
				ccy: p.ccy || p.currency || '',
				currency: formatCcy(p.ccy || p.currency || ''),
				ex: alias, empty: false
			})))
			posNum += pos.list.length
		} else {
			list.renderBuffer({
				isHidden: opt.posSym !== "" || !opt.posNone,
				acc: acc, accS: accS, _symbol: '–', _side: '–', _hedge: '', _oneway: '', _entry: '–', _mark: '–', _liq: '–', _sl: '–', _tp: '–', _size: '–', _sizeUSD: '–', _sizeCoin: '–', relSize: 0, leverage: 0, 
				_profit: '–', pnl: 0, pnlLev: 0, pnlTotal: 0, _date: '–', age: '–', ccy: '', currency: '',
				ex: alias, empty: true, cross: false, isolated: false
			})
		}
		updated = Math.max(updated, pos.updated || 0)
	})

	if (updated) {
		let since = (Date.now() - updated) / 1000
		since = since > 2 ? `(${formatDur(since)} ago)` : ''
		list.renderBuffer(`<tr class="footer"><td colspan="16" title="Last updated at"><span class="glyphicon glyphicon-time"></span>  ${formatDateS(updated)} <span data-since="${updated}">${since}</span></td></tr>`)
	}
	$(".tooltip").tooltip('destroy')
	table.innerHTML = list.getBuffer() || `<tr class="footer"><td colspan="16">NO DATA YET!</td></tr>`
	table.closest('.panel').classList[!list.getBuffer() || !posNum ? 'add' : 'remove'](opt.posEx || opt.posEmpty ? '-' : 'hide', 'empty')	// $$$ check filtering!
}

let updatingPositions = 0
let updatePosLast = 0
let positions = {}

async function updatePos(el, ev = {}, msg) {
	const panel = el?.closest('.panel')
	if (panel) {
		if (panel.classList.contains('noperm')) {
			error(`Please grant necessary permissions for ${panel.dataset.id} under "Setup: Permissions"!`); return
		}
		if (panel.classList.contains('noacc')) {
			error(`No accounts configured for ${panel.dataset.id} – please check "Options / Accounts"!`); return
		}
	}
	// $$$ make parallel; updatingPositions => counter for # updating exchanges?
	if (updatingPositions) return
	++updatingPositions
	qa("[data-action=updatePos]").forEach(el => el.disabled = true)
	el && q("#positions").classList.add('loading')

	positions = await relayMsg('updatePositions', !opt.posEx && !opt.posEmpty && !ev.ctrlKey ? 
		Array.prototype.map.call(qa("[name=positions]:not(.hide)"), ex => ex.dataset.id) : 
		!opt.posEx /*|| ev.ctrlKey*/ ? null : [opt.posEx], 
		undefined, undefined, "ui.renderPositions", undefined, !(ev.ctrlKey || opt.posNone)) || positions

	q("#positions").classList.remove('loading')
	qa("[data-action=updatePos]").forEach(el => el.disabled = false)
	--updatingPositions
	updatePosLast = Date.now()

	if (msg?.do) {
		const btn = q(`tr[data-ex='${msg.ex}'][data-acc='${msg.acc || '*'}' i] button[data-do='${msg.do}']`)
		btn && changePos(btn, {shiftKey: msg.shiftKey, ctrlKey: msg.ctrlKey})
	}
}

function filterPosChanged(name) {
	if (name === "posSym") {
		const name = opt.posSym.toUpperCase()
		qa("[name=positions] tbody tr").forEach(row => {
			const sym = row.dataset.sym
			sym && row.classList[sym.includes(name) ? 'remove' : 'add']('hide')
		})
		// $$$ hide exchanges if !opt.posEx && !opt.posEmpty && no pos
	} else if (!opt.posEx) {
		qa("[name=positions]:not(.noperm):not(.noacc)").forEach(el => el.classList.remove('hide'))
		!opt.posEmpty && positions?.updated && qa("[name=positions].empty").forEach(el => el.classList.add('hide'))
		// $$$ if all empty => show all
	} else {
		qa("[name=positions]").forEach(el => el.classList.add('hide'))
		const sel = q(`[name=positions][data-id=${opt.posEx}]`)
		if (!sel) {
			opt.posEx = ""
			optStore.save(), filterPosChanged()
			return
		}
		sel.classList.remove('hide')
	}
	qa("[name=positions] tr.empty").forEach(el => el.classList[opt.posNone ? 'remove' : 'add']('hide'))
	enableBtn('posEmpty', !opt.posEx)
}

function copySyntax(el) {
	clipboardWrite("text/plain", q("#ordSyntax").innerHTML)
}

function changePos(el, ev) {
	// $$$$$$$ close all pos when not market => somehow make sure + / - offset matches pos side (p=+1,-2,+2 ?)
	// $$$$ give closing orders id (eg "exit")
	// $$$$ don't cancel all orders for pos close first by default? (unless market close)?
	const act = ev?.act || el.dataset.do
	if (!act) return
	if (!el.dataset.ex) el = el.closest("tr")
	const pos = el?.dataset && Object.map(el.dataset, (key, val) => val === '–' ? '' : val) || {}
	const isAll = !pos.ex
	const isOpen = act === 'add'
	const isAdd = isOpen && pos.side
	const isFlip = act === 'flip'
	const isClose = act === 'close' || act === 'market'
	const isMarket = act === 'market' || ev?.altKey
	const isCancel = act === 'cancel' || ev?.ctrlKey
	if (isAdd && ev?.shiftKey) {
		pos.side = pos.size = pos.entry = pos.sym = ""
	}
	const isNew = isOpen && !pos.side
	// $$$$$ isClose && isAll ==> handle different position sides +/- ???
	const isStops = (pos.side || (isOpen && !isAdd) || (isClose && isAll)) && /*!isOnlyCancel &&*/ ev?.shiftKey
	const isOnlyCancel = !isOpen && !isFlip && !isStops && isCancel
	const cancelType = ev?.cancelType
	const what = isOnlyCancel ? "Orders" : "Position"
	const title = (isOnlyCancel ? "Cancel" : {close: isMarket ? "Market Close" : "Close", add: isNew ? "Open <b>New</b>" : "Add to", flip: "Flip", market: "Market Close"}[act])+' '+
		(isAll ? `<b>ALL ${what}${what.endsWith('s')?'':'s'}</b>!` : `${isNew || isOnlyCancel ? '' : pos.side.toUpperCase()+' '}${what} on ${pos.ex}${isNew ? '' : ':'+pos.sym} @ ${pos.acc}`)
	const btn = isOnlyCancel ? "CANCEL" : {add: isNew ? "OPEN" : "ADD", flip: "FLIP"}[act] || "CLOSE"
	let allAcc = ""

	pos.balance = ''
	if (isAll) {
		qa("[name=positions]:not(.hide) tr.long:not(.hide), [name=positions]:not(.hide) tr.short:not(.hide)").forEach(el => {
			const data = el.dataset
			allAcc += `${allAcc.length?',':''}${data.ex}:${data.sym}:${data.acc}`
		})
		if (!allAcc) {
			ok("No open positions listed – please refresh or change your selection!")
			return
		}
		pos.size = pos.entry = pos.sym = "n/a"
		pos.lev = ""
		pos.hasSpot = true
	} else {
		if (!pos.acc) { // Accounts => +Order
			const data = formData(el.closest("form"))
			pos.acc = data.account
		}
		// $$$ update when symbol selected/changed!
		const ex = broker.getByAlias(pos.ex)
		const alias = ex.getAlias()
		pos.hasSpot = ex.hasSpot()
		pos.hasStopsOnFill = ex.hasStopsOnFill()
		pos.hasReducePosOnly = ex.hasReducePosOnly()
		let balance = ex.getBalanceRaw(pos.acc)
		let currency = pos.ccy || (ex.hasSpot() && (ex.getDefaultSymbol()+(alias.startsWith("OKEX")?'':marginSuffix))) || balance.lastSymbol
		// $$$$ check for margin/iso suffix
		balance = balance[currency] || balance[(currency = pos.sym)]
		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 = formatCcy(currency)
			pos.balance = (available === total ? total.toFixed(digits) : available.toFixed(digits-1)+' / '+total.toFixed(digits-1))+' '+currency
		}
		// $$$ get from symbol for new pos!
		if (pos.lev === "0") pos.lev = ({"SIMPLEFX": 400, "SIMPLEFX-DEMO": 400/*, "KRAKENFT": 50, "KRAKENFT-DEMO": 50*/})[pos.ex] || opt.posL
		else if (pos.lev) pos.lev = parseFloat(pos.lev) || opt.posL
	}
	pos.isAll = isAll
	pos.isClose = !isOpen
	pos.noSide = isAll || !isNew
	pos.short = !isAll && pos.side === 'short'
	pos.long = !isAll && (pos.side === 'long' || (isNew && !pos.short))
	pos.market = isMarket || isFlip
	// $$$$ save +/-/% price offset btn as well
	pos.p = isOpen ? opt[isNew ? 'posP' : 'posPA'] || 0 : opt.posPC || 0
	/* $$$$$$$$$$$$
FLIP =>
marketclose=<side> save
<!side>=@lastqty market
	*/
	pos.q = isFlip ? 200 : isOpen ? opt[isNew ? 'posQ' : 'posQA'] : isCancel && !isStops ? 0 : 100
	const tmp = new Template("#changePos")
	tmp.renderBuffer(pos)

	function updateOrder(ev) {
		const f = formData(q(".order form"))
		f.ordId = f.ordId.replace(Command.illegalId, '-')
		f.ordSym && q(".order .bootbox-accept").classList.remove('disabled')
		let doStops = f.ordSL || f.ordTP

		enableBtn("ordPx", !(!isOpen && doStops) && !f.ordMarket)
		enableBtn("ordPxType", !(!isOpen && doStops) && !f.ordMarket)
		enableBtn("ordPxRef", !(!isOpen && doStops) && (!f.ordMarket || f.ordTrig))
		if (f.ordMarket && !f.ordTrig) setRadio("ordPxRef", (f.ordPxRef = ""))
		enableBtn("posPost", !f.ordMarket)
		enableBtn("ordMarket", !(!isOpen && doStops))

		if (doStops) q("#ordTrig").checked = f.ordTrig = false
		enableBtn("ordTrig", !doStops)
		enableBtn("ordTrigPx", f.ordTrig)
		enableBtn("ordTrigPxType", f.ordTrig)

		enableBtn("ordSLPx", f.ordSL)
		enableBtn("ordSLPxType", f.ordSL)
		enableBtn("ordSLPxRef", f.ordSL)
		enableBtn("ordSLType", f.ordSL)
		enableBtn("ordTPPx", f.ordTP)
		enableBtn("ordTPPxType", f.ordTP)
		enableBtn("ordTPPxRef", f.ordTP)
		enableBtn("ordTPType", f.ordTP)
		let stopsReduce = !pos.isClose && opt.posStop
		const stopsOnFill = stopsReduce && pos.hasStopsOnFill
		if (stopsOnFill && (!f.ordSL || f.ordSLType === "market") && (!f.ordTP || f.ordTPType === "market")) {
			doStops = false
		}
		if (pos.hasReducePosOnly) {
			enableBtn("posStop", pos.hasStopsOnFill)
			stopsReduce = false
		} else if (stopsReduce && f.ordTPType === 'limit' && !pos.side) {
			q("#ordTPType-1").click()
			f.ordTPType = "stop"
		}
		pos.isClose && enableBtn("posStop", false)

		const isShort = f.ordSide === 'short'
		const longPos = isShort ? -1 :  1
		const longNeg = isShort ?  1 : -1
		if (f.ordPxType !== '@') f.ordPx *= longPos
		if (f.ordTrigPxType !== '@') f.ordTrigPx *= longPos
		if (f.ordTPPxType !== '@') f.ordTPPx *= longPos
		if (f.ordSLPxType !== '@') f.ordSLPx *= longNeg

		let syntax = isAll ? (opt.posPar ? 'ap=' : 'acc=')+allAcc+'\n' : `acc=${pos.ex}:${f.ordSym/*||(isOnlyCancel?'*':'')*/}:${pos.acc}\n`
		if (isOpen) {
			syntax += 
				// $$$ cancel=open || cancel=order id=ENTRY
				(f.ordSide ? `${f.ordSide}=${f.ordQty}${f.ordQtyType === '%' ? '%' : f.ordQtyType ? ` unit=${f.ordQtyType}` : ''} ` : '')+
				(f.ordLev !== "" ? `lev=${f.ordLev} ` : '')+
				(f.ordTrig /*&& f.ordTrigPx*/ ? `stop=${f.ordTrigPxType === '@' ? '@' : ''}${f.ordTrigPx}${f.ordTrigPxType === '@' ? '' : f.ordTrigPxType} ` : '')+
				(!f.ordMarket && f.ordPx ? `price=${f.ordPxType === '@' ? '@' : ''}${f.ordPx}${f.ordPxType === '@' ? '' : f.ordPxType} ` : '')+
				(f.ordPxRef ? `pr=${f.ordPxRef} ` : '')+
				(f.ordMarket ? `market ` : '')+
				(!f.ordMarket && opt.posPost ? `post ifreject=retry maxtime=${opt.posTime||1}m${doStops ? ' iftime=abort' : ''} ` : '')+
				(!f.ordSide ? `quantity=${f.ordQty}${f.ordQtyType === '%' ? '%' : f.ordQtyType ? ` unit=${f.ordQtyType}` : ''} ` : '')+
				(f.ordQtyType === '%' && f.ordQtyRef !== 'free' ? `yield=${f.ordQtyRef} ` : '')+
				(f.ordId || !f.ordMarket ? `id=${f.ordId || 'ENTRY'} ` : '')+
				(stopsOnFill && f.ordSL && f.ordSLType !== 'stop'   ? `sl=${f.ordSLPxType === '@' ? '@' : ''}${f.ordSLPx}${f.ordSLPxType === '@' ? '' : f.ordSLPxType} ${f.ordSLPxRef === 'pos' ? 'slref=price ':''}` : '')+
				(stopsOnFill && f.ordTP && f.ordTPType === 'market' ? `tp=${f.ordTPPxType === '@' ? '@' : ''}${f.ordTPPx}${f.ordTPPxType === '@' ? '' : f.ordTPPxType} ${f.ordTPPxRef === 'pos' ? 'tpref=price ':''}` : '')+
				(doStops && stopsReduce ? 'save' : '')+
				`\n`
			if (doStops && !stopsReduce) syntax += f.ordMarket ? `wait=2s\n` : 
				`iforder=retry id=${f.ordId || 'ENTRY'} maxtime=${opt.posTime||1}m iftime=cancelall cancelid=${f.ordId || 'ENTRY'} log=cond\n`
		} else if (!doStops) {
			// $$$ f.ordMarket ? => cancel needed eg for OKX BTCUSDT margin pos w open limit!
			if (/*f.ordMarket &&*/ f.ordQty >= 100 && f.ordQtyType ==='%') syntax += `cancel${pos.hasSpot ? ' margin':''}\n`
			else if (f.ordClear || f.ordClearL) syntax += `cancel${f.ordClearL ? (f.ordClear ? '' : '=limit') : '=stops'}${pos.hasSpot ? ' margin':''}\n`
			f.ordQty && (syntax += 
				`${f.ordMarket ? 'market' : ''}close`+
				(f.ordSide ? `=${f.ordSide} ` : ' ')+
				(!f.ordMarket && f.ordPx ? `price=${f.ordPxType === '@' ? '@' : ''}${f.ordPx}${f.ordPxType === '@' ? '' : f.ordPxType} ` : '')+
				(!f.ordMarket && opt.posPost ? `post ifreject=retry maxtime=${opt.posTime||1}m ` : '')+
				((!f.ordTrig && f.ordPxRef) || (f.ordTrig && !f.ordPxRef) ? `pr=${f.ordPxRef || (isShort ? 'ask' : 'bid')} ` : '')+
				(f.ordTrig /*&& f.ordTrigPx*/ ? `stop=${f.ordTrigPxType === '@' ? '@' : ''}${f.ordTrigPx}${f.ordTrigPxType === '@' ? '' : f.ordTrigPxType} ` : '')+
				(f.ordQty !== 100 || f.ordQtyType !== '%' ? `quantity=${f.ordQty}${f.ordQtyType === '%' ? '%' : ''} ` : '')+
				(f.ordQtyType && f.ordQtyType !== '%' ? `unit=${f.ordQtyType} ` : '')+
				(f.ordQtyType === '%' && f.ordQtyRef !== 'pos' ? `yield=${f.ordQtyRef} ` : '')+
				(f.ordQtyType === '%' && f.ordQty > 100 ? `reduce=0 ` : '')+
				(f.ordId ? `id=${f.ordId} ` : '')+
				`\n`)
		}
		if (doStops) {
			if (f.ordClear || f.ordClearL) syntax += `cancel${f.ordClearL ? (f.ordClear ? '' : '=limit') : f.ordSL && f.ordTP ? '=stops' : f.ordSL ? '=sl' : '=tp'}${pos.hasSpot ? ' margin':''}\n`
			if (f.ordSL && f.ordQty) {
				const type = pos.hasStopsOnFill ? 'so' : 'sl'
				if (!stopsOnFill || f.ordSLType !== 'market') {
					syntax += 
						(isOpen && stopsReduce ? `${isShort ? 'long' : 'short'}=lastqty@ reduce ` : 
						`close`+
						(f.ordSide ? `=${f.ordSide} ` : ' '))+
						(!isOpen && (f.ordQty !== 100 || f.ordQtyType !== '%') ? `quantity=${f.ordQty}${f.ordQtyType === '%' ? '%' : f.ordQtyType ? ` unit=${f.ordQtyType}` : ''} ` : '')+
						`${f.ordSLType !== 'market' ? 'price' : type}=${f.ordSLPxType === '@' ? '@' : ''}${f.ordSLPx}${f.ordSLPxType === '@' ? '' : f.ordSLPxType} `+
						(f.ordSLPxRef !== 'pos' ? (isOpen && stopsReduce ? '' : `pr=${isShort ? 'ask' : 'bid'} `) : isOpen && stopsReduce ? 'pr=lastprice ':'')+
						(f.ordSLType !== 'market' ? `${type}=${f.ordLmtDist*longNeg}${f.ordLmtDistType} ${type}ref=price ` : '')+
						(f.ordSLType === 'market' ? `market ` : ``)+
						`id=${!isOpen && f.ordId ? f.ordId+'-' : ''}${!f.ordSLType ? 'L' : ''}SL `+
						(isOpen && stopsReduce ? '' : `expect `)+
						(isOpen && !f.ordMarket && !stopsReduce ? `retries=3 ` : '')+
						`\n`
				}
				if (!f.ordSLType && !stopsOnFill) {
					syntax += 
						(isOpen && stopsReduce ? `${isShort ? 'long' : 'short'}=lastqty@ reduce ` : 
						`close`+
						(f.ordSide ? `=${f.ordSide} ` : ' '))+
						(!isOpen && (f.ordQty !== 100 || f.ordQtyType !== '%') ? `quantity=${f.ordQty}${f.ordQtyType === '%' ? '%' : f.ordQtyType ? ` unit=${f.ordQtyType}` : ''} ` : '')+
						`price=${f.ordSLPxType === '@' ? '@' : ''}${f.ordSLPx}${f.ordSLPxType === '@' ? '' : f.ordSLPxType} `+
						(f.ordSLPxRef !== 'pos' ? (isOpen && stopsReduce ? '' : `pr=${isShort ? 'ask' : 'bid'} `) : isOpen && stopsReduce ? 'pr=lastprice ':'')+
						`${type}=${f.ordStpDist*longNeg}${f.ordStpDistType} ${type}ref=price `+
						`market `+
						`id=${!isOpen && f.ordId ? f.ordId+'-' : ''}MSL `+
						(isOpen && stopsReduce ? '' : `expect `)+
						(isOpen && !f.ordMarket && !stopsReduce ? `retries=3 ` : '')+
						`\n`
				}
			}
			if (f.ordTP && f.ordQty && (!stopsOnFill || f.ordTPType !== 'market')) {
				const type = pos.hasStopsOnFill ? 'so' : 'tp'
				syntax += 
					(isOpen && stopsReduce ? `${isShort ? 'long' : 'short'}=lastqty@ reduce ` : 
					`close`+
					(f.ordSide ? `=${f.ordSide} ` : ' '))+
					(!isOpen && (f.ordQty !== 100 || f.ordQtyType !== '%') ? `quantity=${f.ordQty}${f.ordQtyType === '%' ? '%' : f.ordQtyType ? ` unit=${f.ordQtyType}` : ''} ` : '')+
					`${f.ordTPType === 'market' ? type : 'price'}=${f.ordTPPxType === '@' ? '@' : ''}${f.ordTPPx}${f.ordTPPxType === '@' ? '' : f.ordTPPxType} `+
					(f.ordTPType === 'stop' ? (f.ordTPPxRef !== 'pos' ? (isOpen && stopsReduce ? '' : `pr=${isShort ? 'ask' : 'bid'} `) : isOpen && stopsReduce ? 'pr=lastprice ':'') : 
						(f.ordTPType !== 'market' && f.ordTPPxRef === 'pos' ? `pr=${isOpen && stopsReduce ? 'lastprice' : 'pos'} ` : ''))+
					(f.ordTPType === 'market' ? 'market ' : f.ordTPType === 'stop' ? `${type}=${f.ordLmtDist*longNeg}${f.ordLmtDistType} ${type}ref=price ` : '')+
					`id=${!isOpen && f.ordId ? f.ordId+'-' : ''}TP `+
					(isOpen && stopsReduce ? '' : `expect `)+
					(isOpen && !f.ordMarket && !stopsReduce ? `retries=3 ` : '')+
					`\n`
			}
		}
		q("#ordSyntax").innerHTML = syntax
	}

	bootbox.dialog({
		title: `<span class="glyphicon glyphicon-alert"></span>  ${title}  <span class="glyphicon glyphicon-alert"></span>`,
		className: 'order',
		message: tmp.getBuffer(),
		size: 'large',
		onEscape: true,
		buttons: {
			ok: {
				label: `<span class="glyphicon glyphicon-ok"></span> ${!isOnlyCancel ? 'PLACE ':''}${btn} ${isAll || isOnlyCancel ? '<b>ORDERS</b>' : 'ORDER'}`, className: 'btn-success'+(isNew || isOnlyCancel ? ' disabled' : ''),
				callback: ev => {
					const syntax = q("#ordSyntax").innerHTML
					const f = formData(q(".order form"))
					const side = f.ordSide || ''
					const name = isAll ? `ALL-${btn}` : `${f.ordSym||'ALL'}-${side.toUpperCase()}-${btn}`

					let delay = 100
					if(opt.posLog) {
						openPage('log')
						delay = opt.showDebug ? 500 : 250
					}
					setTimeout(() => relayMsg('runCommandsCheck', name, {isManual: true, name: name, desc: syntax, side: side, 
						log: opt.posOnlyErr ? Command.log.warn/*Command.log.err?*/ : Command.log.all/*Command.log.nosyntax?*/}, opt.posTest), delay)
				}
			},
			cancel: {
				label: '<span class="glyphicon glyphicon-remove"></span> Cancel', className: 'btn-danger'
			},
		},
		onShow: ev => {
			if (isOpen) {
				q("input[name=ordQty]").dataset.opt = isNew ? 'posQ' : 'posQA'
				q("input[name=ordPx]").dataset.opt = isNew ? 'posP' : 'posPA'
			} else {
				q("input[name=ordPx]").dataset.opt = 'posPC'
			}
			initOptions()
			if (isStops) {
				q("#ordSL").checked = q("#ordTP").checked = true
				if (isClose && isMarket) {
					q("#ordSLType-2").click(), q("#ordTPType-2").click()
				}
			}
			if (isCancel) {
				if (!cancelType || cancelType == 1) q("#ordClear").checked = true
				if (!cancelType || cancelType == 2) q("#ordClearL").checked = true
			}
			updateOrder()
			qa(".order form .btn-group .btn-default").forEach(el => el.addEventListener('click', function(){setTimeout(updateOrder, 50)}))
			qa(".order input.form-control, .order form input[type=checkbox], .order form input[type=hidden]").forEach(el => el.addEventListener('change', function(){
				window.clearTimeout(window._updateOrder)
				window._updateOrder = setTimeout(updateOrder, 50)
			}))
			qa(".order input.form-control").forEach(el => el.addEventListener('keyup', function(){
				window.clearTimeout(window._updateOrder)
				window._updateOrder = setTimeout(updateOrder, 100)
			}))
		},
		onShown: ev => ev.currentTarget.querySelector('.bootbox-cancel').focus()
	})
}

function initExchangeList() {
	let exHTML = ''
	let marginHTML = ''
	if (!broker) return

	const exes = broker.getAll()
	for (const ex of exes) {
		const alias = ex.getAlias(-1)
		const entry = `<li><a href="#" data-value="${alias}">${ex.getName()}</a></li>`
		exHTML += entry

		if (ex.canListPos()) {
			canListPos[ex.getAlias()] = alias
			marginHTML += entry
			new Template("#exchange-positions", null, {
				"exchange.id": alias,
				"exchange.name": ex.getName(),
			}).render()
			renderPositions(alias, positions[alias])
		}
	}

	["telebotEx", "defExchange", "posEx"].forEach(name => 
		q('#'+name).nextElementSibling.insertAdjacentHTML('afterbegin', name === 'posEx' ? marginHTML : exHTML))
}

async function handleAutoExports() {
	const now = new Date()
	setTimeout(handleAutoExports, autoInterval - (now % autoInterval) + opt.fireOffset * 1000)

	if (opt.expConfAuto && intervalMatch(opt.expConfAutoInt, now)) {
		doConfigExport("Interval "+opt.expConfAutoInt)
	}
	if (opt.expLogAuto && intervalMatch(opt.expLogAutoInt, now)) {
		doLogExport(true, "Interval "+opt.expLogAutoInt)
	}
	if (opt.expBalAuto && intervalMatch(opt.expBalAutoInt, now)) {
		doBalanceExport(opt.expBalXLS ? "excel" : "csv", "Interval "+opt.expBalAutoInt)
	}
	if (opt.expBalMail && intervalMatch(opt.expBalMailInt, now)) {
		doBalanceExport("email", "Interval "+opt.expBalMailInt, opt.expBalMailTo)
	}
	if (opt.expPosAuto && intervalMatch(opt.expPosAutoInt, now)) {
		if (!(currentPage === "positions" && opt.posAuto && opt.posRate) && Date.now() - updatePosLast > autoPosUpdate) {
			log.notice(`[Auto Export => Interval ${opt.expPosAutoInt}] Updating all position info before export...`)
			await updatePos()
		}
		doPosExport(opt.expPosXLS ? "excel" : "csv", undefined, "Interval "+opt.expPosAutoInt)
	}
	if (opt.expPosMail && intervalMatch(opt.expPosMailInt, now)) {
		if (!(currentPage === "positions" && opt.posAuto && opt.posRate) && Date.now() - updatePosLast > autoPosUpdate) {
			log.notice(`[Auto Export => Interval ${opt.expPosMailInt}] Updating all position info before sending...`)
			await updatePos()
		}
		doPosExport("email", undefined, "Interval "+opt.expPosMailInt, opt.expPosMailTo)
	}
}

function initMenus() {
	$.contextMenu2({
		selector: '#alertlist form',
		animation: {duration: 0, show: 'show', hide: 'hide'},
		zIndex: 99999,
		items: {
			runLong: {name: "Run (Long)  <code class=hk>Ctrl+L</code>", isHtmlName: true, icon: "glyphicon-play", action: "runAlert", idx: 2},
			runShort: {name: "Run (Short)  <code class=hk>Ctrl+P</code>", isHtmlName: true, icon: "glyphicon-play", action: "runAlert", idx: 3},
			testLong: {name: "Test (Long)", icon: "glyphicon-question-sign", action: "runAlert", idx: 4},
			testShort: {name: "Test (Short)", icon: "glyphicon-question-sign", action: "runAlert", idx: 5},
			s1: "-----",
			lock: {name: "Lock...", icon: "glyphicon-lock", action: "setLock"},
			unlock: {name: "Unlock...", icon: "glyphicon-remove-sign", action: "setLock", idx: 1},
			interval: {name: "Edit interval...", icon: "glyphicon-time", action: "confInterval"},
			stats: {name: "Reset stats", icon: "glyphicon-adjust", action: "resetAlertStats"},
			s2: "-----",
			copy: {name: "Copy (Clipboard)", icon: "glyphicon-copy", action: "copyAlert"},
			paste: {name: "Paste (Clipboard)", icon: "glyphicon-paste", action: "pasteAlert"},
			s3: "-----",
			delete: {name: "Delete  <code class=hk>Ctrl+Q</code>", isHtmlName: true, icon: "delete", action: "removeAlert"},
			clone: {name: "Clone", icon: "copy", action: "cloneAlert"},
			add: {name: "Add new  <code class=hk>Ctrl+D</code>", isHtmlName: true, icon: "add", action: "createAlert", global: true},
		},
		callback: function(key, item) {
			item = item.items[key]
			const alert = this[0].dataset.alert
			const btn = qa((item.global ? '' : `[data-alert='${alert}']`)+`[data-action='${item.action}']`)[item.idx || 0] || q("#alert-"+alert)
			window[item.action](btn, {})
		}
	})
	$.contextMenu2({
		selector: '.alert-editor',
		animation: {duration: 0, show: 'show', hide: 'hide'},
		zIndex: 99999,
		items: {
			// $$$$$
			// Ctrl+Alt + Mouse => select in parallel multi line
			// Ctrl+Alt+Right => select next match in parallel/multi
			// Alt+E => goto next error
			undo: {name: "Undo  <code class=hk>Ctrl+Z</code>", isHtmlName: true, icon: "glyphicon-chevron-left"},
			redo: {name: "Redo  <code class=hk>Ctrl+Shift+Z</code>", isHtmlName: true, icon: "glyphicon-chevron-right"},
			s1: "-----",
			cut: {name: "Cut  <code class=hk>Ctrl+X</code>", isHtmlName: true, icon: "cut"},
			copy: {name: "Copy  <code class=hk>Ctrl+C</code>", isHtmlName: true, icon: "copy"},
			paste: {name: "Paste  <code class=hk>Ctrl+V</code>", isHtmlName: true, icon: "paste"},
			all: {name: "Select all  <code class=hk>Ctrl+A</code>", isHtmlName: true, icon: "glyphicon-ok-sign"},
			s2: "-----",
			find: {name: `Find...  <code class=hk>Ctrl+F</code>`, isHtmlName: true, icon: "glyphicon-search"},
			// Ctrl+K => findNext
			replace: {name: `Replace...  <code class=hk>Ctrl+H</code>`, isHtmlName: true, icon: "glyphicon-"},
			s3: "-----",
			moveLinesUp: {name: `Move Up  <code class=hk>Alt+↑</code>`, isHtmlName: true, icon: "glyphicon-"},
			moveLinesDown: {name: `Move Down  <code class=hk>Alt+↓</code>`, isHtmlName: true, icon: "glyphicon-"},
			blockIndent: {name: `Block Indent  <code class=hk>Ctrl+${window.qwertz?'`':']'}</code>`, isHtmlName: true, icon: "glyphicon-"},
			blockOutdent: {name: `Block Outdent  <code class=hk>Ctrl+${window.qwertz?'?':'['}</code>`, isHtmlName: true, icon: "glyphicon-"},
//			gotoLine: {name: `Go to line...  `, isHtmlName: true, icon: "glyphicon-"},
			toUpperCase: {name: `Uppercase  <code class=hk>Ctrl+U</code>`, isHtmlName: true, icon: "glyphicon-"},
			toLowerCase: {name: `Lowercase  <code class=hk>Ctrl+Shift+U</code>`, isHtmlName: true, icon: "glyphicon-"},
			s4: "-----",
			toggleCommentLines: {name: `Toggle Comment  <code class=hk>Ctrl+${window.qwertz?'#':'/'}</code>`, isHtmlName: true, icon: "glyphicon-pause"},
			lookupSymbol: {name: "Insert Symbol...  <code class=hk>Alt+S</code>", isHtmlName: true, icon: "glyphicon-search"},
		},
		callback: function(key, item) {
			const editor = ace.edit(this[0])
			const session = editor.getSession()
			switch (key) {
				case "find":
				case "replace":
					ace.config.loadModule("ace/ext/searchbox", function(e){e.Search(editor, key === "replace")})
					break
				case "cut":
				case "copy":
					document.execCommand(key)
					break
				case "paste":
					navigator.clipboard.readText().then(text => session.insert(session.selection.getCursor(), text))
					break
				case "all":
					session.selection.selectAll()
					break
				case "lookupSymbol":
					window[key](session, this[0].firstChild)
					break
				default:
					editor[key]()
					break
			}
			this[0].firstChild.focus()
		}
	})
/*	$.contextMenu2({
		selector: "button[data-action='tempAlert']",
		trigger: 'left',
		animation: {duration: 0, show: 'show', hide: 'hide'},
		zIndex: 99999,
		items: {
			3: {
				name: "General",
				items: {
					4: {name: "Market Entry (unified)"},
					5: {name: "Simple Limit Entry (unified)"},
					6: {name: "Update TP/SL"},
				}
			},
			6: {
				name: "Checks",
				items: {
					7: {name: "Abort if order w ID found"},
					8: {name: "Abort if order w ID NOT found"},
				}
			},
		},
		callback: function(key, item) {
		}
	})*/
	$.contextMenu2({
		selector: '#varBrowser tr',
		animation: {duration: 0, show: 'show', hide: 'hide'},
		zIndex: 99999,
		items: {
			rename: {name: "Rename  <code class=hk>F2</code>", isHtmlName: true, icon: "glyphicon-refresh", action: "renameVar", direct: true},
			edit: {name: "Edit value  <code class=hk>F4</code>", isHtmlName: true, icon: "edit", action: "editVar", direct: true},
			s1: "-----",
			add: {name: "New...  <code class=hk>Ctrl+D</code>", isHtmlName: true, icon: "add", action: "addVar", direct: true},
			delete: {name: "Delete  <code class=hk>Del</code>", isHtmlName: true, icon: "delete", action: "deleteVars"},
			s2: "-----",
			select: {name: "Select all  <code class=hk>Ctrl+A</code>", isHtmlName: true, icon: "glyphicon-ok-sign", action: "selectVars", direct: true},
			deselect: {name: "Deselect", icon: "glyphicon-remove-sign", action: "deselectVars", direct: true},
			s3: "-----",
			copy: {name: "Copy (Clipboard)", icon: "glyphicon-copy", button: 1},
			csv: {name: "Export (CSV)", icon: "glyphicon-open", button: 2},
			excel: {name: "Export (Excel)", icon: "glyphicon-open", button: 3},
		},
		callback: function(key, item) {
			item = item.items[key]
			item.button ? $('#varBrowser').DataTable().button(item.button).trigger() : window[item.action](this)
		}
	})
	$.contextMenu2({
		selector: '.exchange-page form[data-exchange]',
		animation: {duration: 0, show: 'show', hide: 'hide'},
		zIndex: 99999,
		items: {
			copy: {name: "Copy (Clipboard)", icon: "glyphicon-copy", action: "copyAccount"},
			paste: {name: "Paste (Clipboard)", icon: "glyphicon-paste", action: "pasteAccount"},
			s1: "-----",
			clone: {name: "Clone", icon: "copy", action: "cloneAccount"},
			delete: {name: "Delete  <code class=hk>Ctrl+Q</code>", isHtmlName: true, icon: "delete", action: "removeAccount"},
			add: {name: "Add new  <code class=hk>Ctrl+D</code>", isHtmlName: true, icon: "add", action: "createAccount", global: true},
			s2: "-----",
			test: {name: "Test API", icon: "glyphicon-refresh", action: "testAccount"},
			order: {name: "Order (Wizard)", icon: "add", action: "changePos"},
			delete2: {name: "Delete Balances", icon: "delete", action: "removeAccount", shift: true},
			s0: "-----",
			note: {name: "<code>Ctrl</code> – skip confirm", disabled: true, isHtmlName: true},
		},
		callback: function(key, item, ev) {
			item = item.items[key]
			const acc = this[0].dataset.account
			const ex = this[0].dataset.exchange
			if (!ex) return
			const btn = q((item.global ? `[data-exchange='${ex}']` : `[data-account='${acc}'][data-exchange='${ex}'] `)+`[data-action='${item.action}']`)
			const cb = window[item.action].bind(broker.getByAlias(ex))
			item.shift && (ev.shiftKey = true)
			cb(btn, ev)
		}
	})
	$.contextMenu2({
		selector: '#positions tr',
		animation: {duration: 0, show: 'show', hide: 'hide'},
		zIndex: 99999,
		items: {
			refresh: {name: "Refresh", icon: "glyphicon-refresh", action: "updatePos"},
			details: {name: "Show Details", icon: "glyphicon-search", action: "posInfo"},
			s1: "-----",
			close: {name: "Close...", icon: "glyphicon-minus", action: "changePos", do: "close"},
			add: {name: "Add...", icon: "add", action: "changePos", do: "add"},
			flip: {name: "Flip...", icon: "glyphicon-resize-vertical", action: "changePos", do: "flip"},
			market: {name: "Market Close...", icon: "glyphicon-remove-sign", action: "changePos", do: "close", act: "market"},
			s2: "-----",
			cancel: {name: "Cancel Orders...", icon: "delete", action: "changePos", do: "close", ctrl: true},
			cancel1: {name: "Cancel Stops...", icon: "glyphicon-chevron-right", action: "changePos", do: "close", ctrl: true, cancelType: 1},
			cancel2: {name: "Cancel Limit...", icon: "glyphicon-chevron-left", action: "changePos", do: "close", ctrl: true, cancelType: 2},
			s3: "-----",
			open: {name: "Open New...", icon: "add", action: "changePos", do: "add", shift: true},
			stops: {name: "TP / SL...", icon: "glyphicon-adjust", action: "changePos", do: "close", shift: true},
		},
		callback: function(key, item) {
			item = item.items[key]
			const btn = this[0].querySelector(`[data-action='${item.action}']${item.do ? `[data-do='${item.do}']`:''}`) || this[0]
			window[item.action](btn, {shiftKey: item.shift, ctrlKey: item.ctrl, cancelType: item.cancelType, act: item.act})
		}
	})
	$.contextMenu2({
		selector: 'body',
		animation: {duration: 0, show: 'show', hide: 'hide'},
		zIndex: 99999,
		items: {
			disable: {name: "Pause/Resume App", icon: "glyphicon-pause", action: "disableAll", direct: true},
			so: "-----",
			pause: {name: "Pause Alerts & App", icon: "glyphicon-pause", action: "handbrake", shift: true},
			stop: {name: "Stop Alerts & App", icon: "glyphicon-stop", action: "handbrake", shift: true},
			s1: "-----",
			market: {name: "Refresh Symbol Info", icon: "glyphicon-refresh", action: "clearSymbols"},
			time: {name: "Force Time Sync", icon: "glyphicon-time", action: "timeSync"},
			s2: "-----",
			restart: {name: "Restart App", icon: "glyphicon-repeat", action: "restartApp"},
			exportConf: {name: "Export Config  <code class=hk>Ctrl+E</code>", isHtmlName: true, icon: "glyphicon-open", action: "exportConfig"},
			importConf: {name: "Import Config  <code class=hk>Ctrl+O</code>", isHtmlName: true, icon: "glyphicon-save", action: "importConfig"},
			s3: "-----",
			exportBal1: {name: "Export Balances (CSV)", icon: "glyphicon-open", action: "exportBalances", format: "csv"},
			exportBal2: {name: "Export Balances (Excel)", icon: "glyphicon-open", action: "exportBalances", format: "excel"},
			copyBal: {name: "Copy Balances (Clipboard)", icon: "glyphicon-copy", action: "exportBalances", format: "csv", shift: true},
			clearBal: {name: "Clear Balances", icon: "glyphicon-adjust", action: "resetBalances"},
			s4: "-----",
			export: {name: "Export Log", icon: "glyphicon-floppy-save", action: "exportLog"},
			clear: {name: "Clear Log", icon: "delete", action: "clearLog"},
			scroll: {name: "Toggle Follow", icon: "glyphicon-resize-vertical", opt: "scrollLog"},
			s0: "-----",
			note: {name: "<code>Ctrl</code> – skip confirm", disabled: true, isHtmlName: true},
		},
		callback: function(key, item, ev) {
			item = item.items[key]
			if (item.opt) {
				opt[item.opt] = !opt[item.opt]
				optStore.save()
				const changed = item.opt + 'Changed'
				window[changed] && window[changed]()
			}
			if (item.action) {
				const btn = q(`[data-action='${item.action}']${item.format ? `[data-format='${item.format}']`:''}`)
				const keys = {shiftKey: item.shift, ctrlKey: item.ctrl || ev.ctrlKey}
				item.direct ? window[item.action]() : window[item.action](btn, keys)
			}
		}
	})
}

async function init() {
	await optStore.load(true)
	if (!bg) {
		if (!opt.closeBG) {
			error("Background process is not running – please reload or reinstall the extension!")
		} else {
			ok("Background process is not running – configuration changes are limited!<br\>(no changes to PV Alerts / Global Vars etc. possible)")
		}
	}
	toastr.options = {
		positionClass: 'toast-top-center', 
		showDuration: 250, hideDuration: 500, 
		timeOut: 5000, extendedTimeOut: 1000, 
		showEasing: 'swing', hideEasing: 'swing'
	}
	window.onresize = onResize
	q("#ver").innerText = pvVersion
	document.addEventListener('click', onClick)

	positions = await relayMsg('getPositions') || positions
	const {running, pause} = await relayMsg('getRunning') || {}
	runningChanged(running, pause)

	initExchangeList()
	initOptions()
	initMenus()

	initExchanges()
	await stats.load()
	initPermissions()
	initLicense()
	initVars()

	let langTools = ace.require("ace/ext/language_tools")
	const syntaxCommands = [
		// $$$ move to Command.js
		{value: 'a=', meta: 'Account name(s) of the API credentials you want to use for this or following commands (comma separated, default: *)'},
		{value: 'abort=', meta: 'Shorthand for do=abort'},
		{value: 'above=', meta: '#(-#), %(-%), customX, _name, $name | Filter positions (c/ch=pos) by their average entry price or orders (c/ch=order) by their limit or market stop price'},
		{value: 'aboveeq=', meta: '#(-#), %(-%), customX, _name, $name | Filter positions (c/ch=pos) by their average entry price or orders (c/ch=order) by their limit or market stop price'},
		{value: 'acc=', meta: 'Account name(s) of the API credentials you want to use for this or following commands (comma separated, default: *)'},
		{value: 'account=', meta: 'Account name of the API credentials you want to use for this or following commands (default: *)'},
		{value: 'alertcheck=', meta: 'alert | Process this line only if alert is found and enabled – or process ife=/ifd= (if specified) according to alert state!'},
		{value: 'amount=', meta: '#(-#), %(-%), customX, _name, $name | Portion of your balance or position (!) you want to affect (add "e" for y=equity, "p" for y=pos, "$" for u=currency)'},
		{value: 'ap=', meta: 'Use instead of a= to run ALL following commands in parallel on the given account(s) (comma separated)'},
		{value: 'addpos=', meta: '#(-#), %(-%) | Portion of an open position you want to add to the order size (or absolute amount, when used without %)'},
		{value: 'b=', score: 5, meta: 'buy, long; sell, short | Side of the market you want to place your order on or check for'},
		{value: 'back=', score: 4, meta: 'Shorthand for jump=back'},
		{value: 'below=', meta: '#(-#), %(-%), customX, _name, $name | Filter positions (c/ch=pos) by their average entry price or orders (c/ch=order) by their limit or market stop price'},
		{value: 'beloweq=', meta: '#(-#), %(-%), customX, _name, $name | Filter positions (c/ch=pos) by their average entry price or orders (c/ch=order) by their limit or market stop price'},
		{value: 'book=', score: 3, meta: 'buy, long; sell, short | Side of the market you want to place your order on or check for'},
		{value: 'bound=', score: 2, meta: '#(-#), %(-%), @abs | OANDA only: Worst market price allowed for an order to be filled at'},
		{value: 'bc=', score: 1, meta: '0, 1 | Use last cached balance info for an account if possible, otherwise current balance is queried!'},
		{value: 'buy=', score: 0, meta: 'Shorthand for side=buy quantity=... (default 100%)'},
		{value: 'c=', score: 27, meta: 'order, pos | Cancel open orders or close open positions according to other specified parameters (also: tp/sl/open/limit/close/stops/long/short/buy/sell)'},
		{value: 'cached=', score: 26, meta: 'Shorthand for cachedprice=1 cachedbalance=1 (specify 0 to disable for this command)'},
		{value: 'cachedbalance=', score: 25, meta: '0, 1 | Use last cached balance info for an account if possible, otherwise current balance is queried!'},
		{value: 'cachedprice=', score: 24, meta: '0, 1 | Use last cached price data (ticker) for a symbol if possible, otherwise new ticker data is queried!'},
		{value: 'cancel=', score: 23, meta: 'order (def), pos | Cancel open orders or close open positions according to other specified parameters (also: tp/sl/open/limit/close/stops/long/short/buy/sell)'},
//		{value: 'cancelall=', score: 22, meta: 'Shorthand for cancel=order symbol=*'},
		{value: 'cancelid=', score: 21, meta: 'name(s) | Only cancel orders with matching custom ID(s) (comma separated) for conditional actions cancel/cancelall/cancelside (eg. ifnopos=cancelall)'},
		{value: 'cb=', score: 20, meta: '0, 1 | Use last cached balance info for an account if possible, otherwise current balance is queried!'},
		{value: 'cbrate=', score: 19, meta: '# | Binance Futures only: Callback rate (distance) for trailing stops (min 0.1, max 5; 5 = 5%)'},
		{value: 'ch=', score: 18, meta: 'order, pos | Check for a matching order or position according to other specified parameters'},
		{value: 'cha=', score: 17, meta: 'alert | Process this line only if alert is found and enabled – or process ife=/ifd= (if specified) according to alert state!'},
		{value: 'chid=', score: 16, meta: 'name(s) | Check if order with matching custom ID(s) (comma separated) exists before closing or checking a position'},
		{value: 'check=', score: 15, meta: 'order (def), pos | Check for a matching order or position according to other specified parameters'},
		{value: 'checkalert=', score: 14, meta: 'alert | Process this line only if alert is found and enabled – or process ife=/ifd= (if specified) according to alert state!'},
		{value: 'checkid=', score: 13, meta: 'name(s) | Check if order with matching custom ID(s) (comma separated) exists before closing or checking a position'},
		{value: 'checklock=', score: 12, meta: '1|2,Sym,TF | Check if symbol is locked (2=ign. timeframe), optionally specify "exchange:symbol" and timeframe (minutes)'},
		{value: 'checkorder=', score: 11, meta: 'Shorthand for check=order (optionally specify side or order type eg. checkorder=stop)'},
		{value: 'checkpos=', score: 10, meta: 'Shorthand for check=position (optionally specify side to check eg. checkpos=long)'},
		{value: 'cid=', score: 9, meta: 'name(s) | Check if order with matching custom ID(s) (comma separated) exists before closing or checking a position | alt: ID(s) to filter for cancel conditionals'},
		{value: 'close=', score: 8, meta: 'order, pos (def) | Cancel open orders or close open positions according to other specified parameters (also: tp/sl/open/limit/close/stops/long/short/buy/sell)'},
		{value: 'closeall=', score: 7, meta: 'Shorthand for close=position symbol=* quantity=... (default 100%)'},
		{value: 'closeid=', score: 6, meta: 'name(s) | Custom ID to use for close position conditional actions closeside/closeall (eg. ifnoorder=closeall)'},
		{value: 'closemarket=', score: 5, meta: 'Shorthand for close=position type=market quantity=... (default 100%) (optionally specify side to close eg. closemarket=long)'},
		{value: 'closepos=', score: 4, meta: 'Shorthand for close=position quantity=... (default 100%) (optionally specify side to close eg. closepos=long)'},
		{value: 'cm=', score: 3, meta: '#(-#), %(-%) | Cancel / Close / Check Maximum (see cmo for ordering)'},
		{value: 'cmcid=', score: 2, meta: '<id> | Specifies the CoinMarketCap ticker to query for min/max price change checks (default: bitcoin)'},
		{value: 'cmo=', score: 1, meta: 'newest, oldest; lowest, highest; smallest, biggest; random | Sort order for cancel/close/check'},
		{value: 'convert=', meta: 'src:dest | FTX only: Convert wallet balance of currency "src" into currency "dest" (comma separated list, amount via q=)'},
		{value: 'cp=', meta: '0, 1 | Use last cached price data (ticker) for a symbol if possible, otherwise new ticker data is queried!'},
		{value: 'cr=', meta: '0, 1 | Inverse the filtering of orders (c/ch=order) or positions (c/ch=pos)'},
		{value: 'cross=', meta: 'Shorthand for margintype=crossed'},
		{value: 'crossed=', meta: 'Shorthand for margintype=crossed'},
		{value: 'custom=', meta: 'name(s) | Custom ID for order placement or to target order(s) by for cancelation/check'},
		{value: 'dec=', meta: 'var(:val),... | Shorthand for set=var:(var - val),... (val defaults to 1)'},
		{value: 'delay=', meta: 'seconds, % | Pauses execution between previous and this command (line) for the specified time (supports time units eg. 2.5h = 150m)'},
		{value: 'd=', meta: '0, 1 | Disables placement and cancelation of real orders – helpful for debugging'},
		{value: 'debug=', meta: '0, 1 | Disables placement and cancelation of real orders – helpful for debugging'},
		{value: 'disabled=', meta: '0, 1 | Disables placement and cancelation of real orders – helpful for debugging'},
		{value: 'disable=', meta: `alert | Disable given ${pvShortU} Alert or current running alert if none is specified, use "${pvShortU}" or "APP" to disable ${pvShortU}`},
		{value: 'div=', meta: 'var(:val),... | Shorthand for set=var:(var / val),... (val defaults to 1)'},
		{value: 'do=', meta: '#, label | Skip over given number of commands (-1 = all) or jump to <label>, unless error (see err=, ifn=, ifo= etc.)'},
		{value: 'dumpvars=', meta: '0, 1 | Print all currently available local variables and OHLC plot data to the log'},
		{value: 'e=', meta: 'BitMEX, Binance, ... | Receiving exchange for this command (default: taken from TV alert!)'},
		{value: 'echo=', meta: 'Shorthand for notify=log:...'},
		{value: 'else=', meta: '#, label | When "if=" condition evaluated to FALSE or ZERO: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'enable=', meta: `alert | Enable given ${pvShortU} Alert or current running alert if none is specified, use "${pvShortU}" or "APP" to enable ${pvShortU}`},
		{value: 'end=', meta: 'Shorthand for jump=back'},
		{value: 'eid=', meta: 'name(s) | Only cancel orders with matching custom ID(s) (comma separated) for err=cancel/cancelall/cancelside or to use for closing orders err=closesall/closeside'},
		{value: 'err=', meta: '#, label | On error: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'errid=', meta: 'name(s) | Only cancel orders with matching custom ID(s) (comma separated) for err=cancel/cancelall/cancelside or to use for closing orders err=closesall/closeside'},
		{value: 'error=', meta: '#, label | On error: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'errorid=', meta: 'name(s) | Only cancel orders with matching custom ID(s) (comma separated) for err=cancel/cancelall/cancelside or to use for closing orders err=closesall/closeside'},
		{value: 'errorp=', meta: '#(-#), %(-%), @abs | If error handling is set to close positions: amount to overcut/undercut current bid/ask price'},
		{value: 'errorprice=', meta: '#(-#), %(-%), @abs | If error handling is set to close positions: amount to overcut/undercut current bid/ask price'},
		{value: 'ep=', meta: '#(-#), %(-%), @abs | If error handling is set to close positions: amount to overcut/undercut current bid/ask price'},
		{value: 'ex=', meta: 'BitMEX, Binance, ... | Receiving exchange for this command (default: taken from TV alert!)'},
		{value: 'exit=', meta: 'Shorthand for do=abort'},
		{value: 'exitall=', meta: 'Terminate all currently running alerts'},
		{value: 'exchange=', meta: 'BitMEX, Binance, ... | Receiving exchange for this command (default: taken from TV alert!)'},
		{value: 'expect=', meta: '# | Command expected to return at least # order(s) or none (0), otherwise treat as error (skip retries with negative #)'},
		{value: 'fallback=', meta: '#, label | Fallback action if other jump labels fail: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'fb=', meta: '#, label | Fallback action if other jump labels fail: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'fifo=', meta: '0, 1 | SimpleFX only: Set FIFO order flag (true by default)'},
		{value: 'fill=', meta: '0, 1 | FTX only: Set retryUntilFilled flag (true by default)'},
		{value: 'filtertrades=', meta: '0, 1 | OANDA only: Apply cm/maximum and cmo/order options per trade for close=pos (instead of per position)'},
		{value: 'fixed=', meta: '#(-#) | Fixed price for which to place an order or close a position'},
		{value: 'fixedp=', meta: '#(-#) | Fixed price for which to place an order or close a position'},
		{value: 'fixedprice=', meta: '#(-#) | Fixed price for which to place an order or close a position'},
		{value: 'fp=', meta: '#(-#) | Fixed price for which to place an order or close a position'},
		{value: 'from=', meta: 'seconds, % | Specify starting time window for PNL query; default from now, see since= (supports time units eg. -2d = back 2 days from now)'},
		{value: 'ft=', meta: '0, 1 | OANDA only: Apply cm/maximum and cmo/order options per trade for close=pos (instead of per position)'},
		{value: 'getbalance=', meta: 'Shorthand for updatebalance=1 save=1'},
		{value: 'getorder=', meta: 'Shorthand for check=order save=1'},
		{value: 'getpos=', meta: 'Shorthand for check=pos save=1'},
		{value: 'getprice=', meta: 'Shorthand for updateprice=1 save=1'},
		{value: 'go=', meta: '#, label | Skip over given number of commands (-1 = all) or jump to <label>, unless error (see err=, ifn=, ifo= etc.)'},
		{value: 'goto=', meta: '#, label | Skip over given number of commands (-1 = all) or jump to <label>, unless error (see err=, ifn=, ifo= etc.)'},
		{value: 'gsl=', meta: '0, 1 | OANDA only: Place stop (sl=) as "guaranteed stop loss" (for higher fees)'},
		{value: 'gt=', meta: '#(-#), %(-%), customX, _name, $name | Filter positions (c/ch=pos) by their average entry price or orders (c/ch=order) by their limit or market stop price'},
		{value: 'gte=', meta: '#(-#), %(-%), customX, _name, $name | Filter positions (c/ch=pos) by their average entry price or orders (c/ch=order) by their limit or market stop price'},
		{value: 'guaranteed=', meta: '0, 1 | OANDA only: Place stop (sl=) as "guaranteed stop loss" (for higher fees)'},
		{value: 'h=', meta: '#(-#), %(-%) | Bitfinex: 1 = hidden | BitMEX, Deribit, KuCoin FT: portion to be visible, 0 = hidden | Binance: portion to be invisible'},
		{value: 'hidden=', meta: '#(-#), %(-%) | Bitfinex: 1 = hidden | BitMEX, Deribit, KuCoin FT: portion to be visible, 0 = hidden | Binance: portion to be invisible'},
		{value: 'id=', meta: 'name(s) | Custom ID for order placement or to target order(s) by for cancelation/check'},
		{value: 'iceberg=', meta: '#(-#), %(-%) | Bitfinex: 1 = hidden | BitMEX, Deribit, KuCoin FT: portion to be visible, 0 = hidden | Binance: portion to be invisible'},
		{value: 'inc=', meta: 'var(:val),... | Shorthand for set=var:(var + val),... (val defaults to 1)'},
		{value: 'interval=', meta: `[alert,]interval | Configure ${pvShortU} Alert or current running alert if none specified to run in given interval (+x = x minutes from now)`},
		{value: 'if=', meta: '(condition) | Condition that has to evaluate to true or non-zero before executing the rest of the command line is executed, eg. if=(_myplot > close)'},
		{value: 'ifbg=', meta: '#, label | If current pos/order size is BIGGER than saved one: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifbigger=', meta: '#, label | If current pos/order size is BIGGER than saved one: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifbuy=', meta: '#, label | If BUY order or position found: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifd=', meta: '#, label | If alert specified with cha=xxx is DISABLED: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifdisabled=', meta: '#, label | If alert specified with cha=xxx is DISABLED: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ife=', meta: '#, label | If alert specified with cha=xxx is ENABLED: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifenabled=', meta: '#, label | If alert specified with cha=xxx is ENABLED: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'iferror=', meta: '#, label | On error: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'iffound=', meta: '#, label | If AT LEAST ONE found (or placed): cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifl=', meta: '#, label | If LONG order or position found: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'iflong=', meta: '#, label | If LONG order or position found: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifmatch=', meta: '#, label | If AT LEAST ONE found (or placed): cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifminsp=', meta: '#, label | If bid-ask spread is TOO LOW (see minsp=): cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifminspread=', meta: '#, label | If bid-ask spread is TOO LOW (see minspread=): cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifmaxsp=', meta: '#, label | If bid-ask spread is TOO HIGH (see maxsp=): cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifmaxspread=', meta: '#, label | If bid-ask spread is TOO HIGH (see maxspread=): cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifn=', meta: '#, label | If NONE found/matched (or placed): cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifnone=', meta: '#, label | If NONE found/matched (or placed): cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifnomatch=', meta: '#, label | If NONE found/matched (or placed): cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifnoorder=', meta: '#, label | Shorthand for check=order ifnone=... | cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifnopos=', meta: '#, label | Shorthand for check=pos ifnone=... | cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifnot=', meta: '#, label | If NONE found/matched (or placed): cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifo=', meta: '#, label | If AT LEAST ONE found (or placed): cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifopen=', meta: '#, label | If AT LEAST ONE found (or placed): cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'iforder=', meta: '#, label | Shorthand for check=order iffound=... | cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifp=', meta: '#, label | If found order was PARTIALLY filled: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifpartial=', meta: '#, label | If found order was PARTIALLY filled: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifpos=', meta: '#, label | Shorthand for check=pos iffound=... | cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifr=', meta: '#, label | If type=post order is immediately rejected: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifreject=', meta: '#, label | If type=post order is immediately rejected: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifrejected=', meta: '#, label | If type=post order is immediately rejected: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifs=', meta: '#, label | If SHORT order or position found: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifsell=', meta: '#, label | If SELL order or position found: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifshort=', meta: '#, label | If SHORT order or position found: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifsl=', meta: '#, label | If slippage TOO HIGH (see msl=): cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifslip=', meta: '#, label | If slippage TOO HIGH (see msl=): cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifsm=', meta: '#, label | If current pos/order size is SMALLER than saved one: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ifsmaller=', meta: '#, label | If current pos/order size is SMALLER than saved one: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ift=', meta: '#, label | If more time than maxtime has elapsed: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'iftf=', meta: '#, label | If maxtime has elapsed AND PARTIALLY filled order found: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'iftnf=', meta: '#, label | If maxtime has elapsed AND UNFILLED order found: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'iftime=', meta: '#, label | If more time than maxtime has elapsed: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'iftimefill=', meta: '#, label | If maxtime has elapsed AND PARTIALLY filled order found: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'iftimenofill=', meta: '#, label | If maxtime has elapsed AND UNFILLED order found: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'iftimenopos=', meta: '#, label | If maxtime has elapsed AND NO position found: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'iftimeout=', meta: '#, label | If more time than maxtime has elapsed: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'iftimepos=', meta: '#, label | If maxtime has elapsed AND position found: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'iftnp=', meta: '#, label | If maxtime has elapsed AND NO position found: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'iftp=', meta: '#, label | If maxtime has elapsed AND position found: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ismargin=', meta: '0, 1 | Place as margin order instead of spot; alternative to b=long/short'},
		{value: 'iso=', meta: '0, 1 | Binance / KuCoin: Use isolated margin (instead of crossed) with spot margin order'},
		{value: 'isolated=', meta: '0, 1 | Binance / KuCoin: Use isolated margin (instead of crossed) with spot margin order'},
		{value: 'jd=', meta: 'seconds | Pauses execution between this and the next command IF a conditional jump is made (negative value = only for back jumps)'},
		{value: 'jump=', meta: '#, label | Skip over given number of commands (-1 = all) or jump to <label>, unless error (see err=, ifn=, ifo= etc.)'},
		{value: 'jumpdelay=', meta: 'seconds | Pauses execution between this and the next command IF a conditional jump is made (negative value = only for back jumps)'},
		{value: 'l=', score: 9, meta: 'Leverage, multiplying your available balance while increasing risk (!) | BitMEX, Bybit, Phemex: 0 = cross'},
		{value: 'lc=', score: 8, meta: '# >= 1 | Leverage calculation factor to use instead of specified or detected leverage (override!)'},
		{value: 'limit=', score: 7, meta: 'Shorthand for type=limit'},
		{value: 'lq=', score: 6, meta: '0, 1 | Use previously saved "leftover" quantity from an unfilled or only partially filled order (see save=)'},
		{value: 'lt=', meta: '#(-#), %(-%), customX, _name, $name | Filter positions (c/ch=pos) by their average entry price or orders (c/ch=order) by their limit or market stop price'},
		{value: 'lte=', meta: '#(-#), %(-%), customX, _name, $name | Filter positions (c/ch=pos) by their average entry price or orders (c/ch=order) by their limit or market stop price'},
		{value: 'leftover=', score: 5, meta: '0, 1 | Use previously saved "leftover" quantity from an unfilled or only partially filled order (see save=)'},
		{value: 'lev=', score: 4, meta: 'Leverage, multiplying your available balance while increasing risk (!) | BitMEX, Bybit, OKX, Phemex: 0 = cross'},
		{value: 'levcalc=', score: 3, meta: '# >= 1 | Leverage calculation factor to use instead of specified or detected leverage (override!)'},
		{value: 'leverage=', score: 2, meta: 'Leverage, multiplying your available balance while increasing risk (!) | BitMEX, Bybit, OKX, Phemex: 0 = cross'},
		{value: 'loan=', score: 1, meta: 'BTC, BNB, ... | Binance: margin borrow on specified assets (comma separated, use q=#/% for amount)'},
		{value: 'lock=', meta: '1|0,Sym,TF | Lock (1) or Unlock (0) symbol, optionally specify "exchange:symbol" or custom name and timeframe (minutes)'},
		{value: 'lockcheck=', meta: '1|2,Sym,TF | Check if symbol is locked (2=ign. timeframe), optionally specify "exchange:symbol" or custom name and timeframe (minutes)'},
		{value: 'log=', meta: 'all, nosyntax, nodebug, cond, warn, err, debug, none | Loglevel to use for this command'},
		{value: 'loglevel=', meta: 'all, nosyntax, nodebug, cond, warn, err, debug, none | Loglevel to use for this command'},
		{value: 'logvars=', meta: '0, 1 | Print all currently available local variables and OHLC plot data to the log'},
		{value: 'long=', meta: 'Shorthand for side=long quantity=... (default 100%) (margin trade!)'},
		{value: 'loop=', meta: '#, label | Skip over given number of commands (-1 = all) or jump to <label>, unless error (see err=, ifn=, ifo= etc.)'},
		{value: 'lr=', meta: 'customX, _name, $name | Specifies from which custom plot data to take an optional leverage multiplier'},
		{value: 'lref=', meta: 'customX, _name, $name | Specifies from which custom plot data to take an optional leverage multiplier'},
		{value: 'margin=', meta: '0, 1 | Place as margin order instead of spot; alternative to b=long/short'},
		{value: 'margintype=', meta: 'isolated/iso, crossed/cross; borrow, repay | BinanceFT/Bybit/KrakenFT/OKX/Phemex: Margin type to switch to, Binance: borrow = auto borrow, repay = auto repay'},
		{value: 'market=', meta: 'Shorthand for type=market'},
		{value: 'marketclose=', meta: 'Shorthand for close=position type=market quantity=... (default 100%) (optionally specify side to close eg. closepos=long)'},
		{value: 'marketcloseall=', meta: 'Shorthand for close=position symbol=* type=market quantity=... (default 100%) (optionally specify side to close eg. marketcloseall=long)'},
		{value: 'marketbuy=', meta: 'Shorthand for side=buy type=market'},
		{value: 'marketsell=', meta: 'Shorthand for side=sell type=market'},
		{value: 'marketlong=', meta: 'Shorthand for side=long type=market'},
		{value: 'marketshort=', meta: 'Shorthand for side=short type=market'},
		{value: 'minb=', meta: '#, % | Minimum available balance, if below treat as error'},
		{value: 'minbalance=', meta: '#, % | Minimum available balance, if below treat as error'},
		{value: 'maxb=', meta: '#, % | Maximum available balance, if above treat as error'},
		{value: 'maxbalance=', meta: '#, % | Maximum available balance, if above treat as error'},
		{value: 'minhvd=', meta: '# | Minimum daily historical BTC volatility, if below treat as error'},
		{value: 'mindailyvola=', meta: '# | Minimum daily historical BTC volatility, if below treat as error'},
		{value: 'maxhvd=', meta: '# | Maximum daily historical BTC volatility, if above treat as error'},
		{value: 'maxdailyvola=', meta: '# | Maximum daily historical BTC volatility, if above treat as error'},
		{value: 'minhvw=', meta: '# | Minimum weekly historical BTC volatility, if below treat as error'},
		{value: 'minweeklyvola=', meta: '# | Minimum weekly historical BTC volatility, if below treat as error'},
		{value: 'maxhvw=', meta: '# | Maximum weekly historical BTC volatility, if above treat as error'},
		{value: 'maxweeklyvola=', meta: '# | Maximum weekly historical BTC volatility, if above treat as error'},
		{value: 'minhvm=', meta: '# | Minimum monthly historical BTC volatility, if below treat as error'},
		{value: 'minmonthlyvola=', meta: '# | Minimum monthly historical BTC volatility, if below treat as error'},
		{value: 'maxhvm=', meta: '# | Maximum monthly historical BTC volatility, if above treat as error'},
		{value: 'maxmonthlyvola=', meta: '# | Maximum monthly historical BTC volatility, if above treat as error'},
		{value: 'minp=', meta: '# | Minimum absolute price (if p is specified as percentage)'},
		{value: 'minprice=', meta: '# | Minimum absolute price (if p is specified as percentage)'},
		{value: 'maxp=', meta: '# | Maximum absolute price (if p is specified as percentage)'},
		{value: 'maxprice=', meta: '# | Maximum absolute price (if p is specified as percentage)'},
		{value: 'minpl=', meta: '#(-#), %(-%) | Filter open positions by profit (only if above) – use either absolute contracts/units or %'},
		{value: 'minpnl=', meta: '#(-#), %(-%) | Filter open positions by profit (only if above) – use either absolute contracts/units or %'},
		{value: 'maxpl=', meta: '#(-#), %(-%) | Filter open positions by profit (only if below) – use either absolute contracts/units or %'},
		{value: 'maxpnl=', meta: '#(-#), %(-%) | Filter open positions by profit (only if below) – use either absolute contracts/units or %'},
		{value: 'minq=', meta: '# | Minimum absolute quantity (if q is specified as percentage)'},
		{value: 'minquantity=', meta: '# | Minimum absolute quantity (if q is specified as percentage)'},
		{value: 'maxq=', meta: '# | Maximum absolute quantity (if q is specified as percentage)'},
		{value: 'maxquantity=', meta: '# | Maximum absolute quantity (if q is specified as percentage)'},
		{value: 'mins=', meta: '#(-#), %(-%) | Filter open positions by size (only if above) – use either absolute contracts/units or %'},
		{value: 'minsr=', meta: 'customX, _name, $name | Specifies from which custom plot data to take an optional minsize multiplier'},
		{value: 'minsize=', meta: '#(-#), %(-%) | Filter open positions by size (only if above) – use either absolute contracts/units or %'},
		{value: 'minsizeref=', meta: 'customX, _name, $name | Specifies from which custom plot data to take an optional minsize multiplier'},
		{value: 'max=', meta: '# | Maximum number of iterations for a specific jump (when used with do/goto/jump/loop) or current command'},
		{value: 'maxcount=', meta: '# | Maximum number of iterations for a specific jump (when used with do/goto/jump/loop) or current command'},
		{value: 'maxs=', meta: '#(-#), %(-%) | Filter open positions by size (only if below) – use either absolute contracts/units or %'},
		{value: 'maxsr=', meta: 'customX, _name, $name | Specifies from which custom plot data to take an optional maxsize multiplier'},
		{value: 'maxsize=', meta: '#(-#), %(-%) | Filter open positions by size (only if below) – use either absolute contracts/units or %'},
		{value: 'maxsizeref=', meta: 'customX, _name, $name | Specifies from which custom plot data to take an optional maxsize multiplier'},
		{value: 'maxslip=', meta: '#(-#), %(-%) | Compare current price to last cached price or OHLC / custom plot, only place order if not further *against* us than specified'},
		{value: 'maxslipref=', meta: 'ask, bid, mid, last, pos, _name, $name | Price reference for slippage check (see msl=), mid = ask-bid average, last = last trade price, pos = position avg (margin!)'},
		{value: 'minsp', meta: '#(-#), %(-%) | Compare current ticker bid-ask (or OHLC / custom plot) spread, only place order if spread *higher* than specified value'},
		{value: 'minspread', meta: '#(-#), %(-%) | Compare current ticker bid-ask (or OHLC / custom plot) spread, only place order if spread *higher* than specified value'},
		{value: 'maxsp', meta: '#(-#), %(-%) | Compare current ticker bid-ask (or OHLC / custom plot) spread, only place order if spread *lower* than specified value'},
		{value: 'maxspread', meta: '#(-#), %(-%) | Compare current ticker bid-ask (or OHLC / custom plot) spread, only place order if spread *lower* than specified value'},
		{value: 'maxtime=', meta: '#(-#), %(-%) | Maximum time allowed since initial firing of alert in either seconds or as % of bar time'},
		{value: 'maximum=', score: -1, meta: '#(-#), %(-%) | Cancel / Close / Check Maximum (see cmo for ordering)'},
		{value: 'min1h=', meta: '# | Check CoinMarketCap price ticker for minimum hourly price change percentage – if under treat as error'},
		{value: 'max1h=', meta: '# | Check CoinMarketCap price ticker for maximum hourly price change percentage – if over treat as error'},
		{value: 'min24h=', meta: '# | Check CoinMarketCap price ticker for minimum daily price change percentage – if under treat as error'},
		{value: 'max24h=', meta: '# | Check CoinMarketCap price ticker for maximum daily price change percentage – if over treat as error'},
		{value: 'min7d=', meta: '# | Check CoinMarketCap price ticker for minimum weekly price change percentage – if under treat as error'},
		{value: 'max7d=', meta: '# | Check CoinMarketCap price ticker for maximum weekly price change percentage – if over treat as error'},
		{value: 'mc=', meta: 'Shorthand for close=position type=market quantity=... (default 100%) (optionally specify side to close eg. mc=long)'},
		{value: 'mca=', meta: 'Shorthand for close=position symbol=* type=market quantity=... (default 100%) (optionally specify side to close eg. mca=long)'},
		{value: 'msl=', meta: '#(-#), %(-%) | Compare current price to last cached price or OHLC / custom plot, only place order if not further *against* us than specified'},
		{value: 'mslr=', meta: 'ask, bid, mid, last, pos, _name, $name | Price reference for slippage check (see msl=), mid = ask-bid average, last = last trade price, pos = position avg (margin!)'},
		{value: 'mt=', meta: 'isolated/iso, crossed/cross; borrow, repay | BinanceFT/Bybit/KrakenFT/OKX/Phemex: Margin type to switch to, Binance: borrow = auto borrow, repay = auto repay'},
		{value: 'mul=', meta: 'var(:val),... | Shorthand for set=var:(var * val),... (val defaults to 1)'},
		{value: 'n=', meta: 'target:"message":"subject":rcpt, ... | Send custom notification to targets (d = discord, e = email, i = ifttt, l = log, s = sms/twilio, t = telegram)'},
		{value: 'name=', meta: 'name(s) | Custom ID for order placement or to target order(s) by for cancelation/check'},
		{value: 'notify=', meta: 'target:"message":"subject":rcpt, ... | Send custom notification to targets (d = discord, e = email, i = ifttt, l = log, s = sms/twilio, t = telegram)'},
		{value: 'nr=', meta: '#, label | If NOTHING open/found AT ALL: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'nonmatch=', meta: '0, 1 | Inverse the filtering of orders (c/ch=order) or positions (c/ch=pos)'},
		{value: 'noorder=', meta: '#, label | If NOTHING open/found AT ALL: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'nopos=', meta: '#, label | If NOTHING open/found AT ALL: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'noresult=', meta: '#, label | If NOTHING open/found AT ALL: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'onerror=', meta: '#, label | On error: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'order=', meta: 'newest, oldest; lowest, highest; smallest, biggest; random | Sort order for cancel/close/check'},
		{value: 'p=', meta: '#(-#), %(-%), @abs | Amount to over/undercut currentcut bid/ask price- either by percentage or absolute price!'},
		{value: 'pb=', meta: '#(-#), %(-%), @abs | OANDA only: Worst market price allowed for an order to be filled at'},
		{value: 'par=', meta: 'Use instead of a= to run ALL following commands in parallel on the given accounts'},
		{value: 'parallel=', meta: 'Use instead of a= to run ALL following commands in parallel on the given accounts'},
		{value: 'pauseall=', meta: 'Pause all currently running alerts'},
		{value: 'pc=', meta: '0, 1 | Use last cached price data (ticker) for a symbol if possible, otherwise new ticker data is queried!'},
		{value: 'plref=', meta: 'pos, lev, total | Reference for profit loss calculation, lev = position leveraged, total = account total'},
		{value: 'pnlref=', meta: 'pos, lev, total | Reference for profit loss calculation, lev = position leveraged, total = account total'},
		{value: 'price=', meta: '#(-#), %(-%), @abs | Amount to over/undercut currentcut bid/ask price- either by percentage or absolute price!'},
		{value: 'pr=', meta: 'ask,bid,mid,last,pos,customX,_name,$name... | Price reference for price calc, mid = ask-bid average, last = last trade price, pos = avg entry (margin!)'},
		{value: 'priceref=', meta: 'ask,bid,mid,last,pos,customX,_name,$name... | Price reference for price calc, mid = ask-bid average, last = last trade price, pos = avg entry (margin!)'},
		{value: 'pm=', meta: 'normal / oneway, hedge / hedged, auto | Binance Futures / Bybit / Phemex: Position mode for order or to filter by (default: auto)'},
		{value: 'posmode=', meta: 'normal / oneway, hedge / hedged, auto | Binance Futures / Bybit / Phemex: Position mode for order or to filter by (default: auto)'},
		{value: 'pp=', meta: '0, 1 | Binance Futures only: When the stop triggers the difference between mark and contract price cannot be > than symbols "trigger protect"'},
		{value: 'protect=', meta: '0, 1 | Binance Futures only: When the stop triggers the difference between mark and contract price cannot be > than symbols "trigger protect"'},
		{value: 'q=', meta: '#(-#), %(-%), customX, _name, $name | Portion of your balance or position (!) you want to affect (add "e" for y=equity, "p" for y=pos, "$" for u=currency)'},
		{value: 'quantity=', meta: '#(-#), %(-%), customX, _name, $name | Portion of your balance or position (!) you want to affect (add "e" for y=equity, "p" for y=pos, "$" for u=currency)'},
		{value: 'query=', meta: 'PNL | Query last 100 PNL records for current symbol, specify time window via from=, limit with maximum=/cm=, filter with minpl/maxpl/mins/maxs/gt/gte/lt/lte'},
		{value: 'quit=', meta: 'Shorthand for do=abort'},
		{value: 'qr=', meta: 'customX, _name, $name | Specifies from which custom plot data to take an optional quantity multiplier'},
		{value: 'qref=', meta: 'customX, _name, $name | Specifies from which custom plot data to take an optional quantity multiplier'},
		{value: 'r=', score: 8, meta: '0, 1 | BinanceFT, BitMEX, Bybit, Deribit, FTX, KrakenFT, KuCoinFT, OANDA, Phemex: Place as "reduce only" (default for close=pos)'},
		{value: 'rd=', score: 7, meta: 'seconds | Pauses execution between this and the next command IF a conditional BACK jump is made'},
		{value: 'reduce=', score: 6, meta: '0, 1 | BinanceFT, BitMEX, Bybit, Deribit, FTX, KrakenFT, KuCoinFT, OANDA, Phemex: Place as "reduce only" (default for close=pos)'},
		{value: 'repay=', score: 5, meta: 'BTC, BNB, ... | Binance: margin repay of specified assets (comma separated, use q=#/% for amount)'},
		{value: 'restart=', score: 4, meta: `Force ${pvName} to restart (USE WITH CAUTION!)`},
		{value: 'resumeall=', score: 3, meta: 'Resume all currently paused alerts'},
		{value: 'ret=', score: 2, meta: '# | Command expected to return at least # order(s) or none (0), otherwise treat as error (skip retries with negative #)'},
		{value: 'return=', score: 1, meta: '# | Command expected to return at least # order(s) or none (0), otherwise treat as error (skip retries with negative #)'},
		{value: 'retries=', score: 0, meta: '# | Number of times command is repeated in case of error (overriding Exchange settings)'},
		{value: 'retryall=', meta: '0, 1 | Ignore the built-in list of errors that are usually exempt from retry-on-error options (eg. no balance available, internal errors etc.)'},
		{value: 'retrydelay=', meta: 'seconds | Pauses execution between this and the next command IF a conditional BACK jump is made'},
		{value: 'reverse=', meta: '0, 1 | Inverse the filtering of orders (c/ch=order) or positions (c/ch=pos)'},
		{value: 'rf=', meta: '0, 1 | FTX only: Set retryUntilFilled flag (true by default)'},
		{value: 'run=', meta: `alert | Run another ${pvShortU} Alert using current ex/sym/acc and OHLCV data, waiting for it to complete (similar to "including" the other alert)`},
		{value: 'runen=', meta: `alert | Run another ${pvShortU} Alert (IF ENABLED!) using current ex/sym/acc and OHLCV data, waiting for it to complete (similar to "including" the other alert)`},
		{value: 's=', score: 10, meta: 'BTCUSD, ETHEUR, ... | Market on the receiving exchange to be used for this command (* = ALL, for check/cancel/close) (default: taken from TV alert!)'},
		{value: 'save=', score: 9, meta: '0, 1, prefix | Saves data from order/pos/ticker to vars: filled, left, lastqty, lastqtysum, lastprice, lastavg, laststop, lastid, lastside, lasttype, laststatus, lastpnl etc.'},
		{value: 'savename=', meta: 'prefix | Custom variable name prefix for the save= function'},
		{value: 'savebalance=', meta: 'Shorthand for updatebalance=1 save=1'},
		{value: 'saveorder=', meta: 'Shorthand for check=order save=1'},
		{value: 'savepos=', meta: 'Shorthand for check=pos save=1'},
		{value: 'saveprice=', meta: 'Shorthand for updateprice=1 save=1'},
		{value: 'sell=', score: 10, meta: 'Shorthand for side=sell quantity=... (default 100%)'},
		{value: 'set=', score: 9, meta: 'var:number / var:"text" / var:(expression)... | List of local/OHLC (_name) and global ($name) variable assignments, eg. set=_myplot:(close * 1.5)'},
		{value: 'setvar=', score: 8, meta: 'var:number / var:"text" / var:(expression)... | List of local/OHLC (_name) and global ($name) variable assignments, eg. setvar=_myplot:(close * 1.5)'},
		{value: 'short=', score: 7, meta: 'Shorthand for side=short quantity=... (default 100%) (margin trade!)'},
		{value: 'side=', score: 6, meta: 'buy, long; sell, short | Side of the market you want to place your order on or check for (long/short = margin trade!)'},
		{value: 'since=', score: 5, meta: 'fired, bar, candle, eod, eodutc | Time/count reference to use for max time/count check or query PNL command'},
		{value: 'size=', score: 4, meta: '#(-#), %(-%), customX, _name, $name | Portion of your balance or position (!) you want to affect (add "e" for y=equity, "p" for y=pos, "$" for u=currency)'},
		{value: 'sizeref=', score: 3, meta: 'customX, _name, $name | Specifies from which custom plot data to take an optional quantity multiplier'},
		{value: 'skip=', score: 2, meta: '#, label | Skip over given number of commands (-1 = all) or jump to <label>, unless error (see err=, ifn=, ifo= etc.)'},
		{value: 'sl=', score: 1, meta: '#(-#), %(-%), @abs, customX, _name, $name | Bitfinex: 1 = stop | Spot: Stop loss with buy/sell | Margin: Place buy/sell when price hits stop price'},
		{value: 'slref=', score: 0, meta: 'ask,bid,mid,last,pos,customX,_name,$name... | Price reference for stop loss, mid = ask-bid average, last = last trade price, pos = avg entry (margin!)'},
		{value: 'sleep=', meta: 'seconds, % | Pauses execution between previous and this command (line) for the specified time (supports time units eg. 2.5h = 150m)'},
		{value: 'so=', meta: '#(-#), %(-%), @abs, customX, _name, $name | Stop order – place buy/sell when price hits stop price'},
		{value: 'soref=', meta: 'ask,bid,mid,last,pos,customX,_name,$name... | Price reference for stop order, mid = ask-bid average, last = last trade price, pos = avg entry (margin!)'},
		{value: 'spa', meta: 'ask, bid, mid, last, pos, _name, $name | Price reference for spread check "ask" (high) (see maxsp=), mid = ask-bid average, last = last trade price, pos = position avg (margin!)'},
		{value: 'spreadask', meta: 'ask, bid, mid, last, pos, _name, $name | Price reference for spread check "ask" (high) (see maxsp=), mid = ask-bid average, last = last trade price, pos = position avg (margin!)'},
		{value: 'spb', meta: 'ask, bid, mid, last, pos, _name, $name | Price reference for spread check "bid" (low) (see minsp=), mid = ask-bid average, last = last trade price, pos = position avg (margin!)'},
		{value: 'spreadbid', meta: 'ask, bid, mid, last, pos, _name, $name | Price reference for spread check "bid" (low) (see minsp=), mid = ask-bid average, last = last trade price, pos = position avg (margin!)'},
		{value: 'sr=', meta: 'last, mark, index | BinanceFT, BitMEX, Bybit, Deribit, KrakenFT, KuCoinFT: Price reference for SL/TP stops (default: last)'},
		{value: 'stop=', meta: '#(-#), %(-%), @abs, customX, _name, $name | Stop order – place buy/sell when price hits stop price'},
		{value: 'stoploss=', meta: '#(-#), %(-%), @abs, customX, _name, $name | Bitfinex: 1 = stop | Spot: Stop loss with buy/sell | Margin: Place buy/sell when price hits stop price'},
		{value: 'stopref=', meta: 'last, mark, index | BinanceFT, BitMEX, Bybit, Deribit, KrakenFT, KuCoinFT: Price reference for SL/TP stops (default: last)'},
		{value: 'sv=', meta: '0, 1, prefix | Saves data from order/pos/ticker to vars: filled, left, lastqty, lastqtysum, lastprice, lastavg, laststop, lastid, lastside, lasttype, laststatus, lastpnl etc.'},
		{value: 'svn=', meta: 'prefix | Custom variable name prefix for the save= function'},
		{value: 'sym=', meta: 'BTCUSD, ETHEUR, ... | Market on the receiving exchange to be used for this command (* = ALL, for check/cancel/close) (default: taken from TV alert!)'},
		{value: 'symbol=', meta: 'BTCUSD, ETHEUR, ... | Market on the receiving exchange to be used for this command (* = ALL, for check/cancel/close) (default: taken from TV alert!)'},
		{value: 't=', score: 8, meta: 'limit, market, fok, ioc, post, close, open, settle | Order Type, see Setup: Commands for more info'},
		{value: 'takeprofit=', score: 7, meta: '#(-#), %(-%), @abs, customX, _name, $name | Bitfinex: 1 = stop | Spot: Take profit with buy/sell | Margin: Place buy/sell when price hits stop price'},
		{value: 'tb=', score: 6, meta: 'BTC, BNB, ... | Binance, Deribit, FTX: transfer balances (comma separated) between spot/margin/sub (use y=spot/margin q=#/%, Deribit/FTX: y=sub)'},
		{value: 'tp=', score: 5, meta: '#(-#), %(-%), @abs, customX, _name, $name | Bitfinex: 1 = stop | Spot: Take profit with buy/sell | Margin: Place buy/sell when price hits stop price'},
		{value: 'tpref=', score: 4, meta: 'ask,bid,mid,last,pos,customX,_name,$name... | Price reference for take profit, mid = ask-bid average, last = last trade price, pos = avg entry (margin!)'},
		{value: 'ts=', score: 3, meta: '#(-#), %(-%), @abs, customX, _name, $name | Bitfinex: 1 = trailing | Margin (where supported): Place buy/sell when price hits stop price trailing with offset'},
		{value: 'tsref=', score: 2, meta: 'ask,bid,mid,last,pos,customX,_name,$name... | Price reference for trailing stop0, mid = ask-bid average, last = last trade price, pos = avg entry (margin!)'},
		{value: 'time=', score: 1, meta: '#(-#), %(-%) | Maximum time allowed since initial firing of alert in either seconds or as % of bar time'},
		{value: 'timeout=', score: 0, meta: 'seconds | Timeout in seconds between retries in case of an error (overriding Exchange settings)'},
		{value: 'toggle=', meta: `alert | Toggle given ${pvShortU} Alert or current running alert if none is specified between enabled/disabled, use "${pvShortU}" or "APP" to toggle ${pvShortU}`},
		{value: 'to=', meta: 'seconds, % | Specify ending time window for PNL query; default till now, see since= (supports time units eg. -2d = back 2 days from now)'},
		{value: 'trailing=', meta: '#(-#), %(-%), @abs | Bitfinex: 1 = trailing | Margin (where supported): Place buy/sell when price hits stop price trailing with offset'},
		{value: 'trigger=', meta: `alert | Trigger another ${pvShortU} Alert using current ex/sym/acc and OHLCV data, immediately resuming running this alert IN PARALLEL (similar to external trigger)`},
		{value: 'triggeren=', meta: `alert | Trigger another ${pvShortU} Alert (IF ENABLED!) using current ex/sym/acc and OHLCV data, immediately resuming running this alert IN PARALLEL (similar to external trigger)`},
		{value: 'tr=', meta: 'customX, _name, $name | Transpose given custom plot points (comma separated) from OHLC close price as base to the current commands ticker price'},
		{value: 'trref=', meta: 'ask,bid,mid,last,pos,customX,_name,$name... | Price reference for transpose command, mid = ask-bid average, last = last trade price, pos = avg entry (margin!)'},
		{value: 'transfer=', meta: 'BTC, BNB, ... | Binance, Deribit, FTX: transfer balances (comma separated) between spot/margin/sub (use y=spot/margin q=#/%, Deribit/FTX: y=sub)'},
		{value: 'transpose=', meta: 'customX, _name, $name | Transpose given custom plot points (comma separated) from OHLC close price as base to the current commands ticker price'},
		{value: 'transref=', meta: 'ask,bid,mid,last,pos,customX,_name,$name... | Price reference for transpose command, mid = ask-bid average, last = last trade price, pos = avg entry (margin!)'},
		{value: 'type=', meta: 'limit, market, fok, ioc, post, day, settle, open, close | Order Type, see Setup: Commands for more info'},
		{value: 'u=', score: 8, meta: 'contracts, currency | Unit to be used for the provided quantity (q) parameter (if absolute value)'},
		{value: 'unit=', score: 7, meta: 'contracts, currency | Unit to be used for the provided quantity (q) parameter (if absolute value)'},
		{value: 'unset=', score: 6, meta: 'customX,_name,$name... | List of local/OHLC (_name) and global ($name) variables to delete'},
		{value: 'unsetvar=', score: 5, meta: 'customX,_name,$name... | List of local/OHLC (_name) and global ($name) variables to delete'},
		{value: 'ub=', score: 4, meta: '0, 1 | Update balance info, use to query the current account balance (eg. for notifications) without executing a trade'},
		{value: 'up=', score: 3, meta: '0, 1 | Update and cache price data (ticker) for a symbol, use on its own or as a trade option'},
		{value: 'update=', score: 2, meta: 'Shorthand for updateprice=1 updatebalance=1'},
		{value: 'updatebalance=', score: 1, meta: '0, 1 | Update balance info, use to query the current account balance (eg. for notifications) without executing a trade'},
		{value: 'updateprice=', score: 0, meta: '0, 1 | Update balance info, use to query the current account balance (eg. for notifications) without executing a trade'},
		{value: 'usepos=', meta: '#(-#), %(-%) | Portion of an open position you want to add to the order size (or absolute amount, when used without %)'},
		{value: 'wait=', meta: 'seconds, % | Pauses execution between previous and this command (line) for the specified time (supports time units eg. 2.5h = 150m)'},
		{value: 'wid=', meta: '#, label | If no open order matches given IDs (see cid=): cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'ws=', meta: '#, label | If pos/order found but its on the OPPOSITE side: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'wrongid=', meta: '#, label | If no open order matches given IDs (see cid=): cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'wrongside=', meta: '#, label | If pos/order found but its on the OPPOSITE side: cancel, cancelall, cancelside, closeall, closeside, abort or skip # cmnds / jump to <label>'},
		{value: 'y=', meta: 'free/balance, total/equity, pos/possize, spot, margin | Balance: Available excl. orders/positions, Equity: Total incl. profit/loss (for others see tb=)'},
		{value: 'yp=', meta: '#(-#), %(-%) | Portion of an open position you want to add to the order size (or absolute amount, when used without %)'},
		{value: 'yield=', meta: 'free/balance, total/equity, pos/possize, spot, margin | Balance: Available excl. orders/positions, Equity: Total incl. profit/loss (for others see tb=)'},
		{value: 'yieldpos=', meta: '#(-#), %(-%) | Portion of an open position you want to add to the order size (or absolute amount, when used without %)'},
	]
	langTools.setCompleters([{
		getCompletions: function(editor, session, pos, prefix, callback) {
			if (!prefix.length) {
				callback(null, [])
				return
			}
			// $$$$$$ check context => show number format help / time format help / list internal variables if= / placeholders etc.
			// $$$ check if in string literal or expression abc="def ghi"
			const line = session.getDocument().getLine(pos.row)
			let inParam = false
			for (let i = pos.column - 1; i > 0; --i) {
				if (line[i] === '=') {
					inParam = true
					break
				}
				if (line[i] === ' ' || line[i] === '\t') break
			}
			if (inParam) {
				callback(null, [])
				return
			}
//			callback(null, syntaxCommands.filter(cmd => cmd.value.startsWith(prefix)))
			callback(null, syntaxCommands)
		}
	}])

	let startPage = ''
	if (window.location.hash.length > 3) {
		if (window.location.hash[1] != '/') {
			window.location.hash = '/'+window.location.hash.split('-')[0].substr(1)
		}
		startPage = window.location.hash.substr(2)
	}
	if (startPage === 'alerts') {
		initAlerts()
	} else {
		setTimeout(initAlerts, 100)
	}
	openPage(startPage, true)

	$(document.body).tooltip({selector: '[title]', placement: 'bottom', container: 'body', trigger: 'hover', html: true, delay: {show: 250, hide: 50}})
	$("[title][data-placement=top]").tooltip({placement: 'top', container: 'body', trigger: 'hover', html: true, delay: {show: 250, hide: 50}})
	window.addEventListener('keydown', onHotkey)
	onResize()

	console.log(`Ready after ${formatDur((Date.now() - timeStart) / 1000, 2)}`)

	setTimeout(handleAutoExports, autoInterval - (Date.now() % autoInterval) + opt.fireOffset * 1000)
	setInterval(() => relayMsg('heartbeat'), heartBeatInterval)
}

function onHotkey(ev) {
	if (ev.ctrlKey) {
		switch (ev.key) {
			case 'ArrowUp':
			case 'ArrowDown':
				if(currentPage === 'alerts') {
					ev.preventDefault(), ev.stopPropagation()
					const curr = q(`[data-alert='${currentAlert}']`)
					const currBtn = curr.querySelector("[data-action='minAlert']")
					if (ev.shiftKey) {
						let next = curr
						do {
							next = ev.key === 'ArrowUp' ? next.previousElementSibling : next.nextElementSibling
						} while(next && !next.classList.contains('visible'))

						if (next) {
							opt.onlyOneAlert && !curr.classList.contains('min') && minAlert(currBtn, ev)
							qa("#alertlist form.selected").forEach(el => el.classList.remove('selected'))
							next.classList.add('selected')
							currentAlert = next.dataset.alert

							const nextBtn = next.querySelector("[data-action='minAlert']")
							if (opt.onlyOneAlert && next.classList.contains('min')) {
								minAlert(nextBtn, ev)
							} else {
								const el = isAlertMin(currentAlert) ? next.querySelector("[name='names']") : 
									next.querySelector("[name='commands']").nextSibling.firstChild
								el?.focus()
							}
						}
						break
					}
					(curr.classList.contains('min') ^ (ev.key === 'ArrowUp')) && minAlert(currBtn, ev)
				}
				break

			case 'a':
				if (currentPage === 'variables') {
					if (ev.target.nodeName === 'INPUT') return
					ev.preventDefault(), ev.stopPropagation()
					selectVars()
				}
				break

/*			case 'c':
				if (currentPage.startsWith('exchange-')) {
					const exchange = broker.getByAlias(currentPage.substr(9).toUpperCase())
					copyAccount.call(exchange, document.activeElement)
				}
				break
*/
			case 'd':
				if(currentPage === 'alerts') {
					ev.preventDefault(), ev.stopPropagation()
					createAlert()
				} else if (currentPage.startsWith('exchange-')) {
					ev.preventDefault(), ev.stopPropagation()
					const exchange = broker.getByAlias(currentPage.substr(9).toUpperCase())
					createAccount.call(exchange)
				} else if (currentPage === 'variables') {
					ev.preventDefault(), ev.stopPropagation()
					addVar()
				}
				break

			case 'e':
				ev.preventDefault(), ev.stopPropagation()
				exportConfig()
				break

			case 'i':
			case 'm':
				if(currentPage === 'alerts') {
					ev.preventDefault(), ev.stopPropagation()
					minAlert(ev.key === 'i' ? 'min' : 'max')
				}
				break

			case 'o':
				ev.preventDefault(), ev.stopPropagation()
				importConfig()
				break

			case 'l':
			case 'p':
				if(currentPage === 'alerts') {
					ev.preventDefault(), ev.stopPropagation()
					const btn = q(`[data-alert='${currentAlert}'][data-action='runAlert'][data-side='${ev.key === 'l' ? 'long' : 'short'}']`)
					btn && runAlert(btn, ev)
				}
				break

			case 'Q':
			case 'q':
				if(currentPage === 'alerts') {
					ev.preventDefault(), ev.stopPropagation()
					const btn = q(`[data-alert='${currentAlert}'][data-action='removeAlert']`)
					btn && removeAlert(btn, ev)
				} else if (currentPage.startsWith('exchange-')) {
					const exchange = broker.getByAlias(currentPage.substr(9).toUpperCase())
					removeAccount.call(exchange, document.activeElement, ev)
				} else if (currentPage === 'variables') {
					deleteVars()
				}
				break

			case 's':
				ev.preventDefault(), ev.stopPropagation()
				if(currentPage === 'alerts') {
					const el = document.activeElement
					el?.blur()
					saveAlerts()
					el?.focus()
				} else if (currentPage.startsWith('exchange-')) {
					const el = document.activeElement
					el?.blur()
					const exchange = broker.getByAlias(currentPage.substr(9).toUpperCase())
					saveAccounts.call(exchange)
					el?.focus()
				}
				break

			case 'u':
				ev.preventDefault(), ev.stopPropagation()
				if(currentPage === 'positions') {
					updatePos(q("#positions .panel:not(.hide) [data-action='updatePos']"))
				}
				break

			case 'v':
				if (currentPage.startsWith('exchange-')) {
					if(document.activeElement?.nodeName === 'INPUT') return
					const exchange = broker.getByAlias(currentPage.substr(9).toUpperCase())
					pasteAccount.call(exchange, document.activeElement)
				}
				break
		}

	} else if (ev.altKey && (ev.key === 's' || ev.key === 'S')) {
		if (currentPage === 'alerts') {
			ev.preventDefault(), ev.stopPropagation()
			const btn = q(`[data-alert='${currentAlert}'][data-action='lookupSymbol']`)
			btn && lookupSymbol(btn)

		} else if (currentPage.startsWith('exchange-')) {
			ev.preventDefault(), ev.stopPropagation()
			const exchange = broker.getByAlias(currentPage.substr(9).toUpperCase())
			browseSymbols.call(exchange, undefined, ev)
		}
	} else if (currentPage === 'variables') {
		switch (ev.key) {
			case 'F2': renameVar(); break
			case 'F4': editVar(); break
			case 'Insert': addVar(); break
			case 'Delete': if (ev.target.nodeName === 'INPUT') return
				deleteVars(); break
			case 'ArrowUp':
			case 'ArrowDown':
			case 'PageUp':
			case 'PageDown':
				const table = $('#varBrowser').DataTable()
				const rows = table.rows({order: 'applied', search: 'applied'}).indexes().toArray()
				const selected = table.rows('.selected').indexes()
				const isUp = ev.key.endsWith('Up')
				const isPage = ev.key.startsWith('Page')
				const add = isPage ? 5 : 1
				let row = rows.indexOf(selected.length ? selected[isUp ? 0 : selected.length - 1] : rows[0])
				let newRow = Math.max(0, Math.min(rows.length - 1, row + (isUp ? -add : add)))
				if (!ev.shiftKey) table.rows().deselect()
				else if (isPage) {
					for (let i = Math.min(row, newRow)+1; i < Math.max(row, newRow); ++i) {
						table.row(rows[i]).select()
					}
				}
				table.row(rows[newRow]).select()
		}
	}
}

let lastHashChange = 0
let defaultPage = 'getting-started'
let currentPage = defaultPage
let currentAlert = 0
let livePageInterval = 0
const logViewLimit = 1200
const livePageRate = 1000 / 2
let newVar = null

const livePages = {
	'log': async page => {
		function filter(level, msg) {
			//if (level < 1 && !opt.showDebug) return true
			let filter = 0, matches = 0
			if (opt.showOnlyEvents) {
				++filter
				if(msg.includes("Received Alert")) ++matches
			}
			if (opt.showOnlyOrders) {
				++filter
				if(msg.includes("placed")) ++matches
			}
			if (opt.showOnlyWarns) {
				++filter
				if(level === 2 && !msg.startsWith("Manually")) ++matches
			}
			if (opt.showOnlyErrors) {
				++filter
				if(level === 3) ++matches
			}
			if (opt.showOnlyCond) {
				++filter
				if(level === 5 && msg.includes("Conditional")) ++matches
			}
			if (opt.showOnlyBalance) {
				++filter
				if(msg.includes("available balance")) ++matches
			}
			if (opt.showOnlyNotify) {
				++filter
				if(msg.includes("notification sent")) ++matches
			}
			return filter && !matches
		}
		function getHTML(events) {
			const classes = ['active', '', 'warning', 'danger', 'danger', 'info', 'success']
			let html = ''
			try {
				for (let i = 0; i < events.length; ++i) {
					const ev = events[i]
					if (!ev.d || !ev.m || (ev.l < 1 && !opt.showDebug)) continue
					html += `<tr class="${classes[ev.l]} ${filter(ev.l, ev.m)?'hide':''}" data-l="${ev.l}"><th class="nowrap">${formatDateS(ev.d, true)}</th>`+
							`<td><code class="${classes[ev.l] ? 'label-'+classes[ev.l] : ''}">${log.levelName[ev.l]}</code></td>`+
							`<td>${ev.m.replace('{\n', '<pre>').replace('\n}', '</pre>').replace(/,\n/g, '\n')}</td></tr>`
				}
			} catch(ex){}
			return html
		}
		function scrollToEnd(anim) {
			const scroll = $('#log-container')
			scroll.stop(true).animate({
				scrollTop: scroll[0].scrollHeight - scroll[0].clientHeight
			}, anim ? 200 : 0)
		}

		let last = 0
		$('[id^=show]').on('change.log', function(el){
			if (el.currentTarget.name === 'showDebug') {
				last = Number.MAX_VALUE
				return
			}
			const table = page.querySelector('tbody')
			table.querySelectorAll('tr').forEach(row => filter(Number(row.dataset.l), row.lastElementChild.innerHTML) ? row.classList.add('hide') : row.classList.remove('hide'))
		})

		const events = await log.getEvents(await log.getNext() - logViewLimit, logViewLimit, true)
		if (events) {
			page.querySelector('tbody').innerHTML = getHTML(events)
			last = events.length ? events.last().id+1 : last
			opt.scrollLog && setTimeout(scrollToEnd, 100)
		}

		return async page => {
			// $POLLING$
			const next = await log.getNext()
			if (next != last) {
				const table = page.querySelector('tbody')
				if (next < last) {
					table.innerHTML = '', last = 0
				}

				const events = await log.getEvents(Math.max(last, next - logViewLimit), logViewLimit)
				if (events) {
					table.insertAdjacentHTML('beforeend', getHTML(events))
					last = events.length ? events.last().id+1 : last
					while (table.childElementCount > logViewLimit) {
						table.removeChild(table.firstChild)
					}
					opt.scrollLog && scrollToEnd(true)
				}
			}
		}
	},

	'alerts': page => {
		async function update(page) {
			// $POLLING$
			const {updated, auto} = await relayMsg('getUpdated') || {}
			if (updated > state.initAlerts) {
				const last = state.initAlerts
				state.initAlerts = updated

				function checkDate(alert, what) {
					const date = new Date(alert[what])
					if (date > last) {
						const el = page.querySelector(`#alert-${alert.id}-${what}`)
						if (el) {
							el.classList.remove('updated')
							void el.offsetWidth
							el.classList.add('updated')
							const num = alert[what+'Num']
							el.innerHTML = formatDateS(date) + (num ? `<span class="numicon"></span>`+num : '')
						}
						return true
					}
					return false
				}
				function checkLock(alert) {
					const el = page.querySelector(`[data-alert='${alert.id}'][data-action='showLock']`)
					el?.classList[alert.locks ? 'add' : 'remove']('alert-locked')
				}
				function checkStats(alert) {
					const el = page.querySelector(`#alert-${alert.id}-stats`)
					const stats = alertStats(alert)
					if (el?.innerHTML != stats) {
						el.classList.remove('updated')
						void el.offsetWidth
						el.classList.add('updated')
						el.innerHTML = stats
						return true
					}
					return false
				}

				const alerts = getAlerts()
				let updates = 0
				for (const alert of alerts) {
					let changed = checkDate(alert, 'recv')
					changed = checkDate(alert, 'fired') || changed
					checkLock(alert)
					if (changed || auto) {
						changed = checkStats(alert) || changed
					}
					changed && ++updates
				}
				if (updates && !(["custom", "name", "created"].includes(opt.sort))) {
					filterChanged()
				}
			}
		}
		page.querySelectorAll('label').forEach(el => el.classList.remove('updated'))
		update(page)

		currentAlert && setTimeout(() => {
			try {
				const el = isAlertMin(currentAlert) ? page.querySelector(`#alert-${currentAlert}-names`) : 
					page.querySelector(`#alert-${currentAlert}-commands`).nextSibling.firstChild
				el?.focus()
			} catch(ex){}
		}, 100)
		return update
	},

	'variables': async page => {
		const where = info => {
			const alert = info.alert && getAlerts(info.alert)
			return (alert ? `<span title="<code>${alert.names.join(', ')}</code> #${info.alert}${info.id ? ` (event ${info.id})`:''}">`:'') + 
				(info.name || alert?.names?.join(', ') || info.id || 'unknown') + ':' + info.line + (({'A': " (Auto)", 'M': " (Manual)", 'T': " (Telegram)"})[info.id?.charAt(0)] || '') + (alert ? '</span>':'')
		}
		const format = (key, meta) => meta.value !== undefined && [
			key, meta.value, 
			meta.updated ? formatDateS(meta.updated._) : '', meta.updated ? where(meta.updated) : '',
			meta.created ? formatDateS(meta.created._) : '', meta.created ? where(meta.created) : '',
		]

		const table = $('#varBrowser').DataTable()
		const vars = await globalVars.getAll() || {}
		table.clear().rows.add(Object.mapAsArray(vars, format)).draw()
		let last = Math.abs(vars._) || Date.now()
		setTimeout(table.columns.adjust, 0)

		return async page => {
			// $POLLING$
			const {updated, refresh} = await globalVars.getUpdated() || {}
			if (updated > last) {
				const all = await globalVars.getAll() || {}
				if (refresh) {
					table.clear().rows.add(Object.mapAsArray(all, format)).draw()
				} else {
					let edit = null
					table.rows().nodes().to$().removeClass('updated')
					const data = table.column(0, {order: 'index'}).data()
					Object.each(all, (key, meta) => {
						if (meta.updated?._ > last || meta.created?._ > last) {
							let row = data.indexOf(key)
							row = row >= 0 ? table.row(row).data(format(key, meta)) : table.row.add(format(key, meta))
							row.node().classList.add('updated')
							if (key === newVar) {
								newVar = null, edit = row.node().firstChild
							}
						}
					})
					table.draw()
					if (edit) $(edit).dblclick() // $$$$ pre-select all! for easier overwrite when adding
				}
				last = updated
			}
		}
	},

	'positions': function(page){
		function update(page) {
			if (opt.posAuto && opt.posRate != 0 && onInterval(opt.posRate * 1000, livePageRate, q("#posRateInd"))) {
				updatePos()
			} else if (onInterval(3000, livePageRate)) {
				qa("[name=positions] [data-since]").forEach(el => {
					const since = (Date.now() - Number(el.dataset.since)) / 1000
					el.innerHTML = since > 2 ? `(${formatDur(since)} ago)` : ''
				})
			}
		}
		filterPosChanged(), update(page)
		return update
	},

	'stats': function(page){
		async function update(page) {
			await stats.load()
			
			let html = ''
			function start() {
				html = "<thead><tr><th>Metric</th><th>Current Session</th><th>Total / Previous</th></tr></thead>"
			}
			function add(name = ' ', session = '', total = '') {
				html += `<tr><td>${name}</td><td>${session}</td><td>${total}</td></tr>`
			}

			try {
				start()
				add("Startup Time", formatDate(stats.bootup), formatDate(stats.prevBootup))
				add("⤷ Instances / First Startup", stats.instances, formatDate(stats.bootupFirst))
				add("⤷ Updates / Startups", stats.updates||0, stats.bootups)
				add("⤷ Last Update", formatDate(stats.lastUpdate) + (stats.lastVersion ? ` (${stats.lastVersion})` :''), formatDate(stats.prevUpdate) + (stats.prevVersion ? ` (${stats.prevVersion})` :''))
				add("⤷ Restarts / Auto-Restarts", stats.restarts||0, stats.autoRestarts||0)
				add("Current Uptime", formatDur(stats.uptime, -2), formatDur(stats.uptimeLast, -2))
				add("⤷ Average / Total Uptime", formatDur(stats.uptimeAvg, -2, 0), formatDur(stats.uptimeTotal, -2, 0))
				add("⤷ Shortest / Longest Uptime", formatDur(stats.uptimeMin, -2, Number.MAX_VALUE), formatDur(stats.uptimeMax, -2, 0))
				add("Last Downtime", formatDur(stats.downtime, -2), formatDur(stats.downtimeLast, -2))
				add("⤷ Average / Total Downtime", formatDur(stats.downtimeAvg, -2, 0), formatDur(stats.downtimeTotal, -2, 0))
				add("⤷ Shortest / Longest Downtime", formatDur(stats.downtimeMin, -2, Number.MAX_VALUE), formatDur(stats.downtimeMax, -2, 0))
				add("Remote Config Updates (Sync)", stats.sessionSync||0, stats.totalSync||0)
				add("⤷ Last Remote Config Update", formatDate(stats.lastSync), formatDate(stats.prevSync))
				add("Last Heartbeat", formatDate(stats.heartbeat), formatDate(stats.prevHeartbeat))
				add("Log Writing Errors", stats.sessionLogErr||0, stats.totalLogErr||0)
				add("Ping Errors", stats.sessionPingErr||0, stats.totalPingErr||0)
			} catch(ex){}
			page.querySelector("#general-stats").innerHTML = html
			try {
				start()
				add("Received Events (Listener)", stats.sessionEvents||0, stats.totalEvents||0)
				add("Received Events (Stream)", stats.sessionStreamEvents||0, stats.totalStreamEvents||0)
				add("Received Screener Alerts (Listener)", stats.sessionScreenerEvents||0, stats.totalScreenerEvents||0)
				add("Received Screener Alerts (Stream)", stats.sessionScreenerStreamEvents||0, stats.totalScreenerStreamEvents||0)
				add("Received Events (Auto Interval)", stats.sessionAuto||0, stats.totalAuto||0)
				add("Received Events (TelegramBot)", stats.sessionTelebot||0, stats.totalTelebot||0)
				add("Temp. Blocked (Auto Interval)", stats.sessionAutoSkip||0, stats.totalAutoSkip||0)
				add("Scheduler Misses (Auto Interval)", stats.sessionAutoMiss||0, stats.totalAutoMiss||0)
				add("⤷ Average Slippage", formatDur(stats.sessionOffsetAvg, 2), formatDur(stats.totalOffsetAvg, 2))
				add("⤷ Shortest Slippage", formatDur(stats.sessionOffsetMin, 2), formatDur(stats.totalOffsetMin, 2))
				add("⤷ Longest Slippage", formatDur(stats.sessionOffsetMax, 2), formatDur(stats.totalOffsetMax, 2))
				add("Filtered - Alert Disabled", stats.sessionFilterEn||0, stats.totalFilterEn||0)
				add("Filtered - Unscheduled", stats.sessionFilterSch||0, stats.totalFilterSch||0)
				add("Filtered - Position", stats.sessionFilter||0, stats.totalFilter||0)
				add("Filtered - Once / Solo", stats.sessionFilterOnce||0, stats.totalFilterOnce||0)
				add("Not Filtered - Name Diff", stats.sessionFilterRepName||0, stats.totalFilterRepName||0)
				add("Not Filtered - Rep Timeout", stats.sessionFilterRepRes||0, stats.totalFilterRepRes||0)
				add("Filtered - Repeat", stats.sessionFilterRep||0, stats.totalFilterRep||0)
				add("Filtered - Timeout", stats.sessionFilterTime||0, stats.totalFilterTime||0)
				add("Filtered - Lock", stats.sessionFilterLock||0, stats.totalFilterLock||0)
				add("Legacy Filtered - Position", stats.sessionFilterLegacy||0, stats.totalFilterLegacy||0)
				add("Legacy Filtered - Once / Solo", stats.sessionFilterLegacyOnce||0, stats.totalFilterLegacyOnce||0)
				add("Legacy Filtered - Repeat", stats.sessionFilterLegacyRep||0, stats.totalFilterLegacyRep||0)
				add("Legacy Filtered - Timeout", stats.sessionFilterLegacyTime||0, stats.totalFilterLegacyTime||0)
			} catch(ex){}
			page.querySelector("#event-stats").innerHTML = html
			try {
				start()
				add("TelegramBot Messages", stats.sessionTelebotMsg||0, stats.totalTelebotMsg||0)
				add("⤷ Number of Errors", stats.sessionTelebotErr||0, stats.totalTelebotErr||0)
				add("⤷ Last Message", formatDate(stats.lastTelebotMsg), formatDate(stats.prevTelebotMsg))
				add("⤷ Last Error", formatDate(stats.lastTelebotErr), formatDate(stats.prevTelebotErr))
				add("TelegramBot Starts", stats.sessionTelebotStart||0, stats.totalTelebotStart||0)
				add("⤷ Last Start", formatDate(stats.lastTelebotStart), formatDate(stats.prevTelebotStart))
				add("TelegramBot Stops", stats.sessionTelebotStop||0, stats.totalTelebotStop||0)
				add("⤷ Last Stop", formatDate(stats.lastTelebotStop), formatDate(stats.prevTelebotStop))
			} catch(ex){}
			page.querySelector("#bot-stats").innerHTML = html
			try {
				start()
				add("Running Alerts", stats.sessionRunning)
				add("Max Alerts in Parallel", stats.sessionPar||0, stats.totalPar||0)
				add("Skipped - Too many Parallel", stats.sessionMaxPar||0, stats.totalMaxPar||0)
				add("Skipped - Too much Slippage", stats.sessionMaxTime||0, stats.totalMaxTime||0)
				add("Time of Last Command", formatDate(stats.lastCmd), formatDate(stats.prevCmd))
				add("Total Number of Run Alerts", stats.sessionAlerts||0, stats.totalAlerts||0)
				add("⤷ TradingView Alerts", (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))
				add("⤷ Child Alerts", stats.sessionChildren||0, stats.totalChildren||0)
				add("⤷ Manual Runs", stats.sessionManual||0, stats.totalManual||0)
				add("⤷ Interval Runs", stats.sessionAuto||0, stats.totalAuto||0)
				add("⤷ TelegramBot", stats.sessionTeleRun||0, stats.totalTeleRun||0)
				add("⤷ Triggered", stats.sessionTrigger||0, stats.totalTrigger||0)
				add("⤷ Empty Alerts", stats.sessionAlertsEmpty||0, stats.totalAlertsEmpty||0)
				add("Number of Forked Alerts", stats.sessionForked||0, stats.totalForked||0)
				add("Total Command Runtime", formatDur(stats.sessionTime, -2), formatDur(stats.totalTime, -2))
				add("⤷ Average Runtime", formatDur((stats.sessionTime||0) / (stats.sessionAlerts || 1), 2, 0), formatDur((stats.totalTime||0) / (stats.totalAlerts || 1), 2, 0))
				add("⤷ Shortest Runtime", formatDur(stats.sessionTimeMin, 2), formatDur(stats.totalTimeMin, 2))
				add("⤷ Longest Runtime", formatDur(stats.sessionTimeMax, 2), formatDur(stats.totalTimeMax, 2))
				add("Total Number of Run Commands", stats.sessionCommands||0, stats.totalCommands||0)
				add("⤷ Average Commands per Alert", ((stats.sessionCommands||0) / (stats.sessionAlerts || 1)).toFixed(1), ((stats.totalCommands||0) / (stats.totalAlerts || 1)).toFixed(1))
				add("Number of Conditionals", stats.sessionCond||0, stats.totalCond||0)
				add("Number of Errors", stats.sessionErr||0, stats.totalErr||0)
				add("Number of Warnings", stats.sessionWarn||0, stats.totalWarn||0)
				add("Number of Retries", stats.sessionRetries||0, stats.totalRetries||0)
				add("Number of Maximum Retries", stats.sessionRetriesMax||0, stats.totalRetriesMax||0)
			} catch(ex){}
			page.querySelector("#alert-stats").innerHTML = html
			try {
				start()
				add("Email Notifications sent", stats.email||0, stats.totalEmail||0)
				add("⤷ Number of Errors", stats.emailErr||0, stats.totalEmailErr||0)
				add("⤷ Number of Auth Retries", stats.emailAuth||0, stats.totalEmailAuth||0)
				add("⤷ Last Message", formatDate(stats.emailLast), formatDate(stats.prevEmailLast))
				add("Discord Notifications sent", stats.discord||0, stats.totalDiscord||0)
				add("⤷ Number of Errors", stats.discordErr||0, stats.totalDiscordErr||0)
				add("⤷ Last Message", formatDate(stats.discordLast), formatDate(stats.prevDiscordLast))
				add("Telegram Notifications sent", stats.telegram||0, stats.totalTelegram||0)
				add("⤷ Number of Errors", stats.telegramErr||0, stats.totalTelegramErr||0)
				add("⤷ Last Message", formatDate(stats.telegramLast), formatDate(stats.prevTelegramLast))
				add("Twilio Notifications sent", stats.twilio||0, stats.totalTwilio||0)
				add("⤷ Number of Errors", stats.twilioErr||0, stats.totalTwilioErr||0)
				add("⤷ Last Message", formatDate(stats.twilioLast), formatDate(stats.prevTwilioLast))
				add("IFTTT Notifications sent", stats.iFTTT||0, stats.totalIFTTT||0)
				add("⤷ Number of Errors", stats.iFTTTErr||0, stats.totalIFTTTErr||0)
				add("⤷ Last Message", formatDate(stats.iFTTTLast), formatDate(stats.prevIFTTTLast))
			} catch(ex){}
			page.querySelector("#notification-stats").innerHTML = html
			try {
				start()
				add("Last TradingView Ping", formatDate(stats.lastPing), formatDate(stats.prevPing))
				add("Last TradingView Event", formatDate(stats.lastEvent), formatDate(stats.prevEvent))
				add("TradingView Tab Reloads", stats.sessionTab||0, stats.totalTab||0)
				add(`TradingView Timeouts (${opt.timeoutTVmin}min)`, stats.sessionTimeout||0, stats.totalTimeout||0)
				add("⤷ Last Timeout", formatDate(stats.lastTimeout), formatDate(stats.prevTimeout))
				add("TradingView Listener Stalls", stats.sessionStall||0, stats.totalStall||0)
				add("⤷ Last Listener Stall", formatDate(stats.lastStall), formatDate(stats.prevStall))
				add("TradingView Listener Restores", stats.sessionRest||0, stats.totalRest||0)
				add("⤷ Last Listener Restore", formatDate(stats.lastRest), formatDate(stats.prevRest))
				add("⤷ Last Listener Downtime", formatDur(stats.lastDown, -2, 0), formatDur(stats.prevDown, -2, 0))
				add("⤷ Shortest Listener Downtime", formatDur(stats.sessionDownMin, -2, Number.MAX_VALUE), formatDur(stats.totalDownMin, -2, Number.MAX_VALUE))
				add("⤷ Longest Listener Downtime", formatDur(stats.sessionDownMax, -2, 0), formatDur(stats.totalDownMax, -2, 0))
				add("TradingView Stream Token Loss", stats.sessionStreamToken||0, stats.totalStreamToken||0)
				add("⤷ Last Stream Token Loss", formatDate(stats.lastStreamToken), formatDate(stats.prevStreamToken))
				add("TradingView Stream Stalls", stats.sessionStreamStall||0, stats.totalStreamStall||0)
				add("⤷ Last Stream Stall", formatDate(stats.lastStreamStall), formatDate(stats.prevStreamStall))
				add("TradingView Stream Restores", stats.sessionStreamRest||0, stats.totalStreamRest||0)
				add("⤷ Last Stream Restore", formatDate(stats.lastStreamRest), formatDate(stats.prevStreamRest))
				add("⤷ Last Stream Downtime", formatDur(stats.lastStreamDown, -2, 0), formatDur(stats.prevStreamDown, -2, 0))
				add("⤷ Shortest Stream Downtime", formatDur(stats.sessionStreamDownMin, -2, Number.MAX_VALUE), formatDur(stats.totalStreamDownMin, -2, Number.MAX_VALUE))
				add("⤷ Longest Stream Downtime", formatDur(stats.sessionStreamDownMax, -2, 0), formatDur(stats.totalStreamDownMax, -2, 0))
			} catch(ex){}
			page.querySelector("#tradingview-stats").innerHTML = html
		}
		update(page)
		return update
	}
}

async function importConfig() {
	if (await yesnoA(
		`<b>!!!    WARNING     !!!     WARNING     !!!     WARNING     !!!     WARNING     !!!     WARNING     !!!</b><br/>
		<br/>
		<u><b>Please note:</b></u> By importing a previously saved configuration all your current existing configuration data (API keys, Alerts etc.) will be <b>OVERWRITTEN</b> and this action <b>cannot be undone</b>!<br/>
		<br/>
		Are you sure you want to continue?<br/>
		<br/>
		<b>!!!    WARNING     !!!     WARNING     !!!     WARNING     !!!     WARNING     !!!     WARNING     !!!</b>`)) {

		let config = await openFileA(".json")
		if (config === null) {
			toastr.error("<b>Aborted</b> – no configuration data was imported!")
			return
		}
		try {
			if (config === "") {
				throw new Error("Empty or unreadable file")
			}
			config = JSON.parse(config)
			const check = simpleHash(config.description)
			if ((check !== 155265370 && check !== 264133116) || !config.version) {
				throw new Error("Required fields missing")
			}
		} catch(ex) {
			error(`<u><b>Error:</b></u> The selected file does not seem to be containing ${pvName} configuration data!<br/>
				<br/>
				(Reason: ${ex.message})`)
			return
		}

		if (config.exchanges) {
			let rem = new Permissions(0)
			let add = new Permissions(0)
			let removed = 0
			let added = 0
			const exes = broker.getAll()
			for (const ex of exes) {
				if (!config.exchanges.includes(ex.getAlias())) {
					ex.hasPermission() && rem.grant(ex.getPermissions()) && ++removed
				} else {
					!ex.hasPermission() && add.grant(ex.getPermissions()) && ++added
				}
			}
			rem.filter(perm => !perm.startsWith("webRequest"))

			if (rem.length() || add.length()) {
				if (await yesnoA(
					`<b><u>Note:</u></b> If additional Exchanges were activated in the saved configuration, you may have to confirm their access permissions following this dialog!<br/>
					<br/>Do you want to continue?`)) {

					// $$$$ api-fy
					if (rem.length() && (await browser.permissions.remove(rem.get()))) {
						perms.revoke(rem).revoke(getLegacyPermissions(rem.get(), true))
						log.warn("Permission removed: "+rem.origins())
						bg.exNum -= removed
					}
					if (add.length() && (await browser.permissions.request(add.get()))) {
						perms.grant(add)
						log.success("Permission granted: "+add.origins())
						bg.exNum += added
					}
					perms.save()
				} else {
					toastr.error("<b>Aborted</b> – no configuration data was imported!")
					return
				}
			}
		}
		// $$$$ api-fy
		syncStore.set(Object.filter(config, key => key.startsWith('options_')))
		if (config.options) {
			opt = Object.assign(opt, config.options)
			upgradeOptions()
			optStore.save()
		}
		if (config.accounts) {
			broker.setAccounts(config.accounts)
			bg.accountNum = 0
			Object.each(config.accounts, ex => Object.each(config.accounts[ex], acc => ++bg.accountNum))
		}
		if (config.balances) {
			// $$$$ api-fy
			localStore.get().balances = config.balances
			localStore.updated()
		}
		if (config.positions) {
			// $$$$ api-fy
			localStore.get().positions = config.positions
			localStore.updated()
		}
		if (config.alerts?.length) {
			for (const alert of config.alerts) {
				delete alert.locks
				config["alert_"+alert.id] = alert
			}
			// $$$$ api-fy
			localStore.remove('lastAlert', 'lock')
		}
		if (config.vars) {
			globalVars.setAll(config.vars)
		}
		let alerts = 0
		// $$$$ api-fy
		const store = opt.alertLocal ? localStore : syncStore
		const noStore = opt.alertLocal ? syncStore : localStore
		store.set(Object.filter(config, key => key.startsWith('alert_') && ++alerts))
		if (alerts) {
			store.set('hasAlerts', true)
			noStore.set('hasAlerts', false)
		}
		getAlerts().forEach(upgradeAlert)

		initPermissions()
		initExchanges()
		initAlerts()

		log.notice("Imported saved configuration!")
		toastr.success("Successfully imported the configuration!")
	}
}

async function doConfigExport(autoInt) {
	let exchanges = []
	broker.getAll().forEach(ex => ex.hasPermission() && exchanges.push(ex.getAlias()))

	let config = Object.assign({
			description: pvName+' Configuration File',
			version: pvVersion,
			exported: new Date().toISOString(),
			options: Object.without(opt, ['crc', 'sum']),
			exchanges,
			accounts: broker.getAccounts(),
			balances: broker.getBalancesRaw(),
			positions, vars: await globalVars.getAll(),
		},
		// $$$$ api-fy
		Object.filter(syncStore.get(), key => key.startsWith('options_')),
		// $$$$ api-fy
		Object.filter(localStore.get('hasAlerts') ? localStore.get() : syncStore.get(), key => key.startsWith('alert_')))

	downloadAs(stringifyPretty(config), `${pvName.toLowerCase()}${opt.instName ? '-'+opt.instName : ''}-%d-config.json`)

	log.notice((autoInt ? `[Auto Export => ${autoInt}] `:'') + "Exported current configuration!")
	!autoInt && toastr.success("Successfully exported the current configuration!")
}

async function exportConfig(el, ev) {
	if (ev?.ctrlKey || (await yesnoA(
		`<b><u>Please note:</u></b> Make sure to store the exported configuration <b>securely</b> as it contains all your current configured <b>private API keys</b>!<br/>
		<br/>
		Do you still want to export the configuration?`))) {
		doConfigExport()
	}
}

const logSplitSize = 10 * 1024 * 1024

async function saveLog(doAll, autoInt, events) {
	const level = 0 //opt.showDebug ? 0 : 1
	let chunkNum = 1
	let text = ""
	let lastId = 0
	let total = 0

	try {
		for (let i = 0; i < events.length; ++i) {
			const ev = events[i]
			if (!ev.d || !ev.m || ev.l < level) {
				continue
			}
			lastId = ev.id
			text += formatDate(ev.d, true)+' '+log.levelName[ev.l]+' '+
				ev.m.replace('{\n','\n').replace('\n}','').replace(/,\n/g, '\n').replace(/\n/g, newLine)+newLine

			if (text.length >= logSplitSize) {
				downloadAs(text, `${pvName.toLowerCase()}-%d-${chunkNum++}.log`)
				total += text.length, text = ""
				await sleep(0.3)
			}
		}
	} catch(ex){}

	total += text.length
	downloadAs(text, `${pvName.toLowerCase()}-%d${chunkNum > 1 ? '-'+chunkNum : ''}.log`)
	if (autoInt) {
		ls.plogAuto = lastId + 1
		log.notice(`[Auto Export => ${autoInt}] Exported app log! (${events.length} entries)`)
	}
	return total
}

async function doLogExport(doAll, autoInt) {
	if (doAll) showSpinner('Formatting log...')
	const fromId = autoInt && opt.expLogInc ? Number(ls.plogAuto) || 0 : (doAll ? 0 : await log.getNext() - logViewLimit)

	console.log(formatDate(Date.now(), true)+" Opening log db in read-only mode...")
	const roLog = new LogWorker()
	while(!roLog.isReady()) await sleep(0.2)

	console.log(formatDate(Date.now(), true)+" Querying log events from db...")
	const events = await roLog.getEvents(fromId, doAll ? 0 : logViewLimit, true)
	roLog.closeDB()

	console.log(formatDate(Date.now(), true)+` Formatting ${events?.length} log events and exporting as text...`)
	const total = await saveLog(doAll, autoInt, events)

	console.log(formatDate(Date.now(), true)+` Exported ${toFixed(total/1024, 0)} kb of log data in ${Math.ceil(total/logSplitSize)} file(s) - Done!`)
	hideSpinner()
}

async function exportLog(el, ev) {
	let doAll = ev?.ctrlKey ? 1 : await onetwoA(`Do you want to export the <b>whole log</b> or only the part currently loaded in the <b>log viewer</b>?<br/>
		<br/>
		<u>Note:</u> If you have a long log history this might take a bit, also the log will be split up in chunks of 10MB in size!`, "Whole Log", "In Viewer")

	if (doAll) {
		if (!ev?.ctrlKey) await sleep(0.2)
		doLogExport(doAll === 1)
	}
}

async function clearLog() {
	if (await yesnoA(
		`Do you really want to purge the log?<br/>
		<br/>
		<u>Note:</u> This action cannot be undone!`)) {

		log.clear()
		ls.plogAuto = 0
	}
}

async function restartApp(el, ev) {
	if (ev?.ctrlKey || (await yesnoA(
		`Are you sure you want to force a restart of the app?<br/>
		<br/>
		<u>Note:</u> Depending on your general options this can lead to reloading of all TradingView tabs, interruption of currently running commands and <b>loss of unsaved changes</b>!`))) {

		relayMsg('restart', true)
	}
}

function onResize() {
	ls.zoomLevel = window.devicePixelRatio.toFixed(2)
	changeCSS("#exchange-links", `height:calc(100vh - ${jQuery("#exchange-links").offset().top + 20}px)`, "@media(min-width:768px)")
	changeCSS("#log-container", `height:calc(100vh - ${jQuery("#log-container").offset().top + 25}px)`)
	changeCSS("#alerts .scroll-container", `height:calc(100vh - ${jQuery("#alerts .scroll-container").offset().top + 50}px)`)
	changeCSS("#positions .scroll-container", `height:calc(100vh - ${jQuery("#positions .scroll-container").offset().top + 20}px)`)
//	changeCSS("#varBrowser_wrapper .dataTables_scrollBody", `calc(100vh - ${jQuery('#varBrowser').parent().offset().top + 20}px) !important`)
	q('#varBrowser').parentElement.style.height = `calc(100vh - ${jQuery('#varBrowser').parent().offset().top + 20}px)`
}

async function openPage(id, fromHash) {
	if (!id || id.length < 3) {
		id = defaultPage
	}
	id = id.split('?')[0]
	const page = q(`article[id=${id}]`)
	if (!page) return

	const pages = qa('article')
	if (pages.length < 1) {
		throw new ReferenceError("No pages found!")
	}
	pages.forEach(el => el.classList.add('hide'))
	clearInterval(livePageInterval)

	const livePage = livePages[id] && await livePages[id](page)
	if (livePage) {
		livePageInterval = setInterval(livePage.bind(window, page), livePageRate)
	}
	page.classList.remove('hide')

	qa('ul.nav li').forEach(el => el.classList.remove('active'))
	const nav = q(`.nav [data-page=${id}]`)
	nav.parentNode.classList.add('active')

	if (!fromHash) {
		lastHashChange = Date.now()
		window.location.hash = (id != defaultPage) ? '/'+id : ''
	}
	currentPage = id
	onResize()
}

window.addEventListener('hashchange', function(){
	if (Date.now() - lastHashChange > 300) {
		openPage(window.location.hash.substr(2), true)
	}
})

function enableBtn(name, state = true) {
	qa(`input[name=${name}]`).forEach(el => el.disabled = !state)
	qa(`label[for=${name}]`).forEach(el => el.classList[state ? 'remove' : 'add']('disabled'))
}

function setButton(item, state) {
	if (typeof item === 'string') item = q('#'+item)
	const on = item.querySelector(state ? ".btn-on" : ".btn-off")
	const off = item.querySelector(state ? ".btn-off" : ".btn-on")
	if (on && off) {
		on.classList.add('active'), on.classList.add('pv-act'), on.firstElementChild.checked = true
		off.classList.remove('active'), off.classList.remove('pv-act'), off.firstElementChild.checked = false
	}
}

function setRadio(item, value) {
	if (typeof item === 'string') item = q('#'+item)
	const act = item.querySelector(`input[value="${value}"]`)
	if (act) {
		item.querySelectorAll('label.active').forEach(el => el.classList.remove('active'))
		act.checked = true
		act.parentElement.classList.add('active')
	}
}

function initPermissions() {
	const exPerms = qi("perm-exchanges")
	if (exPerms.childElementCount < 2 && broker) {
		const exes = broker.getAll()
		for (const ex of exes) {
			new Template("#perm-exchange", null, {
				"exchange.alias": ex.getAlias(),
				"exchange.id": ex.getAlias().toLowerCase(),
				"exchange.name": ex.getName(),
				"exchange.url": ex.getWebsite(),
				"exchange.desc": ex.getDescription()
			}).render()
		}
	}

	const items = exPerms.querySelectorAll("li.list-group-item")
	for (const item of items) {
		const ex = broker.getByAlias(item.dataset.exchange)
		const state = ex?.hasPermission()
		item.classList[state ? 'add' : 'remove']('success')
		setButton(item, state)
	}

	for (const addon in addons) {
		const state = perms.hasAny(getLegacyPermissions(addons[addon], true))
		const panel = q(`[data-addon='${addon}']`).closest(".panel")
		panel.classList[state ? 'remove' : 'add']('disabled')
		setButton(panel, state)
	}
	stats.authToken && updateAuthToken(false)
}

async function requestPermission(el) {
	if (el.className.includes("pv-act")) return
	const data = el.dataset

	if (data.addon && qa("[name=license-remove].hide").length > 1) {
		return error(`Sending ${data.addon} notifications is unfortunately a <b>${pvName} PRO only</b> feature at this time!`)
	}
	if (data.addon === 'Email') {
		return updateAuthToken(true)
	}
	const add = new Permissions(addons[data.addon] || this.getPermissions())
	let granted = null
	try {
		granted = await browser.permissions.request(add.get())
	} catch(ex) {
		initPermissions()
		return error(ex.message)
	}

	if (granted) {
		perms.grant(add).save()
		log.success("Permission granted: "+add.origins())
		// $$$$ api-fy
		!data.addon && ++bg.exNum
	}
	initPermissions()
	initExchanges()
}

async function removePermission(el) {
	if (el.className.includes("pv-act")) return
	const data = el.dataset
	if (data.addon === 'Email') {
		await okA(`To remove the permission to send emails in your name, simply revoke
			the access for ${pvName} on the following Google Account permissions page!<br/>
			<br/>
			Afterwards you have to refresh/restart ${pvName} to reflect this change.`)
		initPermissions()
		return window.open("https://myaccount.google.com/permissions")
	}

	let msg = `Are you sure you want to <b>DISABLE</b> ${data.addon || this.getName()}?`
	if (!data.addon) {
		msg += `<br/>
			<br/>
			<u>Note:</u> All stored accounts will be <b>deleted</b>!`
	}

	if (await yesnoA(msg)) {
		const rem = new Permissions(
			(data.addon === 'Discord') ? discordAccess : (data.addon === 'Telegram') ? telegramAccess : 
			(data.addon === 'Twilio') ? twilioAccess : (data.addon === 'IFTTT') ? iftttAccess : 
			this.getPermissions()).filter(perm => !perm.startsWith("webRequest"))

		if (await browser.permissions.remove(rem.get())) {
			// $$$$ api-fy
			perms.revoke(rem).revoke(getLegacyPermissions(rem.get(), true)).save()
			if (!data.addon) {
				this.removeAccounts()
				--bg.exNum
			}
			log.warn("Permission removed: "+rem.origins())
		}
		initPermissions()
		initExchanges()
	} else {
		initPermissions()
	}
}

function cancelLicense(el) {
	const dialog = el.closest(".modal")
	$('#'+dialog.id).modal("hide")
}

function startLicense(el) {
	const data = el.dataset
	if (!data || !data.originalTitle) return
	const product = data.originalTitle.split(' ').slice(1).join(' ')
	const monthly = product.includes("monthly")

	if (!bg.ID) {
		return error("To register you have to enable Google Sync in the browser options (Settings / Sync) and make sure you are logged in to the correct Google account!")
	}
	const id = Math.floor(Date.now() / 1000)
	new Template("#exchange-license", null, {
		"product.id": id,
		"product.name": product,
		"product.account": bg.ID,
		"product.type": monthly ? "Monthly" : "Onetime",
		"product.url": `https://profitview.app/buy${monthly?"/monthly":''}`
	}).render()
	$(`#license-${id}`).on('hidden.bs.modal', function(){
		this.parentNode.removeChild(this)
	}).modal({
		backdrop: "static"
	})

	$(`#license-${id} .glyphicon-copy`).click(function(ev){
		const data = ev.target.dataset
		if (data.clipboard) clipboardWrite("text/plain", data.clipboard)
	})
}

function initLicense() {
	if (q("#license-exchanges tbody").childElementCount < 2 && broker) {
		const exes = broker.getAll()
		for (const ex of exes) {
			const alias = ex.getAlias()
			new Template("#exchange-lic", null, {
				"exchange.alias": alias,
				"exchange.id": alias.toLowerCase(),
				"exchange.name": ex.getName(),
				"exchange.url": ex.getWebsite(),
				"exchange.desc": ex.getDescription(),
				"exchange.sub": ex.getSubscriptions().inactive.length
			}).render()
		}
	}

	const page = qi("license")
	let row = localStore.get('perms', 'crc')
	let col = 0
	let tmp = window.spice || bg?.spice
	let mods = 0

	for (const mod of page.querySelectorAll("table thead .panel")) {
		if (!mod.dataset.package) continue
		++col
		if ((perms.hasAny(Packages.get(mod.dataset.package)) && !row) || (row && col > 2)) {
			mod.classList.add("panel-success")
			mod.classList.remove("panel-default")
			mod.querySelector("[name='license-start']").classList.add("hide")
			mod.querySelector("[name='licensed']").classList.remove("hide")
			if (col > 2) {
				mod.querySelector("[name='licensed']").classList.add("btn-success")
				mod.querySelector("[name='licensed']").disabled = false
				mod.querySelector("[name='licensed']").innerText = "Exp. "+(tmp ? formatDate(tmp).substr(0, 16) : "1 month")
				++mods
			}
			page.querySelectorAll(`.panel table tbody tr td:nth-of-type(${col})`).forEach(el => el.classList.add('success'))
			++mods
		} else if (col > 1) {
			mod.classList.remove("panel-success")
			mod.classList.add("panel-default")
			mod.querySelector("[name='license-start']").classList.remove("hide")
			mod.querySelector("[name='licensed']").classList.add("hide")
			if (mods && col > 2) {
				mod.querySelector("[name='license-start']").classList.add("hide")
				mod.querySelector("[name='licensed']").classList.remove("hide")
				mod.querySelector("[name='licensed']").disabled = true
				mod.querySelector("[name='licensed']").innerHTML = `<span class="glyphicon glyphicon-check"></span> Licensed`
			}
			page.querySelectorAll(`.panel table tbody tr td:nth-of-type(${col})`).forEach(el => el.classList.remove('success'))
		}
	}

	const p1 = ' &nbsp;<span class="glyphicon glyphicon-remove text-danger"></span>'
	const p2 = p1.replace('remove', 'ok').replace('danger', 'success')
	const p3 = perms.hasAny(['c2a17a7ac79d757a6ef0909092c2a100'])
	qi('license-user').innerHTML = bg?.ID || 'n/a'
	qi('license-type').innerHTML = mods ? 'PV 1.x'+(!(window.p=p3)?' PRO':' BUSINESS') + 
		(mods > 1 ? ' (monthly – '+Math.max(0, (tmp - new Date()) / 86400000).toFixed(1)+' days left)' : ' (Lifetime)') + p2 : 
		'TRIAL (' + (tmp && tmp.getTime() != 1586360040000 ? 'not licensed – monthly expired '+Math.max(0, (new Date() - tmp) / 86400000).toFixed(1)+' days ago!)' : 'not licensed!)') + p1
	qi('license-tv').innerHTML = bg?.UUIDD || 'n/a'
	if (!pvCode) {
		q('#getting-started .panel-last').style.display = q('[data-page=license]').style.display = "none"
		q('#permissions .scroll-container p:last-child').innerHTML = q('#license .scroll-container p:last-child').innerHTML
	}
}

function alertStats(alert) {
	return `recv ${alert.recvNum||0}   fired ${alert.firedNum||0}   filt. ${Math.max((alert.recvNum||0)-(alert.firedNum||0)+(alert.autoNum||0)+(alert.intNum||0), 0)}`+
		`   auto ${alert.autoNum||0}   int. ${alert.intNum||0}   man. ${alert.manualNum||0}   err ${alert.errNum||0}   slip ${formatDur(alert.slip, -2, 0)}   /   `+
		`last ${formatDur(alert.time, -2, 0)}   avg ${formatDur(alert.timeAvg, -2, 0)}   min ${formatDur(alert.timeMin, -2, 0)}   max ${formatDur(alert.timeMax, -2, 0)}`
}

async function cloneAlert(el) {
	let alert = formData(el.closest("form"))
	alert.names = await askA("Create a copy with the following name:<br/><br/>", `Clone alert "${alert.names}"`, `${alert.names} (copy)`)

	if (alert.names) {
		;["sort", ...resetProps].forEach(e => delete alert[e])
		alert.id = Date.now() - firstAlertId

		alert.names = alert.names.split(/[,;]/).map(s => s.trim()).filter(Boolean)
		alert.commands = alert.commands.split('\n')
		createAlert(null, null, alert, true)
		q('[data-page=alerts]').classList.add('unsaved')
	}
}

async function createAlert(el, ev, alert, isNew) {
	isNew ||= alert === undefined
	if (isNew && opt.onlyOneAlert) {
		minAlert("min")
	}
	if (isNew && (opt.searchName || opt.searchSyntax)) {
		opt.searchName = opt.searchSyntax = ''
		optStore.save(), filterChanged("searchName")
	}
	alert ||= {
		id: Date.now() - firstAlertId,
		names: [], enabled: true, parallel: false,
		barOnce: true, barSolo: false, noRepeat: false, repName: false, repReset: false, repResetMin: 180,
		barOpen: true, barStart: true, barMid1: true, barMid2: true, barEnd: true, barClose: true, barPos: 0, barPA: false, barAmount: 0,
		ignLock: false, setLock: false, clearLock: false, lockTF: true, lockOnly: false, lockExp: 0,
		auto: false, autoInt: "*", allow: false, detect: false, log: 15,
		commands: '### Commands ###\n\n'.split('\n')
	}
	alert.lockTF === undefined && (alert.lockTF = true)
	alert.lockExp === undefined && (alert.lockExp = 0)
	alert.autoInt === undefined && (alert.autoInt = "*")
	alert.log === undefined && (alert.log = alert.noLog ? 0 : 15)
	alert.names = Object.values(alert.names)
	alert.sort ||= state.alertSort + 1
	state.alertSort = Math.max(state.alertSort, alert.sort)
	const syntax = alert.commands.join('\n')
	const isMinimized = !isNew && isAlertMin(alert.id)

	const alertList = q("#alertlist")
	const form = new Template("alert", alertList, {
		"alert.#": alert.id,
		"alert.sort": alert.sort,
		"alert.created": formatDate(Number(alert.id) + firstAlertId),
		"alert.names": alert.names.join(', '),
		"alert.enabled": alert.enabled,
		"alert.minimized": isMinimized,
		"alert.parallel": alert.parallel,
		"alert.barOnce": alert.barOnce,
		"alert.barSolo": alert.barSolo,
		"alert.noRepeat": alert.noRepeat,
		"alert.repName": alert.repName,
		"alert.repReset": alert.repReset,
		"alert.repResetMin": alert.repResetMin,
		"alert.barOpen": alert.barOpen,
		"alert.barStart": alert.barStart,
		"alert.barMid1": alert.barMid1,
		"alert.barMid2": alert.barMid2,
		"alert.barEnd": alert.barEnd,
		"alert.barClose": alert.barClose,
		"alert.barPos": alert.barPos,
		"alert.barPA": alert.barPA,
		"alert.barAmount": alert.barAmount,
		"alert.ignLock": alert.ignLock,
		"alert.setLock": alert.setLock,
		"alert.clearLock": alert.clearLock,
		"alert.lockTF": alert.lockTF,
		"alert.lockOnly": alert.lockOnly,
		"alert.lockExp": alert.lockExp,
		"alert.auto": alert.auto,
		"alert.autoInt": alert.autoInt,
		"alert.sched": alert.sched,
		"alert.allow": alert.allow,
		"alert.detect": alert.detect,
		"alert.log": alert.log,
		"alert.locked": alert.locks,
		"alert.recv": formatDateS(alert.recv, false, '—') + (alert.recvNum ? `<span class="numicon"></span>`+alert.recvNum : ''),
		"alert.fired": formatDateS(alert.fired, false, '—') + (alert.firedNum ? `<span class="numicon"></span>`+alert.firedNum : ''),
		"alert.stats": alertStats(alert),
		"alert.commands": syntax
	}).render(isNew && !opt.sortRev && alertList.lastElementChild)

	$("button.dropdown-toggle", form).each(function(){
		if (this?.dataset.value) {
			const item = this.nextElementSibling.querySelector(`[data-value='${this.dataset.value}' i]`)
			this.innerHTML = (item?.innerHTML || this.dataset.value) + '<span class="caret"></span>'

			$(this.nextElementSibling).find('a').on('click.drop', function(){
				const btn = this.parentElement.parentElement.previousElementSibling
				btn.innerHTML = this.innerHTML + '<span class="caret"></span>'
				$(btn).dropdown("toggle")
				btn.dataset.value = this.dataset.value
				return false
			})
		}
	})

	const text = $('textarea', form).hide()
	const container = $('<div>', {
		class: text.attr('class'),
		id: `alert-${alert.id}-editor`
	}).insertAfter(text)
	const editor = ace.edit(container[0], {
		mode: "ace/mode/pv",
		theme: "ace/theme/ambiance",
		enableBasicAutocompletion: true,
		enableLiveAutocompletion: true,
		fontSize: '105%',
		showPrintMargin: false,
		minLines: 12,
		maxLines: 36,
		navigateWithinSoftTabs: true
	})
	editor.commands.bindKeys({"ctrl-shift-up": null, "ctrl-shift-down": null, "ctrl-l": null})
	editor.renderer.setScrollMargin(3, 8)
	const session = editor.getSession()
	session.setValue(syntax)
	session.on('change', async ev => {
		const syntax = session.getValue()
		text.val(syntax)

		const check = await relayMsg('syntaxCheck', syntax, form.names.value)
		session.setAnnotations(check)
		if (check?.length) {
			form.classList.add(check.errors ? 'err' : 'warn')
			form.classList.remove(check.errors ? 'warn' : 'err')
		} else {
			form.classList.remove('err')
			form.classList.remove('warn')
		}
		q('[data-page=alerts]').classList.add('unsaved')
	})
	editor.textInput.getElement().addEventListener('keydown', onHotkey)

	$("input", form).on('input.unsaved change.unsaved', ev => { q('[data-page=alerts]').classList.add('unsaved') })
	$("input[name='names']", form).on('change.names', ev => {
		const name = ev.target.value.split(/[,;]/).map(s => s.trim()).filter(Boolean).join(', ')
		ev.target.value = name === '' ? ev.target.defaultValue : name
	})
	$("input[name='enabled']", form).on('change.enabled', ev => { ev.target.closest('form').classList.toggle('disabled') })
	$("input[name='autoInt']", form).on('change.autoInt', ev => {
		const check = intervalMatch(ev.target.value)
		check && error(check+"<br/>"+
			"<br/>"+
			"<u>Note:</u> Please use the <code>Edit interval</code> button next to this field to safely edit the interval!")
	})
	$("input[data-warn]", form).on('change.warn', function(ev){ this.checked && ok(this.dataset.warn) })
	$(".panel-heading", form).on('dblclick.heading', ev => {
		if (ev.target.classList.contains('nodrag')) {
			const selection = window.getSelection()
			const range = document.createRange()
			range.selectNodeContents(ev.target)
			selection.removeAllRanges()
			selection.addRange(range)
			return
		}
		if (ev.target.nodeName !== 'INPUT' && !ev.target.classList.contains('ace_content')) {
			minAlert(ev.target)
		}
	})
	$(form).on('click.alert', ev => {
		qa("#alertlist form.selected").forEach(el => el.classList.remove('selected'))
		ev.currentTarget.classList.add('selected')
		currentAlert = ev.currentTarget.dataset.alert
	})
	$(".dropdown", form).on('show.bs.dropdown', ev => {
		ev.target.closest('.row').style.overflow = 'visible'
	}).on('hidden.bs.dropdown', ev => {
		ev.target.closest('.row').style.overflow = ''
	})

	if (isNew) {
		const scroll = $("#alerts .scroll-container")
		scroll.animate({
			scrollTop: opt.sortRev ? 0 : scroll[0].scrollHeight - scroll[0].clientHeight
		}, 200)

		qa("#alertlist form.selected").forEach(el => el.classList.remove('selected'))

		$("input[name='names']", form).focus()
		currentAlert = alert.id
		filterChanged("onlyActAlert")

		const current = q(`[data-alert='${currentAlert}']`)
		current?.classList?.add('selected')
	} else {
		if (!isMinimized) currentAlert = alert.id

		const check = await relayMsg('syntaxCheck', syntax, form.names.value)
		session.setAnnotations(check)
		if (check?.length) {
			form.classList.add(check.errors ? 'err' : 'warn')
		}
	}
}

function initAlerts() {
	const alertList = q('#alertlist')
	alertList.querySelectorAll('form').forEach(alert => alertList.removeChild(alert))

	const alerts = getAlerts().sort(alertSort)
	state.alertSort = 0
	for (const alert of alerts) {
		if (alert?.names) createAlert(null, null, alert)
	}
	if (!state.alertSort) {
		createAlert()
	} else if (currentAlert) {
		q(`[data-alert='${currentAlert}']`).classList.add('selected')
	}
	state.initAlerts = Date.now()
	hideMinimized()

	filterChanged("onlyActAlert")
	filterChanged()
	showFilterChanged()

	dragula([alertList], {
		revertOnSpill: true,
		moves: (el, source, handle, sibling) => !["INPUT", "BUTTON", "SMALL", "A"].includes(handle.nodeName) && !handle.className.includes("ace") && 
				!handle.classList.contains("btn") && !handle.classList.contains("glyphicon") && !handle.classList.contains("nodrag")
	}).on('drag', () => {
		changeCSS(".tooltip", "display:none !important")
	}).on('dragend', () => {
		$(".tooltip").tooltip("destroy")
		changeCSS(".tooltip", "")
	}).on('drop', () => {
		const forms = qa("#alertlist form")
		let sort = opt.sortRev ? forms.length+1 : 0
		forms.forEach(form => form.sort.value = opt.sortRev ? --sort : ++sort)
		opt.sort = "custom"
		optStore.save()
		q('[data-page=alerts]').classList.add('unsaved')
	})
}

function saveAlerts() {
	const forms = qa("#alertlist form")
	let saved = []
	let total = 0

	// $$$$ api-fy
	const source = localStore.get('hasAlerts') ? localStore.get() : syncStore.get()
	const store = opt.alertLocal ? localStore.get() : syncStore.get()

	try {
		for (const form of forms) {
			let alert = formData(form)
			alert.names = alert.names.split(/[,;]/).map(s => s.trim()).filter(Boolean)

			if (alert.names.length < 1) {
				throw new SyntaxError("Each alert has to be given at least one name!")
			}
			alert.commands = alert.commands.split('\n')

			const current = source['alert_'+alert.id]
			current && resetProps.forEach(e => current[e] && (alert[e] = current[e]))

			if (!opt.alertLocal) {
				const size = JSON.stringify(alert).length+64
				if (size >= 8 * 1024) {
					throw new SyntaxError(`Alert "${alert.names.join(', ')}" exceeds the Google Sync Storage item size quota of 8kb!<br/>
						Please enable local storage for alerts under "General Options"!<br/>
						<br/>
						(nothing saved)`)
				}
				total += size
				if (total >= 75 * 1024) {
					throw new SyntaxError(`The total size of alert definitions exceed the Google Sync Storage quota of around 100kb!<br/>
						Please enable local storage for alerts under "General Options"!<br/>
						<br/>
						(nothing saved)`)
				}
			}
			store[`alert_${alert.id}`] = alert
			saved.push(alert.names.join(', '))
		}
	} catch(ex) {
		return error(ex.message)
	}

	store.hasAlerts = true
	if (source != store) {
		source.hasAlerts = false
		// $$$$ api-fy
		relayMsg('removeKey', source == localStore.get() ? 'local' : 'sync', Object.keys(source).filter(key => key.startsWith('alert_')))
	}
	opt.alertLocal ? localStore.updated() : syncStore.updated()

	toastr.success(`Saved ${forms.length} alerts successfully!`)
	log.info(`Saved ${forms.length} alerts: [${saved.join('] – [')}]`)
	opt.expConfSave && doConfigExport("Alerts changed")
	q('[data-page=alerts]').classList.remove('unsaved')
}

async function removeAlert(el) {
	const form = el.closest('form')
	const data = el.dataset
	const prompt = form.names.value ? `the alert "${form.names.value}"` : "this alert"

	if (await yesnoA(
		`Do you really want to <b>remove</b> ${prompt}?<br/>
		<br/>
		<u>Note:</u> This action cannot be undone!`)) {

		// $$$$ api-fy
		form.remove()
		relayMsg('removeKey', "sync", `alert_${data.alert}`)
		relayMsg('removeKey', "local", `alert_${data.alert}`)

		Object.each((localStore.get().lastAlert || {}).lock, (key, entry) => {
			entry.id == data.alert && localStore.remove('lastAlert', 'lock', key)
		})
		ls.minAlerts = (ls.minAlerts || '').split(',').filter(id => id != data.alert).join()
		filterChanged("onlyActAlert")
	}
}

function getEditor(id) {
	const el = q(`#alert-${id}-editor`)
	return el && ace.edit(el).getSession()
}

async function runAlert(el, ev) {
	const data = el.dataset
	const form = el.closest('form')
	const syntax = form.commands.value
	const name = form.names.value || "MANUAL"
	const prompt = form.names.value ? `the alert "${form.names.value}"` : "this alert"
	const side = form.detect.checked ? "auto" : ev.altKey ? ({long: "buy", short: "sell"})[data.side] : data.side

	if (form.classList.contains('err')) {
		const check = await relayMsg('syntaxCheck', syntax, form.names.value)
		return error(`Syntax error found in alert on line ${check[0].row+1}:<br/>
			<br/>
			${check[0].text}`)
	}

	if (opt.runNoConfirm || (await noyesA(
		`Do you really want to <b>${data.disabled ? "TEST" : "RUN"}</b> ${prompt} right now?<br/>
		<br/>
		<u>Warning:</u> Defaults set under 'General Options' will be used instead of TV alert data!<br/>
		<br/>
		<u>Current defaults:</u><b>${(opt.defAccount ? " account="+opt.defAccount :'')+(opt.defExchange ? " exchange="+opt.defExchange :'')+(opt.defSymbol ? " symbol="+opt.defSymbol :'')}</b>
		${side ? "<br/><u>Side placeholders:</u> <b>"+side.toUpperCase()+"</b>" :''}`))) {

		let delay = 100
		if(opt.runShowLog ^ ev.shiftKey) {
			openPage('log')
			delay = opt.showDebug ? 500 : 250
		}
		--state.initAlerts
		setTimeout(() => relayMsg('runCommandsCheck', name, {isManual: true, name: name, desc: syntax, side: side}, data.disabled, data.alert), delay)
	}
}

function isAlertMin(alert) {
	return alert && (ls.minAlerts || '').split(',').includes(alert.toString())
}

let hideMinimizedTask = 0
function hideMinimized() {
	q('#alertlist').querySelectorAll('.min .help-block, .min .filter-options, .min .panel-body').forEach(e => e.style.display = 'none')
}

function minAlert(el) {
	clearTimeout(hideMinimizedTask)

	if (el === 'max' || el.dataset?.alert === 'max') {
		const subs = q('#alertlist').querySelectorAll('.help-block, .filter-options, .panel-body')
		subs.forEach(e => e.style.display = 'block')
		void subs[0]?.offsetWidth
		qa('#alertlist form.min label.updated').forEach(e => e.classList.remove('updated'))
		qa('#alertlist form.min').forEach(e => e.classList.remove('min'))
		ls.minAlerts = ''
		return
	} else if (el === 'min' || el.dataset?.alert === 'min') {
		let mins = []
		qa("#alertlist form").forEach(e => mins.push(e.dataset.alert) && e.classList.add('min'))
		hideMinimizedTask = setTimeout(hideMinimized, 1000)
		if (mins.length) ls.minAlerts = mins.join()
		return
	}

	const form = el.closest('form')
	const data = form.dataset
	let mins = (ls.minAlerts || '').split(',').filter(Boolean)

	if (form.classList.contains('min')) {
		const subs = form.querySelectorAll('.help-block, .filter-options, .panel-body')
		subs.forEach(e => e.style.display = 'block')
		void subs[0]?.offsetWidth
		form.querySelectorAll('label.updated').forEach(e => e.classList.remove('updated'))
	}

	if (form.classList.toggle('min')) {
		//if (getAlerts(data.alert)) {
			mins.push(data.alert)
		//}
		q(`#alert-${data.alert}-names`).focus()
		hideMinimizedTask = setTimeout(hideMinimized, 1000)
	} else {
		if (opt.onlyOneAlert) {
			mins = []
			qa("#alertlist form").forEach(e => e.dataset.alert !== data.alert && mins.push(e.dataset.alert) && e.classList.add('min'))
			hideMinimizedTask = setTimeout(hideMinimized, 1000)
		} else {
			mins = mins.filter(id => id !== data.alert)
		}
		q(`#alert-${data.alert}-commands`).nextSibling.firstChild.focus()
		currentAlert = data.alert
		// $$$ if MAXED and fits in view => make bottom visible / scroll
		//q(`#alert-${data.alert} .panel-body .help-block`).scrollIntoView(true)
	}
	if (mins.length) ls.minAlerts = mins.join()
}

async function resetRepeat(el) {
	const data = el.dataset

	if (!data.alert) {
		if (await yesnoA("Do you really want to <b>clear</b> the repeat filter info for legacy alerts?")) {
			// $$$$ api-fy
			const lastAlert = localStore.get().lastAlert || {}
			lastAlert.legacy = {}
			lastAlert.legacyFired = {}
			lastAlert.legacyLast = {}
			localStore.updated()
			log.warn("Manually reset legacy repeat filter info!")
		}
	} else {
		const form = el.closest('form')
		const prompt = form.names.value ? `"${form.names.value}"` : "this alert"
		if (await yesnoA(`Do you really want to <b>clear</b> the repeat filter info for ${prompt}?`)) {	// $$$ add note about diff names vs repeat
			// $$$$ api-fy
			const lastAlert = localStore.get().lastAlert || {}
			Object.each(lastAlert.local, el => {
				if (lastAlert.local[el] == data.alert) {
					delete lastAlert.local[el]
					delete lastAlert.localLast[el]
				}
			})
			Object.each(lastAlert.legacy, el => {
				if (lastAlert.legacy[el] == data.alert) {
					delete lastAlert.legacy[el]
					delete lastAlert.legacyFired[data.alert]
					delete lastAlert.legacyLast[el]
				}
			})
			localStore.updated()
			log.warn(`Manually reset repeat filter info for: ${(getAlerts(data.alert) || {names:["Unknown"]}).names.join(', ')}`)
		}
	}
}

function initVars() {
	const table = $('#varBrowser').DataTable({
		data: [],
		columns: [
			{title: "Name", width: "20%"},
			{title: "Value", width: "20%"},
			{title: "Updated", width: "7%"},
			{title: "Updated By", width: "23%"},
			{title: "Created", width: "7%"},
			{title: "Created By", width: "23%"},
		],
		dom: '<"row"<"col-sm-3"f><"col-sm-6"B><"col-sm-3 text-right"i>><"row"<"col-sm-12"t>>',
		buttons: [
			{extend: 'colvis', text: '<span class="glyphicon glyphicon-list"></span> Columns', titleAttr: "Show/Hide columns"}, 
			{extend: 'copy', text: '<span class="glyphicon glyphicon-copy"></span> Copy', titleAttr: "Copy list to clipboard", title: "Global Variables", exportOptions: {modifier: {selected: null}}}, 
			{extend: 'csv', text: '<span class="glyphicon glyphicon-download-alt"></span> CSV', titleAttr: "Export list in CSV format", title: "Global Variables", exportOptions: {modifier: {selected: null}}}, 
			{extend: 'excel', text: '<span class="glyphicon glyphicon-download-alt"></span> Excel', titleAttr: "Export list in Excel format", title: "Global Variables", exportOptions: {modifier: {selected: null}}}, 
		],
		autoWidth: true,
		lengthChange: false,
		select: {
			style: 'os',
			items: 'row',
			toggleable: true,
		},
		language: {
			info: "Showing _END_ of _MAX_",
			infoEmpty: "Showing 0 of _MAX_", // _ENTRIES-TOTAL_?
			infoFiltered: "",
			paginate: {
				previous: '<span class="glyphicon glyphicon-chevron-left"></span>',
				next: '<span class="glyphicon glyphicon-chevron-right"></span>',
			},
			select: {
				rows: {_: ""},
			},
			search: '<span class="input-group-addon"><span class="glyphicon glyphicon-search"></span></span>'
		},
		paging: false,
		scrollY: 'calc(100vh - 240px)',
		order: [[0, 'asc']],
		createdRow: (row, data, dataIndex, cell) => row.firstChild.style.maxWidth = '0',
	})
	new $.fn.dataTable.Buttons(table, {
		buttons: [
			{text: '<span class="glyphicon glyphicon-plus"></span> New', className: 'btn-primary', titleAttr: "Create a new variable<br/><code>Ins</code> or <code>Ctrl+D</code>", action: addVar},
			{text: '<span class="glyphicon glyphicon-trash"></span> Delete', className: 'btn-danger mr-5', titleAttr: "Delete selected variables after confirmation <code>Del</code> or <code>Ctrl+Q</code>", action: deleteVars},
		]
	})
	table.buttons(1, null).container().insertBefore(table.buttons(0, null).container())
	q("#varBrowser_filter label").classList.add("input-group")
	q("#varBrowser_filter input").classList.remove("input-sm")
	q("#varBrowser_filter input").classList.add("input")
	q("#varBrowser_filter input").placeholder = "Filter by name / value"
	q("#varBrowser_filter input").spellcheck = false
	setTimeout(table.columns.adjust, 0)

	table.MakeCellsEditable({
		inputCss: "form-control input",
		columns: [0, 1],
		confirmationButton: {
			confirmCss: "btn-sm btn-success",
			cancelCss: "btn-sm btn-danger",
		},
		allowNulls: {
			columns: [1],
			errorClass: "error",
		},
		onValidate: function(cell, row, val) {
			if (cell.index().column === 0) {
				if (Command.illegalVar.test(val)) {
					toastr.clear(), toastr.error("Please use only A-Z, 0-9 or underscore for variable names!")
					return false
				}/* else if (val[0] === '_') {
					toastr.clear(), toastr.error("Global variable names cannot begin with an underscore!")
					return false
				}*/ else if ((val = val.toLowerCase()) !== cell.data() && table.rows().data().map(row => row[0]).toArray().includes(val)) {
					toastr.clear(), toastr.error(`A variable with the name "${val}" already exists!`)
					return false
				}
			}
			return true
		},
		onUpdate: function(cell, row, oldVal) {
			const newVal = cell.data()
			if (newVal != oldVal) {
				if (cell.index().column === 0) {
					cell.data(newVal.toLowerCase())
					globalVars.rename(oldVal, newVal), table.draw(), table.rows().nodes().to$().removeClass('updated')
				} else {
					globalVars.set(row.data()[0], newVal === "" || isNaN(newVal) ? newVal : Number(newVal), {id: 'M'+(Date.now() % 100000000)})
				}
			}
		}
	})
}

function selectVars() {
	 $('#varBrowser').DataTable().rows().select()
}

function deselectVars() {
	$('#varBrowser').DataTable().rows().deselect()
}

async function addVar() {
	const key = await globalVars.getNewName()
	globalVars.set(key, "", {id: 'M'+(Date.now() % 100000000)})
	newVar = key
}

async function deleteVars(el) {
	const table = $('#varBrowser').DataTable()
	const selected = table.rows({selected: true})
	const hover = q('tr:hover')
	const vars = (selected.count() ? selected : table.rows(el || (hover ? $(hover) : -1))).data().map(row => row[0]).toArray()
	if (vars.length) {
		if (await yesnoA(
				`Do you really want to <b>delete</b> the variable${vars.length > 1 ? 's':''} "${vars.join('", "')}"?<br/>
				<br/>
				<u>Note:</u> This action cannot be undone!`)) {
			globalVars.remove(vars)
		}
	} else {
		confirm("Notice", `Please select one or more variables to <b>delete</b> first!<br/>
			<br/>
			<u>Note:</u> Use <code>Shift</code> and <code>Ctrl</code> keys to select multiple rows at once`)
	}
}

function renameVar(el, isEdit) {
	const table = $('#varBrowser').DataTable()
	const selected = table.row({selected: true})
	const hover = q('tr:hover')
	el = (selected.count() ? selected : table.row(el || (hover ? $(hover) : -1))).node()?.firstChild
	$(isEdit ? el?.nextSibling : el).dblclick()
}

function editVar(el) {
	renameVar(el, true)
}

async function getChatID(el) {
	if (!q("[data-addon='Telegram'].btn-on").classList.contains("pv-act")) {
		return error(`Please allow ${pvName} to connect to Telegram first!`)
	}
	if (!opt.telegramToken) {
		return error("Please fill in the correct Bot Token first!")
	}

	if (!opt.telegramChat || (await yesnoA("Do you really want to <b>replace</b> the current Chat ID with an auto detected one?"))) {
		fetch(`https://api.telegram.org/bot${opt.telegramToken}/getUpdates`).then(resp => {
			if (!resp.ok) {
				throw new Error(telegramErr(resp))
			}
			return resp.json()
		}).then(function(data){
			const chatID = (data.result[0] && data.result[0].message && data.result[0].message.chat && data.result[0].message.chat.id) || bg.telebotLastSender
			if (!chatID) {
				throw new Error('No message received yet!')
			}
			q("#telegramChat").value = opt.telegramChat = chatID
			optStore.save()
			toastr.success("Chat ID successfully detected!")
			// $$$$$ check telebot whitelist and ask to add
		}).catch(ex => {
			error(`Unable to retrieve the last active Chat ID, make sure you sent at least one message to your bot before!<br/>
				<br/>
				(Error: ${ex.message})`)
		})
	}
}

function telebotWLChanged() {
	if (opt.telebotWL) {
		opt.telebotWL = opt.telebotWL.replace(/@/g, '').split(',').numbers().filter(Boolean).join(", ")
		optStore.save()
	}
	// $$$$$ else ... check if bot enabled and warn about empty whitelist!
}

function telebotChWLChanged() {
	if (opt.telebotChWL) {
		opt.telebotChWL = opt.telebotChWL.replace(/@/g, '').split(',').numbers().filter(Boolean).join(", ")
		optStore.save()
	}
	// $$$$$ else ... check if bot enabled and warn about empty whitelist!
}

async function testNotify(el) {
	const data = el.dataset
	if (qa("[name=license-remove].hide").length > 1) {
		return error(`Sending ${data.addon} notifications is unfortunately a <b>${pvName} PRO only</b> feature at this time!`)
	}
	if (!q(`[data-addon='${data.addon}'].btn-on`).classList.contains("pv-act")) {
		return error(`Please allow ${pvName} to connect to ${data.addon} first!`)
	}

	if (await yesnoA(`Do you really want to <b>send</b> a test ${data.addon} notification?`)) {
		try {
			if (window[data.addon.toLowerCase()+"Message"]("test", data.addon !== "IFTTT" ? "** This is just a test! **" : null, null,
				`You should be able to see the test message now or in the next few minutes (Email / SMS)!<br/>
				<br/>
				If it doesn't show up please check the configuration again carefully and have a look at the ${pvName} log.`) === undefined) {
				throw new Error(`An error occured while trying to test ${data.addon} notifications – check "Setup: Permissions" and the license status under "Setup: Unlock PRO"!`)
			}
		} catch(ex) {
			error(ex.message)
		}
	}
}

async function resetBalances(el, ev) {
	if (ev?.ctrlKey || (await yesnoA("Do you really want to <b>purge all currently saved balance</b> info?"))) {
		await relayMsg('broker.purgeBalances')
		const msg = "All balance info purged!"
		log.notice(msg), toastr.success(msg)
	}
}

async function doBalanceExport(format = "csv", autoInt, email) {
	const filename = `${pvName.toLowerCase()}${opt.instName ? '-'+opt.instName : ''}-${formatDateFN()}-balances`
	const cols = ["Exchange","Alias","Account","Asset","Available","Total","Updated"]
	if (format === "copy" || format === "email") {
		let output = cols.join() + newLine
		const list = await relayMsg('broker.getBalances') || []
		list.forEach(e => output += `${e.name},${e.alias},${e.acc},${e.ccy},${e.avail},${e.total},${formatDate(e.update)}${newLine}`)
		if (format === "copy") {
			clipboardWrite("text/plain", output)
		} else {
			emailMessage("balances", "** All balance info exported as CSV **", email, null, null, output, filename+'.csv', 'application/csv')
			const msg = (autoInt ? `[Auto Export => ${autoInt}] `:'') + `All balance info sent via email to ${email || opt.emailAddress}!`
			log.notice(msg), !autoInt && toastr.success(msg)
		}
		return
	}

	$(document.body).append(`<table id="export" style="display:none"></table>`)
	$('#export').DataTable({
		searching: false,
		ordering: false,
		paging: false,
		info: false,
		dom: 'Bt',
		buttons: [
			{extend: format, filename: filename, title: ''}
		],
		columns: cols.map(e => {return {title: e}}),
		data: await relayMsg('broker.getBalances', true) || [],
		initComplete: function() {
			$('#export').DataTable().button(0).trigger()
			$('#export_wrapper').remove()
			const msg = (autoInt ? `[Auto Export => ${autoInt}] `:'') + `All balance info exported as ${format.toUpperCase()}!`
			log.notice(msg), !autoInt && toastr.success(msg)
		}
	})
}

async function exportBalances(el, ev) {
	if (ev?.shiftKey) {
		return doBalanceExport("copy")
	}
	const format = el.dataset.format
	if (ev?.ctrlKey || (await yesnoA(`Do you really want to <b>export all</b> saved balance info in ${format.toUpperCase()} format?`))) {
		doBalanceExport(format)
	}
}

function getLockInfo(alert) {
	let count = 0
	let info = '<table class="table table-striped table-condensed" id="lockinfo"><thead><tr><th>Exchange / Symbol</th><th>Timeframe</th><th>Lock Time</th><th>Exp.</th></tr></thead>'

	// $$$$ api-fy
	if (localStore.get().lastAlert) {
		Object.each(localStore.get().lastAlert.lock, (key, entry) => {
			if (!alert || entry.id == alert) {
				const expired = entry.exp ? entry.exp+'m' + (timeAdd(entry.time, entry.exp * 60) < new Date() ? ' – <b>EXPIRED!</b>' : '') : '–'
				info += `<tr><td>${key}</td><td>${formatTF(entry.res)}</td><td>${formatDateS(entry.time)}</td><td>${expired}</td></tr>`
				++count
			}
		})
	}
	if (!count) {
		return "<b>– No locks found! –</b><br/><br/>"
	}

	info += '</table>'
	return info
}

function showLock(el) {
	const data = el.dataset
	const form = el.closest('form')
	const prompt = form.names.value ? `"${form.names.value}"` : "this alert"
	ok(`The following symbols have been locked by ${prompt}:<br/>
		<br/>
		${getLockInfo(data.alert)}`)
}

async function setLock(el) {
	const data = el.dataset
	const form = el.closest('form')
	const prompt = form?.names?.value ? `"${form.names.value}"` : "this alert"

	if (data.lock > 0) {
		let symbol = await askA(`Please enter the full exchange and symbol you want to lock, optionally followed 
			by the timeframe in minutes (eg. BYBIT:BTCUSDT ${opt.defaultRes}) – default timeframe is ${opt.defaultRes}<br/>
			<br/>`,
			`Lock Symbol via ${prompt}`, opt.defExchange.toUpperCase() + ':' + opt.defSymbol.toUpperCase())

		if (symbol) {
			symbol = symbol.trim().replace(/,/g, ' ').replace(/\s\s+/g, ' ').toUpperCase().split(':')
			let exchange = symbol[0]
			symbol = symbol[1].split(' ')
			let res = Number(symbol[1]) || opt.defaultRes
			symbol = symbol[0]

			if (!symbol) {
				error("Please enter data in the form of &lt;exchange&gt;:&lt;symbol&gt; – eg. BYBIT:BTCUSDT !")
			} else if (!broker.getByAlias(exchange)) {
				error(`The entered exchange does not seem to be valid!<br/>
					<br/>
					Your input: ${exchange}`)
			} else if (await yesnoA(`Are you sure you want ${prompt} to lock the following exchange/symbol combination?<br/>
					<br/>
					Exchange: ${exchange}<br/>
					Symbol: ${symbol}<br/>
					Timeframe: ${formatTF(res)}`)) {

				relayMsg('lockSymbol', getAlerts(data.alert), exchange+':'+symbol, res)
				q(`[data-alert='${data.alert}'][data-action='showLock']`).classList.add('alert-locked')
			}
		}
	} else if (data.lock < 0) {
		if (await yesnoA(`Do you really want to <b>remove ALL</b> locking information from <b>ALL</b> symbols?<br/>
				<br/>
				${getLockInfo()}
				<u>Note:</u> This might cause flipping or early exit of existing trades depending on your alerts and strategy!`)) {

			relayMsg('unlockSymbol')
			qa("[data-action='showLock']").forEach(el => el.classList.remove('alert-locked'))
		}
	} else {
		if (await yesnoA(`Do you really want to <b>remove ALL</b> of the following locks placed by ${prompt}?<br/>
				<br/>
				${getLockInfo(data.alert)}
				<u>Note:</u> This might cause flipping or early exit of existing trades depending on your alerts and strategy!`)) {

			relayMsg('unlockSymbol', getAlerts(data.alert, {}))
			q(`[data-alert='${data.alert}'][data-action='showLock']`).classList.remove('alert-locked')
		}
	}
}

async function resetCount(el) {
	if (await yesnoA("Do you really want to <b>reset</b> the icon badge counter?")) {
		stats.resetBadge(), optStore.save()
		const msg = "Icon badge counter reset!"
		log.info(msg), toastr.success(msg)
	}
}

async function resetTotals() {
	if (await yesnoA("Do you really want to <b>reset</b> the total & previous metrics?")) {
		stats.resetTotals()
		const msg = "Total & previous metrics reset!"
		log.info(msg), toastr.success(msg)
	}
}

function confInterval(el) {
	const isOpt = el.dataset.conf
	const alert = el.dataset.alert
	const form = !isOpt && el.closest('form')
	const prompt = form?.names?.value ? `"${form.names.value}"` : isOpt ? (el.dataset.desc || "this option") : "this alert"

	function getInterval(asArray) {
		let i = 0, exp = []
		;["minute", "hour", "day", "month", "weekday"].forEach(which => {
			const what = q('input[type=radio][name='+which+']:checked').value

			if (what == 0) {
				exp.push("*")
			} else if (what == 1) {
				exp.push("*/" + q('#' + which + 'EveryVal').value)
			} else {
				const el = q('#' + which + 'List')
				let range = -1, from, to, list = []

				Array.prototype.forEach.call(el.options, opt => {
					if (opt.selected) {
						list.push(opt.value)
						if (range == -1 || range == 1) {
							++range
							from = opt.value
						}
						to = opt.value
					} else if (!range) ++range
				})
				if (!range || range == 1) {
					exp.push(from + (to != from ? "-"+to : ""))
				} else {
					exp.push(list.join(","))
				}
			}
			++i
		})
		while (exp.length > 1 && exp.slice(-1)[0] === "*") exp.pop()
		return asArray ? exp : exp.join(' ')
	}
	function updateText() {
		q(".bootbox-body h3").innerHTML = interval2Text(getInterval(true))
	}

	const type = !isOpt && q(`#alert-${alert}-auto`).checked ? 'Interval' : !isOpt && q(`#alert-${alert}-sched`).checked ? 'Schedule' : 'Interval / Schedule'
	bootbox.dialog({
		title: `<span class="glyphicon glyphicon-time"></span>  Customize ${type} for ${prompt}`,
		message: q("#cronbox").innerHTML,
		size: 'large',
		onEscape: true,
		backdrop: true,
		buttons: {
			apply: {
				label: '<span class="glyphicon glyphicon-ok"></span> Apply',
				className: 'btn-success',
				callback: function(){
					if (isOpt) {
						opt[isOpt] = getInterval()
						optStore.save()
					} else {
						q(`#alert-${alert}-autoInt`).value = getInterval()
						q('[data-page=alerts]').classList.add('unsaved')
					}
				}
			},
			cancel: {
				label: '<span class="glyphicon glyphicon-remove"></span> Cancel',
				className: 'btn-danger'
			},
		}
	})

	const exp = isOpt ? (opt[isOpt] || "") : q(`#alert-${alert}-autoInt`).value
	const parts = exp.split(' ').filter(Boolean)
	let i = 0

	for (const part of parts) {
		const which = ["minute", "hour", "day", "month", "weekday"][i]
		if (++i > 5) break
		if (part === "*") continue
		const isEvery = part.startsWith("*/") || ((i < 3 || i > 4) && part.startsWith("0/")) || ((i >= 3 && i <= 4) && part.startsWith("1/"))
		const el = q('#' + which + (isEvery ? 'EveryVal' : 'List'))
		qa('[name='+which+']')[el.tagName === "SELECT" ? 2 : 1].checked = true

		if (isEvery) {
			const every = Number(part.slice(2)) || 2
			el.value = every
		} else if (part.includes("-")) {
			const range = part.split("-").map(e => Number(e) || 0)
			const from = Math.min(range[0], range[1])
			const to = Math.max(range[0], range[1])
			Array.prototype.forEach.call(el.options, opt => opt.value >= from && opt.value <= to && (opt.selected = true))

		} else if (part.includes(",")) {
			const list = part.split(",")
			Array.prototype.forEach.call(el.options, opt => list.includes(opt.value) && (opt.selected = true))

		} else {
			const val = parseInt(part)
			Array.prototype.forEach.call(el.options, opt => opt.value == val && (opt.selected = true))
		}
	}

	qa(".cronbox input[type=number], .cronbox select").forEach(el => el.addEventListener('focus', function(){
		q('#'+this.name.replace("List", "Multi").replace("EveryVal", "EveryX")).checked = true
	}))
	qa(".cronbox input, .cronbox select").forEach(el => el.addEventListener('change', updateText))
	updateText()
}

async function resetAlertStats(el, ev) {
	const timeOnly = ev.shiftKey
	const countOnly = ev.ctrlKey && !timeOnly
	const form = el.closest('form')
	const prompt = form.names.value ? `"${form.names.value}"` : "this alert"

	if (await yesnoA(`Do you really want to <b>reset</b> the ${timeOnly?'time ':countOnly?'count ':''}stats of ${prompt}?<br/>
		<br/>
		<u>Note:</u> This action cannot be undone!`)) {

		// $$$$ api-fy
		const fields = [...(countOnly ? [] : ["time", "timeAvg", "timeMin", "timeMax", "slip"]), ...(timeOnly ? [] : ["recvNum", "firedNum", "intNum", "autoNum", "manualNum", "errNum"])]
		let alert = getAlerts(el.dataset.alert)
		fields.forEach(e => delete alert[e])

		--state.initAlerts
	}
}

function clearInput(el) {
	el = el.previousElementSibling
	opt[el.dataset.opt] = el.value = el.defaultValue
	optStore.save()

	const changed = (el.dataset.change || el.dataset.opt) + 'Changed'
	window[changed] && window[changed](el.name)
}

function runningChanged(running, pause) {
	qa('#pauseBtn, #resumeBtn, #stopBtn').forEach(el => el.disabled = running < 1 || pause < 0)
	q('#stopBtn').lastChild.data = ` Stop${running > 0 ? ` (${running})` :''}`
	const isPaused = pause > 0
	q(isPaused ? '#resumeBtn': '#pauseBtn').classList.remove('hidden')
	q(isPaused ? '#pauseBtn' : '#resumeBtn').classList.add('hidden')
}

async function handbrake(el, ev) {
	const isStop = el.id.startsWith("stop")
	const {running, pause} = await relayMsg('getRunning') || {}
	const isPaused = pause > 0

	if (!running) {
		ev.shiftKey && disableAll(true)
		return ev.ctrlKey || ok("No alert is currently running!")
	}
	if (pause < 0) return ok("Please wait for all alerts to terminate!")

	if (isStop) {
		if (ev.ctrlKey || (await yesnoA(`Do you really want to <b>terminate all</b> currently running alerts? (${running})<br/>
			<br/>
			<u>Note:</u> Depending on your alert commands this may leave newly opened positions unprotected!`))) {

			relayMsg('stopAlerts')
			ev.shiftKey && disableAll(true)
		}
	} else {
		relayMsg('pauseAlerts', isPaused ? false : true)
		runningChanged(running, isPaused ? 0 : 1)
		ev.shiftKey && disableAll(!isPaused)
	}
}

let browserEx = null
let browserSym = null
let browserTitle = null
let browserData = null

function lookupSymbol(editor, focusEl) {
	// $$$ check if already open - then ignore!
	if (editor.dataset?.alert) {
		editor = q(`#alert-${editor.dataset.alert}-editor`)
		focusEl = editor.firstChild
		editor = ace.edit(editor).getSession()
	}

	let alias = editor.dataset && opt[editor.dataset.ex] || opt.defExchange
	if (editor.getValue) {
		const synEx = findSyntax("e|ex|exchange", editor.getValue(), editor.selection.getCursor().row)
		const synAcc = findSyntax("a|acc|account|ap|par|parallel", editor.getValue(), editor.selection.getCursor().row)
		alias = synAcc?.contains(':') && synAcc.split(':', 1)[0] || synEx || alias
	} else {
		focusEl = editor
	}
	alias = broker.getByAlias(alias?.toUpperCase())?.getAlias(-1)

	let preSelect = 0
	let exData = []
	const exes = broker.getAll()
	for (const ex of exes) {
		exData.push([ex.hasSubscription() && ex.hasPermission() ? 
			'<span class="glyphicon glyphicon-ok text-success"></span>' : '<span class="glyphicon glyphicon-remove text-danger"></span>', 
			ex.getName(), ex.getAlias(-1), ex.getDescription()])
		if (ex.getAlias(-1) === alias) {
			preSelect = exData.length
		}
	}

	bootbox.dialog({
		title: "Select Exchange for Symbol Browser",
		size: 'large',
		className: 'exchange-browser',
		message: '<table id="exBrowser" class="table-striped" width="100%"></table>',
		buttons: {
			confirm: {
				label: '<span class="glyphicon glyphicon-chevron-right"></span> Continue',
				className: 'btn-success disabled',
				callback: function() {
					setTimeout(browseSymbols.bind(broker.getByAlias(browserEx), null, null, editor, focusEl), 200)
				},
			},
			cancel: {
				label: '<span class="glyphicon glyphicon-remove"></span> Cancel',
				className: 'btn-danger',
			}
		},
		onHidden: function(){
			focusEl?.focus()
		},
		onShow: function(){
			const table = $('#exBrowser').DataTable({
				data: exData,
				dom: '<"row"<"col-sm-6"f><"col-sm-6 text-right"B>><"row"<"col-sm-12"t>><"row"<"col-sm-5"i><"col-sm-7">>',
				columns: [
					{title: "Act", width: "3%"},
					{title: "Name", width: "22.5%"},
					{title: "Alias", width: "22.5%"},
					{title: "Description", width: "52%"},
				],
				buttons: [
					{extend: 'colvis', text: '<span class="glyphicon glyphicon-list"></span> Columns', titleAttr: "Show/Hide columns"}, 
					{extend: 'copy', text: '<span class="glyphicon glyphicon-copy"></span> Copy', titleAttr: "Copy list to clipboard", title: browserTitle, exportOptions: {modifier: {selected: null}}}, 
					{extend: 'csv', text: '<span class="glyphicon glyphicon-download-alt"></span> CSV', titleAttr: "Export list in CSV format", title: browserTitle, exportOptions: {modifier: {selected: null}}}, 
					{extend: 'excel', text: '<span class="glyphicon glyphicon-download-alt"></span> Excel', titleAttr: "Export list in Excel format", title: browserTitle, exportOptions: {modifier: {selected: null}}}, 
				],
				autoWidth: false,
				lengthChange: false,
				select: {
					style: 'single',
					items: 'row',
					toggleable: false,
				},
				language: {
					info: "Showing _TOTAL_ of _MAX_",
					infoEmpty: "Showing 0 of _MAX_", // _ENTRIES-TOTAL_?
					infoFiltered: "",
					paginate: {
						previous: '<span class="glyphicon glyphicon-chevron-left"></span>',
						next: '<span class="glyphicon glyphicon-chevron-right"></span>',
					},
					select: {
						rows: {_: ""},
					},
					search: '<span class="input-group-addon"><span class="glyphicon glyphicon-search"></span></span>'
				},
				pageLength: 25,
				scrollY: 25 * 20,
				paging: false,
				order: [[2, 'asc']]
			}).on('select', function(e, dt, type, indexes){
				if (type === 'row') {
					browserEx = dt.rows(indexes).data()[0][2]
					q(".exchange-browser .bootbox-accept").classList.remove('disabled')
				}
			})
			q("#exBrowser_filter label").classList.add("input-group")
			q("#exBrowser_filter input").classList.remove("input-sm")
			q("#exBrowser_filter input").classList.add("input")
			q("#exBrowser_filter input").placeholder = "Filter by name"
			preSelect && table.row(preSelect-1).select().node().scrollIntoView(true)
		}
	})
}

async function browseSymbols(el, ev, editor, focusEl) {
	if (ev?.shiftKey) this.purgeSymbolCache()
	// $$$ if (binance bitfinex deribit ftx kraken kucoin okex)
	if (this.getSymbolCache()._refresh) {
		showSpinner('')
		await sleep(0.1)
	}
	try {
		await this.updateSymbols()
	} catch(ex) {
		hideSpinner()
		const err = getError(ex)
		error(`The following error occured when trying to update the list of available symbols for this exchange:<br/>
			<br/>
			<b>${err}</b><br/>
			<br/>
			<u>Note:</u> Some exchanges require at least one valid API key for this action!`)
		return
	}
	const symbols = this.getSymbolCache()

	browserData = []
	for (const name in symbols) {
		const sym = symbols[name]
		if (typeof sym !== 'object') continue

		const qtyPrec = sym.quantityPrecision || sym.quantity_precision || sym.tradeUnitsPrecision || sym.lotSize || sym.lot_decimals || sym.step || sym.baseQtyPrecision || sym.basePrecision || 0
		browserData.push([
			name, sym.contractType || sym.instType || sym.type || sym.typ || sym.kind || sym.product_type || /*sym.aclass_quote ||*/ "–", 
			sym.pricePrecision || sym.price_precision || sym.displayPrecision || sym.precision || sym.pair_decimals || sym.digits || sym.quotePrecision || 0, 
			qtyPrec < 0 ? "÷ "+Math.abs(qtyPrec) : qtyPrec, 
			(sym.minimumTradeSize || sym.MinTradeSize || sym.minimum_order_size || sym.base_min_size || sym.baseMinSize || sym.initMargin || sym.minTradeQuantity || 0) + (sym.minNotional || sym.quote_min_size ? ` (${sym.minNotional || sym.quote_min_size})`:'')
		])
	}

	browserEx = this.getAlias(-1)
	browserSym = null
	browserTitle = "Available Symbols on "+this.getName()

	const data = editor?.dataset || el?.dataset
	const isEditor = editor?.getValue
	const isOpt = !isEditor && editor
	const isButton = !editor && data?.btn

	bootbox.dialog({
		title: browserTitle,
		size: 'large',
		className: 'symbol-browser',
		message: '<table id="symbolBrowser" class="table-striped" width="100%"></table>',
		buttons: {
			confirm: {
				label: `<span class="glyphicon glyphicon-${isEditor ? 'chevron-right' : 'ok'}"></span> ` +
					(data?.btn || (isEditor ? 'Insert Syntax' : isOpt ? 'Set Default' : 'Copy Syntax')),
				className: 'btn-success disabled',
				callback: function() {
					if (isEditor) {
						const row = editor.selection.getCursor().row
						let line = editor.getLine(row)
						// $$$$$ acc=ex:sym:acc
						if (line.match(/(^|\s|#)(e|ex|exchange)=/i)) {
							line = line.replace(/(^|\s|#)(e|ex|exchange)=((?:\".*?\"|[^=\s]+?)*)/i, " ex="+browserEx)
						} else {
							line += " ex="+browserEx
						}
						if (line.match(/(^|\s|#)(s|sym|symbol)=/i)) {
							line = line.replace(/(^|\s|#)(s|sym|symbol)=((?:\".*?\"|[^=\s]+?)*)/i, " sym="+(browserSym.includes(' ')?`"${browserSym}"`:browserSym))
						} else {
							line += " sym="+(browserSym.includes(' ')?`"${browserSym}"`:browserSym)
						}
						editor.replace(new ace.Range(row, 0, row, Number.MAX_VALUE), line.trim())
					} else if (isOpt) {
						opt[data.ex] = browserEx
						opt[data.sym] = browserSym
						initOptions(true)
						optStore.save()
					} else if (isButton) {
						qa(`input[name=${data.sym}], span[for=${data.sym}]`).forEach(el => {
							switch (el.nodeName) {
								case 'INPUT': el.value = browserSym; el.dispatchEvent(new Event('change')); break
								case 'SPAN': el.innerHTML = browserSym; break
							}
						})
					} else {
						clipboardWrite("text/plain", "ex=" + browserEx + (browserSym ? " sym="+(browserSym.includes(' ')?`"${browserSym}"`:browserSym):''))
					}
				},
			},
			cancel: {
				label: '<span class="glyphicon glyphicon-remove"></span> Cancel',
				className: 'btn-danger',
			}
		},
		onHidden: function(){
			focusEl?.focus()
		},
		onShow: function(){
			// $$$$ limit rows depending on screen size
			symbolPagingChanged()
			let expires = ((symbols._updated + opt.infoExpire * 60 * 60 * 1000) - Date.now()) / 1000
			$(`<div class="input-group" style="width:5.5em;position:absolute;left:47%;bottom:49px">
				<button class="btn btn-default dropdown-toggle" type="button" style="height:38px" data-toggle="dropdown" data-opt="symbolPaging" title="Number of entries per page">
					25 <span class="caret"></span>
				</button>
				<ul class="dropdown-menu">
					<li><a href="#" data-value="10">10</a></li>
					<li><a href="#" data-value="25">25</a></li>
					<li><a href="#" data-value="50">50</a></li>
					<li><a href="#" data-value="75">75</a></li>
					<li><a href="#" data-value="100">100</a></li>
					<li><a href="#" data-value="0">scroll</a></li>
				</ul>
			</div>
			<div class="row">
				<div class="col-sm-12">Last updated: ${formatDateS(symbols._updated)} (${expires < 0 ? 'expired!' : 'expires in '+formatDur(expires)})</div>
			</div>`).insertAfter('#symbolBrowser_wrapper')
			initOptions()
		},
		onShown: function(){
			hideSpinner()
		}
	})
}

function symbolPagingChanged() {
	$('#symbolBrowser_wrapper').html('<table id="symbolBrowser" class="table-striped" width="100%"></table>')
	const paging = Number(opt.symbolPaging || 25)
	$('#symbolBrowser').DataTable({
		data: browserData,
		dom: '<"row"<"col-sm-6"f><"col-sm-6 text-right"B>><"row"<"col-sm-12"t>><"row"<"col-sm-5"i><"col-sm-7">>',
		columns: [
			{title: "Symbol", width: "25%"},
			{title: "Type"},
			{title: "Price Precision"},
			{title: "Quantity Precision"},
			{title: "Minimum Quantity"},
		],
		columnDefs: [{targets: '_all', width: "18%"}],
		buttons: [
			{extend: 'colvis', text: '<span class="glyphicon glyphicon-list"></span> Columns', titleAttr: "Show/Hide columns"}, 
			{extend: 'copy', text: '<span class="glyphicon glyphicon-copy"></span> Copy', titleAttr: "Copy list to clipboard", title: browserTitle, exportOptions: {modifier: {selected: null}}}, 
			{extend: 'csv', text: '<span class="glyphicon glyphicon-download-alt"></span> CSV', titleAttr: "Export list in CSV format", title: browserTitle, exportOptions: {modifier: {selected: null}}},
			{extend: 'excel', text: '<span class="glyphicon glyphicon-download-alt"></span> Excel', titleAttr: "Export list in Excel format", title: browserTitle, exportOptions: {modifier: {selected: null}}},
		],
		autoWidth: false,
		lengthChange: false,
		select: {
			style: 'single',
			items: 'row',
			toggleable: false,
		},
		language: {
			info: "Showing _START_-_END_ of _TOTAL_",
			infoEmpty: "Showing 0 of _MAX_", // _ENTRIES-TOTAL_?
			infoFiltered: "(_MAX_ total)",
			paginate: {
				previous: '<span class="glyphicon glyphicon-chevron-left"></span>',
				next: '<span class="glyphicon glyphicon-chevron-right"></span>',
			},
			select: {
				rows: {_: ""},
			},
			search: '<span class="input-group-addon"><span class="glyphicon glyphicon-search"></span></span>'
		},
		pageLength: paging || 25,
		scrollY: (paging || 25) * 20,
		paging: paging > 0,
	}).on('select', function(e, dt, type, indexes){
		if (type === 'row') {
			browserSym = dt.rows(indexes).data()[0][0]
			q(".symbol-browser .bootbox-accept").classList.remove('disabled')
		}
	})
	q("#symbolBrowser_filter label").classList.add("input-group")
	q("#symbolBrowser_filter input").classList.remove("input-sm")
	q("#symbolBrowser_filter input").classList.add("input")
	q("#symbolBrowser_filter input").placeholder = "Filter by symbol / type"
}

function copyAlert(form) {
	let alert = formData(form)
	alert.names = alert.names.split(',').map(s => s.trim()).filter(Boolean)
	alert.commands = alert.commands.split('\n')

	// $$$$ api-fy
	const source = localStore.get('hasAlerts') ? localStore.get() : syncStore.get()
	const current = source['alert_'+alert.id]
	current && resetProps.forEach(e => current[e] && (alert[e] = current[e]))

	clipboardWrite("text/plain", stringifyPretty(alert))
}

async function pasteAlert(el) {
	let text = await navigator.clipboard.readText()
	let alert = null
	try {
		alert = JSON.parse(text)
	} catch(ex) {}

	if (!alert || !alert.commands || !Array.isArray(alert.commands)) {
		return error(`Clipboard data is not a valid ${pvName} alert!`)
	}

	if ((alert.names = await askA("Create a new alert with the following name:<br/><br/>", 
			`Paste alert "${alert.names}"`, `${alert.names} (new)`))) {
		;["sort", "locks"].forEach(e => delete alert[e])
		alert.id = Date.now() - firstAlertId

		alert.names = alert.names.split(',').map(s => s.trim()).filter(Boolean)
		createAlert(null, null, alert, true)
	}
}

async function clearIdCache() {
	if (await yesnoA(`Do you really want to <b>purge all</b> currently cached custom order IDs?<br/>
		<br/>
		<u>Warning:</u> This will cause running alerts/upcoming exits to fail on all exchanges without native custom ID support when using IDs to target orders!`)) {
		await relayMsg('broker.purgeIds')
		const msg = "All custom order IDs removed from cache!"
		log.notice(msg), toastr.success(msg)
	}
}

async function clearSymbols(el, ev) {
	if (ev?.ctrlKey || (await yesnoA(`Do you really want to <b>clear all</b> currently cached symbol info?<br/>
		It will automatically be refreshed by the next order command that requires it.`))) {
		await relayMsg('broker.purgeSymbolCache')
		const msg = "All current symbol info removed from cache!"
		log.notice(msg), toastr.success(msg)
	}
}

let idData = null

function showIdCache() {
	idData = []
	Object.each(localStore.get().idcache, (name, ex) => {
		Object.each(ex, (id, e) => idData.push([name, id, e[0], formatDate(e[1])]))
	})
	bootbox.dialog({
		title: "Cached Order IDs",
		size: 'large',
		message: '<table id="idBrowser" class="table-striped" width="100%"></table>',
		buttons: {
			ok: {
				label: '<span class="glyphicon glyphicon-ok"></span> OK',
				className: 'btn-success'
			}
		},
		onShow: function(){
			// $$$$ limit rows depending on screen size
			idPagingChanged()
			$(`<div class="input-group" style="width:5.5em;position:absolute;left:47%;bottom:30px">
				<button class="btn btn-default dropdown-toggle" type="button" style="height:38px" data-toggle="dropdown" data-opt="idPaging" title="Number of entries per page">
					25 <span class="caret"></span>
				</button>
				<ul class="dropdown-menu">
					<li><a href="#" data-value="10">10</a></li>
					<li><a href="#" data-value="25">25</a></li>
					<li><a href="#" data-value="50">50</a></li>
					<li><a href="#" data-value="75">75</a></li>
					<li><a href="#" data-value="100">100</a></li>
					<li><a href="#" data-value="0">scroll</a></li>
				</ul>
			</div>`).insertAfter('#idBrowser_wrapper')
			initOptions()
		}
	})
}

async function timeSync(el, ev) {
	await stats.load()
	if (ev?.ctrlKey || (await yesnoA(`Do you want to synchronize the internal clock correction with supported exchanges?<br/><br/>Last time sync: ${formatDate(stats.lastTimeSync)}`))) {
		openPage('log')
		await relayMsg('timeSync', true)
	}
}

function idPagingChanged() {
	$('#idBrowser_wrapper').html('<table id="idBrowser" class="table-striped" width="100%"></table>')
	const paging = Number(opt.idPaging || 25)
	$('#idBrowser').DataTable({
		data: idData,
		dom: '<"row"<"col-sm-6"f><"col-sm-6 text-right"B>><"row"<"col-sm-12"t>><"row"<"col-sm-5"i><"col-sm-7">>',
		columns: [
			{title: "Exchange", width: "20%"},
			{title: "Order #", width: "33%"},
			{title: "ID", width: "29%"},
			{title: "Date", width: "18%"},
		],
		buttons: [
			{extend: 'colvis', text: '<span class="glyphicon glyphicon-list"></span> Columns', titleAttr: "Show/Hide columns"}, 
			{extend: 'copy', text: '<span class="glyphicon glyphicon-copy"></span> Copy', titleAttr: "Copy list to clipboard"}, 
			{extend: 'csv', text: '<span class="glyphicon glyphicon-download-alt"></span> CSV', titleAttr: "Export list in CSV format"},
			{extend: 'excel', text: '<span class="glyphicon glyphicon-download-alt"></span> Excel', titleAttr: "Export list in Excel format"},
		],
		autoWidth: false,
		lengthChange: false,
		select: {
			style: 'os',
			items: 'row',
			toggleable: true,
		},
		language: {
			info: "Showing _START_-_END_ of _TOTAL_",
			infoEmpty: "Showing 0 of _MAX_", // _ENTRIES-TOTAL_?
			infoFiltered: "(_MAX_ total)",
			paginate: {
				previous: '<span class="glyphicon glyphicon-chevron-left"></span>',
				next: '<span class="glyphicon glyphicon-chevron-right"></span>',
			},
			select: {
				rows: {_: ""},
			},
			search: '<span class="input-group-addon"><span class="glyphicon glyphicon-search"></span></span>'
		},
		pageLength: paging || 25,
		scrollY: (paging || 25) * 20,
		paging: paging > 0,
	})
	q("#idBrowser_filter label").classList.add("input-group")
	q("#idBrowser_filter input").classList.remove("input-sm")
	q("#idBrowser_filter input").classList.add("input")
	q("#idBrowser_filter input").placeholder = "Filter by exchange / id"
}

function posInfo(el) {
	const data = el.dataset
	if (!data.ex || !data.acc) return
	const allPos = positions[data.ex][data.acc].list.reduce((obj, pos) => ({...obj, [pos._symbol+'.'+pos._side]: pos}), {})
	const pos = allPos[data.sym+'.'+data.side]
	if (!pos) return

// $$$$$$ add copy / export buttons!
	let info = `<div style="max-height:calc(50vh);overflow-y:auto"><table class="table table-striped table-condensed" id="posinfo"><thead><tr><th>Field</th><th>Value</th></tr></thead></div>`
	Object.each(/*Object.sort(pos)*/pos, (key, val) => info += `<tr><td>${key}</td><td>${val}</td></tr>`)

	confirm(`Details of ${data.ex}:${data.sym} ${data.side} ${data.acc !== '*'?'@ '+data.acc:''}`, info)
}

function doPosExport(format = "csv", id, autoInt, email) {
	const filename = `${pvName.toLowerCase()}${opt.instName ? '-'+opt.instName : ''}-${formatDateFN()}-positions`
	const exes = broker.getAll()
	const cols = ["Exchange","Alias","Account","Symbol","Side","Hedge","Entry","Mark","Liq","Size","SizeUSD","SizeCoin","RelSize","Currency","Lev","Cross","Profit","PnL","PnLLev","PnLTotal","Opened","Age"]
	const data = []
	for (const ex of exes) {
		if (!ex.canListPos()) continue
		const alias = ex.getAlias(-1)
		if (id && alias != id) continue

		const exPos = positions[alias]
		Object.each(exPos, (acc, pos) => {
			if (pos.error || !pos.list) return
			pos.list.forEach(p => data.push([
				ex.getName(), 
				alias, 
				acc, 
				p._symbol, 
				p._side, 
				p._hedge === "both" ? 0 : p._hedge ? 1 : '', 
				p._entry, 
				toDecimal(Number(p._mark)) || '', 
				toDecimal(Number(p._liq)) || '', 
				toDecimal(p._size), 
				p._sizeUSD ? Number(p._sizeUSD).toFixed(2) : '', 
				p._sizeCoin ? Number(p._sizeCoin).toFixed(4) : '', 
				(p.relSize * 100).toFixed(2) + '%', 
				p.ccy || p.currency || '', 
				Number(p.leverage || 1).toFixed(2).replace('.00', ''), 
				p.cross === true ? 1 : p.isolated === true ? 0 : '',
				toDecimal(p._profit), 
				(p.pnl * 100).toFixed(2) + '%', 
				(p.pnlLev * 100).toFixed(2) + '%', 
				(p.pnlTotal * 100).toFixed(2) + '%', 
				p._date ? formatDate(p._date) : '', 
				p._date ? formatDur(tillNow(p._date)) : '', 
			]))
		})
	}

	if (format === "copy" || format === "email") {
		const output = cols.join() + newLine + data.map(e => e.join()).join(newLine) + newLine
		if (format === "copy") {
			clipboardWrite("text/plain", output)
		} else {
			emailMessage("positions", "** All position info exported as CSV **", email, null, null, output, filename+'.csv', 'application/csv')
			const msg = (autoInt ? `[Auto Export => ${autoInt}] `:'') + `${id || 'All'} position info sent via email to ${email || opt.emailAddress}!`
			log.notice(msg), !autoInt && toastr.success(msg)
		}
		return
	}
	$(document.body).append(`<table id="export" style="display:none"></table>`)
	$('#export').DataTable({
		searching: false,
		ordering: false,
		paging: false,
		info: false,
		dom: 'Bt',
		buttons: [
			{extend: format, filename: filename, title: ''}
		],
		columns: cols.map(e => {return {title: e}}),
		data: data,
		initComplete: function() {
			$('#export').DataTable().button(0).trigger()
			$('#export_wrapper').remove()
			const msg = (autoInt ? `[Auto Export => ${autoInt}] `:'') + `${id || 'All'} position info exported as ${format.toUpperCase()}!`
			log.notice(msg), !autoInt && toastr.success(msg)
		}
	})
}

async function exportPos(el, ev) {
	const info = el?.closest("[name=positions]")
	const id = info?.dataset?.id
	const name = info?.dataset?.name || "all"
	if (ev?.shiftKey) {
		return doPosExport("copy", id)
	}
	const format = ev?.altKey ? "excel" : "csv"
	if (ev?.ctrlKey || (await yesnoA(`Do you really want to <b>export ${name}</b> saved position info in ${format.toUpperCase()} format?`))) {
		doPosExport(format, id)
	}
}
