// Positions tab + Orders tab const POS_CAT_COLOR = { FX: '#3B82F6', Metals: '#E0A21A', Energy: '#E2563B', Indices: '#8B5CF6', Crypto: '#F7931A' }; function posPair(sym) { return sym.length === 6 && sym.match(/^[A-Z]{6}$/) ? sym.slice(0, 3) + '/' + sym.slice(3) : sym; } // compact 3-part price function PPx({ value, inst, size = 13, color }) { const { base, pips, frac } = splitPip(value, inst.digits, inst.pipDigits); return ( // A 3-part price is three ADJACENT spans, which is exactly the shape // RTL reorders: 1.08|57|4 renders as 4|57|1.08 — an unreadable price, // not a cosmetic difference. Pinning the wrapper to LTR keeps the // parts in order; the surrounding row still mirrors normally (ID5). {base} {pips} {frac && {frac}} ); } function SideTag({ side, vol, theme }) { const isBuy = side === 'buy'; return ( {isBuy ? t('common.side.buy') : t('common.side.sell')} {vol.toFixed(2)} ); } // OriginTag — PD5 attribution, deferred here from P2 (docs/public-api.md §6 P3). // // Nothing renders for a terminal-placed row: the wire omits `origin` for // TERMINAL and for every pre-P2 row, so the test is presence and an unbadged // row means "you placed this". Badging all of them would be noise over the two // that matter. // // The label is the token's own name — arbitrary user text in an unknown // script — so it sits in a , or an Arabic bot name inside an LTR row (and // "vwap-bot" inside an Arabic one) reorders against the row around it. The // channel word renders from the CODE (FR4), never from the wire string. function OriginTag({ row, theme }) { if (!row || !row.origin) return null; const channel = tCode('origin', row.origin); const label = row.originLabel || channel; return ( {label} ); } // One ticket. showSymbol → flat list (leads with dot + symbol); else inside a Net group. function TicketLine({ pos, inst, priceState, theme, showSymbol, onTap, last }) { const pl = computePL(pos, priceState.bid, priceState.ask); const isProfit = pl >= 0; const curPrice = pos.side === 'buy' ? priceState.bid : priceState.ask; const c = POS_CAT_COLOR[inst.cat] || theme.accent; const meta = (label, val, col) => ( {label} {val} ); return (
onTap(pos)} style={{ padding: showSymbol ? '11px 16px' : '9px 16px 9px 28px', cursor: 'pointer', borderBottom: last ? 'none' : `0.5px solid ${theme.border}`, }} > {/* line 1 */}
{showSymbol &&
} {showSymbol && {posPair(pos.sym)}} #{pos.ticket} {fmtMoney(pl)}
{/* line 2 */}
{pos.openTime} {meta(t('common.takeProfitShort'), pos.tp ? fmtPrice(pos.tp, inst.digits) : '—', pos.tp ? theme.buy : theme.textTer)} {meta(t('common.stopLossShort'), pos.sl ? fmtPrice(pos.sl, inst.digits) : '—', pos.sl ? theme.sell : theme.textTer)}
); } // Net (exposure) group — aggregates all tickets of one symbol function NetGroup({ sym, group, prices, theme, expanded, onToggle, onTapTicket }) { const inst = INSTRUMENTS.find(i => i.sym === sym); const ps = prices[sym]; if (!inst || !ps) return null; const netVol = group.reduce((s, p) => s + (p.side === 'buy' ? p.vol : -p.vol), 0); const netSide = netVol >= 0 ? 'buy' : 'sell'; const pl = group.reduce((s, p) => s + computePL(p, ps.bid, ps.ask), 0); const isProfit = pl >= 0; const c = POS_CAT_COLOR[inst.cat] || theme.accent; return (
{/* group header */}
onToggle(sym)} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '13px 14px', cursor: 'pointer' }}>
{posPair(sym)} {netSide === 'buy' ? t('common.side.buy') : t('common.side.sell')} {Math.abs(netVol).toFixed(2)} {group.length > 1 && {t('positions.net.tickets', { n: group.length })}} {fmtMoney(pl)}
{/* expanded tickets */} {expanded && (
{group.map((p, i) => ( ))}
)}
); } // Tap-a-position action sheet → Close / Modify (in-sheet SL/TP editor, O6) function PositionActionSheet({ pos, prices, theme, onClose, onConfirmClose, onModify }) { const inst = INSTRUMENTS.find(i => i.sym === pos.sym); const ps = prices[pos.sym]; const pip = inst ? Math.pow(10, -inst.pipDigits) : 0.0001; const rnd = v => inst ? +v.toFixed(inst.digits) : v; const cur0 = ps ? (pos.side === 'buy' ? ps.bid : ps.ask) : 0; const [editing, setEditing] = React.useState(false); const [tpOn, setTpOn] = React.useState(!!pos.tp); const [slOn, setSlOn] = React.useState(!!pos.sl); const [tp, setTp] = React.useState(pos.tp || rnd(cur0 + (pos.side === 'buy' ? 50 : -50) * pip)); const [sl, setSl] = React.useState(pos.sl || rnd(cur0 - (pos.side === 'buy' ? 50 : -50) * pip)); const [busy, setBusy] = React.useState(false); // { msg, detail } — FR4: `msg` renders from the reject CODE, and the // server's own English text is demoted to the tooltip so support can still // read it. `detail` is null when the server sent no text. const [err, setErr] = React.useState(null); if (!inst || !ps) return null; const pl = computePL(pos, ps.bid, ps.ask); const isProfit = pl >= 0; const curPrice = pos.side === 'buy' ? ps.bid : ps.ask; const c = POS_CAT_COLOR[inst.cat] || theme.accent; const estAt = level => computePL(pos, level, level); const save = async () => { if (busy) return; setBusy(true); setErr(null); const res = await onModify(pos, { sl: slOn ? sl : 0, tp: tpOn ? tp : 0 }); setBusy(false); if (res.ok) onClose(); else setErr({ msg: res.rejectCode ? tCode('reject', res.rejectCode) : (res.error || t('positions.modify.failed')), detail: res.error || null, }); }; const row = (label, val, col) => (
{label} {val}
); return (
e.stopPropagation()} style={{ width: '100%', background: theme.bgElevated, borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: '12px 16px 28px', borderTop: `0.5px solid ${theme.border}`, animation: 'slideUp 280ms cubic-bezier(0.2,0.9,0.3,1.05)' }}>
{/* header */}
{posPair(pos.sym)}
#{pos.ticket} · {pos.openTime}
{fmtMoney(pl)}
{row(t('common.openPrice'), fmtPrice(pos.openPrice, inst.digits))} {row(t('common.currentPrice'), fmtPrice(curPrice, inst.digits))} {!editing && row(t('common.takeProfit'), pos.tp ? fmtPrice(pos.tp, inst.digits) : '—', pos.tp ? theme.buy : theme.textTer)} {!editing && row(t('common.stopLoss'), pos.sl ? fmtPrice(pos.sl, inst.digits) : '—', pos.sl ? theme.sell : theme.textTer)}
{editing && (
setTp(rnd(tp - pip))} onPlus={() => setTp(rnd(tp + pip))} est={estAt(tp)} theme={theme} inst={inst} /> setSl(rnd(sl - pip))} onPlus={() => setSl(rnd(sl + pip))} est={estAt(sl)} theme={theme} inst={inst} />
)} {err &&
{err.msg}
} {!editing ? (
setEditing(true)} style={{ flex: 1, padding: '14px', borderRadius: 13, background: theme.bgSubtle, color: theme.text, textAlign: 'center', fontWeight: 700, fontSize: 14, cursor: 'pointer' }}>{t('common.action.modify')}
{ onConfirmClose(pos); onClose(); }} style={{ flex: 1, padding: '14px', borderRadius: 13, background: theme.sell, color: '#fff', textAlign: 'center', fontWeight: 700, fontSize: 14, cursor: 'pointer', boxShadow: `0 6px 16px ${theme.sell}44` }}> {t('positions.action.closeAmount', { amount: fmtMoney(pl) })}
) : (
{ setEditing(false); setErr(null); }} style={{ flex: 1, padding: '14px', borderRadius: 13, background: theme.bgSubtle, color: theme.text, textAlign: 'center', fontWeight: 700, fontSize: 14, cursor: 'pointer' }}>{t('common.action.back')}
{busy ? t('common.action.saving') : t('common.action.save')}
)}
); } function PositionsTab({ positions, prices, theme, onTapPosition, account }) { const [mode, setMode] = React.useState('net'); // net | tickets const [expanded, setExpanded] = React.useState({}); // sym → bool const totalPL = positions.reduce((s, p) => { const ps = prices[p.sym]; return ps ? s + computePL(p, ps.bid, ps.ask) : s; }, 0); // Used margin is authoritative from the monolith (account.usedMargin); // equity is balance+credit + edge-computed floating PL (R28). const acct = account || { balance: 0, credit: 0, usedMargin: 0 }; const margin = acct.usedMargin || 0; const equity = (acct.balance || 0) + (acct.credit || 0) + totalPL; const freeMargin = equity - margin; const isProfit = totalPL >= 0; // group by symbol for net mode const groups = {}; for (const p of positions) { (groups[p.sym] ||= []).push(p); } const groupSyms = Object.keys(groups); const toggle = (sym) => setExpanded(e => ({ ...e, [sym]: !e[sym] })); // Isolated like every other money run (ID5) — a bare "$1,840.25" in RTL // renders as "1,840.25$", which reads as a different notation. const fmtUSD = (n) => bidiIsolate('$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })); return (
{/* header */}
{t('nav.positions')}
{t('positions.upl')} {fmtMoney(totalPL)}
{positions.length > 0 && (
{t('common.margin')} {fmtUSD(margin)} {t('common.freeMargin')} {fmtUSD(freeMargin)}
)}
{positions.length > 0 ? ( <> {/* Net / Positions toggle */}
{/* first element of each pair is the mode CODE, second the label */} {[['net', t('positions.mode.net')], ['tickets', t('positions.mode.tickets')]].map(([m, label]) => (
setMode(m)} style={{ flex: 1, textAlign: 'center', padding: '8px 0', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: 'pointer', letterSpacing: -0.1, background: mode === m ? theme.text : 'transparent', color: mode === m ? theme.bg : theme.textSec, transition: 'all 140ms', }}>{label}
))}
{mode === 'net' ? (
{groupSyms.map(sym => ( ))}
) : (
{positions.map((pos, i) => { const inst = INSTRUMENTS.find(in_ => in_.sym === pos.sym); const ps = prices[pos.sym]; if (!inst || !ps) return null; return ; })}
)} ) : ( )}
); } // Compact pending-order row — same visual language as TicketLine. // Market orders can pass through here transiently (in-flight / last-look // hold) — they have no trigger price, so show their state instead of 0. function OrderLine({ order, inst, priceState, theme, onTap, last }) { const isBuy = order.type.startsWith('Buy'); const isMarket = !order.triggerPrice; const curPrice = isBuy ? priceState.ask : priceState.bid; const pip = Math.pow(10, -inst.pipDigits); const pipsAway = (order.triggerPrice - curPrice) / pip; const c = POS_CAT_COLOR[inst.cat] || theme.accent; return (
onTap(order)} style={{ padding: '11px 16px', cursor: 'pointer', borderBottom: last ? 'none' : `0.5px solid ${theme.border}` }} > {/* line 1 */}
{posPair(order.sym)} {t('order.type.' + order.typeCode)} {order.vol.toFixed(2)} #{order.ticket} {isMarket ? {tCode('status', order.status)} : }
{/* line 2 */}
{t('orders.now')} {!isMarket && {t('orders.pipsAway', { pips: Math.abs(pipsAway).toFixed(1) })}} {t('orders.exp')} {tCode('tif', order.expiry)}
); } // Tap-an-order action sheet → Modify / Cancel function OrderActionSheet({ order, prices, theme, onClose, onConfirmCancel, onModify }) { const inst = INSTRUMENTS.find(i => i.sym === order.sym); const ps = prices[order.sym]; const pip = inst ? Math.pow(10, -inst.pipDigits) : 0.0001; const rnd = v => inst ? +v.toFixed(inst.digits) : v; const [editing, setEditing] = React.useState(false); const [trig, setTrig] = React.useState(order.triggerPrice); const [tpOn, setTpOn] = React.useState(!!order.tp); const [slOn, setSlOn] = React.useState(!!order.sl); const isBuySt = order.type.startsWith('Buy'); const [tp, setTp] = React.useState(order.tp || rnd(order.triggerPrice + (isBuySt ? 50 : -50) * pip)); const [sl, setSl] = React.useState(order.sl || rnd(order.triggerPrice - (isBuySt ? 50 : -50) * pip)); const [busy, setBusy] = React.useState(false); // { msg, detail } — see PositionActionSheet: FR4 renders the reject CODE and // keeps the server's English text as the tooltip. const [err, setErr] = React.useState(null); if (!inst || !ps) return null; const isBuy = isBuySt; const isMarket = !order.triggerPrice; // in-flight/held market order: no trigger, not modifiable const curPrice = isBuy ? ps.ask : ps.bid; const pipsAway = (order.triggerPrice - curPrice) / pip; const c = POS_CAT_COLOR[inst.cat] || theme.accent; // PL estimate if the entry fills at trig and exits at the given level. const estAt = level => computePL({ sym: order.sym, side: order.side, vol: order.vol, openPrice: trig }, level, level); const save = async () => { if (busy) return; setBusy(true); setErr(null); const res = await onModify(order, { price: trig, sl: slOn ? sl : 0, tp: tpOn ? tp : 0 }); setBusy(false); if (res.ok) onClose(); else setErr({ msg: res.rejectCode ? tCode('reject', res.rejectCode) : (res.error || t('orders.modify.failed')), detail: res.error || null, }); }; const row = (label, val, col) => (
{label} {val}
); return (
e.stopPropagation()} style={{ width: '100%', background: theme.bgElevated, borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: '12px 16px 28px', borderTop: `0.5px solid ${theme.border}`, animation: 'slideUp 280ms cubic-bezier(0.2,0.9,0.3,1.05)' }}>
{posPair(order.sym)} {t('order.type.' + order.typeCode)} {order.vol.toFixed(2)}
#{order.ticket} · {order.created}
{/* Status is a CODE (FR4) — rendered from the code and uppercased by CSS, so the bundle keeps natural case. */} {!editing && (isMarket ? row(t('common.status'), {order.status ? tCode('status', order.status) : ''}) : row(t('common.triggerPrice'), fmtPrice(order.triggerPrice, inst.digits)))} {row(t('common.currentPrice'), fmtPrice(curPrice, inst.digits))} {!editing && !isMarket && row(t('orders.distance'), t('orders.pipsValue', { pips: Math.abs(pipsAway).toFixed(1) }))} {!editing && row(t('common.takeProfit'), order.tp ? fmtPrice(order.tp, inst.digits) : '—', order.tp ? theme.buy : theme.textTer)} {!editing && row(t('common.stopLoss'), order.sl ? fmtPrice(order.sl, inst.digits) : '—', order.sl ? theme.sell : theme.textTer)} {!editing && !isMarket && row(t('common.expiry'), tCode('tif', order.expiry))}
{editing && (
{t('common.triggerPrice')}
setTrig(rnd(trig - pip))} style={{ width: 28, height: 28, borderRadius: 8, background: theme.bgSubtle, display: 'flex', alignItems: 'center', justifyContent: 'center', color: theme.text, fontSize: 17, cursor: 'pointer', flexShrink: 0, userSelect: 'none' }}>−
setTrig(rnd(trig + pip))} style={{ width: 28, height: 28, borderRadius: 8, background: theme.bgSubtle, display: 'flex', alignItems: 'center', justifyContent: 'center', color: theme.text, fontSize: 17, cursor: 'pointer', flexShrink: 0, userSelect: 'none' }}>+
{t('orders.pipsFromMarket', { pips: Math.abs((trig - curPrice) / pip).toFixed(1) })}
)} {editing && (
setTp(rnd(tp - pip))} onPlus={() => setTp(rnd(tp + pip))} est={estAt(tp)} theme={theme} inst={inst} /> setSl(rnd(sl - pip))} onPlus={() => setSl(rnd(sl + pip))} est={estAt(sl)} theme={theme} inst={inst} />
)} {err &&
{err.msg}
} {!editing ? (
{!isMarket &&
setEditing(true)} style={{ flex: 1, padding: '14px', borderRadius: 13, background: theme.bgSubtle, color: theme.text, textAlign: 'center', fontWeight: 700, fontSize: 14, cursor: 'pointer' }}>{t('common.action.modify')}
}
{ onConfirmCancel(order); onClose(); }} style={{ flex: 1, padding: '14px', borderRadius: 13, background: theme.sell, color: '#fff', textAlign: 'center', fontWeight: 700, fontSize: 14, cursor: 'pointer', boxShadow: `0 6px 16px ${theme.sell}44` }}> {t('orders.action.cancelOrder')}
) : (
{ setEditing(false); setErr(null); }} style={{ flex: 1, padding: '14px', borderRadius: 13, background: theme.bgSubtle, color: theme.text, textAlign: 'center', fontWeight: 700, fontSize: 14, cursor: 'pointer' }}>{t('common.action.back')}
{busy ? t('common.action.saving') : t('common.action.save')}
)}
); } function OrdersTab({ orders, prices, theme, onTapOrder }) { return (
{t('nav.orders')}
{t('orders.pendingCount', { n: orders.length })}
{orders.length > 0 ? (
{orders.map((order, i) => { const inst = INSTRUMENTS.find(it => it.sym === order.sym); const ps = prices[order.sym]; if (!inst || !ps) return null; return ; })}
) : ( )}
); } function EmptyState({ theme, title, body, icon }) { return (
{title}
{body}
); } Object.assign(window, { PositionsTab, OrdersTab, NetGroup, TicketLine, PositionActionSheet, OrderLine, OrderActionSheet, EmptyState, OriginTag });