// Live API layer (CFD v2) — replaces the design's mock data.jsx + price sim. // Talks to the Concierge: REST for commands, one WebSocket per active account // for the price feed + account events. Owns the globals the design components // consume (INSTRUMENTS, CATEGORIES, computePL, pipValue) and self-computes // display PL at the edge (R28). Cabinet (deposit/withdraw/transfer/reset/open) // calls Concierge placeholder endpoints — see docs/bff-api.md. const CATEGORIES = ['FX', 'Metals', 'Energy', 'Indices', 'Crypto']; const CAT_ICONS = { FX: (c) => , Metals: (c) => , Energy: (c) => , Indices: (c) => , Crypto: (c) => , }; let INSTRUMENTS = []; const SYM_BY_BACKEND = {}; // "GBP/USD" → instrument const SYM_BY_DISPLAY = {}; // "GBPUSD" → instrument async function apiJSON(method, path, body) { const res = await fetch(path, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined, // Send the HttpOnly ts_session cookie; the token is no longer in the body. credentials: 'same-origin', }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || res.statusText); return data; } async function initInstruments() { const { symbols } = await apiJSON('GET', '/api/symbols'); INSTRUMENTS = symbols.map(s => ({ sym: s.sym, backend: s.backend, name: s.name, cat: s.cat, digits: s.digits, tickSize: s.tickSize, contract: s.contract, minVol: s.minVol, volStep: s.volStep, maxVol: s.maxVol, quote: s.backend.includes('/') ? s.backend.split('/')[1] : 'USD', marginPct: parseFloat(s.marginPct) || 1, marginStrategy: s.marginStrategy || 'with-leverage', // ui_pip_size: FX quotes a fractional pip (5th/3rd decimal is sub-pip). pipDigits: s.cat === 'FX' ? Math.max(0, s.digits - 1) : s.digits, bid: 0, spread: 0, pctDay: 0, vol: 0, dayOpen: 0, dayHigh: 0, dayLow: 0, // M3 day stats (loadStats) })); INSTRUMENTS.forEach(i => { SYM_BY_BACKEND[i.backend] = i; SYM_BY_DISPLAY[i.sym] = i; LIVE.prices[i.sym] = { bid: 0, ask: 0, flashBid: null, flashAsk: null, spark: [] }; }); window.INSTRUMENTS = INSTRUMENTS; } // Display PL in deposit ccy; JPY crosses converted via live USD/JPY mid. function computePL(pos, curBid, curAsk) { const inst = SYM_BY_DISPLAY[pos.sym]; if (!inst) return 0; const closePrice = pos.side === 'buy' ? curBid : curAsk; if (!closePrice) return 0; const diff = pos.side === 'buy' ? (closePrice - pos.openPrice) : (pos.openPrice - closePrice); let pl = diff * pos.vol * inst.contract; if (inst.backend.endsWith('/JPY')) { const jpy = LIVE.prices['USDJPY']; const mid = jpy && jpy.bid && jpy.ask ? (jpy.bid + jpy.ask) / 2 : 0; pl = mid > 0 ? pl / mid : 0; } return pl; } function leverageOf(acct) { if (!acct || !acct.leverage) return 100; const m = String(acct.leverage).match(/(\d+)\s*$/); return m ? parseInt(m[1]) : 100; } // quoteToUSD — USD per 1 unit of the instrument's QUOTE currency, from the // live crosses (a GBP/JPY notional is JPY; showing it with a $ sign was // wrong twice). Returns 0 when no conversion pair is streaming. function quoteToUSD(inst) { const q = inst.quote || 'USD'; if (q === 'USD') return 1; const usdQ = LIVE.prices['USD' + q]; // USD/JPY, USD/CHF: USD per q = 1/mid if (usdQ && usdQ.bid && usdQ.ask) return 2 / (usdQ.bid + usdQ.ask); const qUsd = LIVE.prices[q + 'USD']; // EUR/USD-style: USD per q = mid if (qUsd && qUsd.bid && qUsd.ask) return (qUsd.bid + qUsd.ask) / 2; return 0; } // USD notional of a volume at a price; 0 = conversion unavailable. function usdValue(inst, vol, price) { return vol * inst.contract * price * quoteToUSD(inst); } // Margin estimate in USD for display (the gate is authoritative): notional // converted to deposit ccy × margin%, over leverage when the symbol's // strategy uses it. function marginEstimate(inst, vol, price) { const usd = usdValue(inst, vol, price) * (inst.marginPct || 1); if (inst.marginStrategy === 'without-leverage') return usd; return usd / leverageOf(LIVE.activeAccount()); } function pipValue() { return 10; } // legacy design helper // ---- live state store ---- const LIVE = { // authed replaces the old in-memory token as the client-side "logged in" // flag: the session token now lives only in the HttpOnly ts_session cookie, // which the browser replays automatically (incl. across reloads and on the // WebSocket handshake), so JS never holds it. authed gates the reconnect // loop and the account/history fetchers the way a truthy token used to. authed: false, accountId: null, accounts: [], // cabinet shape (from GET /api/accounts) account: { balance: 0, credit: 0, usedMargin: 0 }, // active acct authoritative figures prices: {}, positions: [], orders: [], history: [], lastExec: {}, toasts: [], // short-lived execution/alert popups (pushToast) connected: false, sessionExpired: false, // server said the login session is gone → re-login marginEvent: null, listeners: new Set(), version: 0, activeAccount() { return this.accounts.find(a => a.id === this.accountId) || null; }, // CP1: the active account's platform (10=live, 110=demo) routes every // request at the Concierge. Defaults to live for pre-CP1 shapes. activePlatform() { const a = this.activeAccount(); return a && a.platform ? a.platform : 10; }, }; window.LIVE = LIVE; // pushToast — brief popup feedback for asynchronous outcomes (fills, partial // fills, rejects, SL/TP closes). Execution is async by design; without these // the terminal is silent about what happened to your order. // kind: success | warn | error | info // detail: FR4 — the server's own English `reject.text` is DIAGNOSTIC, never the // primary display string. It rides here and surfaces as the toast's tooltip // (plus a console line); what the user reads is the localized code. let toastSeq = 0; function pushToast(kind, title, body, detail) { const toast = { id: 'n' + (++toastSeq), kind, title, body, detail }; LIVE.toasts = [toast, ...LIVE.toasts].slice(0, 4); notify(); setTimeout(() => { LIVE.toasts = LIVE.toasts.filter(x => x.id !== toast.id); notify(); }, 5000); } // One notification per order outcome (events can repeat post-state). const notifiedOrders = {}; function rememberNotified(ticket) { notifiedOrders[ticket] = true; const keys = Object.keys(notifiedOrders); if (keys.length > 300) delete notifiedOrders[keys[0]]; } function notify() { LIVE.version++; LIVE.listeners.forEach(fn => fn(LIVE.version)); } function useLive() { const [, setV] = React.useState(0); React.useEffect(() => { const fn = v => setV(v); LIVE.listeners.add(fn); return () => LIVE.listeners.delete(fn); }, []); return LIVE; } // The month name is a WORD no bundle could override while it came out of // toLocaleDateString. The time is digits and a colon in every locale (ID6) and // now comes from fmtClock — which also fixes the hour12:false h24 quirk that // rendered 00:30 as 24:30 (IO9). function fmtTime(ms) { const d = new Date(ms); return fmtMonthDayTime(d, fmtClock(d)); } function mapPosition(p) { const inst = SYM_BY_BACKEND[p.sym]; return { id: p.id, ticket: p.id % 100000000, // display ticket derived from position id sym: inst ? inst.sym : p.sym, side: p.side, vol: p.vol, openPrice: p.openPrice, openTime: fmtTime(p.openTime), sl: p.sl || null, tp: p.tp || null, closePending: !!p.closePending, ...mapOrigin(p), }; } // mapOrigin lifts the PD5 attribution off a wire row. // // `origin` is ABSENT on a terminal-placed row — frontsrv omits the fields // rather than sending "terminal" — so the blotter's badge test is presence, // not a comparison, and the overwhelming majority of rows cost nothing. The // token id is what the kill preview counts by: it is the same key the backend // sweeps by, unlike the label, which is only a display name. function mapOrigin(row) { return { origin: row.origin || null, originLabel: row.originLabel || '', originTokenId: row.originTokenId || 0, }; } function mapOrder(o) { const inst = SYM_BY_BACKEND[o.sym]; const typeWord = o.type === 'Limit' ? 'Limit' : o.type === 'Stop' ? 'Stop' : 'Market'; return { id: o.ticket, ticket: o.ticket, sym: inst ? inst.sym : o.sym, // `type` stays the canonical English token because the UI BRANCHES on it // (startsWith('Buy'), includes('Stop')) — it is a code, not copy. // `typeCode` is the display counterpart: t('order.type.' + typeCode). type: (o.side === 'buy' ? 'Buy ' : 'Sell ') + typeWord, typeCode: (o.side === 'buy' ? 'buy' : 'sell') + typeWord, side: o.side, vol: o.vol, // LIMIT keeps its trigger in price, STOP in stopPrice. triggerPrice: (typeWord === 'Stop' ? o.stopPrice : o.price) || o.price || o.stopPrice || 0, sl: o.sl || null, tp: o.tp || null, // `expiry` is the TIF CODE the blotter renders through tCode('tif', …); // the monolith rests every internal pending as GTC today. created: fmtTime(o.created), expiry: 'GTC', status: o.status, ...mapOrigin(o), }; } const RESTING = new Set(['working', 'submitted', 'partial', 'reserved', 'held', 'pending']); function syncActiveBalanceIntoAccounts() { const a = LIVE.activeAccount(); if (a) { a.balance = LIVE.account.balance; a.equity = undefined; } } function onWSMessage(msg) { switch (msg.t) { case 'price': { const inst = SYM_BY_BACKEND[msg.s]; if (!inst) return; const prev = LIVE.prices[inst.sym]; const bid = msg.bid || (prev ? prev.bid : 0); const ask = msg.ask || (prev ? prev.ask : 0); if (inst.bid === 0 && bid > 0) inst.bid = bid; if (bid && ask) inst.spread = Math.round((ask - bid) / inst.tickSize * 10) / 10; // Live %-change vs day open. High/low come from the candle store only // (loadStats): the consolidated sim book can flicker a one-tick spike // that would wrongly pin the day extreme if extended client-side. if (inst.dayOpen > 0 && bid > 0) { inst.pctDay = (bid - inst.dayOpen) / inst.dayOpen * 100; } const up = prev ? bid >= prev.bid : true; const spark = prev && prev.spark.length ? [...prev.spark.slice(-23), bid] : [bid]; LIVE.prices[inst.sym] = { bid, ask, flashBid: prev && prev.bid !== bid ? (up ? 'up' : 'down') : null, flashAsk: prev && prev.ask !== ask ? (up ? 'up' : 'down') : null, spark, }; clearTimeout(LIVE._flashTimer); LIVE._flashTimer = setTimeout(() => { for (const k in LIVE.prices) LIVE.prices[k] = { ...LIVE.prices[k], flashBid: null, flashAsk: null }; notify(); }, 380); notify(); break; } case 'snapshot': { LIVE.account = { balance: msg.balance, credit: msg.credit, usedMargin: msg.usedMargin }; LIVE.positions = (msg.positions || []).map(mapPosition); LIVE.orders = (msg.orders || []).filter(o => RESTING.has(o.status)).map(mapOrder); syncActiveBalanceIntoAccounts(); notify(); break; } case 'position': { const mapped = mapPosition(msg.position); if (msg.kind === 'close') { const existing = LIVE.positions.find(p => p.id === mapped.id); LIVE.positions = LIVE.positions.filter(p => p.id !== mapped.id); // SL/TP fire asynchronously — the close popup is the only immediate // signal a position just went away. const src = existing || mapped; const inst = SYM_BY_DISPLAY[src.sym]; // FR4: the title renders from the wire kind (open | close | mod). pushToast('info', tCode('positionEvent', msg.kind), t('toast.position.closed.body', { side: t('common.sideUpper.' + (src.side === 'buy' ? 'buy' : 'sell')), qty: src.vol.toFixed(2), symbol: inst ? inst.backend : src.sym, })); // History comes from the server (one aggregated row per close order, // VWAP price, summed PL). Fabricating an entry from this event showed // only the FINAL partial fill's slice (a 0.10 close displayed as // "0.02" with the last fill's PL) — never hand-build it here. scheduleHistoryRefresh(); } else { const idx = LIVE.positions.findIndex(p => p.id === mapped.id); if (idx >= 0) LIVE.positions[idx] = mapped; else LIVE.positions = [mapped, ...LIVE.positions]; } scheduleAccountRefresh(); notify(); break; } case 'order': { const o = msg.order; if (RESTING.has(o.status)) { const mapped = mapOrder(o); const idx = LIVE.orders.findIndex(x => x.id === mapped.id); if (idx >= 0) LIVE.orders[idx] = mapped; else LIVE.orders = [mapped, ...LIVE.orders]; } else { LIVE.orders = LIVE.orders.filter(x => x.id !== o.ticket); // Terminal outcome → one popup. Partial fills matter most: an IOC // that ran out of book fills X of Y and silently cancels the rest. if (!notifiedOrders[o.ticket]) { rememberNotified(o.ticket); const inst = SYM_BY_BACKEND[o.sym]; const sym = inst ? (inst.backend || o.sym) : o.sym; // FR4 + ID6: the side is a localized TERM (uppercase lives in the // bundle, not in a JS .toUpperCase() that mangles non-Latin scripts); // the quantity and price are formatted numbers and stay ASCII. const side = t('common.sideUpper.' + (o.side === 'buy' ? 'buy' : 'sell')); const price = o.avgFill && inst ? o.avgFill.toFixed(inst.digits) : null; const rejCode = o.reject ? (o.reject.code || '') : ''; // The server's English reject text never reaches the screen — it is // support/diagnostic detail behind the toast tooltip and the console. const detail = o.reject ? (o.reject.text || '') : ''; if (o.status === 'filled') { pushToast('success', t('toast.order.filled.title'), price ? t('toast.order.filled.bodyAt', { side, qty: o.filledVol.toFixed(2), symbol: sym, price }) : t('toast.order.filled.body', { side, qty: o.filledVol.toFixed(2), symbol: sym })); } else if (rejCode === 'timeout') { // Orphan sweep: the venue never confirmed the order's outcome. pushToast('error', t('toast.order.unconfirmed.title'), t('toast.order.unconfirmed.body', { side, qty: o.vol.toFixed(2), symbol: sym }), detail); } else if (o.status === 'rejected') { if (detail) console.info('[reject]', rejCode, detail); pushToast('error', t('toast.order.rejected.title'), rejCode ? t('toast.order.rejected.bodyWhy', { side, qty: o.vol.toFixed(2), symbol: sym, reason: tCode('reject', rejCode) }) : t('toast.order.rejected.body', { side, qty: o.vol.toFixed(2), symbol: sym }), detail); } else if (o.filledVol > 0) { // cancelled with fills = partial pushToast('warn', t('toast.order.partial.title'), price ? t('toast.order.partial.bodyAt', { filled: o.filledVol.toFixed(2), qty: o.vol.toFixed(2), symbol: sym, price }) : t('toast.order.partial.body', { filled: o.filledVol.toFixed(2), qty: o.vol.toFixed(2), symbol: sym })); } else if (o.status === 'cancelled') { pushToast('info', t('toast.order.cancelled.title'), t('toast.order.cancelled.body', { side, qty: o.vol.toFixed(2), symbol: sym })); } } } notify(); break; } case 'exec': { LIVE.lastExec[msg.positionId] = { priceTicks: msg.priceTicks, time: Date.now() }; break; } case 'balance': { LIVE.account.balance = msg.balance; LIVE.account.credit = msg.credit; syncActiveBalanceIntoAccounts(); scheduleAccountRefresh(); // usedMargin isn't in this event — resync it notify(); break; } case 'margin': { LIVE.marginEvent = { kind: msg.kind, level: msg.level, at: Date.now() }; // FR4: the margin event renders from its wire kind (call | stopout). pushToast(msg.kind === 'stopout' ? 'error' : 'warn', tCode('margin', msg.kind), t('toast.margin.body', { level: msg.level.toFixed(1) })); notify(); break; } case 'agent_msg': { // PD9, one-way: an agent reporting to its human. Two destinations, and // deliberately different notification paths — the feed is the record and // lives in its own store (agentNotify), so appending one does NOT wake // the price-path listeners; the toast is the live courtesy and goes // through the ordinary stack. agentPush(msg); // PD9 calls `title` "a short headline for the toast and the feed row", // so every level surfaces. The body is capped at 2 KB server-side and a // toast is four lines of chrome, so it is clipped here rather than left // to push the stack off the screen. Sliced by CODE POINT: a byte or // UTF-16 slice can cut a surrogate pair in half and render a replacement // glyph, which on a CJK or emoji-carrying message is most of them. const from = msg.tokenName || t('agent.unnamedSession'); const body = Array.from(String(msg.body || '')); pushToast(AGENT_TOAST_KIND[msg.level] || 'info', t('agent.toast.title', { name: from, title: msg.title || '' }), body.length > 160 ? body.slice(0, 160).join('') + '…' : body.join('')); break; } case 'ui_cmd': { // PD11. An agent driving the view the human is watching — chart, chart // annotations, and the trade ticket. Handled in its own store so a // command does not wake the price-path listeners; see uiCmdReceived. uiCmdReceived(msg); break; } case 'error': { // Terminal server signal. session_expired = the login session is gone; // re-login rather than reconnect with a dead token. if (msg.code === 'session_expired') handleSessionExpired(); break; } } } // handleSessionExpired ends the live session locally: clear the authed flag // (which stops the onclose reconnect loop — it guards on LIVE.authed), clear // account state, and flag the app to fall back to the login screen. Fires // before the socket's onclose, so no reconnect is scheduled after expiry. // The dead ts_session cookie is harmless; the next login overwrites it. function handleSessionExpired() { if (LIVE.sessionExpired) return; // idempotent — one signal is enough LIVE.sessionExpired = true; LIVE.authed = false; LIVE.connected = false; LIVE.positions = []; LIVE.orders = []; notify(); } // Trailing debounce: a burst of fills fires many position/balance events; // refresh the authoritative figures once, after the burst settles — a // mid-burst fetch left usedMargin stale (seen live: $427 shown while flat). function scheduleAccountRefresh() { clearTimeout(LIVE._acctTimer); LIVE._acctTimer = setTimeout(refreshAccount, 600); } // Same trailing pattern for history: the projector needs a beat to fold the // close's executions; one reload after the burst gets the aggregated row. function scheduleHistoryRefresh() { clearTimeout(LIVE._histTimer); LIVE._histTimer = setTimeout(loadHistory, 1200); } let refreshPending = false; async function refreshAccount() { if (refreshPending || !LIVE.authed || !LIVE.accountId) return; refreshPending = true; try { const snap = await apiJSON('GET', `/api/snapshot?account=${LIVE.accountId}&platform=${LIVE.activePlatform()}`); LIVE.account = { balance: snap.balance, credit: snap.credit, usedMargin: snap.usedMargin }; syncActiveBalanceIntoAccounts(); notify(); } catch (e) { /* transient */ } finally { refreshPending = false; } } function connectWS() { const old = LIVE.ws; if (old) { old._replaced = true; try { old.close(); } catch (e) {} } const proto = location.protocol === 'https:' ? 'wss' : 'ws'; // No token in the URL — the browser sends the ts_session cookie on the // WebSocket handshake (same-origin). const ws = new WebSocket(`${proto}://${location.host}/api/stream?account=${LIVE.accountId}&platform=${LIVE.activePlatform()}`); LIVE.ws = ws; ws.onmessage = (e) => { try { onWSMessage(JSON.parse(e.data)); } catch (err) { console.error(err); } }; ws.onopen = () => { LIVE.connected = true; notify(); }; ws.onclose = () => { if (ws._replaced) return; // superseded by a newer socket (account switch) LIVE.connected = false; notify(); if (LIVE.authed && LIVE.ws === ws) setTimeout(connectWS, 1500); }; } // ---- commands ---- // bootSession brings the client up for an authenticated session (the token is // in the HttpOnly cookie, not JS): load accounts, pick the active one, open the // stream, load history/stats. Shared by fresh login and reload-resume. async function bootSession() { LIVE.sessionExpired = false; LIVE.authed = true; await loadAccounts(); // active = first trading account, else first const act = LIVE.accounts.find(a => a.trading) || LIVE.accounts[0]; LIVE.accountId = act ? act.id : null; connectWS(); loadHistory(); startStatsLoop(); notify(); } async function apiLogin(email, password) { const res = await apiJSON('POST', '/api/login', { email, password }); await bootSession(); return res; } // Demo self-signup: create a client + login and trigger the activation email. // Returns { ok, message, devActivationUrl? } — the dev link is only present in // DEV_MODE so the flow is testable locally without real email. async function apiRegister(email, password, confirmPassword) { return apiJSON('POST', '/api/register', { email, password, confirmPassword }); } // apiResumeSession restores a live session on reload using the HttpOnly // ts_session cookie (the browser replays it automatically). On a valid session // the terminal skips the login screen and comes straight up; returns false when // there is no valid session (server 401), so the caller shows login. async function apiResumeSession() { try { await apiJSON('GET', '/api/session'); // throws on 401 (no/invalid session) } catch (e) { LIVE.authed = false; return false; } await bootSession(); return true; } async function loadAccounts() { try { const res = await apiJSON('GET', '/api/accounts'); LIVE.accounts = res.accounts || []; } catch (e) { LIVE.accounts = []; } } // Day stats from the candle store (M3): dayOpen/high/low per symbol; the // price handler keeps pctDay live between polls. async function loadStats() { try { const res = await apiJSON('GET', '/api/stats'); for (const st of (res.stats || [])) { const inst = SYM_BY_DISPLAY[st.symbol]; if (!inst) continue; inst.dayOpen = st.dayOpen; inst.dayHigh = st.dayHigh; inst.dayLow = st.dayLow; const bid = LIVE.prices[inst.sym] ? LIVE.prices[inst.sym].bid : 0; const ref = bid || st.last; if (st.dayOpen > 0 && ref > 0) inst.pctDay = (ref - st.dayOpen) / st.dayOpen * 100; } notify(); } catch (e) { /* stats unavailable — grid shows 0.00% */ } } function startStatsLoop() { loadStats(); if (!LIVE._statsTimer) LIVE._statsTimer = setInterval(loadStats, 30000); } // ---- instrument detail (M4/M5/M6) ---- const apiCandles = (inst, tf, limit) => apiJSON('GET', `/api/instruments/candles?symbol=${encodeURIComponent(inst.backend)}&tf=${tf}&limit=${limit || 60}`); const apiDepth = (inst) => apiJSON('GET', `/api/instruments/depth?symbol=${encodeURIComponent(inst.backend)}&account=${LIVE.accountId}&platform=${LIVE.activePlatform()}`); const apiSpec = (inst) => apiJSON('GET', `/api/instruments/spec?symbol=${encodeURIComponent(inst.backend)}&account=${LIVE.accountId}&platform=${LIVE.activePlatform()}`); async function apiLogout() { try { await apiJSON('POST', '/api/logout'); } catch (e) {} // Tear the live session down: stop the reconnect loop, close the socket, and // clear per-session state so the next login (or user) starts clean. LIVE.authed = false; if (LIVE.ws) { try { LIVE.ws._replaced = true; LIVE.ws.close(); } catch (e) {} LIVE.ws = null; } LIVE.accountId = null; LIVE.positions = []; LIVE.orders = []; LIVE.history = []; LIVE.account = { balance: 0, credit: 0, usedMargin: 0 }; apiClearPrefs(); // never write one user's bag over the next user's agentResetFeeds(); // nor leave their agents' messages on screen notify(); } // ---- user preferences ---- // DB-backed per-user preferences (ts_user_preferences, migration 004), // replacing browser localStorage: they follow the USER across devices and // browsers instead of being pinned to one machine. The bag is one opaque JSON // object; it holds { favorites: [...], locale: "es" }. // // POST /api/preferences SHALLOW-MERGES the posted keys server-side, so a writer // can only ever affect the keys it actually sends. The mirror below is // therefore an optimisation — it keeps the local view consistent without a // re-read — and no longer a correctness requirement. // // It used to be both, and that was not enough. The endpoint replaced the whole // object, so a writer sending { locale } alone deleted the user's favorites; // sending the whole bag fixed that for ONE client and not for two. Each tab's // mirror is a snapshot taken at boot, so a save from tab B carries B's stale // locale and reverts the language tab A just set — and the mirror-image case // reverts favorites, which predates the locale work. The merge moved to the // server (internal/frontsrv/preferences.go) to eliminate the class. let PREFS = {}; let PREFS_LOADED = false; // apiLoadPrefs fetches the bag once per session and memoises it. A failed load // leaves an empty bag, which degrades to "no favorites, browser-default // language" rather than to an error the user has to see. async function apiLoadPrefs(force) { if (PREFS_LOADED && !force) return PREFS; try { const prefs = await apiJSON('GET', '/api/preferences'); PREFS = (prefs && typeof prefs === 'object' && !Array.isArray(prefs)) ? prefs : {}; } catch (e) { PREFS = {}; // store unavailable → start clean } PREFS_LOADED = true; return PREFS; } // apiSavePrefs merges a patch into the mirror and persists the WHOLE bag. // Fire-and-forget: callers update their own state optimistically, and a failed // save only means the next device won't see the change. function apiSavePrefs(patch) { PREFS = Object.assign({}, PREFS, patch); PREFS_LOADED = true; apiJSON('POST', '/api/preferences', PREFS).catch(() => {}); } // apiClearPrefs drops the mirror on logout, so one user's bag can never be // written back over the next user's on a shared browser. function apiClearPrefs() { PREFS = {}; PREFS_LOADED = false; } async function apiLoadFavorites() { const prefs = await apiLoadPrefs(); return Array.isArray(prefs.favorites) ? prefs.favorites : []; } function apiSaveFavorites(favs) { apiSavePrefs({ favorites: favs }); } // The user's locale preference (ID4). Not per-account — a language is a // property of the person, not of which account they are looking at. async function apiLoadLocale() { const prefs = await apiLoadPrefs(); return typeof prefs.locale === 'string' ? prefs.locale : null; } function apiSaveLocale(locale) { apiSavePrefs({ locale: locale }); } // Server-side closed-trade history (H1, Stage 2). Live close events during // the session still prepend in real time; a (re)load replaces the list. async function loadHistory() { if (!LIVE.authed || !LIVE.accountId) return; try { const res = await apiJSON('GET', `/api/history?account=${LIVE.accountId}&platform=${LIVE.activePlatform()}&period=All`); LIVE.history = (res.trades || []).map((tr, i) => ({ id: 'srv' + i + '-' + tr.closeT, ticket: tr.ticket, positionId: tr.positionId, sym: tr.sym, side: tr.side, vol: tr.vol, openP: tr.openP, closeP: tr.closeP, openT: new Date(tr.openT), closeT: new Date(tr.closeT), pl: tr.pl, openExecs: tr.openExecs || [], closeExecs: tr.closeExecs || [], })); notify(); } catch (e) { /* history unavailable — session-local only */ } } function apiSwitchAccount(id) { if (id === LIVE.accountId) return; LIVE.accountId = id; LIVE.positions = []; LIVE.orders = []; LIVE.history = []; LIVE.account = { balance: 0, credit: 0, usedMargin: 0 }; connectWS(); // re-subscribes → fresh snapshot for the new account loadHistory(); notify(); } // order: { inst, side, vol, type 'Market'|'Limit'|'Stop', entry, tp, sl } async function apiPlaceOrder(order) { const { inst, side, vol, type, entry, tp, sl } = order; const toTicks = (px) => px ? Math.round(px / inst.tickSize) : 0; try { const res = await apiJSON('POST', '/api/orders', { account: LIVE.accountId, platform: LIVE.activePlatform(), symbol: inst.backend, side, vol, orderType: (type || 'Market').toUpperCase(), priceTicks: type === 'Market' ? 0 : toTicks(entry), slTicks: toTicks(sl), tpTicks: toTicks(tp), clientOrderId: 'term-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6), }); // FR4: `rejectCode` is what the UI renders (tCode('reject', …)); `error` // keeps the server's own text as diagnostic detail for tooltips/support. if (res.status === 'rejected') { return { ok: false, // Falling back to a CODE keeps the display path localized: an empty // code used to leave callers rendering the raw English `error`. rejectCode: (res.reject && res.reject.code) || 'unspecified', error: (res.reject && (res.reject.text || res.reject.code)) || '', }; } return { ok: true, ticket: res.ticket, status: res.status }; } catch (e) { return { ok: false, error: String(e.message || e) }; } } async function apiClosePosition(pos) { pos.closePending = true; notify(); try { const res = await apiJSON('POST', '/api/close', { account: LIVE.accountId, platform: LIVE.activePlatform(), positionId: pos.id, clientOrderId: 'close-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6), }); if (res.status === 'rejected') { pos.closePending = false; notify(); return { ok: false, rejectCode: (res.reject && res.reject.code) || 'unspecified', error: res.reject ? res.reject.text : '' }; } return { ok: true }; } catch (e) { pos.closePending = false; notify(); return { ok: false, error: String(e.message || e) }; } } async function apiCancelOrder(order) { try { // handleCancel answers 200 with a `reject` body rather than an HTTP error, // so a refused cancel has to be read out of the payload — ignoring it // reported success for an order that is still working. const res = await apiJSON('POST', '/api/cancel', { account: LIVE.accountId, platform: LIVE.activePlatform(), ticket: order.id }); if (res.reject) { return { ok: false, rejectCode: res.reject.code || 'unspecified', error: res.reject.text || res.reject.code }; } return { ok: true }; } catch (e) { return { ok: false, error: String(e.message || e) }; } } // Modify SL/TP on an open position (O6). sl/tp in display price; 0 clears. async function apiModifyPosition(pos, { sl, tp }) { const inst = SYM_BY_DISPLAY[pos.sym]; if (!inst) return { ok: false, error: t('error.unknownSymbol') }; try { const res = await apiJSON('POST', '/api/positions/modify', { account: LIVE.accountId, platform: LIVE.activePlatform(), positionId: pos.id, symbol: inst.backend, sl, tp, }); if (res.reject) return { ok: false, rejectCode: res.reject.code || 'unspecified', error: res.reject.text || res.reject.code }; return { ok: true }; } catch (e) { return { ok: false, error: String(e.message || e) }; } } // Modify an internal pending (O7). price = trigger in display units; // routed to price (LIMIT) or stopPrice (STOP). sl/tp: 0 clears, null keeps. async function apiModifyOrder(order, { price, sl, tp }) { const inst = SYM_BY_DISPLAY[order.sym]; if (!inst) return { ok: false, error: t('error.unknownSymbol') }; const body = { account: LIVE.accountId, platform: LIVE.activePlatform(), ticket: order.id, symbol: inst.backend, sl, tp, }; if (price != null) { if (order.type.includes('Stop')) body.stopPrice = price; else body.price = price; } try { const res = await apiJSON('POST', '/api/orders/modify', body); if (res.reject) return { ok: false, rejectCode: res.reject.code || 'unspecified', error: res.reject.text || res.reject.code }; return { ok: true }; } catch (e) { return { ok: false, error: String(e.message || e) }; } } // ---- cabinet (placeholder BFF — see docs/bff-api.md C4–C9) ---- // These hit Concierge stub endpoints, then refresh the accounts list. async function cabinet(path, body) { const res = await apiJSON('POST', path, { ...body }); await loadAccounts(); notify(); return res; } const apiDeposit = (id, amt) => cabinet('/api/cabinet/deposit', { accountId: id, amount: amt }); const apiWithdraw = (id, amt) => cabinet('/api/cabinet/withdraw', { accountId: id, amount: amt }); const apiTransfer = (from, to, amt) => cabinet('/api/cabinet/transfer', { fromId: from, toId: to, amount: amt }); const platformOf = (id) => { const a = LIVE.accounts.find(x => x.id === id); return a && a.platform ? a.platform : 10; }; const apiReset = (id, to) => cabinet('/api/cabinet/demo-reset',{ accountId: id, platform: platformOf(id), targetBalance: to }); async function apiOpenAccount(acct) { const res = await apiJSON('POST', '/api/accounts/open', { type: acct.type, plan: acct.plan, leverage: acct.leverage, currency: acct.currency, }); await loadAccounts(); // A demo account (from A7) is real and tradeable — switch to it so the user // lands on their new account. Live opens stay a non-tradeable placeholder. if (res.account && res.account.id) { apiSwitchAccount(res.account.id); } else { notify(); } return res; } // ---- API access / agent sessions (Track P P1 — docs/public-api.md §5) ---- // Cookie-authenticated management of the user's public /v1 tokens. Deliberately // NOT /v1 endpoints: an agent must never be able to mint or revoke a token, // including its own (PD10). // // apiMintAgentSession returns { session, token }; the plaintext `token` exists // only in that response — the server keeps a SHA-256 and cannot recover it. const apiAgentSessions = () => apiJSON('GET', '/api/agent-sessions'); const apiMintAgentSession = (body) => apiJSON('POST', '/api/agent-sessions/mint', body); const apiRevokeAgentSession = (tokenId) => apiJSON('POST', '/api/agent-sessions/revoke', { tokenId }); // ---- P3: message feed, kill switch, live caps ---- // // apiKillAgentSession is the escalating kill (PD10). `level` is 1..4 and each // level CONTAINS the one below it: 1 disconnect · 2 revoke · 3 +cancel orders · // 4 +flatten positions. The response reports per-item outcomes and is the only // honest account of what happened — see the panel's outcome report. const apiKillAgentSession = (tokenId, level) => apiJSON('POST', '/api/agent-sessions/kill', { tokenId, level }); // AGENT is the panel's store, and it is deliberately NOT part of LIVE. // // LIVE.listeners fires on every price tick — that is what makes the grid and // the charts move — and the agent panel must not be dragged along with it. A // separate listener set means a feed append re-renders the panel and nothing // else, and a price tick re-renders everything else and not the panel. The // panel components are memo barriers over stable props for the other half of // the same guarantee. // // One feed per view: key "0" is every session, key "" is one. Both exist // because the endpoint pages by an exclusive `before` cursor, and two views // sharing one cursor cannot both paginate. const AGENT = { feeds: Object.create(null), unread: 0, version: 0, listeners: new Set(), }; // Level → toast colour. PD9's vocabulary comes off the wire, so the DISPLAY // string is tCode('agentLevel', …) (FR4) and this map carries only the tone. const AGENT_TOAST_KIND = { info: 'info', success: 'success', warning: 'warn', alert: 'error' }; function agentFeedKey(tokenId) { return String(tokenId || 0); } function agentFeed(tokenId) { const key = agentFeedKey(tokenId); let feed = AGENT.feeds[key]; if (!feed) { feed = { tokenId: tokenId || 0, rows: [], nextBefore: 0, loaded: false, loading: false, err: null }; AGENT.feeds[key] = feed; } return feed; } function agentNotify() { AGENT.version++; AGENT.listeners.forEach(fn => fn(AGENT.version)); } // useAgentStore subscribes a component to the feed/unread store only. function useAgentStore() { const [, setV] = React.useState(AGENT.version); React.useEffect(() => { const fn = (v) => setV(v); AGENT.listeners.add(fn); // A message can land between the first render and this effect. setV(AGENT.version); return () => AGENT.listeners.delete(fn); }, []); return AGENT; } // agentInsert prepends one message, ignoring a duplicate id. Duplicates are // routine rather than exotic: a live frame arrives, and the next page fetch // legitimately returns the same row because the feed is newest-first. function agentInsert(feed, row) { if (feed.rows.some(m => m.id === row.id)) return; feed.rows = [row, ...feed.rows]; } function agentPush(msg) { const row = { id: msg.id, tokenId: msg.tokenId || 0, tokenName: msg.tokenName || '', level: msg.level || 'info', title: msg.title || '', body: msg.body || '', ref: msg.ref || null, createdAt: msg.createdAt || Date.now(), }; agentInsert(agentFeed(0), row); // Only into a per-session feed that ALREADY exists. Creating one here would // leave it holding a single live row with `loaded` false, and the panel would // then show that one message as if it were the whole history. const key = agentFeedKey(row.tokenId); if (row.tokenId && AGENT.feeds[key]) agentInsert(AGENT.feeds[key], row); AGENT.unread++; agentNotify(); } // apiLoadAgentMessages fills (or extends) one view's page. // // `older` walks backwards with the server's exclusive cursor. The absence of // `nextBefore` is the end of the feed — the endpoint sets it only when the page // FILLED, precisely so the client does not have to get a count comparison right. async function apiLoadAgentMessages(tokenId, older) { const feed = agentFeed(tokenId); if (feed.loading) return; if (older && !feed.nextBefore) return; feed.loading = true; feed.err = null; agentNotify(); const params = []; if (tokenId) params.push('token_id=' + encodeURIComponent(tokenId)); if (older) params.push('before=' + encodeURIComponent(feed.nextBefore)); try { const res = await apiJSON('GET', '/api/agent-sessions/messages' + (params.length ? '?' + params.join('&') : '')); const rows = res.messages || []; if (older) { const seen = new Set(feed.rows.map(m => m.id)); feed.rows = feed.rows.concat(rows.filter(m => !seen.has(m.id))); } else { // MERGE, never assign. An agent_msg frame can land while this request is // in flight — agentPush prepends it to a feed that has not loaded yet — // and `feed.rows = rows` would drop it on the floor: the toast has already // fired, the unread badge has already counted it, and the first page is // never fetched again, so that message would be gone for the session. // Sorted by id because the feed renders newest-first and a live row is // newer than everything the server just returned. const seen = new Set(rows.map(m => m.id)); feed.rows = feed.rows.filter(m => !seen.has(m.id)).concat(rows).sort((a, b) => b.id - a.id); } feed.nextBefore = res.nextBefore || 0; feed.loaded = true; } catch (e) { feed.err = String((e && e.message) || e); } feed.loading = false; agentNotify(); } function agentMarkRead() { if (!AGENT.unread) return; AGENT.unread = 0; agentNotify(); } // agentResetFeeds drops everything on logout, so one user's automation chatter // can never be read by the next person on a shared browser. function agentResetFeeds() { AGENT.feeds = Object.create(null); AGENT.unread = 0; agentNotify(); } // ---- kill preview: the server's own dry run ---- // // apiKillPreview asks what a press WOULD act on, and it executes nothing: no // revoke, no cancel, no close, no budget spent, and it does not take the // per-token kill lock (internal/frontsrv/agentkill.go, killDryRun). Asked at // level 4, so one request answers both L3's question (`cancelled`) and L4's // (`closed`). // // ——— its own route, and never a flag on the kill ——— // // The static SPA and the Concierge binary do not move together: the local stack // bind-mounts web/terminal, so merging reaches the browser immediately while the // container keeps the old binary until someone rebuilds it. encoding/json // ignores fields a build does not know, so `{"dryRun":true}` posted to // /api/agent-sessions/kill on an older backend does not preview — it FLATTENS // THE ACCOUNT. An unrecognised ROUTE is a 404: inert, and something this // function can degrade on. // // For the same reason there is deliberately NO fallback below. If the preview // route answers anything other than a preview, the panel says it could not // check; it must never re-ask the destructive route "just for the numbers". // // This used to be counted in the browser, from LIVE.orders / LIVE.positions, // and it was wrong in both directions. LIVE holds the SELECTED ACCOUNT only, // while the panel lists tokens across every account the human holds — so a // runaway bot on the account they were not watching previewed {0,0}, and since // every preview element was gated on `> 0` the L4 dialog rendered with no // warning box at all, pixel-identical to killing an idle token. On the account // it could see, it overstated instead: the client cannot apply the sweep's role // deny-list (an S/L, T/P or CLOSE order is never cancelled, and the WS order // row carries no role) or its close_pending skip, so "3 orders would be // cancelled" would be followed by "1 order cancelled" and read as two orders // still live. // // One enumeration on the server decides both the preview and the action, so // they cannot disagree. const apiKillPreview = (tokenId) => apiJSON('POST', '/api/agent-sessions/kill/preview', { tokenId, level: 4 }); // useKillPreview runs that dry run once per dialog and reports its state // HONESTLY: `loading` and `err` are distinct from a zero count, because "the // server says nothing is open" and "I could not ask" must never render the // same. The caller shows text in those two cases — never a silent zero. function useKillPreview(tokenId) { const [state, setState] = React.useState({ loading: true, err: null, orders: 0, positions: 0 }); React.useEffect(() => { if (!tokenId) { setState({ loading: false, err: null, orders: 0, positions: 0 }); return undefined; } let alive = true; setState({ loading: true, err: null, orders: 0, positions: 0 }); apiKillPreview(tokenId).then(res => { if (!alive) return; // Only a GENUINE preview may produce a number. A 200 that is not one — a // static-file fallback, a proxy interstitial, some future build answering // differently — must degrade to "could not check", never to zero, which is // the one wrong answer this panel can give. `dryRun` is set by the preview // route and by nothing else, so it is the marker to test. if (!res || res.dryRun !== true || !Array.isArray(res.cancelled) || !Array.isArray(res.closed)) { setState({ loading: false, err: 'not a preview response', orders: 0, positions: 0 }); return; } setState({ loading: false, err: null, orders: res.cancelled.length, positions: res.closed.length, // A snapshot the server could not read is a failed preview even though // the request itself succeeded — otherwise an unreachable monolith // renders as "nothing to close". ...((res.failed || []).some(f => f.kind === 'snapshot') ? { err: 'snapshot' } : null), }); }).catch(e => { // Includes the 404 an older backend gives for this route. No retry against // the kill endpoint — see above. if (alive) setState({ loading: false, err: String((e && e.message) || e), orders: 0, positions: 0 }); }); return () => { alive = false; }; }, [tokenId]); return state; } // killPreviewText turns that state into one line of copy plus a semantic tone // the two shells colour their own way. Shared because the DECISION — which of // the four things to say — is the part that must not differ between the phone // and the desktop; only the paint does. // // There is a line in every state, deliberately. The old panel rendered the // count only when it was above zero, so "nothing is open" and "I never found // out" were both drawn as empty space. function killPreviewText(preview, kind) { const orders = kind === 'orders'; if (preview.loading) return { text: t('agent.kill.preview.checking'), tone: 'mute' }; if (preview.err) return { text: t('agent.kill.preview.unavailable'), tone: 'warn' }; const n = orders ? preview.orders : preview.positions; if (n > 0) { return { text: tn(orders ? 'agent.kill.willCancel' : 'agent.kill.willClose', n), tone: orders ? 'warn' : 'danger', }; } return { text: t(orders ? 'agent.kill.preview.noOrders' : 'agent.kill.preview.noPositions'), tone: 'mute' }; } // copyText puts a string on the clipboard, falling back to the textarea trick // where navigator.clipboard is unavailable (plain-http dev origins). Returns // whether anything was copied, so the caller can tell the user the truth. async function copyText(text) { try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return true; } } catch (e) { /* fall through */ } const ta = document.createElement('textarea'); try { ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); return document.execCommand('copy'); } catch (e) { return false; } finally { // Always remove it. A throw between the append and the remove would // otherwise leave the plaintext token sitting in the DOM for the life of // the page — readable by anything that can walk it. if (ta.parentNode) ta.parentNode.removeChild(ta); } } // ============================================================================ // P6 — the UI-command bridge (PD11) // ============================================================================ // // `ui_cmd` frames let an agent drive the chart the human is watching, mark it // up, and fill in the trade ticket. It CANNOT submit the ticket: nothing in // this file calls apiPlaceOrder, and the prefill below sets form state and a // banner, exactly as if the human had typed the numbers and then been told who // really did. // // Its own store and its own listener set, for the reason AGENT has one: LIVE's // notify() wakes every price-path subscriber, which on the desktop shell is up // to twelve charts. A chart annotation that re-rendered the whole grid would // undo the static/live split those charts are built around. Nothing here is on // the tick path, so nothing here shares the tick path's listeners. // // Everything is keyed by a monotonic `seq`. The shells consume these as HINTS // — the same shape desktop already uses for a clicked price (priceHint) — // because a command is an event, not state: showing GBP/USD twice in a row // must work, and it will not if the consumer diffs values. const UICMD = { version: 0, listeners: new Set(), seq: 0, chart: null, // {sym, tf, by, tokenId, at, seq} — last show_chart prefill: null, // {sym, side, vol, orderType, price, stopPrice, sl, tp, by, tokenId, at, seq} notes: Object.create(null), // sym → {levels, markers, key, by, tokenId, at} }; function uiNotify() { UICMD.version++; UICMD.listeners.forEach(fn => fn(UICMD.version)); } // useUiCommands subscribes a component to the UI-command store only. function useUiCommands() { const [, setV] = React.useState(UICMD.version); React.useEffect(() => { const fn = (v) => setV(v); UICMD.listeners.add(fn); setV(UICMD.version); // a command can land between first render and this effect return () => UICMD.listeners.delete(fn); }, []); return UICMD; } // uiNotes returns the annotation set for one wire symbol, or null. function uiNotes(backendSym) { return UICMD.notes[backendSym] || null; } // uiClearNotes lets the human take their chart back. An agent can redraw, and // that is fine — what matters is that the person looking at the chart is never // stuck with someone else's marks on it. function uiClearNotes(backendSym) { if (!UICMD.notes[backendSym]) return; delete UICMD.notes[backendSym]; uiNotify(); } // uiClearPrefill drops the agent-filled banner and its hint. // // Called when the human dismisses it AND after they submit, so the next ticket // they open is theirs. Note what it does NOT do: it does not clear the ticket's // values. Wiping the fields on dismiss would make "I have read this" destroy // the thing they just read. function uiClearPrefill() { if (!UICMD.prefill) return; UICMD.prefill = null; uiNotify(); } // ---- the view-context provider (the reverse direction) ---- // // get_view_context is the one place the terminal SPEAKS on this socket, which // was receive-only until P6. Each shell registers a function returning what it // currently has on screen; api.jsx owns the wire format so the two shells // cannot answer in two shapes. let viewContextProvider = null; function registerViewContext(fn) { viewContextProvider = fn; return () => { if (viewContextProvider === fn) viewContextProvider = null; }; } // answerViewContext replies to one correlated request. // // Silence is a legitimate answer and the edge handles it — it 504s after 2s // with `no_terminal_connected`, which is the honest response when this tab // cannot say what it is showing. So a missing provider or a throwing one sends // nothing rather than an empty shape a caller would read as fact. function answerViewContext(corrId) { if (!corrId || !viewContextProvider) return; const ws = LIVE.ws; if (!ws || ws.readyState !== 1) return; let view = null; try { view = viewContextProvider(); } catch (e) { return; } if (!view || !view.sym) return; try { ws.send(JSON.stringify({ t: 'ui_ctx', corrId, sym: view.sym, tf: view.tf || '', selection: view.selection || {}, })); } catch (e) { /* socket died mid-answer; the edge times out */ } } // uiAnnotationKey is the PRIMITIVE digest the desktop chart's memo comparator // compares. // // This is load-bearing rather than an optimisation detail. ChartStatic is // wrapped in React.memo with a hand-written comparator, and the annotation // arrays are new object identities on every frame — comparing them by identity // would repaint twelve charts on every tick, which is precisely the cost the // static/live split exists to avoid. So the arrays travel with a string that // changes only when their CONTENTS change. function uiAnnotationKey(levels, markers) { const l = levels.map(v => v.price + '' + (v.label || '') + '' + (v.color || '')).join('|'); const m = markers.map(v => v.time + '' + (v.price || 0) + '' + (v.label || '')).join('|'); return l + '' + m; } // uiCmdReceived handles one ui_cmd frame. // // `tokenName` is REQUIRED on the wire (WsUiCmdFrame), because the shells render // it over anything an agent filled in. The fallback below is defence against a // server that breaks that promise, not an expected path — and it deliberately // still names an agent rather than rendering nothing, because the failure mode // this whole mechanism exists to prevent is a human believing they composed a // ticket they did not. function uiCmdReceived(msg) { const by = msg.tokenName || t('agent.unnamedSession'); const tokenId = msg.tokenId || 0; const args = msg.args || {}; const at = Date.now(); switch (msg.command) { case 'get_view_context': answerViewContext(msg.corrId); return; // changes nothing on screen, so nothing to notify case 'show_chart': { const inst = SYM_BY_BACKEND[args.sym]; if (!inst) return; UICMD.chart = { sym: inst.sym, tf: args.tf || '', by, tokenId, at, seq: ++UICMD.seq }; break; } case 'annotate_chart': { const inst = SYM_BY_BACKEND[args.sym]; if (!inst) return; const levels = Array.isArray(args.levels) ? args.levels : []; const markers = Array.isArray(args.markers) ? args.markers : []; if (!levels.length && !markers.length) { delete UICMD.notes[args.sym]; } else { UICMD.notes[args.sym] = { levels, markers, key: uiAnnotationKey(levels, markers), by, tokenId, at, }; } break; } case 'prefill_ticket': { const inst = SYM_BY_BACKEND[args.sym]; if (!inst) return; UICMD.prefill = { sym: inst.sym, side: args.side === 'sell' ? 'sell' : 'buy', vol: Number(args.vol) || 0, // Wire UPPER ("MARKET") → the shells' title case ("Market"). The two // casings are a documented trap of this API and the conversion belongs // here, once, rather than in each ticket. orderType: TICKET_TYPE_BY_WIRE[String(args.order_type || 'MARKET').toUpperCase()] || 'Market', price: Number(args.price) || 0, stopPrice: Number(args.stop_price) || 0, sl: Number(args.sl) || 0, tp: Number(args.tp) || 0, by, tokenId, at, seq: ++UICMD.seq, }; // A toast as well as the banner. The banner is on the ticket and the // human may be looking somewhere else entirely; the toast is what makes // them look. Both name the agent. pushToast('info', t('agent.prefill.toast.title', { name: by }), t('agent.prefill.toast.body', { side: UICMD.prefill.side === 'sell' ? t('agent.prefill.sell') : t('agent.prefill.buy'), vol: UICMD.prefill.vol, sym: inst.sym, })); break; } default: return; // unknown commands are ignored, never guessed at } uiNotify(); } const TICKET_TYPE_BY_WIRE = { MARKET: 'Market', LIMIT: 'Limit', STOP: 'Stop' }; Object.assign(window, { useUiCommands, uiNotes, uiClearNotes, uiClearPrefill, registerViewContext, CATEGORIES, CAT_ICONS, computePL, pipValue, marginEstimate, usdValue, quoteToUSD, pushToast, apiAgentSessions, apiMintAgentSession, apiRevokeAgentSession, copyText, apiKillAgentSession, apiLoadAgentMessages, agentFeed, useAgentStore, agentMarkRead, apiKillPreview, useKillPreview, killPreviewText, initInstruments, useLive, apiLogin, apiRegister, apiResumeSession, apiSwitchAccount, apiLogout, apiPlaceOrder, apiClosePosition, apiCancelOrder, apiModifyPosition, apiModifyOrder, apiCandles, apiDepth, apiSpec, apiDeposit, apiWithdraw, apiTransfer, apiReset, apiOpenAccount, apiLoadFavorites, apiSaveFavorites, apiLoadLocale, apiSaveLocale, get INSTRUMENTS() { return INSTRUMENTS; }, });