/* global React */ /* Vault Control — Basket Studio: the place a basket is composed and sized. * (2026-08-06: resolver cutover — arm/disarm removed. Live state is now compiled from * broker subscriptions by the resolver; Control edits basket composition only.) */ const { Card: BkCard } = window.BraveAlphaCapitalDesignSystem_c4b179; const { LineChart: BkLine, MeterBar: BkMeter, Legend: BkLegend, SearchSelect: BkSearchSelect } = window; const { Stat: BkStat, InfoDot: BkInfo, FMT: BkF, toneOf: bkTone, ModeTag: BkMode, Msg: BkMsg } = window; const { assetClass: bkAssetClass, ClassChips: BkClassChips, classCounts: bkClassCounts } = window; const { DeployTag: BkDeployTag, venueOf: bkVenueOf, depsBySid: bkDepsBySid } = window; const BK = window.VC; const bkInput = { fontFamily: "var(--font-mono)", fontSize: 13, padding: "7px 10px", borderRadius: 7, border: "1px solid var(--line)", background: "var(--paper)", color: "var(--ink)", outline: "none", width: "100%", boxSizing: "border-box" }; const bkBtn = { fontFamily: "var(--font-body)", fontSize: 14, fontWeight: 600, color: "#fff", background: "var(--btn-ink)", border: "none", borderRadius: 8, padding: "10px 18px", cursor: "pointer" }; const bkGhost = { fontFamily: "var(--font-body)", fontSize: 13, fontWeight: 600, color: "var(--ink-soft)", background: "none", border: "1px solid var(--line)", borderRadius: 8, padding: "9px 15px", cursor: "pointer" }; const bkIsSim = (a) => (bkVenueOf ? bkVenueOf(a) : a) === "PAPER" || a === "paper_main" || a === "hyperliquid_testnet"; function scFmtNav(v) { return Number(v).toFixed(2); } function scAgo(iso) { return BK.ago(iso); } const BK_PERIODS = ["ALL", "1D", "1W", "1M", "3M", "6M", "1Y"]; function bkPeriodStart(period, latestMs) { if (!latestMs || period === "ALL") return null; if (period === "1D") return latestMs - 864e5; if (period === "1W") return latestMs - 7 * 864e5; const d = new Date(latestMs); const monthsBack = period === "1M" ? 0 : period === "3M" ? 2 : period === "6M" ? 5 : 11; return Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - monthsBack, 1); } function bkBasketPeriodData(b, period) { const navAll = (b.nav_series || []).slice().sort((a, z) => String(a.ts_date).localeCompare(String(z.ts_date))); const members = b.members || []; const totalWeight = members.reduce((sum, m) => sum + Number(m.alloc_pct || 0), 0) || 1; const weightedTrades = []; members.forEach((m) => { const weight = Number(m.alloc_pct || 0) / totalWeight; BK.bySource(BK.tradesFor(m.strategy_id), "live").forEach((t) => { if (t.open || !t.exit) return; weightedTrades.push({ ...t, strategy_id: m.strategy_id, rawPnl: Number(t.pnl || 0), weight, contribution: Number(t.pnl || 0) * weight, pnl: Number(t.pnl || 0) * weight }); }); }); weightedTrades.sort((a, z) => BK.parseT(a.exit) - BK.parseT(z.exit)); const latestNavMs = navAll.length ? Date.parse(navAll[navAll.length - 1].ts_date + "T00:00:00Z") : 0; const latestTradeMs = weightedTrades.length ? BK.parseT(weightedTrades[weightedTrades.length - 1].exit) : 0; const startMs = bkPeriodStart(period, Math.max(latestNavMs || 0, latestTradeMs || 0)); const trades = startMs ? weightedTrades.filter((t) => BK.parseT(t.exit) >= startMs) : weightedTrades; let nav = navAll; if (startMs && navAll.length) { let first = navAll.findIndex((p) => Date.parse(p.ts_date + "T00:00:00Z") >= startMs); if (first < 0) first = navAll.length - 1; nav = navAll.slice(Math.max(0, first - 1)); } const stats = BK.computeStats(trades); const navReturn = nav.length > 1 ? (Number(nav[nav.length - 1].nav) / Number(nav[0].nav) - 1) * 100 : null; const byDay = {}; trades.forEach((t) => { const day = String(t.exit).slice(0, 10); (byDay[day] = byDay[day] || []).push(t); }); const hoverCards = nav.map((p) => { const dayTrades = byDay[p.ts_date] || []; const contribution = dayTrades.reduce((sum, t) => sum + t.contribution, 0); const detail = dayTrades.length === 1 ? `${dayTrades[0].strategy_id} · ${dayTrades[0].type}` : dayTrades.length ? `${dayTrades.length} closed trades` : "NAV snapshot"; const sub = dayTrades.length ? dayTrades.slice(0, 3).map((t) => `${t.strategy_id} ${(t.contribution >= 0 ? "+" : "") + t.contribution.toFixed(2)}%`).join(" · ") + (dayTrades.length > 3 ? ` · +${dayTrades.length - 3} more` : "") : "no trade closed this day"; return { title: p.ts_date, value: Number(p.nav).toFixed(2), detail, pnl: dayTrades.length ? contribution : null, pnlLabel: dayTrades.length ? (contribution >= 0 ? "+" : "") + contribution.toFixed(2) + "%" : "", sub }; }); const perStrategy = {}; members.forEach((m) => { const own = trades.filter((t) => t.strategy_id === m.strategy_id); perStrategy[m.strategy_id] = { contribution_pct: (own.reduce((factor, t) => factor * (1 + t.contribution / 100), 1) - 1) * 100, trades: own.length, }; }); return { nav, trades, stats, navReturn, hoverCards, perStrategy, startMs }; } function BasketPeriodPanel({ period, onPeriodChange }) { return (
Period
{BK_PERIODS.map((id) => )}
); } function BasketExposurePanel({ exposure, cap, periodReturn }) { return (

Current basket exposure

Allocation, gross exposure and trailing performance for the selected basket.

{(exposure || []).map((x, i) => { const allocPct = x.alloc * 100; const allocTone = allocPct > 100.01 ? "var(--red)" : allocPct >= 80 ? "var(--gold)" : "var(--green)"; const grossPct = cap > 0 ? x.gross / cap * 100 : 0; const grossTone = cap > 0 && x.gross > cap ? "var(--red)" : "#3E6C8E"; const displayReturn = periodReturn === undefined ? x.ret3m : periodReturn == null ? null : Number(periodReturn); return (
{x.b.name} {displayReturn == null ? "—" : (displayReturn >= 0 ? "+" : "") + displayReturn.toFixed(1) + "%"}
{x.n} strategies{x.armed ? " · " + x.armed + " armed" : ""} {x.funding.length ? x.funding.map((f) => f.name + " " + f.pct.toFixed(0) + "%").join(" · ") : "unfunded"}
Alloc {allocPct.toFixed(1)}% Gross {x.gross.toFixed(2)}×
); })} {(!exposure || exposure.length === 0) &&
No baskets yet.
}
); } function AllBasketsPanel({ baskets, currentId, onSelect, period }) { return (

All baskets

NameReturnWin rateMDD
{(baskets || []).map((b) => { const pd = bkBasketPeriodData(b, period); const server = period === "3M" && b.latest ? (b.latest.metrics || null) : null; const ret = server && server.total_return_pct != null ? Number(server.total_return_pct) : pd.navReturn == null ? null : Number(pd.navReturn); const wr = server && server.win_rate != null ? Number(server.win_rate) : pd.stats && Number.isFinite(Number(pd.stats.winRate)) ? Number(pd.stats.winRate) : null; const dd = server && server.max_drawdown_pct != null ? Number(server.max_drawdown_pct) : pd.stats && Number.isFinite(Number(pd.stats.mdd)) ? Number(pd.stats.mdd) : null; const selected = String(b.id) === String(currentId); return ( ); })}
); } function BasketStudio({ vault, mode, refresh, openStrategy }) { const deps = vault.deploymentsAll || vault.deployments || []; const account = vault.account || {}; const cap = Number(account.cap || 0); const [envs, setEnvs] = React.useState([]); React.useEffect(() => { BK.api3("list_environments").then((r) => { if (r.status === 200 && r.body.ok) setEnvs(r.body.environments || []); }).catch(() => { }); }, []); const fundingOf = (basketName) => { const out = []; envs.forEach((e) => (e.baskets || []).forEach((b) => { if (b.enabled !== false && String(b.basket_ref) === String(basketName)) out.push({ name: e.display_name || e.slug, pct: Number(b.alloc_pct) }); })); return out; }; // Per-basket exposure — driven by the BASKET LIST (so a brand-new basket like DCX Signals // appears the moment it exists), joined to its execution rows if any. The return column is // the server-computed trailing-3M metric — the SAME number as the basket card, one source // of truth (the old card compounded full-history deployment rows, which never matched). const builder = (vault.builderBaskets || []).filter((b) => b.status !== "archived"); const depBySid = {}; deps.forEach((d) => { (depBySid[d.strategy_id] = depBySid[d.strategy_id] || []).push(d); }); const exposure = builder.map((b) => { const mem = b.members || []; const rows = mem.flatMap((m) => depBySid[m.strategy_id] || []); const armed = rows.filter((d) => d.enabled === true || d.mode === "live"); const alloc = armed.reduce((s, d) => s + Number(d.alloc_pct || 0), 0); const gross = armed.reduce((s, d) => s + Number(d.alloc_pct || 0) * Number(d.max_leverage || 0), 0); const ret3m = b.latest && b.latest.metrics ? Number(b.latest.metrics.total_return_pct) : null; const nav = (b.nav_series || []).map((p) => Number(p.nav)); return { b, n: mem.length, armed: armed.length, alloc, gross, ret3m, nav, funding: fundingOf(b.name) }; }); return (
); } function ShowcaseStudio({ openStrategy, deps, vault, refreshVault, exposure, cap }) { const [data, setData] = React.useState(null); // { baskets, registry } const [err, setErr] = React.useState(null); const [loading, setLoading] = React.useState(true); function load() { setLoading(true); BK.api2("list_baskets").then((r) => { setLoading(false); if (r.status === 200 && r.body.ok) { setData(r.body); setErr(null); } else setErr("Error " + r.status + ": " + (r.body.error || "unknown")); }).catch((e) => { setLoading(false); setErr("Network error: " + e.message); }); } React.useEffect(load, []); const baskets = (data && data.baskets) || []; const registry = ((data && data.registry) || []).filter((r) => /^id\d+$/.test(r.id || "")); // ONE basket on screen at a time, exchange-style: like a pair selector defaulting to // BTC-USDT, we default to Crypto Momentum (or the first basket) and clicking the name // opens the full list — each with its return and win rate — to swap the chart. const [selId, setSelId] = React.useState(null); const [period, setPeriod] = React.useState("3M"); const current = baskets.find((b) => b.id === selId) || baskets.find((b) => /crypto momentum/i.test(b.name || "")) || baskets[0] || null; const [, setTradeRevision] = React.useState(0); React.useEffect(() => { if (!baskets.length || !BK.ensureTrades) return; const ids = Array.from(new Set(baskets.flatMap((b) => (b.members || []).map((m) => m.strategy_id)))); if (!ids.length) return; let alive = true; BK.ensureTrades(ids).then(() => { if (alive) setTradeRevision((n) => n + 1); }).catch(() => { }); return () => { alive = false; }; }, [data]); const periodData = current ? bkBasketPeriodData(current, period) : null; const selectedPeriodReturn = period === "3M" && current && current.latest && current.latest.metrics ? Number(current.latest.metrics.total_return_pct || 0) : periodData && periodData.navReturn != null ? periodData.navReturn : null; return (
{err &&
{err}
} {loading && !data &&
Loading baskets…
} {current && (
String(x.b.id) === String(current.id))} cap={cap} periodReturn={selectedPeriodReturn} />
)} {data && !baskets.length && ( No baskets yet — compose the first one above. It computes instantly from the last 3 months of the trade record. )}
); } // Save-time rules shared by create + edit. // // One-per-asset used to be a hard block. It is now a CAP (3), because a // committee on one asset is the input to consensus sizing: the votes net into a // single position whose leverage scales with agreement // (5 x |weighted net| x majority/total, weights = health p_edge). // // It is capped rather than unlimited, and warned rather than silent, because // consensus is NOT yet enforced in the execution path — until it is, several // strategies on one asset open INDEPENDENT positions at the venue. Composing a // committee is safe on paper today; arming one to a live account is not. const BK_MAX_PER_ASSET = 3; function bkAssetCounts(rows, regById) { const byAsset = {}; for (const r of rows) { const reg = regById[r.sid] || {}; const asset = String(reg.asset || reg.symbol || r.sid).toUpperCase(); (byAsset[asset] = byAsset[asset] || []).push(r.sid); } return byAsset; } function bkValidateComposition(rows, regById) { const ids = rows.map((r) => r.sid); if (!ids.length) return "Add at least one strategy."; if (new Set(ids).size !== ids.length) return "Each strategy can appear only once."; const byAsset = bkAssetCounts(rows, regById); for (const asset of Object.keys(byAsset)) { const n = byAsset[asset].length; if (n > BK_MAX_PER_ASSET) { return n + " strategies on " + asset.split("/")[0] + " (" + byAsset[asset].join(", ") + ") — a consensus committee is capped at " + BK_MAX_PER_ASSET + " per asset so one name cannot dominate the book."; } } for (const r of rows) { const a = Number(r.alloc); if (!(a > 0 && a <= 150)) return "Every strategy needs an allocation between 0 and 150%."; } const total = rows.reduce((s, r) => s + (Number(r.alloc) || 0), 0); if (total > 150.01) return "Allocations total " + total.toFixed(1) + "% — they must not exceed 150%."; return null; } // Non-blocking: surfaced next to the save button so a committee is a deliberate // choice, not an accident. function bkCompositionWarning(rows, regById) { const byAsset = bkAssetCounts(rows, regById); const multi = Object.keys(byAsset).filter((a) => byAsset[a].length > 1); if (!multi.length) return null; // CORRECTED 2026-08-17: this used to quote 10x/5x/~3x from a scheme two // revisions old, and to warn that "consensus is not yet wired into execution". // It is wired — bridge.py._process_symbol_group nets every multi-deployment // symbol group, and broker_accounts.detail.consensus_enabled is true on all // six accounts — so that warning was telling operators to de-risk for a reason // that no longer held. Numbers below are the v2 tiers in consensus.py. // // 2026-08-18: dropped a follow-up line that read "only members actually deployed // on the venue vote — basket membership alone does not". That described a // symptom, not the rule. MEMBERSHIP IS VOTING: resolve.py compiles // (environment subscription x basket membership) into live_deployments and // resolver-sync re-applies it every minute, so an ARMED basket deploys all of // its members and every one of them votes. Verified against the live book — // every subscribed basket has 100% of its members deployed (Crypto Momentum // 7/7, US Core 7/7, BTC Test 21/21); the unsubscribed ones simply trade // nothing. The real distinction is armed vs not armed, which is what this now // says. return "Consensus committee on " + multi.map((a) => a.split("/")[0]).join(", ") + ". Members on the same symbol net into ONE sized position, weighted by each " + "strategy's health: 3+ all agreeing runs 1.5x your configured leverage, 1-2 " + "agreeing runs it as configured, an actively opposed committee is minimized to " + "0.375x, and equal weight both ways is flat. Every member votes once this basket " + "is armed — subscribe it to an account under Environments and the resolver " + "deploys the whole basket within a minute; until then nothing in it trades."; } // Live consensus readout for one asset's committee, so the mechanism is visible // where the decision is made rather than buried in a doc. // // This MUST mirror alpha-agent/execution/consensus.py (decide) plus bridge.py's // _process_symbol_group clamp, because operators size baskets off this number. // v2 CONVICTION TIERS (desk rule 2026-08-11): // // actively opposed (long AND short present) -> 0.375 x base MINIMIZED // unanimous with 3+ ACTIVE voters -> 1.5 x base CONVICTION // no dissent, 1-2 voters (rest flat) -> 1.0 x base UI LEVERAGE // // Direction still comes from the weighted net, so a strong minority can out-vote // a weak majority and equal weights deadlock to flat. Weight = health p_edge, 0 // at or below the 0.25 retire bar (SILENCED — a silenced member does not count // toward the 3-voter unanimity threshold), 1 when unscored. // // FIXED 2026-08-17: this preview was still on the pre-v2 exponential curve // (1.5 x 2^(|net|-3), then clamped to min(base, raw)) that v2 replaced on // 08-11, so it UNDERSTATED what the venue actually does in every directional // case — measured against execution/consensus.py on live committees: TSLA on // hyperliquid_main showed "SHORT 1.08x" while the bridge sized SHORT 3x, and // HYPE on hyperliquid-btc-test showed "LONG 1.81x" against an actual LONG 3x. // The old min(base, raw) clamp also made it structurally impossible to display // the unanimity boost at all: a unanimous trio on a 3x base reads 3x here but // is sized 4.5x at the venue. The accompanying "only sizes DOWN" claim was // false under v2 — it can now size UP, to a hard ceiling of 1.5 x base. const BK_CONSENSUS_BASE = 5; const BK_MIN_P_EDGE = 0.25; // Mirrors consensus.py's module constants — keep in lockstep. const BK_DISSENT_MULT = 0.375; const BK_UI_MULT = 1.0; const BK_UNANIMOUS_MULT = 1.5; const BK_UNANIMOUS_MIN_VOTERS = 3; // The STORED p_edge is min(p_edge_wr, p_edge_mu), and the bootstrap-mean leg // returns 0.5 when it has too few trades to have an opinion — so an abstention // dominates the min and every strategy under 8 live trades reads exactly 0.500 // however strong its backtest prior. Recombine from the legs, which are stored. function bkPEdge(h) { const sig = (h && h.signals) || {}; const wr = sig.p_edge_wr, mu = sig.p_edge_mu; const n = (h && h.n_live_trades) != null ? h.n_live_trades : sig.n; const muOk = n != null && Number(n) >= 8 && mu != null; if (wr == null) return muOk ? Number(mu) : null; return muOk ? Math.min(Number(wr), Number(mu)) : Number(wr); } function bkWeight(pEdge) { if (pEdge === null || pEdge === undefined || isNaN(pEdge)) return 1; // unscored votes normally return Number(pEdge) <= BK_MIN_P_EDGE ? 0 : Number(pEdge); // retire-band is silenced } function bkConsensus(members, base) { base = Number(base) || BK_CONSENSUS_BASE; const live = members.filter((m) => m.side === "long" || m.side === "short"); const on = live.filter((m) => m.weight > 0); const longs = on.filter((m) => m.side === "long").length; const shorts = on.filter((m) => m.side === "short").length; const wl = on.filter((m) => m.side === "long").reduce((s, m) => s + m.weight, 0); const ws = on.filter((m) => m.side === "short").reduce((s, m) => s + m.weight, 0); const total = wl + ws, net = wl - ws; const silenced = live.filter((m) => m.weight <= 0).length; if (total <= 0) return { side: "flat", lev: 0, why: silenced ? "all silenced by health" : "nobody signalling", silenced }; if (Math.abs(net) < 1e-9) return { side: "flat", lev: 0, why: "deadlocked — no trade", silenced }; const agree = Math.max(wl, ws) / total; let mult, tier; if (longs && shorts) { mult = BK_DISSENT_MULT; tier = "opposed " + longs + "v" + shorts + " — minimized"; } else if (on.length >= BK_UNANIMOUS_MIN_VOTERS) { mult = BK_UNANIMOUS_MULT; tier = on.length + "/" + on.length + " unanimous — conviction"; } else { mult = BK_UI_MULT; tier = on.length + " signalling, rest flat"; } // bridge.py sizes off min(base x 1.5, decided) — the unanimity boost and no // more. Reproduce that ceiling here so the badge is the number that actually // reaches the venue. return { side: net > 0 ? "long" : "short", // 4dp, matching consensus.py's `round(base * mult, 4)` — rounding this to 2dp // instead reported 1.13x for a dissenting committee on a 3x base where the // venue is sent exactly 1.125x. Small, but this badge claims to BE the venue // number, so it should not disagree with it at any decimal place. lev: Math.round(Math.min(base * 1.5, base * mult) * 10000) / 10000, why: tier + " · " + Math.round(agree * 100) + "% agreement", silenced, }; } // Current side per strategy, from the OPEN-TRADE ledger rather than vault.signals. // FIXED 2026-08-17: list_vault builds `signals` only for strategies deployed on the // hyperliquid_vault account (`sids` = live_deployments filtered to ALLOWED_ACCOUNT), so // every basket whose members live elsewhere read back as an empty map and every // committee rendered "FLAT - nobody signalling" even with open positions. MegaCap // Consensus (basket 11) sits on hyperliquid_main: MU had id323 LONG since 08-12 and // id245 LONG since 08-14, both invisible here; 8 of its 23 members were open and every // row showed FLAT. Only basket 3 (Crypto Momentum) is on hyperliquid_vault, so every // other basket was affected. `openTrades` in the same list_vault payload is // account-agnostic, so read that first and keep `signals` only as a fallback. function bkSideMap(vault) { const out = Object.assign({}, (vault && vault.signals) || {}); ((vault && vault.openTrades) || []).forEach((t) => { const side = String(t.type || "").toLowerCase(); if (side === "long" || side === "short") out[t.strategy_id] = side; }); return out; } function BkConsensusPreview({ rows, vault, base }) { if (!rows || rows.length < 2) return null; // a single strategy is not a committee const sigs = bkSideMap(vault); const health = {}; ((vault && vault.health) || []).forEach((h) => { health[h.strategy_id] = h; }); const members = rows.map((r) => { const h = health[r.sid] || {}; return { sid: r.sid, side: String(sigs[r.sid] || "flat"), weight: bkWeight(bkPEdge(h)) }; }); const c = bkConsensus(members, base); const tone = c.side === "flat" ? "var(--muted)" : c.side === "long" ? "var(--green)" : "var(--red)"; return ( `${m.sid}: ${m.side}${m.weight <= 0 ? " (SILENCED — health at/below retire bar)" : " w=" + m.weight.toFixed(2)}`).join("\n") + `\n\nConviction tiers on the ${Number(base) || BK_CONSENSUS_BASE}x you configured:` + `\n 3+ voters all agreeing -> 1.5x it (${(( Number(base) || BK_CONSENSUS_BASE) * 1.5)}x) — conviction boost` + `\n 1-2 agreeing, rest flat -> exactly what you configured` + `\n actively opposed (any long vs short) -> 0.375x it — minimized` + `\n equal weight both ways -> flat, no trade` + `\nAbstention is not dissent, and a health-silenced member does not count toward the 3.` + `\nConsensus can size UP to 1.5x configured on unanimity — every account risk cap, the` + ` per-order notional cap and the daily-loss check still bind on top.`} style={{ fontFamily: "var(--font-mono)", fontSize: 10.5, border: "1px solid var(--line)", borderRadius: 100, padding: "2px 9px", color: tone, cursor: "help", whiteSpace: "nowrap" }}> now: {c.side === "flat" ? "FLAT" : c.side.toUpperCase() + " " + c.lev + "x"} · {c.why} {c.silenced ? " · " + c.silenced + " silenced" : ""} ); } // Amber, not red: a committee is a legitimate composition, but it changes how the // basket behaves at the venue and that must be visible before saving. function BkCommitteeNote({ rows, regById }) { const w = bkCompositionWarning(rows || [], regById || {}); if (!w) return null; return (
Consensus committee — {w}
); } // ---- Builder: collapsed by default — one header line until you need it ------- function ShowcaseBuilder({ registry, onCreated }) { const first = registry.length ? registry[0].id : ""; const [open, setOpen] = React.useState(false); const [name, setName] = React.useState(""); const [note, setNote] = React.useState(""); const [rows, setRows] = React.useState([{ sid: first, alloc: "" }]); const [msg, setMsg] = React.useState(null); const [busy, setBusy] = React.useState(false); const [cls, setCls] = React.useState("all"); const clsCounts = bkClassCounts(registry); const regView = cls === "all" ? registry : registry.filter((r) => bkAssetClass(r.asset) === cls); const regById = {}; registry.forEach((r) => { regById[r.id] = r; }); const optsFor = (sid) => (regView.some((s) => s.id === sid) ? regView : [registry.find((s) => s.id === sid), ...regView].filter(Boolean)); React.useEffect(() => { if (registry.length && rows.length === 1 && !rows[0].sid) setRows([{ sid: registry[0].id, alloc: "" }]); }, [registry]); function setRow(i, key, val) { setRows((cur) => cur.map((r, ix) => (ix === i ? { ...r, [key]: val } : r))); } function addRow() { const used = new Set(rows.map((r) => r.sid)); const pool = regView.length ? regView : registry; const next = pool.find((r) => !used.has(r.id)) || registry.find((r) => !used.has(r.id)); setRows((cur) => [...cur, { sid: next ? next.id : first, alloc: "" }]); } function delRow(i) { setRows((cur) => cur.filter((_, ix) => ix !== i)); } const total = rows.reduce((s, r) => s + (Number(r.alloc) || 0), 0); function create() { const nm = name.trim(); if (nm.length < 2) { setMsg({ t: "Give the basket a name (2+ chars).", err: true }); return; } const bad = bkValidateComposition(rows, regById); if (bad) { setMsg({ t: bad, err: true }); return; } setBusy(true); setMsg({ t: "Creating basket + computing 3-month index…", err: false }); BK.api2("create_basket", { name: nm, note: note.trim() || null, members: rows.map((r) => ({ strategy_id: r.sid, alloc_pct: Number(r.alloc) / 100 })), }).then((r) => { setBusy(false); if (r.status === 200 && r.body.ok) { const rf = r.body.refresh || {}; setMsg({ t: "✓ '" + nm + "' created — " + (rf.trades_in_window ?? 0) + " trades in the 3M window, index backfilled.", err: false }); setName(""); setNote(""); setRows([{ sid: first, alloc: "" }]); onCreated(); } else setMsg({ t: "Error " + r.status + ": " + (r.body.error || "unknown"), err: true }); }).catch((e) => { setBusy(false); setMsg({ t: "Network error: " + e.message, err: true }); }); } const lbl = { display: "block", fontSize: 11.5, fontWeight: 600, letterSpacing: ".04em", textTransform: "uppercase", color: "var(--muted)", marginBottom: 6 }; return ( {open && (
setName(e.target.value)} style={bkInput} />
setNote(e.target.value)} style={bkInput} />
Filter strategies by asset class
{rows.map((r, i) => (
setRow(i, "sid", value)} ariaLabel="Search strategy" options={optsFor(r.sid).map((s) => ({ value: s.id, label: `${s.id} · ${s.name || s.symbol} (${s.symbol} ${s.timeframe || ""})`, keywords: `${s.asset || ""} ${s.direction || ""} ${s.status || ""}` }))} /> setRow(i, "alloc", e.target.value)} style={{ ...bkInput, textAlign: "right" }} />
))}
150.01 ? "var(--red)" : "var(--green)" }}>Σ {total.toFixed(1)}% / 150%
)}
); } // ---- One basket card --------------------------------------------------------- function ShowcaseBasketCard({ b, registry, period = "3M", onPeriodChange, periodData, onChanged, openStrategy, deps, vault, refreshVault }) { const [msg, setMsg] = React.useState(null); const [busy, setBusy] = React.useState(false); const [confirmDel, setConfirmDel] = React.useState(false); const [showMembers, setShowMembers] = React.useState(false); // members collapse by default const [navOpen, setNavOpen] = React.useState(false); // NAV calculation expander const [renaming, setRenaming] = React.useState(false); const [nameInput, setNameInput] = React.useState(b.name); React.useEffect(() => { if (!showMembers) return; const closeOnEscape = (e) => { if (e.key === "Escape" && !busy) { setShowMembers(false); setEditing(false); setMsg(null); } }; document.addEventListener("keydown", closeOnEscape); return () => document.removeEventListener("keydown", closeOnEscape); }, [showMembers, busy]); function startRename() { setNameInput(b.name); setMsg(null); setRenaming(true); } function saveRename() { const name = nameInput.trim(); if (!name) { setMsg({ t: "Name can't be empty.", err: true }); return; } if (name === b.name) { setRenaming(false); return; } setBusy(true); setMsg({ t: "Renaming…", err: false }); BK.api2("update_basket", { id: b.id, name }).then((r) => { setBusy(false); if (r.status === 200 && r.body.ok) { setRenaming(false); setMsg(null); onChanged(); } else setMsg({ t: "Error " + r.status + ": " + (r.body.error || "unknown"), err: true }); }).catch((e) => { setBusy(false); setMsg({ t: "Network error: " + e.message, err: true }); }); } // Composition editor — THE one place to pick a basket's strategies and set each // ASSET's allocation + leverage. Alloc % and leverage are set ONCE per asset, not // per strategy: the committee funds one shared position (bridge.py sums member // alloc_pct to size it, and takes the tightest member's leverage cap regardless of // which strategy is actually signalling) — so per-strategy inputs were both // confusing and didn't reflect what execution actually does with them. The typed // Alloc % is the asset's TOTAL share of the basket; on save it's split evenly // across that asset's members so the group sums to exactly what you typed. const [editing, setEditing] = React.useState(false); const [erows, setErows] = React.useState([]); // [{ sid }] — membership only const [easset, setEasset] = React.useState({}); // { ASSET: { alloc, lev } } const [promoteSid, setPromoteSid] = React.useState(""); // explicit pick for "+ Add asset" const regChoices = (registry || []).filter((r) => /^id\d+$/.test(r.id || "")); const regById = {}; regChoices.forEach((r) => { regById[r.id] = r; }); const assetOf = (sid) => { const g = regById[sid] || {}; return String(g.asset || g.symbol || sid).toUpperCase(); }; const parseLev = (s) => { const m = String(s || "").match(/[\d.]+/); const n = m ? Number(m[0]) : 0; return n > 0 ? n : 0; }; const defLev = (sid) => { const n = parseLev((regById[sid] || {}).leverage); return n > 0 ? String(n) : "1"; }; function setEassetField(asset, k, v) { setEasset((c) => ({ ...c, [asset]: { ...(c[asset] || {}), [k]: v } })); } function startEdit() { const rows = (b.members || []).map((mm) => ({ sid: mm.strategy_id })); const byAsset = {}; (b.members || []).forEach((mm) => { const asset = assetOf(mm.strategy_id); const g = (byAsset[asset] = byAsset[asset] || { allocSum: 0, lev: mm.max_leverage != null ? String(mm.max_leverage) : defLev(mm.strategy_id) }); g.allocSum += Number(mm.alloc_pct) * 100; }); const ea = {}; Object.keys(byAsset).forEach((asset) => { ea[asset] = { alloc: byAsset[asset].allocSum ? String(Math.round(byAsset[asset].allocSum * 100) / 100) : "", lev: byAsset[asset].lev }; }); setErows(rows); setEasset(ea); setPromoteSid(""); setMsg(null); setEditing(true); setShowMembers(true); } function setErow(i, k, v) { setErows((c) => c.map((r, ix) => (ix === i ? { ...r, [k]: v } : r))); } // "+ Add asset" — explicit pick (Deepak's dropdown UX) from strategies on an asset // NOT YET in the basket. Adding another member to an asset already present is // exclusively the per-asset "+ add strategy on {ASSET}" button below, so this can // never land on an existing committee by accident. function addErow() { const next = regChoices.find((r) => r.id === promoteSid); if (!next) { setMsg({ t: "Choose an asset to add.", err: true }); return; } const asset = String(next.asset || next.symbol || next.id).toUpperCase(); if (erows.some((r) => assetOf(r.sid) === asset)) { setMsg({ t: asset.split("/")[0] + " is already in this basket — use \"+ add strategy on " + asset.split("/")[0] + "\" instead.", err: true }); return; } setErows((c) => [...c, { sid: next.id }]); setEasset((c) => (c[asset] ? c : { ...c, [asset]: { alloc: "", lev: defLev(next.id) } })); setPromoteSid(""); setMsg(null); } // Add another member to ONE asset's committee. Scoped to the asset so the // button can never silently pull in an unrelated name, and refuses past the // cap rather than letting the save fail later. function addErowFor(asset) { const used = new Set(erows.map((r) => r.sid)); const onAsset = erows.filter((r) => assetOf(r.sid) === asset); if (onAsset.length >= BK_MAX_PER_ASSET) { setMsg({ t: "Committee on " + asset.split("/")[0] + " is full (" + BK_MAX_PER_ASSET + ").", err: true }); return; } const next = regChoices.find((r) => !used.has(r.id) && String(r.asset || r.symbol || "").toUpperCase() === asset); if (!next) { setMsg({ t: "No other registered strategy on " + asset.split("/")[0] + " is available.", err: true }); return; } setErows((c) => [...c, { sid: next.id }]); setMsg(null); } function delErow(i) { setErows((c) => { const removed = c[i]; const next = c.filter((_, ix) => ix !== i); if (removed) { const asset = assetOf(removed.sid); if (!next.some((r) => assetOf(r.sid) === asset)) { setEasset((ce) => { const cp = { ...ce }; delete cp[asset]; return cp; }); } } return next; }); } const eTot = Object.values(easset).reduce((s, a) => s + (Number(a.alloc) || 0), 0); // Choices for "+ Add asset" — one entry per strategy, but only on assets NOT // already in this basket (adding to an existing asset is the per-asset button). const promoteChoices = (() => { const usedAssets = new Set(erows.map((r) => assetOf(r.sid))); return regChoices.filter((r) => !usedAssets.has(String(r.asset || r.symbol || r.id).toUpperCase())); })(); function bkAssetGroups() { const byAsset = {}; erows.forEach((r) => { const a = assetOf(r.sid); (byAsset[a] = byAsset[a] || []).push(r.sid); }); return byAsset; } function validateEasset() { const byAsset = bkAssetGroups(); if (!Object.keys(byAsset).length) return "Add at least one strategy."; for (const asset of Object.keys(byAsset)) { if (byAsset[asset].length > BK_MAX_PER_ASSET) { return byAsset[asset].length + " strategies on " + asset.split("/")[0] + " — a consensus committee is capped at " + BK_MAX_PER_ASSET + " per asset."; } // A SINGLE asset may never exceed 100%. Above that the order is guaranteed to be // rejected by the execution risk gate, every pass, forever: sizing computes // notional = equity x (summed alloc x consensus leverage), while // RiskManager.check_order caps it at equity x resolve_leverage_cap(), whose // tightest term is that same consensus leverage. The leverage cancels, so the // check reduces to "summed alloc <= 1" regardless of what leverage is set. // Verified 2026-08-09 against the real sizing+risk code: 3 x 50% @ 1x -> 1.50x // equity notional vs a 1.0x cap (BLOCKED); the same 150% @ 3x -> 4.50x vs 3.0x // (BLOCKED). The 150% budget below is basket-WIDE and only reachable by // spreading it across several assets — the risk gate is per symbol. const a = Number((easset[asset] || {}).alloc); if (!(a > 0 && a <= 100)) { return asset.split("/")[0] + " needs an allocation between 0 and 100%. " + "One asset above 100% can never trade — live execution's risk gate rejects any " + "order whose notional exceeds equity x its own leverage, and allocation multiplies " + "that notional. Spread the extra across other assets instead."; } const lev = Number((easset[asset] || {}).lev); if (!(lev > 0)) return asset.split("/")[0] + ": leverage must be greater than 0."; } const total = Object.keys(byAsset).reduce((s, a) => s + (Number((easset[a] || {}).alloc) || 0), 0); if (total > 150.01) return "Allocations total " + total.toFixed(1) + "% — they must not exceed 150%."; return null; } function saveComposition() { const bad = validateEasset(); if (bad) { setMsg({ t: bad, err: true }); return; } const byAsset = bkAssetGroups(); const members = []; Object.keys(byAsset).forEach((asset) => { const sids = byAsset[asset]; const av = easset[asset] || {}; const perMember = (Number(av.alloc) || 0) / sids.length / 100; // split the asset's total evenly const lev = Number(av.lev); sids.forEach((sid) => members.push({ strategy_id: sid, alloc_pct: perMember, max_leverage: lev })); }); setBusy(true); setMsg({ t: "Saving composition…", err: false }); BK.api2("update_basket", { id: b.id, members }).then((r) => { setBusy(false); if (r.status === 200 && r.body.ok) { setEditing(false); setMsg(null); onChanged(); } else setMsg({ t: "Error " + r.status + ": " + (r.body.error || "unknown"), err: true }); }).catch((e) => { setBusy(false); setMsg({ t: "Network error: " + e.message, err: true }); }); } const m = (b.latest && b.latest.metrics) || null; const nav = b.nav_series || []; const lastNav = nav.length ? Number(nav[nav.length - 1].nav) : null; const shownNav = periodData && Array.isArray(periodData.nav) ? periodData.nav : nav; const shownStats = periodData && periodData.stats ? periodData.stats : null; const shownLastNav = shownNav.length ? Number(shownNav[shownNav.length - 1].nav) : lastNav; const shownFirstNav = shownNav.length ? Number(shownNav[0].nav) : null; const shownNavTone = shownLastNav == null || shownFirstNav == null ? "var(--muted)" : shownLastNav >= shownFirstNav ? "var(--green)" : "var(--red)"; const serverMetrics = period === "3M" ? m : null; const shownPerStrategy = serverMetrics && serverMetrics.per_strategy ? serverMetrics.per_strategy : periodData && periodData.perStrategy ? periodData.perStrategy : {}; const shownReturn = serverMetrics ? Number(serverMetrics.total_return_pct || 0) : (periodData && periodData.navReturn != null ? periodData.navReturn : null); const lastContributionIndex = periodData && periodData.hoverCards ? periodData.hoverCards.reduce((last, card, i) => card && card.pnl != null ? i : last, Math.max(0, shownNav.length - 1)) : Math.max(0, shownNav.length - 1); const metricValue = (periodValue, fallback) => periodValue != null && isFinite(Number(periodValue)) ? Number(periodValue) : fallback; const sharpeView = serverMetrics ? Number(serverMetrics.sharpe_ann) : metricValue(shownStats && shownStats.sharpe, null); const winRateView = serverMetrics ? Number(serverMetrics.win_rate) : metricValue(shownStats && shownStats.winRate, null); const drawdownView = serverMetrics ? Number(serverMetrics.max_drawdown_pct) : metricValue(shownStats && shownStats.mdd, null); const metricText = (v, dp = 1, suffix = "") => v == null || !isFinite(Number(v)) ? "—" : Number(v).toFixed(dp) + suffix; const regOf = (sid) => registry.find((r) => r.id === sid) || {}; // Deployment rows per member — arm/disarm renders on REAL venues only (paper is a no-op). const bkDepMap = bkDepsBySid ? bkDepsBySid(deps || []) : {}; const depOfSid = (sid) => bkDepMap[sid] || null; const liveVenues = Array.from(new Set((b.members || []) .map((mm) => depOfSid(mm.strategy_id)) .filter((d) => d && (d.enabled === true || d.mode === "live") && !bkIsSim(d.account_id)) .map((d) => (bkVenueOf ? bkVenueOf(d.account_id) : d.account_id)))); const liveReal = liveVenues.length > 0; const canDelete = !liveReal; // deleting a basket trading real money would orphan live positions function act(action, extra, label) { setBusy(true); setMsg({ t: label + "…", err: false }); BK.api2(action, extra).then((r) => { setBusy(false); if (r.status === 200 && r.body.ok) { setMsg(null); onChanged(); } else setMsg({ t: "Error " + r.status + ": " + (r.body.error || "unknown"), err: true }); }).catch((e) => { setBusy(false); setMsg({ t: "Network error: " + e.message, err: true }); }); } const st = { fontSize: 10.5, fontWeight: 600, letterSpacing: ".05em", textTransform: "uppercase", color: "var(--muted)" }; const btn = { fontFamily: "var(--font-body)", fontSize: 12.5, fontWeight: 600, color: "var(--ink-soft)", background: "none", border: "1px solid var(--line)", borderRadius: 7, padding: "6px 13px", cursor: "pointer", opacity: busy ? 0.5 : 1 }; const topStat = (label, value, color) => (
{label}
{value}
); return (
{renaming ? (
setNameInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") saveRename(); if (e.key === "Escape") setRenaming(false); }} style={{ ...bkInput, width: 220, fontFamily: "var(--font-serif)", fontSize: 16 }} />
) : (

{b.name}

)} {(() => { const bs = liveReal ? { t: "LIVE", c: "var(--green)", bd: "var(--green)" } : { t: "PAPER", c: "#3d7ea6", bd: "#3d7ea6" }; const tip = liveReal ? "Executing on " + liveVenues.join(", ") : "Paper index — not live on any real account"; return {bs.t}; })()} {b.show_on_website === false && ( Hidden from site )}
{b.note ? b.note + " · " : ""}created {String(b.created_at).slice(0, 10)} · refreshed {b.last_refreshed_at ? scAgo(b.last_refreshed_at) : "never"} · daily auto-refresh
{/* key stats live HERE, beside the NAV, above the chart */} {(shownReturn != null || serverMetrics || shownStats) && (
{topStat("Return · " + (period === "ALL" ? "all" : period), shownReturn == null ? "—" : BkF.pct(shownReturn), shownReturn == null ? "var(--muted)" : bkTone(shownReturn))} {topStat("Sharpe", sharpeView == null ? "—" : BkF.num(sharpeView))} {topStat("Win rate", metricText(winRateView, 1, "%"))} {topStat("Max DD", metricText(drawdownView, 1, "%"), "var(--red)")} {topStat("Profit factor", serverMetrics ? (serverMetrics.profit_factor == null ? "∞" : BkF.num(Number(serverMetrics.profit_factor))) : shownStats ? (isFinite(shownStats.pf) ? BkF.num(shownStats.pf) : "∞") : "—")} {topStat("Trades · " + (period === "ALL" ? "all" : period), String(serverMetrics ? (serverMetrics.trades || 0) : shownStats ? shownStats.trades : 0))}
)} {!serverMetrics && !shownStats &&
No closed trades in the selected {period === "ALL" ? "all-time" : period} period yet.
}
NAV (base 100)
{/* NAV calculation expander */} {navOpen && (
How this NAV is built. This is a since-inception index — it starts at 100 the moment the basket is created and is never rebased to a rolling window. It is also live-only: source='backtest' trades never form part of the index or the return — only real paper and live fills count, so a basket you create today starts flat at 0% and only moves once its members actually close a trade. For every day since inception, take each member strategy's closed LIVE trades, weight each trade's P&L by that strategy's allocation ({(b.members || []).map((mm) => mm.strategy_id + " " + (Number(mm.alloc_pct) * 100).toFixed(0) + "%").join(" · ")}), compound them within the day, and multiply the running NAV by that day's factor: NAV(d) = NAV(d−1) × ∏(1 + weight × pnl%).
When composition changes: the NAV series is append-only — days already written keep the old composition's trades (a disarmed strategy's history is never overwritten), and from the day of the change forward only the new members' trades count. The 3M metrics above are a live-only subset of this same since-inception series, recomputed each refresh over the trailing window using the current members — so right after a swap the chart and the return figure can legitimately differ until the window rolls over.
Multiple strategies on one asset: this NAV sums each member's own trade ledger independently — it does not net same-asset committees the way live execution's consensus sizing does. See the composition warning when you put 2+ strategies on one asset.
{Object.keys(shownPerStrategy).length > 0 && (
{Object.keys(shownPerStrategy).map((sid) => { const p = shownPerStrategy[sid]; return ( {sid} contributed {BkF.pct(p.contribution_pct, 2)} over {p.trades}t · {period === "ALL" ? "all time" : period} ); })}
)} {b.latest &&
window {String(b.latest.window_start).slice(0, 10)} → {String(b.latest.window_end).slice(0, 10)} · refreshed daily
}
)} {shownNav.length > 1 && (
Basket NAV · {period === "ALL" ? "all history" : period}
Move across the curve to see the strategy trades behind each NAV point.
trade contribution tile
= Number(shownNav[0].nav) ? "#1F7A4D" : "#B23A3A", data: shownNav.map((p) => Number(p.nav)) }]} area accent={shownLastNav >= Number(shownNav[0].nav) ? "#1F7A4D" : "#B23A3A"} baseline={100} height={320} hover hoverCard={periodData && periodData.hoverCards} initialHoverIndex={lastContributionIndex} yFmt={(v) => v.toFixed(1)} xLabels={(i) => { if (i >= shownNav.length - 1) return "Now"; const d = shownNav[i] && shownNav[i].ts_date; if (!d) return ""; return BK.monthLabel(d.slice(0, 7)).split(" ")[0] + " " + Number(d.slice(8, 10)); }} />
)} {/* Members — collapsed by default; expand for weights, per-strategy results, arm/disarm */}
{showMembers && (
{ if (e.target === e.currentTarget && !busy) { setShowMembers(false); setEditing(false); setMsg(null); } }} style={{ position: "fixed", inset: 0, zIndex: 100, display: "flex", alignItems: "center", justifyContent: "center", padding: 24, background: "rgba(3,7,10,.72)", backdropFilter: "blur(5px)" }}>
{b.name} strategies
{editing ? "Edit asset committees, allocation and leverage" : `${(b.members || []).length} strategies · contribution shown for ${period === "ALL" ? "all time" : period}`}
{BK_PERIODS.map((id) => )}
{!editing && (
StrategyWeightLevContribution · {period === "ALL" ? "all" : period}Execution
{(() => { // Group BY ASSET. A 2+ member group is a consensus committee funding ONE // shared position — showing N separate rows, each repeating that asset's // per-member sleeve (e.g. 3 BTC rows all reading "9%"), reads as if 3x the // real notional were committed to that asset. It isn't: bridge.py sums // those sleeves to size ONE position. Collapse the committee into one row // at its TOTAL allocation instead; a single-strategy asset is unchanged. const order = [], byAsset = {}; (b.members || []).forEach((mm) => { const asset = assetOf(mm.strategy_id); if (!byAsset[asset]) { byAsset[asset] = []; order.push(asset); } byAsset[asset].push(mm); }); const rowGrid = { display: "grid", gridTemplateColumns: "minmax(160px,1.4fr) 76px 64px 120px 84px", gap: 12, alignItems: "center", fontSize: 13 }; return order.map((asset) => { const group = byAsset[asset]; if (group.length === 1) { const mm = group[0]; const sid = mm.strategy_id; const r = regOf(sid); const p = shownPerStrategy[sid] || null; const d = depOfSid(sid); return (
{(Number(mm.alloc_pct) * 100).toFixed(0)}% {mm.max_leverage != null ? Number(mm.max_leverage) + "×" : "—"} {p ? {BkF.pct(p.contribution_pct, 2)} · {p.trades}t : } {d ? {bkVenueOf(d.account_id)} {d.enabled === true || d.mode === "live" ? "LIVE" : "PREVIEW"} : }
); } // Committee — one combined row at the group's TOTAL alloc%, plus the // live consensus readout (what decides direction & leverage, per-strategy // allocation never does). Leverage is per-asset now (saveComposition // writes the same value to every member), so show it once; legacy data // where members still disagree shows each distinct value. const totalAlloc = group.reduce((s, mm) => s + Number(mm.alloc_pct || 0), 0); const levs = Array.from(new Set(group.map((mm) => mm.max_leverage).filter((v) => v != null).map(Number))); const levLabel = levs.length === 0 ? "—" : levs.length === 1 ? levs[0] + "×" : levs.map((v) => v + "×").join("/"); let contribSum = null, tradesSum = 0, anyContrib = false; group.forEach((mm) => { const p = shownPerStrategy[mm.strategy_id] || null; if (p) { anyContrib = true; contribSum = (contribSum || 0) + Number(p.contribution_pct); tradesSum += Number(p.trades || 0); } }); return (
{asset.split("/")[0]} {group.length} strategies ({ sid: mm.strategy_id }))} vault={vault} base={levs[0]} /> {(totalAlloc * 100).toFixed(0)}% {levLabel} {anyContrib ? {BkF.pct(contribSum, 2)} · {tradesSum}t : } {(() => { const d = depOfSid(group[0].strategy_id); if (!d) return ; const live = d.enabled === true || d.mode === "live"; return {bkVenueOf(d.account_id)} {live ? "LIVE" : "PREVIEW"}; })()}
{group.map((mm) => { const sid = mm.strategy_id; const r = regOf(sid); return ( ); })}
); }); })()}
)} {editing && (
Basket composition
Set allocation and leverage once per asset, then choose the strategies that form its committee.
Allocated
150.01 ? "var(--red)" : eTot >= 120 ? "var(--gold-ink)" : "var(--green)" }}>{eTot.toFixed(0)}% / 150%
{/* Grouped BY ASSET, because the venue holds one position per asset: every row under a heading is a member of that asset's consensus committee, and the heading shows what the committee currently decides. Flat rows hid that relationship entirely. */}
{(() => { const order = [], seen = {}; erows.forEach((r, i) => { const asset = assetOf(r.sid); if (!seen[asset]) { seen[asset] = []; order.push(asset); } seen[asset].push(i); }); return order.map((asset) => { const idxs = seen[asset]; const full = idxs.length >= BK_MAX_PER_ASSET; const av = easset[asset] || { alloc: "", lev: "" }; return (
{asset.split("/")[0]} {idxs.length} / {BK_MAX_PER_ASSET} strategies erows[i])} vault={vault} base={av.lev} />
{/* ONE alloc % + leverage pair for the whole asset committee */}
setEassetField(asset, "alloc", e.target.value)} style={{ ...bkInput, textAlign: "right" }} /> setEassetField(asset, "lev", e.target.value)} title="Leverage (> 0)" style={{ ...bkInput, textAlign: "right" }} />
Committee members
{idxs.map((i) => { const r = erows[i]; const usedElsewhere = new Set(erows.filter((_, ix) => ix !== i).map((e) => e.sid)); // only offer strategies on THIS asset, so a committee can't be // formed by accident out of unrelated names const opts = regChoices.filter((s) => s.id === r.sid || (!usedElsewhere.has(s.id) && String(s.asset || s.symbol || "").toUpperCase() === asset)); return (
setErow(i, "sid", value)} ariaLabel={`Search ${asset} strategy`} options={opts.map((s) => ({ value: s.id, label: `${s.id} · ${s.symbol || s.name} ${s.timeframe ? "(" + s.timeframe + ")" : ""}`, keywords: `${s.name || ""} ${s.asset || ""} ${s.direction || ""}` }))} />
); })}
); }); })()}
({ value: s.id, label: `${s.id} · ${s.symbol || (s.asset || "").split("/")[0] || s.name} · ${s.name || "Unnamed"}${s.timeframe ? " (" + s.timeframe + ")" : ""}`, keywords: `${s.asset || ""} ${s.direction || ""} ${s.status || ""}` }))} />
)}
)}
{!canDelete ? : confirmDel ? : } {confirmDel && canDelete && }
); } Object.assign(window, { BasketStudio, ShowcaseStudio, ShowcaseBuilder, ShowcaseBasketCard });