// 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; },
});