/* zengtrade Studio shim v2: the REAL terminal as a MULTI-USER crypto product. * * Loaded BEFORE app.js in the deployed /dashboard copy only (build.py injects it; * the operator's local terminal never loads this file). * * Data plane (crypto-global, no operator-Mac dependency in steady state): * shared : engine_state table in Supabase (regime, catalog, metrics), * published server-side, read by every client (RLS: public read). * Falls back to /dashboard/data/*.json static seeds if a key is missing. * per-user: deployment / book_state / trade rows under RLS, each customer * sees exactly their own book, overlaid onto the shared payloads. * writes : Deploy/Pause/Stop buttons upsert the user's own deployment rows; * the 24/7 worker picks them up next cycle. Live mode stays locked. * prices : Binance public REST/WS run directly in the customer's browser. */ (function () { "use strict"; /* OAuth callbacks must land on /login so Supabase can exchange ?code= before session checks. */ if (/[?&]code=/.test(location.search) || /access_token=/.test(location.hash)) { location.replace("/login" + location.search + location.hash); return; } var SUPA = "https://ponvarxeytfcntckczbn.supabase.co"; var ANON = "sb_publishable_w-pQMK0bj-91EPHXtA0sMQ__CTu_rf1"; function trackPageview() { try { fetch(SUPA + "/rest/v1/event", { method: "POST", headers: { apikey: ANON, "Content-Type": "application/json", Prefer: "return=minimal" }, body: JSON.stringify({ name: "pageview", path: ("/dashboard" + location.hash).slice(0, 290), ref: (document.referrer || "").slice(0, 290), }), keepalive: true, }).catch(function () {}); } catch (e) {} } var LS_AUTH = "sb-ponvarxeytfcntckczbn-auth-token"; /* strategy_keys the cloud worker can actually run (mirror of worker REGISTRY). * Everything else in the catalog is research/other-venue: visible, not deployable. */ var DEPLOYABLE = {}; ["trend_follow","momo","momentum","bollinger","rsi2","macross","ema_cross","adx_trend", "zscore","nr7","orb","vwap_rev","vwap_mom","ema_scalp","bb_breakout","supertrend", "vwap_pull","rsi_intraday"].forEach(function (k) { DEPLOYABLE[k] = 1; }); /* ---- session ---- */ function session() { try { var s = JSON.parse(localStorage.getItem(LS_AUTH) || "null"); if (!s) return null; var exp = s.expires_at || (s.session && s.session.expires_at) || 0; if (exp * 1000 <= Date.now()) return null; var tok = s.access_token || (s.session && s.session.access_token); var uid = (s.user && s.user.id) || (s.session && s.session.user && s.session.user.id); var email = (s.user && s.user.email) || (s.session && s.session.user && s.session.user.email) || ""; return tok && uid ? { tok: tok, uid: uid, email: email } : null; } catch (e) { return null; } } var sess = session(); if (!sess) { location.replace("/login"); return; } trackPageview(); // BUG FIX (2026-09-19): the header profile avatar was static markup ("SB", the founder's own // initials left over from building this), shown to every real user regardless of who's // actually signed in, and had zero click handler at all - a dead CTA. Wired to the real // signed-in user's own initials. // UX FIX (2026-09-19, founder): a full navigation away from wherever the user currently is // (mid Algo Studio session) on a single avatar click was too heavy - a small dropdown with a // couple of CTAs is the right pattern here, matching how this control works on any SaaS // product. Menu items reuse the existing real destinations (/account) and the existing // real sign-out mechanism instead of inventing new ones. (function wireProfileAvatar() { var el = document.querySelector(".profile"); if (!el) return; var local = (sess.email || "").split("@")[0]; el.textContent = local ? local.slice(0, 2).toUpperCase() : "?"; el.setAttribute("title", sess.email || "Account"); el.style.cursor = "pointer"; el.style.position = "relative"; el.setAttribute("role", "button"); el.setAttribute("aria-haspopup", "true"); el.setAttribute("aria-expanded", "false"); el.setAttribute("tabindex", "0"); var menu = null; function closeMenu() { if (!menu) return; menu.remove(); menu = null; el.setAttribute("aria-expanded", "false"); document.removeEventListener("click", onOutsideClick, true); document.removeEventListener("keydown", onKeydown, true); } function onOutsideClick(e) { if (menu && !menu.contains(e.target) && e.target !== el) closeMenu(); } function onKeydown(e) { if (e.key === "Escape") closeMenu(); } async function doSignOut(btn) { if (btn) { btn.disabled = true; btn.textContent = "Signing out…"; } try { await fetch(SUPA + "/auth/v1/logout", { method: "POST", headers: sbHeaders() }); } catch (e) {} try { localStorage.removeItem(LS_AUTH); } catch (e) {} try { localStorage.removeItem("tradepro.terminal.v1"); } catch (e) {} if (btn) btn.textContent = "Signed out ✓"; setTimeout(function () { location.href = "/login"; }, 250); } function openMenu() { menu = document.createElement("div"); menu.setAttribute("role", "menu"); menu.style.cssText = "position:absolute;top:calc(100% + 8px);right:0;min-width:200px;" + "background:var(--surface,#fff);border:1px solid var(--line,#e3e8f0);border-radius:12px;" + "box-shadow:var(--shadow,0 8px 24px rgba(15,26,42,.14));padding:6px;z-index:200;" + "font-family:var(--sans);text-align:left"; menu.innerHTML = '
' + (sess.email || "") + '
' + '' + '' + ''; el.appendChild(menu); el.setAttribute("aria-expanded", "true"); menu.querySelector('[data-pm-item="evidence"]').onclick = function (e) { e.stopPropagation(); location.href = "/app"; }; menu.querySelector('[data-pm-item="account"]').onclick = function (e) { e.stopPropagation(); location.href = "/account"; }; menu.querySelector('[data-pm-item="signout"]').onclick = function (e) { e.stopPropagation(); doSignOut(e.currentTarget); }; menu.querySelectorAll("button").forEach(function (b) { b.onmouseenter = function () { b.style.background = "var(--surface-2,#f4f6fa)"; }; b.onmouseleave = function () { b.style.background = "none"; }; }); setTimeout(function () { document.addEventListener("click", onOutsideClick, true); document.addEventListener("keydown", onKeydown, true); }, 0); } el.onclick = function (e) { e.stopPropagation(); if (menu) closeMenu(); else openMenu(); }; el.onkeydown = function (e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); el.onclick(e); } }; })(); setTimeout(checkFirstClosedTrade, 2000); setInterval(function () { if (document.visibilityState === "visible") checkFirstClosedTrade(); }, 30000); function sbHeaders(extra) { var h = { apikey: ANON, Authorization: "Bearer " + (session() || {}).tok, "Content-Type": "application/json" }; if (extra) for (var k in extra) h[k] = extra[k]; return h; } function jresp(obj, code) { return new Response(JSON.stringify(obj), { status: code || 200, headers: { "Content-Type": "application/json" } }); } function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; }); } /* ---- real (non-custodial) order execution bridge ---- * assets/app.js (the terminal itself) has zero Supabase awareness - it only ever writes to * localStorage. This is the ONLY place that talks to Supabase for the whole /dashboard page, so * it's the natural, minimal-blast-radius integration point for real orders, same reasoning as * every other real (non-paper) call already bridged from here. Trading mode feature-detects * window.ztExchange before ever showing a Live toggle - on the untouched local terminal (no * studio.js, no session) this global simply doesn't exist, so real orders are structurally * unreachable outside the authenticated production surface, not just hidden by a UI check. */ window.ztExchange = { status: function () { return fetch(SUPA + "/rest/v1/exchange_connection?exchange=eq.binance&select=exchange,connected_at", { headers: sbHeaders() }) .then(function (r) { return r.ok ? r.json() : []; }) .then(function (rows) { return (rows && rows[0]) ? { connected: true, connectedAt: rows[0].connected_at } : { connected: false }; }) .catch(function () { return { connected: false }; }); }, placeOrder: function (spec) { return fetch(SUPA + "/functions/v1/place-order", { method: "POST", headers: sbHeaders(), body: JSON.stringify(spec) }) .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d }; }).catch(function () { return { ok: false, data: { error: "unexpected response" } }; }); }) .catch(function () { return { ok: false, data: { error: "network error, please try again" } }; }); }, // same exchange-connect Edge Function saas/web/js/exchange.js's connectExchange() calls from // the /app Account page - this is just a second caller, reachable from the header chip // without leaving the terminal. connect: function (apiKey, apiSecret) { return fetch(SUPA + "/functions/v1/exchange-connect", { method: "POST", headers: sbHeaders(), body: JSON.stringify({ apiKey: apiKey, apiSecret: apiSecret }) }) .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d }; }).catch(function () { return { ok: false, data: { error: "unexpected response" } }; }); }) .catch(function () { return { ok: false, data: { error: "network error, please try again" } }; }); }, // MONETIZATION FIX (2026-09-20): lets the terminal show a Pro/Elite upgrade prompt BEFORE a // free-tier user pastes real exchange credentials, instead of only finding out via a 403 from // exchange-connect at the very end. This is a UX convenience only - the real gate is server-side // (_shared/tier.mjs, checked again independently by both exchange-connect and place-order), so // there's no harm if this read is stale or skipped. RLS scopes the row to the caller already. tier: function () { return fetch(SUPA + "/rest/v1/profile?select=tier", { headers: sbHeaders() }) .then(function (r) { return r.ok ? r.json() : []; }) .then(function (rows) { return (rows && rows[0] && rows[0].tier) || "free"; }) .catch(function () { return "free"; }); }, liveOrders: function () { return fetch(SUPA + "/rest/v1/live_order?select=id,symbol,side,qty,avg_price,notional_usd,binance_order_id,status,created_at&order=created_at.desc&limit=50", { headers: sbHeaders() }) .then(function (r) { return r.ok ? r.json() : []; }) .catch(function () { return []; }); }, }; /* ---- toasts: reuse the terminal's own #toastWrap/.toast component (same one every other * tab's confirmations use) instead of a bespoke floating card. Fixes two real bugs the ad hoc * version had: (1) it rendered at document.body scale with up to 4 action links, so on shorter * viewports it grew tall enough to cover the very form it was nudging the user to fill in; * (2) it looked and behaved differently from every other notification in the product. */ function ztToast(o) { var host = document.getElementById("toastWrap"); if (!host || (o.uniqueClass && host.querySelector("." + o.uniqueClass))) return null; var t = document.createElement("div"); t.className = "toast" + (o.uniqueClass ? " " + o.uniqueClass : ""); t.setAttribute("role", "status"); var icoFn = typeof window.icon === "function" ? window.icon : function () { return ""; }; var acts = (o.actions || []).map(function (a) { return '"; }).join(""); t.innerHTML = '
' + icoFn(o.icon, 22) + '
' + '
' + esc(o.title) + "" + o.body + "
" + '
' + acts + "
"; host.appendChild(t); function dismissToast() { if (typeof window.dismiss === "function") { window.dismiss(t); return; } t.classList.add("out"); setTimeout(function () { t.remove(); }, 300); } (o.actions || []).forEach(function (a) { var btn = t.querySelector('[data-zt-act="' + a.key + '"]'); if (btn) btn.onclick = function () { if (a.onClick) a.onClick(); dismissToast(); }; }); t._ztDismiss = dismissToast; if (o.timeout) t._timer = setTimeout(function () { if (document.body.contains(t)) dismissToast(); }, o.timeout); return t; } function trackEvent(name) { try { ORIG(SUPA + "/rest/v1/event", { method: "POST", headers: sbHeaders({ Prefer: "return=minimal" }), body: JSON.stringify({ name: name, path: location.pathname.slice(0, 290) }), keepalive: true, }).catch(function () {}); } catch (e) {} } /* GA4 (zengtrade, web stream 15728393601): not an ES module here, so a plain defensive * wrapper around window.gtag rather than importing js/ga.js. Fires alongside every * trackEvent() call, a second destination, not a replacement for the Supabase funnel. */ function gaEvent(name, params) { try { if (typeof window.gtag === "function") window.gtag("event", name, params || {}); } catch (e) {} } function showForwardHint() { try { if (localStorage.getItem("zt_seen_forward")) return; localStorage.setItem("zt_seen_forward", "1"); } catch (e) { return; } function renderHint(workerUp) { setTimeout(function () { var sub = workerUp ? "Trades appear in Evidence as the worker runs on live prices (usually within 15 min)." : "Deploy saved. Trades will run when the paper worker is back online. Check the banner above."; /* the toast itself stays a clean 2-button confirmation (matching every other toast in the * app); this inline link is a secondary funnel touchpoint, not a 3rd action button, kept * as a small text link (.mon-acc-link, same class the Monitor row's own inline link uses) * so it doesn't compete visually with "View evidence". */ var coinsLink = ' More coin strategies'; ztToast({ uniqueClass: "zt-forward-toast", icon: "check", title: "Strategy deployed", body: sub + coinsLink, timeout: 9000, actions: [ { key: "view", cls: "primary", label: "View evidence", onClick: function () { location.href = "/app#forward"; } }, { key: "ok", cls: "ghost", label: "Dismiss" }, ], }); }, 800); } ORIG(SUPA + "/rest/v1/engine_state?key=eq._worker_heartbeat&select=updated_at", { headers: sbHeaders(), }).then(function (r) { return r.json(); }).then(function (rows) { var ts = rows && rows[0] && rows[0].updated_at; var up = ts && (Date.now() - new Date(ts).getTime() <= 12 * 60 * 1000); renderHint(!!up); }).catch(function () { renderHint(false); }); } var FREE_DEPLOY_LIMIT = 1; function markCheckoutRef(ref) { try { sessionStorage.setItem("zt_checkout_ref", ref); localStorage.setItem("zt_checkout_ref", ref); } catch (e) {} } function goUpgradeFromFreeLimit() { markCheckoutRef("free_limit_upgrade"); location.href = "/app#pricing"; } function maybeWorkerBanner() { ORIG(SUPA + "/rest/v1/engine_state?key=eq._worker_heartbeat&select=updated_at", { headers: sbHeaders(), }).then(function (r) { return r.json(); }).then(function (rows) { var ts = rows && rows[0] && rows[0].updated_at; if (!ts) return showWorkerDown(); var age = Date.now() - new Date(ts).getTime(); if (age > 12 * 60 * 1000) showWorkerDown(); }).catch(function () {}); } function showWorkerDown() { if (document.getElementById("ztWorkerDown")) return; var el = document.createElement("div"); el.id = "ztWorkerDown"; el.className = "zt-banner-warn"; el.setAttribute("role", "status"); el.innerHTML = "Paper worker offline: deploys save, but trades pause until the worker restarts. " + 'View evidence · ' + 'Worker status · ' + 'E2E status · ' + 'How paper trading works'; /* in-flow, not fixed: every other system banner in the terminal (rdy-banner, bot-banner, * rg-banner) sits in the page instead of floating over it. A fixed top bar here used to * render on top of the sticky .topbar (position:sticky;top:0;z-index:50) on every tab. */ document.body.insertBefore(el, document.body.firstChild); } /* ---- customers are pinned to the crypto book; persona (Investing/Trading/Algo Studio) is now * a real, user-chosen header toggle, not an operator-only surface - do not touch it here. ---- */ try { var K = "tradepro.terminal.v1"; var st0 = JSON.parse(localStorage.getItem(K) || "null") || {}; // SECURITY FIX (2026-09-20): this blob was never scoped to a signed-in user - on a shared // device, sign-out only ever cleared the auth token (see doSignOut below), never this key, so // whoever's orders/DCA plans/deployed strategies/layouts were here stayed visible to the next // person who signed in. Tag the blob with the current user's id and wipe it on mismatch, so a // different user always starts clean, without assets/app.js (which has no auth awareness of // its own) needing to know anything about this. if (st0._owner && st0._owner !== sess.uid) st0 = {}; st0._owner = sess.uid; // BUG FIX (2026-09-19): this used to force st0.persona = "algo" unconditionally on every single // page load, before crypto-only.js or app.js even ran - silently clobbering a real saved // Investing/Trading choice back to Algo Studio on every reload. That was correct back when // Trading/Investing were operator-only, but they are now a real customer-facing header toggle; // this file must leave persona alone, same as the equivalent fix already made in crypto-only.js. st0.algo = st0.algo || {}; st0.algo.market = "crypto"; st0.algo.view = st0.algo.view || "monitor"; localStorage.setItem(K, JSON.stringify(st0)); } catch (e) {} var ORIG = window.fetch.bind(window); // BUG FIX (2026-09-06): this used to be called right after its definition, BEFORE the // `var ORIG = ...` line below: ORIG was still undefined at that point (var hoisting only // hoists the declaration, not the assignment), so maybeWorkerBanner() threw "ORIG is not a // function" on every single /dashboard/ page load. That uncaught error silently skipped this // call, meaning the "paper worker is offline" banner never showed even while the worker was // genuinely down. Now it only runs once ORIG actually holds the native fetch. maybeWorkerBanner(); /* ---- shared payloads: engine_state row, else static seed ---- */ function engineGet(fileKey) { return ORIG(SUPA + "/rest/v1/engine_state?key=eq." + fileKey + "&select=value", { headers: sbHeaders() }) .then(function (r) { return r.ok ? r.json() : []; }) .then(function (rows) { if (rows && rows.length) return rows[0].value; return ORIG("/dashboard/data/" + fileKey + ".json") .then(function (r) { return r.ok ? r.json() : {}; }); }) .catch(function () { return {}; }); } /* ---- per-user rows (RLS scopes automatically to the JWT's user) ---- */ function mine(pathq) { return ORIG(SUPA + "/rest/v1/" + pathq, { headers: sbHeaders() }) .then(function (r) { return r.ok ? r.json() : []; }) .catch(function () { return []; }); } /* ---- overlay: shared crypto monitor + THIS user's deployments/book ---- */ function posList(b) { var pos = (b && b.positions) || {}; return Object.keys(pos).map(function (sym) { var p = pos[sym] || {}; return { sym: sym, qty: p.qty, entry: p.entry }; }); } function cryptoMonitor() { return Promise.all([ engineGet("api_crypto_monitor"), mine("deployment?select=strategy_key,status,params&mode=eq.paper"), mine("book_state?select=strategy_key,realised,positions"), ]).then(function (all) { var shared = all[0] || {}, deps = all[1] || [], books = all[2] || []; var running = {}, book = {}; deps.forEach(function (d) { if (d.status === "running") running[d.strategy_key] = 1; }); books.forEach(function (b) { book[b.strategy_key] = b; }); var out = JSON.parse(JSON.stringify(shared)); var totalReal = 0, totalOpen = 0; (out.strategies || []).forEach(function (s) { var b = book[s.id]; var pl = posList(b); s.realisedPnl = b ? Number(b.realised || 0) : 0; s.openPnl = 0; // client marks-to-market later; honest zero for now s.paperPnl = s.realisedPnl; s.positions = pl; s.openPositions = pl.length; s.deployed = !!running[s.id]; s.wired = !!DEPLOYABLE[s.id]; // only worker-runnable strategies are deployable totalReal += s.realisedPnl; totalOpen += pl.length; }); /* NOTE: custom (Builder) strategies are NOT injected into the Monitor here. * The terminal renders Monitor rows from its fixed CRYPTO_STRATEGIES catalog and * ignores unknown ids, so a pushed custom row would never render yet WOULD skew * the header totals. Custom strategies live fully in the Builder tab (deploy, * status, per-strategy P&L). Their realised P&L is intentionally excluded from * the built-in Monitor totals to keep rows and totals consistent. */ out.totals = { realised: +totalReal.toFixed(4), unreal: 0, pnl: +totalReal.toFixed(4), open: totalOpen }; /* the customer's "harness" is the cloud worker, and it is ALWAYS running - * never show the operator's "start python3 ..." empty state to a customer */ out.running = true; out.perUser = true; return out; }); } /* ---- real per-user closed trades: the system of record (mirrors saas/web/js/app.js's * metrics()/perStrategy() exactly, so /dashboard and /app never disagree on a number). ---- */ function myTrades() { return mine("trade?select=strategy_key,symbol,pnl,cost,entry,exit,closed_at,regime" + "&closed_at=not.is.null&order=closed_at.asc"); } function tradeStats(trades) { var n = trades.length, net = 0, wins = 0, grossW = 0, grossL = 0; trades.forEach(function (t) { var p = Number(t.pnl || 0); net += p; if (p > 0) { wins++; grossW += p; } else { grossL += -p; } }); return { n: n, net: net, wins: wins, losses: n - wins, winPct: n ? +(100 * wins / n).toFixed(1) : null, profitFactor: grossL ? +(grossW / grossL).toFixed(2) : (grossW ? 99 : null), expectancy: n ? net / n : null }; } function groupBy(list, keyFn) { var map = {}; list.forEach(function (x) { var k = keyFn(x); (map[k] = map[k] || []).push(x); }); return map; } /* ---- retention (R&D charter P0): the ONE moment worth interrupting for ----------------- * Before this, a user who deployed had zero signal anything happened unless they opened * Forward Test themselves. This fires once, only for the very first closed trade an account * ever gets (not every trade after - a strategy can close several a day and a toast per * trade would turn into exactly the "toast-theater" pattern QA already flagged elsewhere). * localStorage flag makes the check free after the first hit; myTrades() is the same * RLS-scoped query the Forward/Accuracy tabs already trust. */ function checkFirstClosedTrade() { try { if (localStorage.getItem("zt_seen_first_trade")) return; } catch (e) { return; } myTrades().then(function (trades) { if (!trades || !trades.length) return; try { localStorage.setItem("zt_seen_first_trade", "1"); } catch (e) {} var t = trades[0], pnl = Number(t.pnl || 0), win = pnl >= 0; setTimeout(function () { ztToast({ uniqueClass: "zt-first-trade-toast", icon: win ? "check" : "activity", title: "Your first paper trade just closed", body: (t.symbol || t.strategy_key) + " closed " + (win ? "up " : "down ") + (win ? "+$" : "-$") + Math.abs(pnl).toFixed(2) + ". Real evidence from live prices, not a backtest projection.", timeout: 12000, actions: [ { key: "view", cls: "primary", label: "View evidence", onClick: function () { location.href = "/app#forward"; } }, { key: "ok", cls: "ghost", label: "Dismiss" }, ], }); }, 800); }); } /* ---- pricing modal (founder, 2026-09-16): the dashboard's Pricing chip used to navigate away * to /app#pricing for even just BROWSING plans - full page nav to compare three cards. This * shows the same real plans in place instead; only the actual purchase (an external redirect to * a NOWPayments-hosted invoice, unavoidable either way - see billing.js) leaves the page. There * used to be an in-dashboard pricing modal before this session; it was removed because it showed * fabricated pre-pivot India-equity prices in rupees with no relation to the real crypto billing. * This one is real: PLANS below is a verbatim copy of saas/web/js/billing.js's PLANS (that file's * own comment already calls it a mirror of the Edge Function's authoritative pricing - one more * client-side copy of an already-accepted pattern, not a new one). Keep both in sync by hand if * pricing ever changes; there's no shared bundle between /dashboard and /app to import across. */ // Session 2026-09-16 revision: trimmed to the 4 most decision-relevant features per plan (was // 5) so the modal fits common desktop viewports without an internal scroll - nothing fabricated // or hidden, the dropped line per plan (Accuracy & Analytics / Email & push alerts / Priority // support & early access) is still real and still shown on the full comparison at /pricing/, // linked in the footer. Prices/tiers themselves are unchanged and still mirror billing.js. var PLANS = [ { id: "free", name: "Free", monthly: 0, annual: 0, tagline: "Learn and paper-trade, free forever", features: ["1 paper strategy", "Live crypto prices, 24/7", "Backtest + Forward Test", "Honest cost accounting"] }, { id: "pro", name: "Pro", monthly: 19, annual: 190, featured: true, tagline: "Founding price · unlimited paper", features: ["Everything in Free", "Unlimited paper strategies", "Live execution (coming soon)*", "Tick-level stops & kill-switch"] }, { id: "elite", name: "Elite", monthly: 79, annual: 790, tagline: "Maximum firepower", features: ["Everything in Pro", "Perps + options engines", "Multiple exchange accounts", "Custom risk parameters"] }, ]; var PLAN_ICON = { free: "○", pro: "◈", elite: "✦" }; var pmCycle = "month"; function dashboardCheckout(plan, cycle) { var sess = session(); if (!sess) { location.href = "/login/?mode=signup"; return; } var btn = document.querySelector('.pm-cta[data-plan="' + plan + '"]'); if (btn) { btn.disabled = true; btn.textContent = "Starting checkout…"; } try { var planDef = PLANS.filter(function (p) { return p.id === plan; })[0]; var value = planDef ? (cycle === "year" ? planDef.annual : planDef.monthly) : 0; if (window.gtag) window.gtag("event", "begin_checkout", { currency: "USD", value: value, items: [{ item_id: plan, item_name: "zengtrade " + plan + " (" + cycle + ")", price: value, quantity: 1 }] }); } catch (e) {} fetch(SUPA + "/functions/v1/nowpayments-create-invoice", { method: "POST", headers: sbHeaders(), body: JSON.stringify({ plan: plan, cycle: cycle }), }) .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); }) .then(function (res) { if (res.ok && res.d && res.d.invoice_url) { window.location.href = res.d.invoice_url; return; } if (btn) { btn.disabled = false; btn.textContent = "Choose " + esc((PLANS.filter(function (p) { return p.id === plan; })[0] || {}).name || plan); } ztToast({ icon: "alert", title: "Could not start checkout", body: (res.d && res.d.error) || "Please try again.", timeout: 7000, actions: [{ key: "ok", cls: "ghost", label: "Dismiss" }] }); }) .catch(function () { if (btn) { btn.disabled = false; btn.textContent = "Choose " + esc((PLANS.filter(function (p) { return p.id === plan; })[0] || {}).name || plan); } ztToast({ icon: "alert", title: "Could not start checkout", body: "Please try again.", timeout: 7000, actions: [{ key: "ok", cls: "ghost", label: "Dismiss" }] }); }); } function planCardHtml(p) { var price = pmCycle === "year" ? p.annual : p.monthly; var per = p.id === "free" ? "" : (pmCycle === "year" ? "/yr" : "/mo"); var cta = p.id === "free" ? '' : '"; return '
' + (p.featured ? '
Most popular
' : "") + '
' + (PLAN_ICON[p.id] || "○") + '' + esc(p.name) + "
" + '
$' + price + (per ? "" + per + "" : "") + "
" + '
' + esc(p.tagline) + "
" + "" + cta + (p.id !== "free" ? '
Cancel anytime · secure checkout
' : "") + "
"; } function renderPricingModal() { var wrap = document.getElementById("ztPricingModal"); if (!wrap) return; wrap.innerHTML = '
' + '
' + '
simple · honest · cancel anytime
' + "

Simple, honest pricing

" + "

Start free. Upgrade when you want more.

" + '
' + '' + '' + "
" + '
' + PLANS.map(planCardHtml).join("") + "
" + "
"; wrap.querySelector(".pm-close").onclick = hidePricingModal; wrap.querySelectorAll(".pm-cycle button").forEach(function (b) { b.onclick = function () { pmCycle = b.dataset.c; renderPricingModal(); }; }); wrap.querySelectorAll(".pm-cta[data-plan]").forEach(function (b) { b.onclick = function () { dashboardCheckout(b.dataset.plan, pmCycle); }; }); } var pmLastFocus = null; function pmFocusables() { var wrap = document.getElementById("ztPricingModal"); if (!wrap) return []; return [].slice.call(wrap.querySelectorAll("button:not([disabled]), a[href]")); } function hidePricingModal() { var scrim = document.getElementById("ztPricingScrim"), modal = document.getElementById("ztPricingModal"); if (scrim) scrim.classList.remove("show"); if (modal) modal.classList.remove("show"); document.removeEventListener("keydown", pmKeydown); if (pmLastFocus && pmLastFocus.focus) { try { pmLastFocus.focus(); } catch (e) {} } pmLastFocus = null; } // Escape closes; Tab/Shift+Tab wrap within the modal instead of leaking focus onto the // dashboard behind it - a real, if small, "feels polished not thrown together" signal for // anyone navigating by keyboard, and it's genuinely easy to get wrong with a bare dialog. function pmKeydown(e) { if (e.key === "Escape") { hidePricingModal(); return; } if (e.key !== "Tab") return; var f = pmFocusables(); if (!f.length) return; var first = f[0], last = f[f.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } function showPricingModal() { var scrim = document.getElementById("ztPricingScrim"), modal = document.getElementById("ztPricingModal"); if (!scrim) { scrim = document.createElement("div"); scrim.id = "ztPricingScrim"; scrim.className = "modal-scrim"; scrim.onclick = hidePricingModal; document.body.appendChild(scrim); } if (!modal) { modal = document.createElement("div"); modal.id = "ztPricingModal"; modal.className = "pricing-modal"; modal.setAttribute("role", "dialog"); modal.setAttribute("aria-modal", "true"); modal.setAttribute("aria-label", "Pricing"); document.body.appendChild(modal); } pmLastFocus = document.activeElement; renderPricingModal(); scrim.classList.add("show"); modal.classList.add("show"); document.addEventListener("keydown", pmKeydown); var closeBtn = modal.querySelector(".pm-close"); if (closeBtn) setTimeout(function () { closeBtn.focus(); }, 50); } window.ztOpenPricingModal = showPricingModal; /* BUG FIX (2026-09-09): Forward Test, Accuracy(*), and Analytics used to render the SHARED * engine_state payload verbatim - a brand-new, zero-deployment account saw the same * platform-wide numbers (145 closed trades, -$28,170 net) as every other user. These three * overlay functions replace that with each user's own rows from the `trade` table (RLS-scoped, * the same source /app already reads correctly). Users with no closed trades naturally fall * through to the product's existing "No closed trades yet" empty states - no code change * needed there, since an empty strategies/trades array already renders that path. * (*Accuracy is the go-live readiness bar, a platform-wide gate by design - that one was fixed * separately by the query-string cache-key fix above, which stopped it reading India's book.) */ function cryptoForwardOverlay() { return Promise.all([engineGet("api_crypto_monitor"), myTrades()]).then(function (all) { var catalog = (all[0] && all[0].strategies) || [], trades = all[1] || []; var byStrat = groupBy(trades, function (t) { return t.strategy_key; }); var strategies = catalog.filter(function (s) { return byStrat[s.id]; }).map(function (s) { var st = tradeStats(byStrat[s.id]); return { id: s.id, name: s.name, instr: s.instr || "spot", closed: st.n, winPct: st.winPct, profitFactor: st.profitFactor, expectancy: st.expectancy, netPnl: st.net }; }); var t = tradeStats(trades); return { running: true, strategies: strategies, totals: { closed: t.n, wins: t.wins, losses: t.losses, winPct: t.winPct, profitFactor: t.profitFactor, netPnl: t.net } }; }); } function cryptoAnalyticsOverlay() { return Promise.all([ engineGet("api_crypto_monitor"), myTrades(), mine("book_state?select=strategy_key,positions"), ]).then(function (all) { var catalog = (all[0] && all[0].strategies) || [], trades = all[1] || [], books = all[2] || []; var nameOf = {}; catalog.forEach(function (s) { nameOf[s.id] = s.name; }); var openPos = 0; books.forEach(function (b) { openPos += Object.keys((b && b.positions) || {}).length; }); var t = tradeStats(trades); var eq = 0, equity = trades.map(function (x) { eq += Number(x.pnl || 0); return { cum: +eq.toFixed(2) }; }); var totalCost = trades.reduce(function (a, x) { return a + Number(x.cost || 0); }, 0); var wins = trades.filter(function (x) { return Number(x.pnl) > 0; }); var losses = trades.filter(function (x) { return Number(x.pnl) <= 0; }); var avgWin = wins.length ? wins.reduce(function (a, x) { return a + Number(x.pnl); }, 0) / wins.length : 0; var avgLoss = losses.length ? losses.reduce(function (a, x) { return a + Number(x.pnl); }, 0) / losses.length : 0; var peak = 0, run = 0, maxDD = 0; trades.forEach(function (x) { run += Number(x.pnl || 0); if (run > peak) peak = run; maxDD = Math.max(maxDD, peak - run); }); var mkBucket = function () { return { n: 0, wins: 0, net: 0 }; }; var bySymbol = {}, byRegime = {}, byHour = {}, byStratRaw = {}; trades.forEach(function (x) { var p = Number(x.pnl || 0), win = p > 0; [ [bySymbol, x.symbol], [byRegime, x.regime || "-"], [byStratRaw, x.strategy_key], [byHour, x.closed_at ? (new Date(x.closed_at).getUTCHours() + "").padStart(2, "0") + ":00" : "-"], ].forEach(function (pair) { var m = pair[0][pair[1]] || (pair[0][pair[1]] = mkBucket()); m.n++; if (win) m.wins++; m.net += p; }); }); var byStrategy = Object.keys(byStratRaw).map(function (k) { return { name: nameOf[k] || k, instr: "spot", pnl: byStratRaw[k].net }; }).sort(function (a, b) { return b.pnl - a.pnl; }); var byInstrument = t.n ? { spot: { n: Object.keys(byStratRaw).length, openPos: openPos, realised: t.net, open: 0, pnl: t.net } } : {}; var recent = trades.slice().reverse().slice(0, 40).map(function (x) { return { time: x.closed_at ? x.closed_at.slice(5, 16).replace("T", " ") : "", strat: nameOf[x.strategy_key] || x.strategy_key, sym: x.symbol, pnl: Number(x.pnl || 0), cost: Number(x.cost || 0), reason: "", regime: x.regime || "-" }; }); return { running: true, regime: (all[0] && all[0].regime) || null, totals: { realised: t.net, unreal: 0, pnl: t.net }, stats: { n: t.n, winRate: t.winPct, profitFactor: t.profitFactor, avgWin: avgWin, avgLoss: avgLoss, maxDrawdown: -Math.abs(maxDD), totalCost: totalCost, net: t.net }, equity: equity, byInstrument: byInstrument, byReason: {}, bySymbol: bySymbol, byHour: byHour, byRegime: byRegime, byStrategy: byStrategy, recent: recent }; }); } /* Risk Governor needs live state (health score, drawdown ladder, kill-switch, trade audit) * that only exists inside the worker's Python process and isn't published per user yet - * rather than fabricate those numbers client-side (or keep showing the shared platform book), * this reports itself unavailable until the worker publishes real per-user risk state. */ function cryptoRiskOverlay() { return Promise.resolve({ running: true, available: false }); } /* ---- deploy / pause / stop -> the user's own deployment rows ---- */ function strategyPost(body) { var id = body.id, want = body.state; var s = session(); if (!s) { location.replace("/login"); return Promise.resolve(jresp({ error: "signed out" }, 401)); } if (want === "live") return Promise.resolve(jresp({ locked: true, reason: "Live trading unlocks after your paper track record clears the gate." })); if (!DEPLOYABLE[id] && id.indexOf("custom_") !== 0) return Promise.resolve(jresp({ error: "This strategy needs a venue not yet enabled for customer accounts (perps/options/pairs land later)." })); if (want === "paper") { return ORIG(SUPA + "/rest/v1/deployment?on_conflict=user_id,strategy_key", { method: "POST", headers: sbHeaders({ Prefer: "resolution=merge-duplicates,return=minimal" }), body: JSON.stringify({ user_id: s.uid, strategy_key: id, mode: "paper", status: "running" }), }).then(function (r) { if (r.ok) { trackEvent("deploy_click"); trackEvent("deploy_success"); gaEvent("deploy_click", { strategy: id }); gaEvent("deploy_success", { strategy: id }); showForwardHint(); return jresp({ ok: true, id: id, state: "paper" }); } return r.json().then(function (e) { var msg = (e && (e.message || e.details || e.hint)) || "Deploy failed - try again."; if (/FREE_LIMIT/i.test(msg)) { setTimeout(goUpgradeFromFreeLimit, 900); return jresp({ error: "Free includes " + FREE_DEPLOY_LIMIT + " paper strategy. Upgrade to Pro for unlimited.", upgrade: "/app#pricing", upgrade_ref: "free_limit_upgrade", }); } return jresp({ error: msg }); }).catch(function () { return jresp({ error: "Deploy failed - try again." }); }); }); } /* pause / stop -> stopped (worker only distinguishes running/stopped) */ return ORIG(SUPA + "/rest/v1/deployment?strategy_key=eq." + encodeURIComponent(id), { method: "PATCH", headers: sbHeaders({ Prefer: "return=minimal" }), body: JSON.stringify({ status: "stopped" }), }).then(function (r) { return jresp(r.ok ? { ok: true, id: id, state: want } : { error: "Update failed - try again." }); }); } /* ---- the fetch shim ---- */ function isEngine(u) { return u.indexOf("/api/") === 0 || u.indexOf("http://localhost:8756") === 0 || u.indexOf("http://127.0.0.1:8756") === 0; } window.fetch = function (input, init) { var u = typeof input === "string" ? input : (input && input.url) || ""; if (!isEngine(u)) return ORIG(input, init); var method = ((init && init.method) || "GET").toUpperCase(); var noOrigin = u.replace(/^https?:\/\/[^/]+/, "").replace(/^\//, ""); var qIdx = noOrigin.indexOf("?"); var path = qIdx === -1 ? noOrigin : noOrigin.slice(0, qIdx); var query = qIdx === -1 ? "" : noOrigin.slice(qIdx + 1); var fileKey = path.replace(/\//g, "_"); // BUG FIX (2026-09-09): query params (market, strategy, period, ...) used to be stripped // before computing the cache key, so ?market=crypto and ?market=in collapsed onto the same // engine_state row / static seed file - Indian-equity strategy names (Iron Condor NIFTY, etc.) // leaked into the crypto Accuracy tab, and switching backtest strategy/period re-fetched the // identical cached response every time. Folding the query into the key (matching the // api_readiness_book_market_crypto.json naming convention already on disk) fixes both. if (query) fileKey += "_" + query.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, ""); if (method === "POST" && path === "api/strategy") { var body = {}; try { body = JSON.parse((init && init.body) || "{}"); } catch (e) {} return strategyPost(body); } if (method !== "GET") return Promise.resolve(jresp({ ok: false, error: "Not available in the customer Studio." }, 403)); if (fileKey === "api_crypto_monitor") return cryptoMonitor().then(function (payload) { return jresp(payload); }); if (fileKey === "api_crypto_forward") return cryptoForwardOverlay().then(function (payload) { return jresp(payload); }); if (fileKey === "api_crypto_analytics") return cryptoAnalyticsOverlay().then(function (payload) { return jresp(payload); }); if (fileKey === "api_crypto_risk") return cryptoRiskOverlay().then(function (payload) { return jresp(payload); }); return engineGet(fileKey).then(function (payload) { return jresp(payload); }); }; /* ---- SSE stream -> inert (terminal falls back to its polling path) ---- */ var RealES = window.EventSource; if (RealES) { window.EventSource = function (url, cfg) { if (String(url).indexOf("/api/stream") !== -1) { return { readyState: 2, url: String(url), withCredentials: false, close: function () {}, addEventListener: function () {}, removeEventListener: function () {}, dispatchEvent: function () { return false; }, onopen: null, onmessage: null, onerror: null }; } return new RealES(url, cfg); }; window.EventSource.prototype = RealES.prototype; } /* ---- hide operator-only controls (they act on the operator's Mac/broker) ---- */ var css = [ ".funds-chip", "[data-relogin]", ".mkt-relogin", ".pf-relogin", ".bot-relogin", ".ais-relogin", "[data-harness]", "#glGo", "[data-stopall]", '[data-algomkt="in"]', ".mkt-live", ".mkt-dot", ".he-stat.off", ".tix-offline", "#modeFab", "#personaGate", // persona switcher removed: the Studio IS the product /* BUG FIX (2026-09-09): the "Regime Detection Engine" what-if simulator (Nifty trend vs * 20/50/200 DMA, India VIX, Advance/Decline sliders) is entirely India-equity content with no * crypto equivalent - hide its trigger (and the panel/scrim themselves, belt-and-suspenders) * rather than let a curious customer open it and see an Indian-market simulator on a crypto * product. */ "#engineBtn", "#engine", "#engineScrim" ].join(",") + "{display:none !important}" + /* worker-down banner: same warn tokens as every other caution note in the terminal * (.note-warn, .rdy-banner.no), just in the page flow instead of floating over it. */ ".zt-banner-warn{padding:8px 14px;text-align:center;font:600 12.5px/1.4 var(--sans);" + "background:var(--tint-warn);color:var(--amber);border-bottom:1px solid var(--bd-warn)}" + ".zt-banner-warn a{color:inherit;font-weight:700;text-decoration:none;margin:0 2px}" + ".zt-banner-warn a:hover{text-decoration:underline}"; var nudgeTimer = null; function nudgeDeployIfCold() { var delay = 5000; try { if (localStorage.getItem("zt_fresh_signup")) { delay = 800; localStorage.removeItem("zt_fresh_signup"); } } catch (e) {} nudgeTimer = setTimeout(function () { mine("deployment?select=strategy_key&status=eq.running&limit=1").then(function (rows) { if (rows && rows.length) return; /* the CTA below is "Open Library", showing it to someone already composing a custom * strategy in Builder is exactly the noisy, badly-timed nudge this used to be. */ if (document.querySelector(".ztb")) return; ztToast({ uniqueClass: "zt-cold-nudge", icon: "send", title: "Deploy your first strategy", body: "Paper-trade on live Binance prices: it takes under a minute, zero risk.", timeout: 9000, actions: [ { key: "lib", cls: "primary", label: "Open Library", onClick: function () { var lib = document.querySelector('[data-algoview="library"]'); if (lib) lib.click(); } }, { key: "later", cls: "ghost", label: "Not now" }, ], }); }); }, delay); } /* ---- first-run guided tour: orientation, not a sales nudge (nudgeDeployIfCold above already * owns "go deploy something" and fires separately). Once ever per browser, explaining the tab * flow the terminal's own renderAlgo() comment already documents: watch -> browse -> hold -> * prove -> protect. Library (browse real, rule-based strategies) -> Monitor (watch it live) -> * Forward Test (the real evidence, never a backtest) -> Risk Governor (limits, not hope). * DIY-simplicity agenda (founder, 2026-09-12): no human ever walks a new user through this UI, * so the product has to, once, briefly, and skippably. Suppresses nudgeDeployIfCold's toast for * this page load so a fresh signup never sees both at once (see nudgeTimer above). */ var TOUR_STEPS = [ { view: "library", title: "Browse real, rule-based strategies", body: "Every strategy shows its rule, when it works, when it fails, and what protects you if it's wrong, before you ever deploy it." }, { view: "monitor", title: "Watch it live", body: "Once you deploy a strategy in paper, this is where you see it running: real positions, real Binance prices, nothing simulated." }, { view: "forward", title: "See the real evidence", body: "Every number here comes from trades that have actually closed. Never a backtest, never a projection." }, { view: "risk", title: "Protected by design", body: "This won't let any single strategy or coin take over your paper book. Confidence comes from limits, not hope." }, ]; var TOUR_CSS = "#ztTourWrap{position:fixed;inset:0;z-index:400}" + ".zt-tour-scrim{position:absolute;inset:0;background:rgba(10,15,12,.55);cursor:pointer}" + ".zt-tour-spot{position:fixed;border-radius:10px;box-shadow:0 0 0 4px var(--accent-line,rgba(31,208,122,.4)),0 0 0 9999px rgba(10,15,12,.55);pointer-events:none;transition:top .2s ease,left .2s ease,width .2s ease,height .2s ease}" + ".zt-tour-card{position:fixed;width:300px;max-width:calc(100vw - 24px);background:var(--surface);border:1px solid var(--line);border-radius:var(--radius,14px);box-shadow:var(--shadow-lg,0 20px 54px -14px rgba(0,0,0,.4));padding:14px 16px;font-size:13.5px}" + ".zt-tour-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}" + ".zt-tour-progress{font-size:10.5px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--slate)}" + ".zt-tour-x{background:none;border:0;color:var(--slate);font-size:18px;line-height:1;cursor:pointer;padding:0 2px}" + ".zt-tour-card b{display:block;font-size:15px;color:var(--navy);margin-bottom:4px}" + ".zt-tour-card p{margin:0 0 10px;color:var(--slate);line-height:1.5}" + ".zt-tour-link{display:inline-block;margin-bottom:10px;font-size:12.5px;font-weight:600;color:var(--accent-d);text-decoration:none}" + ".zt-tour-link:hover{text-decoration:underline}" + ".zt-tour-actions{display:flex;align-items:center;justify-content:space-between;gap:10px}" + ".zt-tour-skip{background:none;border:0;color:var(--slate);font:inherit;font-size:12.5px;font-weight:600;cursor:pointer;padding:6px 4px}" + ".zt-tour-skip:hover{color:var(--navy)}" + ".zt-tour-next{background:var(--accent);color:#fff;border:0;border-radius:9px;padding:8px 16px;font:inherit;font-weight:700;font-size:12.5px;cursor:pointer}" + ".zt-tour-next:hover{background:var(--accent-d)}"; function endTour() { try { localStorage.setItem("zt_seen_dashboard_tour", "1"); } catch (e) {} var wrap = document.getElementById("ztTourWrap"); if (wrap) wrap.remove(); document.removeEventListener("keydown", tourKeydown); document.removeEventListener("click", tourPersonaWatch, true); } function tourKeydown(e) { if (e.key === "Escape") endTour(); } /* BUG FIX (2026-09-19): this tour's steps each spotlight an Algo Studio tab button via * getBoundingClientRect() captured once, with no ongoing link back to that element. If the * user switches to Investing/Trading mid-tour, renderAlgo() wipes Algo Studio's tab bar from * the DOM (it's no longer the active mode) - the spotlighted button is gone, but the fixed- * position spotlight div just stays frozen at its last coordinates, landing on whatever * happens to share that pixel position in the new mode's identical tab-bar shell. End the tour * the instant the user switches mode instead of leaving it stranded like that. */ function tourPersonaWatch(e) { if (document.getElementById("ztTourWrap") && e.target.closest && e.target.closest("[data-mode-btn]")) endTour(); } function positionTourSpot(el, wrap) { var r = el.getBoundingClientRect(), pad = 6; var spot = wrap.querySelector(".zt-tour-spot"); spot.style.top = (r.top - pad) + "px"; spot.style.left = (r.left - pad) + "px"; spot.style.width = (r.width + pad * 2) + "px"; spot.style.height = (r.height + pad * 2) + "px"; var card = wrap.querySelector(".zt-tour-card"); var cw = 300, margin = 12; var top = r.bottom + pad + margin; if (top + 180 > window.innerHeight) top = Math.max(margin, r.top - pad - 190); var left = Math.min(Math.max(margin, r.left), window.innerWidth - cw - margin); card.style.top = top + "px"; card.style.left = left + "px"; } function showTourStep(i) { var step = TOUR_STEPS[i]; var el = document.querySelector('[data-algoview="' + step.view + '"]'); if (!el) { endTour(); return; } if (el.scrollIntoView) el.scrollIntoView({ inline: "center", block: "nearest" }); var wrap = document.getElementById("ztTourWrap"); if (!wrap) { wrap = document.createElement("div"); wrap.id = "ztTourWrap"; document.body.appendChild(wrap); } var last = i === TOUR_STEPS.length - 1; wrap.innerHTML = '
' + '
' + '"; positionTourSpot(el, wrap); wrap.querySelectorAll("[data-tourdismiss]").forEach(function (b) { b.onclick = endTour; }); wrap.querySelector(".zt-tour-next").onclick = function () { if (last) endTour(); else showTourStep(i + 1); }; } function initDashboardTour() { try { if (localStorage.getItem("zt_seen_dashboard_tour")) return; } catch (e) { return; } var tries = 0; (function waitForTabs() { // BUG FIX (2026-09-19): this tour is Algo-Studio-only content (Library/Monitor/Forward // Test/Risk Governor) written before the Investing/Trading header toggle existed, when // Algo Studio was the only real destination - it had no awareness of which mode was // actually active. A user landing on the default first-visit persona (algo) then switching // to Investing/Trading before this fired (a completely normal first action now) still got // shown Algo Studio's tour, spotlighting an already-removed tab button - see the // tourPersonaWatch comment for the rest of that chain. Only ever start it while the user is // actually looking at Algo Studio, and bail (not retry) once they've moved on themselves. if (document.documentElement.dataset.persona !== "algo") return; if (!document.querySelector('[data-algoview="' + TOUR_STEPS[0].view + '"]')) { if (++tries < 30) setTimeout(waitForTabs, 300); return; } if (nudgeTimer) { clearTimeout(nudgeTimer); nudgeTimer = null; } document.addEventListener("keydown", tourKeydown); document.addEventListener("click", tourPersonaWatch, true); showTourStep(0); })(); } function inject() { var st = document.createElement("style"); st.textContent = css + ZTB_CSS + TOUR_CSS; document.head.appendChild(st); } if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", inject); else inject(); nudgeDeployIfCold(); setTimeout(initDashboardTour, 1200); /* injectAppLink()/injectStudioBlurb() removed (founder, 2026-09-16): a redundant nav chip * ("Evidence & billing") plus an almost-identical text banner right below it, both just * announcing the same /app link, cluttering the terminal's topbar for zero added function - * every real path into /app (View evidence on the retention toast, the Pricing chip, the * Builder's free-tier upsell message) already exists elsewhere and is untouched by this. */ /* ---- force the crypto book after boot (full setMarket path: live tape + WS) ---- */ function forceCrypto(tries) { var b = document.querySelector('[data-algomkt="crypto"]'); if (b && !b.classList.contains("on")) b.click(); else if (!b && tries > 0) setTimeout(function () { forceCrypto(tries - 1); }, 700); } if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", function () { setTimeout(function () { forceCrypto(6); }, 700); }); else setTimeout(function () { forceCrypto(6); }, 700); /* ================= Strategy Builder tab ================= * Injected into the Algo Studio tab bar, rendered with the terminal's own * component classes (.av-tab, .flow-*, .sel, .seg, .btn-primary) so it is * visually indistinguishable from a built-in tab. Users compose the SIGNALS; * the engine keeps the RAILS (ATR stops, cost gate, cooldown, profit lock). */ /* label = dropdown text · p1/p2 = param labels · value:true shows the free value * field (vlbl = its label, def = sensible default applied when switching signal) */ var IND_META = { rsi: { label: "RSI (oversold / overbought)", p1: "Period", value: true, vlbl: "Level 0-100", def: 30 }, stoch: { label: "Stochastic %K (turn timing)", p1: "Period", value: true, vlbl: "Level 0-100", def: 20 }, zscore: { label: "Z-score (stretch from mean)", p1: "Lookback", value: true, vlbl: "Std devs", def: -2 }, dist_sma: { label: "% distance from average", p1: "SMA period", value: true, vlbl: "Percent", def: -5 }, roc: { label: "Momentum % (rate of change)", p1: "Bars", value: true, vlbl: "Percent", def: 5 }, price_vs_sma: { label: "Price vs SMA", p1: "SMA period" }, price_vs_ema: { label: "Price vs EMA", p1: "EMA period" }, sma_cross: { label: "SMA cross (fast vs slow)", p1: "Fast", p2: "Slow" }, ema_cross: { label: "EMA cross (fast vs slow)", p1: "Fast", p2: "Slow" }, macd_cross: { label: "MACD vs signal line", p1: "Fast", p2: "Slow" }, bollinger_touch: { label: "Bollinger band touch", p1: "Period", value: true, vlbl: "Std devs", def: 2 }, breakout: { label: "N-bar breakout (Donchian)", p1: "Lookback" }, vol_spike: { label: "Volume spike (× average)", p1: "Avg window", value: true, vlbl: "× average", def: 2 }, }; var COINS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT", "XRPUSDT"]; function slug(name) { return "custom_" + String(name || "my_strategy").toLowerCase() .replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 24); } /* Layout scaffolding only below, every actual control (inputs, selects, segmented toggles, * pill chips, buttons, notes, errors) is drawn with the terminal's own component classes * (.flow-input, .seg/.seg-btn, .preset, .type-pill, .flow-note, .flow-err, .btn-ghost, .num) * so Builder looks and behaves like a tab the rest of the app actually shipped, not a bolt-on. * The few *-ztb classes that remain have no existing counterpart: they only place things on * the page (grid/row/card shells), they don't reskin anything the app already themes. */ var ZTB_CSS = ".ztb{max-width:880px;margin:0 auto;display:flex;flex-direction:column;gap:14px;padding:4px 2px 26px;font-size:13.5px}" + ".ztb-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:14px 16px;box-shadow:var(--shadow)}" + ".ztb-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}" + ".ztb-head b{font-size:15.5px;color:var(--navy)}" + ".ztb-head p{margin:2px 0 0;color:var(--slate);font-size:12.5px}" + ".ztb-badge{font-size:10.5px;font-weight:800;letter-spacing:.06em;color:var(--green-d);background:var(--up-flash);border-radius:99px;padding:4px 11px;white-space:nowrap}" + ".ztb-grid{display:grid;grid-template-columns:1.2fr 1fr;gap:16px 26px}" + "@media(max-width:760px){.ztb-grid{grid-template-columns:1fr}}" + ".ztb-lbl{display:block;font-size:10.5px;font-weight:700;letter-spacing:.08em;color:var(--slate);text-transform:uppercase;margin:0 0 6px}" + /* coin toggles: same soft-accent "on" treatment as the Library's holding-style pills * (.lib-hold-tab), so a selected chip means the same thing on every tab. */ ".ztb-chips{display:flex;gap:8px;flex-wrap:wrap}" + ".ztb-chip{display:inline-flex;align-items:center;border:1px solid var(--line);background:var(--surface-2);border-radius:999px;padding:6px 13px;font:inherit;font-size:12.5px;font-weight:700;color:var(--slate);cursor:pointer;transition:.15s}" + ".ztb-chip:hover{border-color:var(--green);color:var(--navy)}" + ".ztb-chip.on{background:var(--accent-soft);border-color:var(--accent-line);color:var(--green-d)}" + ".ztb-chip:focus-visible{outline:none;box-shadow:var(--ring)}" + ".ztb-presets{display:flex;gap:8px;flex-wrap:wrap;align-items:center;color:var(--slate);font-size:12px;font-weight:600}" + ".ztb-rules{display:grid;grid-template-columns:1fr 1fr;gap:14px}" + "@media(max-width:860px){.ztb-rules{grid-template-columns:1fr}}" + ".ztb-rule{border-left:3px solid var(--green)}" + ".ztb-rule[data-side=exit]{border-left-color:var(--amber)}" + ".ztb-row{display:flex;gap:10px;flex-wrap:wrap;margin-top:2px}" + ".ztb-f{flex:1 1 86px;min-width:86px}" + ".ztb-f.sig{flex:2 1 100%}" + ".ztb-sent{margin-top:11px;font-size:12.5px;color:var(--slate);background:var(--surface-2);border-radius:8px;padding:8px 11px;line-height:1.5}" + ".ztb-sent b{color:var(--navy)}" + /* Deploy is the page's one primary action, sized up from the standard .btn-primary the way * a form's submit button reasonably is elsewhere in the app, but on the same --accent * token, so it still tracks the regime color like every other primary CTA instead of a * hardcoded green that drifts from the rest of the UI the moment the regime isn't bull. */ ".ztb-deploy{background:var(--accent);color:#fff;border:0;border-radius:11px;font:inherit;font-weight:800;font-size:14px;padding:12px 24px;cursor:pointer;box-shadow:var(--shadow);transition:.15s;align-self:flex-start}" + ".ztb-deploy:hover{background:var(--accent-d);box-shadow:var(--shadow-hover)}" + ".ztb-item{display:flex;align-items:center;gap:12px;padding:11px 2px;border-top:1px solid var(--line)}" + ".ztb-item:first-of-type{border-top:0}" + ".ztb-item .nm{font-weight:700;color:var(--navy)}" + ".ztb-item .stt{font-size:11px;color:var(--slate)}" + ".ztb-item .num{margin-left:auto;font-weight:800}"; var PRESETS = { dip: { name: "RSI dip buyer", style: "reversion", ivl: "day", coins: ["BTCUSDT", "ETHUSDT"], entry: { ind: "rsi", p1: 14, p2: 50, op: "<", value: 30 }, exit: { ind: "rsi", p1: 14, p2: 50, op: ">", value: 55 } }, cross: { name: "Golden cross rider", style: "trend", ivl: "day", coins: ["BTCUSDT", "ETHUSDT", "SOLUSDT"], entry: { ind: "ema_cross", p1: 12, p2: 26, op: ">", value: 0 }, exit: { ind: "ema_cross", p1: 12, p2: 26, op: "<", value: 0 } }, snap: { name: "Stretch snap-back", style: "reversion", ivl: "day", coins: ["BTCUSDT", "ETHUSDT"], entry: { ind: "zscore", p1: 20, p2: 50, op: "<", value: -2 }, exit: { ind: "zscore", p1: 20, p2: 50, op: ">", value: 0 } }, }; function describe(c) { var dir = c.op === "<" ? "drops below" : "rises above"; var rel = c.op === "<" ? "below" : "above"; switch (c.ind) { case "rsi": return "RSI(" + c.p1 + ") " + dir + " " + c.value + ""; case "stoch": return "Stochastic %K(" + c.p1 + ") " + dir + " " + c.value + ""; case "zscore": return "Z-score(" + c.p1 + ") " + dir + " " + c.value + ""; case "dist_sma": return "price stretches " + (c.op === "<" ? "more than " + Math.abs(c.value) + "% below" : "more than " + c.value + "% above") + " its " + c.p1 + "-bar average"; case "roc": return c.p1 + "-bar momentum " + dir + " " + c.value + "%"; case "price_vs_sma": return "price is " + rel + " its " + c.p1 + "-bar SMA"; case "price_vs_ema": return "price is " + rel + " its " + c.p1 + "-bar EMA"; case "sma_cross": return "SMA(" + c.p1 + ") is " + rel + " SMA(" + c.p2 + ")"; case "ema_cross": return "EMA(" + c.p1 + ") is " + rel + " EMA(" + c.p2 + ")"; case "macd_cross": return "MACD(" + c.p1 + "," + c.p2 + ") is " + rel + " its signal line"; case "bollinger_touch": return "price closes " + (c.op === ">" ? "above the upper" : "below the lower") + " Bollinger band (" + c.p1 + ", " + c.value + "σ)"; case "breakout": return "price breaks " + (c.op === ">" ? "above the " + c.p1 + "-bar high" : "below the " + c.p1 + "-bar low"); default: return "volume runs " + rel + " " + c.value + "× its " + c.p1 + "-bar average"; } } function condHtml(side, d) { d = d || (side === "entry" ? PRESETS.dip.entry : PRESETS.dip.exit); var opts = Object.keys(IND_META).map(function (k) { return '"; }).join(""); return '
' + '' + (side === "entry" ? "Enter when" : "Exit when") + "" + '
' + 'Signal" + 'Period' + '' + 'Condition" + 'Value' + "
" + '
' + "
"; } function readCond(root, side) { var el = root.querySelector('.ztb-cond[data-side="' + side + '"]'); var g = function (f) { var n = el.querySelector('[data-f="' + f + '"]'); return n ? n.value : null; }; return { ind: g("ind"), p1: +g("p1") || 14, p2: +g("p2") || 50, op: g("op"), value: +g("value") || 0 }; } function syncCond(el, applyDefault) { var meta = IND_META[el.querySelector('[data-f="ind"]').value] || {}; el.querySelector('[data-p="p1"] [data-lbl]').textContent = meta.p1 || "Period"; el.querySelector('[data-p="p2"]').hidden = !meta.p2; if (meta.p2) el.querySelector('[data-lbl="p2"]').textContent = meta.p2; var vf = el.querySelector('[data-p="value"]'); vf.hidden = !meta.value; if (meta.value) { vf.querySelector(".ztb-lbl").textContent = meta.vlbl || "Value"; if (applyDefault && meta.def !== undefined) vf.querySelector("[data-f=value]").value = meta.def; } var side = el.dataset.side, root = el.closest(".ztb"); var s = el.querySelector("[data-sent]"); if (s && root) s.innerHTML = (side === "entry" ? "Buy when " : "Sell when ") + describe(readCond(root, side)) + (side === "entry" ? "" : " - or earlier if the engine's stop or target fires."); } function applyPreset(p) { var root = document.querySelector(".ztb"); if (!root) return; document.getElementById("ztbName").value = p.name; root.querySelectorAll(".ztb-chip[data-coin]").forEach(function (c) { c.classList.toggle("on", p.coins.indexOf(c.dataset.coin) >= 0); }); ["ztbIvl", "ztbStyle"].forEach(function (id) { var v = id === "ztbIvl" ? p.ivl : p.style; root.querySelectorAll("#" + id + " button").forEach(function (b) { b.classList.toggle("on", b.dataset.v === v); }); }); ["entry", "exit"].forEach(function (side) { var el = root.querySelector('.ztb-cond[data-side="' + side + '"]'); if (el) { el.outerHTML = condHtml(side, p[side]); } }); root.querySelectorAll(".ztb-cond").forEach(syncCond); } function myCustoms() { return Promise.all([ mine("deployment?select=strategy_key,status,params&strategy_key=like.custom_*"), mine("book_state?select=strategy_key,realised"), ]).then(function (all) { var books = {}; (all[1] || []).forEach(function (b) { books[b.strategy_key] = Number(b.realised || 0); }); return (all[0] || []).map(function (d) { return { key: d.strategy_key, name: (d.params && d.params.name) || d.strategy_key, status: d.status, realised: books[d.strategy_key] || 0 }; }); }); } function customList(rows) { if (!rows.length) return '
No strategies yet. Compose one above, hit Deploy, and it starts trading paper on live prices within about five minutes.
'; return rows.map(function (r) { var run = r.status === "running"; return '
' + '' + '' + esc(r.name) + '
' + (run ? "Running · paper · live prices" : "Stopped") + "
" + '' + (r.realised >= 0 ? "+" : "") + "$" + r.realised.toFixed(2) + "" + '" + '
'; }).join(""); } function builderPane() { return '
' + '
Strategy Builder

You compose the signals - the engine keeps the rails.

PAPER · LIVE PRICES
' + '
Start from:' + '' + '' + '
' + '
' + 'Name' + 'Coins' + COINS.map(function (c, i) { return '"; }).join("") + "" + 'Timeframe' + 'Exit style' + "
" + '
' + condHtml("entry") + condHtml("exit") + "
" + '

' + icon("shield", 13) + 'The rails are not optional. Every custom strategy runs through the same engine as the built-ins: ATR stops, a cost-aware entry gate that refuses trades which only feed fees, an anti-churn cooldown, a breakeven-after-cost profit lock, and a $1,000 paper notional per position. Global cost model: 35bps round-trip.

' + '
' + '' + '
My strategies
Loading…
' + "
"; } function renderBuilder() { var v = document.getElementById("algoView"); if (!v) return; var head = v.querySelector(".av-head"); if (!head) return; v.querySelectorAll(".av-tab").forEach(function (t) { t.classList.toggle("on", t.hasAttribute("data-ztbuilder")); }); while (head.nextSibling) head.parentNode.removeChild(head.nextSibling); head.insertAdjacentHTML("afterend", builderPane()); v.querySelectorAll(".ztb-cond").forEach(syncCond); myCustoms().then(function (rows) { var el = document.getElementById("ztbList"); if (el) el.innerHTML = customList(rows); }); } function refreshList() { myCustoms().then(function (rows) { var el = document.getElementById("ztbList"); if (el) el.innerHTML = customList(rows); }); } /* create/remove, not hidden-attribute toggling, matches flowError()/clearFlowError() in * app.js. (.flow-err sets display:flex, which as author CSS beats the UA [hidden] rule, so * toggling .hidden on a pre-rendered .flow-err leaves an empty bar visible when "hidden".) */ function ztbShowError(msg) { var host = document.getElementById("ztbErrHost"); if (!host) return; host.innerHTML = '"; } function ztbClearError() { var host = document.getElementById("ztbErrHost"); if (host) host.innerHTML = ""; } function deployCustom() { var root = document.querySelector(".ztb"); if (!root) return; var name = (document.getElementById("ztbName").value || "").trim() || "My strategy"; var coins = [].slice.call(root.querySelectorAll(".ztb-chip.on[data-coin]")).map(function (c) { return c.dataset.coin; }); var seg = function (id) { var b = root.querySelector("#" + id + " .on"); return b ? b.dataset.v : null; }; var spec = { name: name, universe: coins.length ? coins : COINS, interval: seg("ztbIvl") || "day", style: seg("ztbStyle") || "reversion", entry: readCond(root, "entry"), exit: readCond(root, "exit") }; var s = session(); if (!s) { location.replace("/login"); return; } ORIG(SUPA + "/rest/v1/deployment?on_conflict=user_id,strategy_key", { method: "POST", headers: sbHeaders({ Prefer: "resolution=merge-duplicates,return=minimal" }), body: JSON.stringify({ user_id: s.uid, strategy_key: slug(name), mode: "paper", status: "running", params: spec }), }).then(function (r) { if (r.ok) { ztbClearError(); trackEvent("deploy_click"); trackEvent("deploy_success"); gaEvent("deploy_click", { strategy: slug(name), custom: true }); gaEvent("deploy_success", { strategy: slug(name), custom: true }); showForwardHint(); refreshList(); } else r.json().then(function (e) { var msg = (e && (e.message || e.details || e.hint)) || "Deploy failed - try again."; if (/FREE_LIMIT/i.test(msg)) { ztbShowError("Free includes 1 strategy: upgrade at /app#pricing"); setTimeout(goUpgradeFromFreeLimit, 900); return; } ztbShowError(msg); }).catch(function () { ztbShowError("Deploy failed - try again."); }); }); } function customAction(key, act) { if (act === "delete") return ORIG(SUPA + "/rest/v1/deployment?strategy_key=eq." + encodeURIComponent(key), { method: "DELETE", headers: sbHeaders({ Prefer: "return=minimal" }) }).then(refreshList); return ORIG(SUPA + "/rest/v1/deployment?strategy_key=eq." + encodeURIComponent(key), { method: "PATCH", headers: sbHeaders({ Prefer: "return=minimal" }), body: JSON.stringify({ status: act === "resume" ? "running" : "stopped" }), }).then(refreshList); } /* keep our tab present across the app's full innerHTML re-renders, and keep the * Builder pane alive: the app's async data loads call renderAlgo() which rebuilds * #algoView wholesale - if the user is on Builder, we re-render it on top. */ var builderOn = false; function injectTab() { var bar = document.querySelector("#algoView .av-tabs"); if (bar && !bar.querySelector("[data-ztbuilder]")) { var b = document.createElement("button"); b.className = "av-tab"; b.setAttribute("role", "tab"); b.setAttribute("data-ztbuilder", "1"); b.textContent = "Builder"; bar.appendChild(b); } if (builderOn && document.getElementById("algoView") && !document.querySelector(".ztb")) renderBuilder(); } function watch() { var v = document.getElementById("algoView"); if (!v) return setTimeout(watch, 600); injectTab(); new MutationObserver(injectTab).observe(v, { childList: true, subtree: true }); } document.addEventListener("click", function (e) { var t = e.target.closest && e.target.closest("[data-ztbuilder]"); if (t) { e.preventDefault(); builderOn = true; renderBuilder(); var nudge = document.querySelector(".zt-cold-nudge"); if (nudge && nudge._ztDismiss) nudge._ztDismiss(); return; } if (e.target.closest && e.target.closest("[data-algoview]")) { builderOn = false; } if (e.target.id === "ztbDeploy") { deployCustom(); return; } var pre = e.target.closest && e.target.closest("[data-ztpreset]"); if (pre) { applyPreset(PRESETS[pre.dataset.ztpreset]); return; } var coin = e.target.closest && e.target.closest(".ztb-chip[data-coin]"); if (coin) { coin.classList.toggle("on"); return; } var seg = e.target.closest && e.target.closest("#ztbIvl button, #ztbStyle button"); if (seg) { seg.parentNode.querySelectorAll("button").forEach(function (x) { x.classList.remove("on"); }); seg.classList.add("on"); return; } var act = e.target.closest && e.target.closest("[data-zta]"); if (act) { customAction(act.closest(".ztb-item").dataset.key, act.dataset.zta); return; } }); document.addEventListener("change", function (e) { if (e.target.matches && e.target.matches('.ztb-cond [data-f="ind"]')) syncCond(e.target.closest(".ztb-cond"), true); // switching signal applies its sane default value }); document.addEventListener("input", function (e) { var c = e.target.closest && e.target.closest(".ztb-cond"); if (c) syncCond(c); // live plain-English sentence as they type }); if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", watch); else watch(); })();