)}
{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 (
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}
);
})}
{(() => {
// 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 (
);
}
// 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 (
{/* 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. */}
{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 (