/* global React */
/* Vault Control — Strategies screen: registry table, basket groups, deep dive.
* Arm/Disarm moved to Basket Studio (2026-07-21) — the basket is where execution is managed;
* this screen is the per-strategy record: health, returns, pause/retire. */
const { Card: SgCard } = window.BraveAlphaCapitalDesignSystem_c4b179;
const { LineChart: SgLine, MonthBars: SgBars, Sparkline: SgSpark, MeterBar: SgMeter, ModeBars: SgModeBars } = window;
const { Stat: SgStat, InfoDot: SgInfo, FMT: SgF, toneOf: sgTone, ModeTag: SgMode, SigTag: SgSig, DecayTag: SgDecay, SourceTag: SgSrc, Msg: SgMsg } = window;
const { assetClass: sgAssetClass, ClassChips: SgClassChips, classCounts: sgClassCounts } = window;
const { DeployTag: SgDeployTag, depsBySid: sgDepsBySid, venueOf: sgVenueOf } = window;
const SG = window.VC;
const SG_CLS_LABEL = { crypto: "Crypto", commodities: "Commodities", us_eq: "US Equity" };
function sgRegOf(vault, sid) { return (vault.registry || []).find((r) => r.id === sid) || {}; }
// Search ALL accounts (deploymentsAll), not just the vault-scoped list — else an hl-main row is
// invisible here and the dialog shows dashes. Prefer the row on the requested account when given.
function sgDepOf(vault, sid, accountId) {
const all = vault.deploymentsAll || vault.deployments || [];
if (accountId) { const m = all.find((d) => d.strategy_id === sid && d.account_id === accountId); if (m) return m; }
return all.find((d) => d.strategy_id === sid) || null;
}
// Small tag for an asset class — used wherever a strategy row appears.
function ClassTag({ asset }) {
const k = sgAssetClass(asset);
const col = k === "crypto" ? "#3E6C8E" : k === "commodities" ? "var(--gold-ink)" : "#5C8A6F";
return {SG_CLS_LABEL[k]};
}
// ---- Exit-type classification: Fixed / Non-Fixed / Both ---------------------------------
// "Fixed" = exit is pure TP/SL (either a fixed-% risk key in signal_params, e.g. tp_pct/sl_pct,
// or exit-logic text that only does entry_price arithmetic, e.g. "close >= entry_price*1.02" —
// or no exit info at all, which defaults here to Fixed since that's the shape a native
// tp_pct/sl_pct-only strategy takes when no exit-logic string is stored).
// "Non-Fixed" = exit is one or more real indicator conditions, no entry_price arithmetic and no
// fixed-% risk key anywhere.
// "Both" = at least one indicator condition AND a fixed component (entry_price arithmetic
// OR'd in, or a risk key present) — the common "indicator OR entry_price*N" pattern.
const SG_FIXED_RISK_KEYS = ["hard_stop_pct", "sl_pct", "tp_pct", "sl_roi_pct", "tp_roi_pct", "fixed_bracket", "hard_stop"];
const SG_EXIT_TYPE_LABEL = { fixed: "Fixed", non_fixed: "Non-Fixed", both: "Both" };
function sgExitClauses(logic) {
if (logic == null || logic === "False" || logic === false || logic === "") return [];
return String(logic).split(/\s+OR\s+/i).map((s) => s.trim()).filter(Boolean);
}
function sgClassifyExitType(r) {
const sp = r.signal_params || {};
const clauses = [
...sgExitClauses(r.long_exit_logic != null ? r.long_exit_logic : sp.exit_long),
...sgExitClauses(r.short_exit_logic != null ? r.short_exit_logic : sp.exit_short),
...sgExitClauses(sp.exit),
];
const hasPriceClause = clauses.some((c) => /entry_price/i.test(c));
const hasIndicatorClause = clauses.some((c) => !/entry_price/i.test(c));
const hasRiskKey = SG_FIXED_RISK_KEYS.some((k) => sp[k] != null);
const hasFixed = hasPriceClause || hasRiskKey;
if (hasIndicatorClause && hasFixed) return "both";
if (hasIndicatorClause) return "non_fixed";
return "fixed";
}
function ExitTypeTag({ r }) {
const k = sgClassifyExitType(r);
const col = k === "fixed" ? "#5C8A6F" : k === "non_fixed" ? "#3E6C8E" : "var(--gold-ink)";
const title = k === "fixed" ? "Exit is pure fixed TP/SL (no indicator condition)"
: k === "non_fixed" ? "Exit is a dynamic indicator condition (no fixed TP/SL)"
: "Exit combines a dynamic indicator condition with a fixed TP/SL";
return {SG_EXIT_TYPE_LABEL[k]};
}
// Filter chips for the Type column — same visual language as chrome.jsx's ClassChips,
// kept local to this screen since exit-type filtering only makes sense here.
const SG_EXIT_TYPES = [["all", "All"], ["fixed", "Fixed"], ["non_fixed", "Non-Fixed"], ["both", "Both"]];
function sgTypeCounts(regs) {
return (regs || []).reduce((acc, r) => {
const k = sgClassifyExitType(r); acc[k] = (acc[k] || 0) + 1; acc.all = (acc.all || 0) + 1; return acc;
}, {});
}
function TypeChips({ value, onChange, counts }) {
return (
{SG_EXIT_TYPES.map(([id, label]) => {
const on = value === id;
const n = counts ? (counts[id] || 0) : null;
return (
);
})}
);
}
// ---- Arm / Disarm panel (used from Basket Studio — execution lives with the basket) --------
// One path: DIRECT apply (apply_change) writes live_deployments; the bridge picks it up on
// its next ~60s reconcile. Server-side guards (per-sleeve cap, Σalloc≤100%, kill-switch) + the
// audit row live in the vault_deploy edge fn — the passcode is the gate.
function RequestPanel({ ctx, vault, onClose, onDone }) {
// ONE writer per field. This panel writes LIVENESS only (enabled/mode) — never sizing.
// The basket owns alloc %/leverage/basket_code and the resolver rewrites them from it on
// every ~60s tick, so anything set here would be silently reverted inside a minute.
const dep = sgDepOf(vault, ctx.sid, ctx.account_id) || {};
const reg = sgRegOf(vault, ctx.sid) || {};
// Which real account this row lives on — vault_deploy is account-aware, so we send it explicitly.
const acctId = ctx.account_id || dep.account_id || null;
const venue = acctId ? (sgVenueOf ? sgVenueOf(acctId) : acctId) : "";
const [note, setNote] = React.useState("");
const [msg, setMsg] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const isArm = ctx.type === "arm";
const isDisarm = ctx.type === "disarm";
const sym = reg.symbol || (reg.asset || "").split("/")[0] || dep.symbol || "";
const inp = { fontFamily: "var(--font-mono)", fontSize: 13.5, padding: "9px 11px", borderRadius: 8, border: "1px solid var(--line)", background: "var(--paper)", color: "var(--ink)", outline: "none", width: "100%", boxSizing: "border-box" };
// What the basket/resolver has already sized this leg at — shown, not edited.
const curA = dep.alloc_pct != null ? Number(dep.alloc_pct) * 100 : null;
const curL = dep.max_leverage != null ? Number(dep.max_leverage) : null;
const pctS = (x) => (x == null ? "—" : x.toFixed(1) + "%");
const levS = (x) => (x == null ? "—" : x + "×");
const sleeveX = (curA != null && curL != null) ? (curA / 100 * curL).toFixed(2) + "x" : "—";
const armWarn = isArm; // arm = real money; always warn
// Identity (sid · venue) lives in the title; the summary just says what happens, with real numbers.
function summaryLine() {
if (isDisarm) return sym + " goes back to Preview — the open position closes within about a minute.";
return sym + " goes live at " + pctS(curA) + " × " + levS(curL) + " — " + sleeveX + " exposure. Real orders within ~60s.";
}
function submit() {
setBusy(true); setMsg({ t: "Applying…", err: false });
SG.api("apply_change", { strategy_id: ctx.sid, change_type: ctx.type, account_id: acctId, note: note.trim() || null })
.then((r) => {
setBusy(false);
if (r.status === 200 && r.body.ok) {
setMsg({ t: "✓ applied — the bridge picks it up in ≤60s." + (r.body.warning ? " (" + r.body.warning + ")" : ""), err: false });
setTimeout(() => { onClose(); onDone(); }, 1100);
return;
}
setMsg({ t: "Error " + r.status + ": " + (r.body.error || "unknown"), err: true });
}).catch((e) => { setBusy(false); setMsg({ t: "Network error: " + e.message, err: true }); });
}
return (
{/* The one thing that matters: what this does. Real numbers, sourced from the basket. */}
{summaryLine()}
{isArm && (
Sizing is set by the basket — edit weights & leverage on the basket card.
)}
setNote(e.target.value)} style={inp} />
);
}
function ReqButton({ label, tone: tn, onClick }) {
const col = tn === "bad" ? "var(--red)" : "var(--gold-ink)";
return (
);
}
// Small chip for the registry lifecycle status — shown wherever the row renders.
const SG_STATUS_CFG = {
active: { label: "ACTIVE", c: "var(--green)" },
watch: { label: "WATCH", c: "var(--gold-ink)" },
retire: { label: "RETIRED", c: "var(--red)" },
// pre-054 tags: kept so a stale cached payload still renders a labelled chip
// rather than silently falling back to ACTIVE.
incubation: { label: "INCUBATION", c: "#3d7ea6" },
probation: { label: "PROBATION", c: "var(--gold-ink)" },
shadow: { label: "SHADOW", c: "var(--muted)" },
archived: { label: "ARCHIVED", c: "var(--ink)" },
deprecated: { label: "DEPRECATED", c: "var(--red)" },
};
function StatusChip({ status }) {
const cfg = SG_STATUS_CFG[status] || SG_STATUS_CFG.active;
return {cfg.label};
}
// Lifecycle status control: a two-way toggle between the only two non-terminal tags,
// active and watch, plus the terminal Retire button below. Every transition is blocked
// server-side while the strategy is armed live on a real account, so the UI stays simple.
function LifecycleControl({ sid, status, basketName, onDone }) {
const [next, setNext] = React.useState(status || "active");
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState(null);
// Retire is the ONE-WAY DOOR and it lives in RetireControl below (two-step, 3.5s
// auto-reset). It is deliberately NOT in this dropdown: 'archived' used to sit here and
// applied on a single click, which is how a strategy could leave the book by accident.
// What is left here — active <-> watch — is reversible, so a single Apply is fine.
React.useEffect(() => { setNext(status || "active"); }, [status]);
if (status === "retire") return null;
function apply() {
if (next === status) return;
// No terminal destination remains in this control, so nothing needs arming here.
setBusy(true); setErr(null);
SG.api("set_strategy_status", { strategy_id: sid, status: next }).then((r) => {
setBusy(false);
if (r.status === 200 && r.body.ok) { onDone && onDone(); }
else setErr(r.body.error || ("HTTP " + r.status));
}).catch((e) => { setBusy(false); setErr(e.message); });
}
const changed = next !== status;
const options = ["active", "watch"];
return (
{changed && }
{err && {err}}
);
}
// Retire (status → retire; terminal from the UI — the row disappears from Control).
// A basket member can't be retired, and instead of a greyed-out button the cell SAYS WHY:
// it shows the basket's name, so the reason is readable without hovering. The server
// enforces the same rule (409 with the basket name), so this label is honest, not decorative.
function RetireControl({ sid, basketName, onDone }) {
const [step, setStep] = React.useState(0); // 0 idle · 1 confirm · 2 busy
const [err, setErr] = React.useState(null);
React.useEffect(() => {
if (step !== 1) return;
const t = setTimeout(() => setStep(0), 3500);
return () => clearTimeout(t);
}, [step]);
if (basketName) {
return (
);
}
function click() {
if (step === 0) { setStep(1); setErr(null); return; }
if (step !== 1) return;
setStep(2);
SG.api("set_strategy_status", { strategy_id: sid, status: "retire" })
.then((r) => {
setStep(0);
if (r.status === 200 && r.body.ok) { onDone && onDone(); }
else setErr(r.body.error || ("HTTP " + r.status));
})
.catch((e) => { setStep(0); setErr(e.message); });
}
const col = step === 1 ? "var(--red)" : "var(--gold-ink)";
return (
{err && {err}}
);
}
// ---- time-window chips -------------------------------------------------------
const SG_WINDOWS = [["1W", 7], ["1M", 30], ["3M", 90], ["6M", 180], ["All", null]];
// Filter groups on the list header are visually identical chip rows, and both open
// with an "All (N)" chip — so side by side they read as one broken control. Give
// each the same small caps label the Window control already had.
function FilterGroup({ label, children }) {
return (
{label}
{children}
);
}
function WinChips({ value, onChange }) {
return (
{SG_WINDOWS.map(([lab, d]) => {
const on = value === d;
return ;
})}
);
}
function sgWinLabel(win) { const hit = SG_WINDOWS.find(([, d]) => d === win); return hit ? hit[0] : "All"; }
// Combined return cell: ONE number (backtest + paper + live compounded), red when negative,
// plus an explicit labeled sub-line when real live fills exist — never two unlabeled figures.
function RetCell({ st, stLive }) {
if (!st) return
{shownRegs.map((r, i) => {
const sid = r.id;
const st = SG.lightStatsFor(sid, win);
const stLive = SG.statsFor(sid, "live", win);
const dec = decayOf(sid);
// composite: prefer the server health score (same number Monitor shows),
// fall back to the client composite so the column is never blank when
// the ledger exists but the health job hasn't run.
const score = dec.score != null ? dec.score : (st && st.composite != null ? st.composite : null);
return (
{st ? st.winRate.toFixed(0) + "%" : "—"}= 70 ? "var(--green)" : st.composite >= 45 ? "var(--ink)" : "var(--red)") : "var(--muted)" }}>{st && st.composite != null ? Math.round(st.composite) : "—"}{st ? String(st.trades) : "—"}= 70 ? "var(--green)" : score >= 45 ? "var(--ink)" : "var(--red)" }}
title={score == null ? "No composite yet — needs a trade ledger or a health-engine run"
: (dec.score != null ? "Server composite (health engine): return, Sharpe, profit factor, win rate and drawdown control, weighted"
: "Client composite from the trade ledger — health engine has not scored this one yet")}>
{score == null ? "—" : Math.round(score)}
breakeven); retire below 0.25.")
: "Client-side estimate — health engine has not scored this strategy yet"}>
{basketOf[sid] ? basketOf[sid].map((name) => {name}) : —}
);
})}
{shownRegs.length === 0 &&
No strategies match this search, asset class, and type.
);
const detailTrades = SG.backtestingTradesFor(sid);
const allStats = SG.computeStats(detailTrades);
if (allStats) allStats.composite = SG.compositeScore(allStats);
const periodDays = { "1D": 1, "1W": 7, "1M": 30, "3M": 90, "6M": 180, "1Y": 365 };
const allClosed = allStats ? allStats.ordered : [];
const latestClosedMs = allClosed.length ? Math.max(...allClosed.map((t) => SG.parseT(t.exit) || 0)) : 0;
const selectedDays = periodDays[period];
// Window buttons use calendar boundaries for monthly P&L and period charts:
// 1M means the first through last day of the latest calendar month, 3M means
// the first day of the month three months back through the latest month, etc.
// 1D/1W are NOT calendar-anchored to the latest trade — they're a true rolling
// window from wall-clock now (matching the list page's inWindow() convention),
// so a strategy with no recent trades correctly shows "nothing in this window"
// instead of a stale window built around whenever it last traded.
const calendarStartMs = (() => {
if (!selectedDays) return null;
if (selectedDays <= 7) return Date.now() - selectedDays * 864e5;
if (!latestClosedMs) return null;
const d = new Date(latestClosedMs);
const monthsBack = selectedDays === 30 ? 0 : selectedDays === 90 ? 2 : selectedDays === 180 ? 5 : 11;
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - monthsBack, 1);
})();
const periodTrades = selectedDays
? allClosed.filter((t) => (SG.parseT(t.exit) || 0) >= calendarStartMs)
: allClosed;
// computeStats(periodTrades) returns null for an empty list — genuinely possible
// now that 1D/1W are true rolling windows from now (a strategy with nothing in the
// last 24h correctly has zero period trades). Everything below reads s.ordered /
// s.winRate / etc. unconditionally, so fall back to a zeroed stats shape rather
// than letting a null s crash the page — this renders as an honest "nothing
// happened in this window," not stale data and not a blank screen.
const EMPTY_STATS = { trades: 0, wins: 0, losses: 0, winRate: 0, pf: 0, avgWin: 0, avgLoss: 0,
sharpe: 0, mdd: 0, totalReturn: 0, base100: 100, avgDurH: 0, equity: [100], ordered: [],
expectancy: 0, bySrc: {} };
const s = SG.computeStats(periodTrades) || Object.assign({}, EMPTY_STATS);
s.composite = SG.compositeScore(s);
// Prefer the health engine's verdict over the local heuristic. computeDecay()
// compares recent win rate to the first half and judges against 50%; the engine
// judges p_edge against this strategy's BREAKEVEN win rate. For a low-win /
// high-payoff strategy those two disagree sharply, and the engine is right.
const srvH = (vault.health || []).find((h) => h.strategy_id === sid) || null;
const SRV_BAND_D = { ok: "OK", watch: "WATCH", retire: "DECAYED" };
const decLocal = SG.computeDecay(periodTrades);
const dec = (period === "ALL" && srvH && SRV_BAND_D[srvH.status])
? Object.assign({}, decLocal, {
status: SRV_BAND_D[srvH.status], server: true,
signals: srvH.signals || {}, healthScore: srvH.health_score, nLive: srvH.n_live_trades,
})
: decLocal;
const decayHeadline = dec.status === "OK" ? "No decay detected" : dec.status === "WATCH" ? "Edge is weakening" : dec.status === "DECAYED" ? "Material decay detected" : "Not enough data";
const decaySummary = dec.status === "N/A" ? (dec.why || "More closed trades are required")
: dec.dWR >= 0 && dec.dEX >= 0 ? "Recent win consistency and profit per trade are both above the baseline."
: dec.dWR < 0 && dec.dEX < 0 ? "Both recent win consistency and profit per trade are below the baseline."
: dec.dWR < 0 ? "Win consistency has weakened, while profit per trade remains above baseline."
: "Profit per trade has weakened, while win consistency remains above baseline.";
const live = dep && (dep.enabled === true || dep.mode === "live");
// which basket holds this strategy — Retire is basket-guarded, and it's useful context
const bkName = (() => {
let n = null;
(vault.builderBaskets || []).filter((b) => b.status !== "archived")
.forEach((b) => (b.members || []).forEach((m) => { if (m.strategy_id === sid) n = b.name; }));
return n;
})();
if (!allStats) return (
No trades recorded for {r.name || sid}.
);
const parts = SG.compositeParts(s);
const monthly = s.ordered.reduce((acc, t) => {
const month = t.exit ? t.exit.slice(0, 7) : null;
if (month) acc[month] = (acc[month] || 1) * (1 + t.pnl / 100);
return acc;
}, {});
Object.keys(monthly).forEach((month) => { monthly[month] = (monthly[month] - 1) * 100; });
const months = Object.keys(monthly).sort();
const perTrade = s.ordered.map((t) => t.pnl);
const backtestSpanTrades = s.ordered.filter((t) => t.source === "backtest");
const backtestSpanSource = backtestSpanTrades.length ? backtestSpanTrades : s.ordered;
const backtestStartMs = backtestSpanSource.length ? Math.min(...backtestSpanSource.map((t) => SG.parseT(t.entry || t.exit) || Infinity)) : null;
const backtestEndMs = backtestSpanSource.length ? Math.max(...backtestSpanSource.map((t) => SG.parseT(t.exit || t.entry) || 0)) : null;
const utcDay = (ms) => {
const d = new Date(ms);
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
};
const backtestDays = backtestStartMs != null && isFinite(backtestStartMs) && backtestEndMs != null && backtestEndMs > 0
? Math.max(1, Math.round((utcDay(backtestEndMs) - utcDay(backtestStartMs)) / 864e5) + 1)
: 0;
const backtestSpanLabel = backtestDays
? `${new Date(backtestStartMs).toISOString().slice(0, 10)} → ${new Date(backtestEndMs).toISOString().slice(0, 10)}`
: "no backtest range";
const rollingForPeriod = (win = 10) => {
const out = [];
for (let i = win - 1; i < s.ordered.length; i++) {
const slice = s.ordered.slice(i - win + 1, i + 1);
out.push(slice.filter((t) => t.pnl > 0).length / win * 100);
}
return out;
};
const longStats = SG.computeStats(s.ordered.filter((t) => t.type === "LONG"));
const shortStats = SG.computeStats(s.ordered.filter((t) => t.type === "SHORT"));
const longCount = longStats ? longStats.trades : 0;
const shortCount = shortStats ? shortStats.trades : 0;
const directionTotal = Math.max(1, longCount + shortCount);
const sideShare = (n) => (n / directionTotal) * 100;
const rewardRisk = (st) => st && st.losses ? st.avgWin / Math.abs(st.avgLoss) : null;
const tradeWindowDays = { "1D": 1, "1W": 7, "1M": 30, "3M": 90, "6M": 180, "1Y": 365 };
const dateInputIso = (value) => {
const m = String(value || "").match(/^(\d{2})-(\d{2})-(\d{4})$/);
return m ? `${m[3]}-${m[2]}-${m[1]}` : "";
};
const tradeFromIso = dateInputIso(tradeFrom), tradeToIso = dateInputIso(tradeTo);
const filteredTrades = allStats.ordered.filter((t) => {
if (tradeSide !== "ALL" && t.type !== tradeSide) return false;
const exitMs = SG.parseT(t.exit);
const days = tradeWindowDays[tradeWindow];
// Rolling from wall-clock now, not from this strategy's last trade — see the
// matching fix on the period-return calc above for why (a strategy that hasn't
// traded in a month should show an empty 1D/1W trade log, not a stale one).
if (days && exitMs < Date.now() - days * 864e5) return false;
const exitDay = t.exit ? t.exit.slice(0, 10) : "";
if (tradeFromIso && exitDay < tradeFromIso) return false;
if (tradeToIso && exitDay > tradeToIso) return false;
return true;
}).slice().reverse();
// Live positions for THIS strategy. vault.openTrades is the same feed the Trades
// screen uses, already enriched with mark price and unrealised P&L.
const openRows = (vault.openTrades || []).filter((t) =>
t && t.strategy_id === sid &&
(tradeSide === "ALL" || String(t.type || "").toUpperCase() === tradeSide));
const tradesPerPage = 10;
const tradePages = Math.max(1, Math.ceil(filteredTrades.length / tradesPerPage));
const safeTradePage = Math.min(tradePage, tradePages);
const pagedTrades = filteredTrades.slice((safeTradePage - 1) * tradesPerPage, safeTradePage * tradesPerPage);
const pill = { fontFamily: "var(--font-mono)", fontSize: 12, padding: "3px 10px", borderRadius: 100, background: "var(--paper)", border: "1px solid var(--line)", color: "var(--ink-soft)" };
// Equity-curve annotations: mark where the record changes nature — backtest → paper track,
// and the first real broker fill — so "which part of this curve is real" reads off the chart.
// (equity[0] = 100; trade k closes at equity index k+1.)
const firstPaper = s.ordered.findIndex((t) => t.source !== "backtest");
const firstReal = s.ordered.findIndex((t) => t.source === "live_real");
const markers = [];
if (firstPaper > 0) markers.push({ i: firstPaper + 1, label: "PAPER →", color: "#3d7ea6" });
if (firstReal > 0 && firstReal !== firstPaper) markers.push({ i: firstReal + 1, label: "LIVE →", color: "#1F7A4D" });
// Per-source breakdown — the honest split behind the one combined number.
const srcStat = (m) => SG.computeStats(s.ordered.filter((t) => m === "backtest" ? t.source === "backtest" : m === "live" ? t.source === "live" : t.source === "live_real"));
const stBT = srcStat("backtest"), stLv = srcStat("live"), stRl = srcStat("live_real");
const srcRow = (label, stx, col) => stx && (
{label}{(stx.totalReturn >= 0 ? "+" : "") + stx.totalReturn.toFixed(1) + "%"}{stx.trades}t · {stx.winRate.toFixed(0)}% win
);
const pairedStat = (label, left, right, leftColor, rightColor, leftLabel, rightLabel, leftSub, rightSub) => (
{/* The DSL this strategy actually trades. It was previously only visible from
Monitor, so the strategy's own page never said what it does. Rendered via
window because screens-monitor.jsx loads after this file — resolved at render,
and skipped harmlessly if that script ever fails to load. */}
{window.StrategyLogicPanel && (
)}
{lb}{hint}{dl >= 0 ? `Improved by +${dl.toFixed(dp)}${unit}` : `Declined by ${Math.abs(dl).toFixed(dp)}${unit}`}
Baseline
{b.toFixed(dp)}%
→
Recent
{rc.toFixed(dp)}%
);
})}
WATCH at -10pp win rate or -0.75% expectancy. DECAYED at -20pp / -1.5%.
{dec.signals &&
Health confidence · p_edge{dec.signals.p_edge != null ? dec.signals.p_edge.toFixed(3) : "—"}
}
)}
{/* Decay — its own full-width panel: the verdict, the two comparisons behind it, and the
rolling win-rate trend so a fading edge is visible as a falling line, not just a chip. */}
Decay check
{dec.status === "N/A" ? (
n/a — {dec.why || "insufficient data"}. Every non-retired strategy is checked on every refresh; this label fills in as closed trades accrue.
Verdict is the health engine, not the win-rate delta above: p_edge = P(true win rate > this
strategy’s breakeven win rate {dec.signals.breakeven_wr != null ? (dec.signals.breakeven_wr * 100).toFixed(1) + "%" : "—"})
over {dec.nLive != null ? dec.nLive : "?"} live trades, seeded from the backtest baseline.
RETIRE below 0.25, WATCH below 0.50. Breakeven — not 50% — is the bar, so a low-win / high-payoff
strategy stays healthy at a win rate that would look alarming above.
{dec.signals.cusum_alarm ? " CUSUM has fired: the constant-edge assumption just broke." : ""}
{dec.signals.sprt_verdict && dec.signals.sprt_verdict !== "undecided" ? " SPRT verdict: " + dec.signals.sprt_verdict + "." : ""}
The Δ bars above are the legacy first-half-vs-recent heuristic, kept for context only.
) : (
baseline = {dec.baseLabel} ({dec.nBase} trades) vs last {dec.nRec} {dec.recentLabel}. WATCH at Δwin ≤ −10pp or Δexp ≤ −0.75%; DECAYED at −20pp / −1.5%.
Health engine has not scored this strategy yet — this is the local estimate.
)}
{(() => {
const roll = rollingForPeriod(10);
if (roll.length < 3) return
rolling win-rate trend appears from ~12 closed trades
;
return (
Rolling win rate — 10-trade window
v.toFixed(0)} xLabels={(i) => "T" + (i + 10)} hover />
);
})()}
)}
Trade history
{openRows.length > 0 ? `${openRows.length} open · ` : ""}{filteredTrades.length} closed · 10 per page
Side
{["ALL", "LONG", "SHORT"].map((id) => {
const active = tradeSide === id;
const col = id === "LONG" ? "var(--green)" : id === "SHORT" ? "var(--red)" : "var(--gold)";
return ;
})}
Date range
Entry timeExit timeDirectionSourceDurationP&L
{/* Open positions lead the history. The trade ledger holds CLOSED trades only,
so without this the page shows a strategy's whole record while staying silent
on whether it is in the market right now — an answer that lived only on the
Trades screen. Rendered as rows in the same grid rather than a banner so the
log reads as one continuous record, newest state first.
They are deliberately outside the pager (an open trade has no exit time, so
the date/period filters cannot apply to it) but DO honour the direction
filter, which is meaningful for them. */}
{openRows.map((t, i) => {
const isShort = String(t.type || "").toLowerCase() === "short";
const entryMs = SG.parseT(t.entry_time);
const heldH = entryMs ? (Date.now() - entryMs) / 3.6e6 : NaN;
const pnl = t.unrealized_pnl_pct;
const pxAgeMin = t.price_ts ? Math.round((Date.now() / 1000 - Number(t.price_ts)) / 60) : null;
return (
{t.entry_time ? String(t.entry_time).slice(0, 16).replace("T", " ") : "—"}
OPEN
{/* Unrealised P&L is priced from the latest snapshot candle, which can lag —
say how old rather than implying a live tick. */}
{pxAgeMin != null && pxAgeMin > 20 && (
px {pxAgeMin}m old
)}
{isShort ? "SHORT" : "LONG"}{isFinite(heldH) ? SgF.dur(heldH) : "—"}
{pnl == null ? "—" : (pnl > 0 ? "+" : "") + pnl.toFixed(2) + "%"}
{pnl == null ? "—" : (pnl > 0 ? "+" : "") + pnl.toFixed(2) + "%"}
{isFinite(heldH) ? SgF.dur(heldH) : "—"}