"use client"; import type { CSSProperties } from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { optimizePortfolio } from "@/lib/api"; import { FONT } from "@/lib/theme"; import { MAX_IBM_VQE_ASSETS } from "@/lib/quantumPortfolioJobs"; import { clipNormalizeWeights, portfolioMetricsFromWeights, runOptimisation, } from "@/lib/simulationEngine"; export type LabData = { assets: Array<{ name: string; annReturn: number; annVol: number; sector: string; }>; corr: number[][]; }; export type SidebarSnapshot = { objective: string; weightMin: number; weightMax: number; seed: number; cardinality: number | null; kScreen: number | null; kSelect: number | null; }; export type ObjectiveOption = { value: string; label: string; group: string; }; export type LabContext = { marketMode: "live" | "synthetic"; isLiveLoaded: boolean; ibmConnected: boolean; nAssets: number; }; type Theme = Record; type Props = { data: LabData | null; theme: Theme; objectiveOptions: ObjectiveOption[]; sidebar: SidebarSnapshot; labContext?: LabContext; }; type BenchSpec = { objective: string; weightMin: number; weightMax: number; seed: number; K: string; kScreen: string; kSelect: string; nLayers: number; nRestarts: number; lambdaRisk: number; gamma: number; }; type MetricsLite = { sharpe: number; portReturn: number; portVol: number; nActive: number; }; type Snapshot = { spec: BenchSpec; weights: number[]; metrics: MetricsLite | null; hypothesisLabel: string; ts: string; }; const SHARPE_CLOSE_EPS = 0.02; const SHARPE_LARGE_GAP = 0.15; function emptySpec(sidebar: SidebarSnapshot): BenchSpec { return { objective: sidebar.objective, weightMin: sidebar.weightMin, weightMax: sidebar.weightMax, seed: sidebar.seed, K: sidebar.cardinality != null ? String(sidebar.cardinality) : "", kScreen: sidebar.kScreen != null ? String(sidebar.kScreen) : "", kSelect: sidebar.kSelect != null ? String(sidebar.kSelect) : "", nLayers: 3, nRestarts: 8, lambdaRisk: 1, gamma: 8, }; } function specDiff(before: BenchSpec, after: BenchSpec): string[] { const out: string[] = []; if (before.objective !== after.objective) { out.push(`objective: ${before.objective} → ${after.objective}`); } if (before.weightMin !== after.weightMin) { out.push( `w_min: ${(before.weightMin * 100).toFixed(1)}% → ${(after.weightMin * 100).toFixed(1)}%`, ); } if (before.weightMax !== after.weightMax) { out.push( `w_max: ${(before.weightMax * 100).toFixed(0)}% → ${(after.weightMax * 100).toFixed(0)}%`, ); } if (before.seed !== after.seed) { out.push(`seed: ${before.seed} → ${after.seed}`); } const keys: (keyof BenchSpec)[] = [ "K", "kScreen", "kSelect", "nLayers", "nRestarts", "lambdaRisk", "gamma", ]; for (const k of keys) { if (before[k] !== after[k]) { out.push(`${k}: ${before[k]} → ${after[k]}`); } } if (out.length === 0) out.push("No field changes vs previous spec"); return out; } function maxWeightDelta(a: number[], b: number[]): number { if (a.length !== b.length) return Infinity; let m = 0; for (let i = 0; i < a.length; i++) { m = Math.max(m, Math.abs(a[i] - b[i])); } return m; } function metricsLiteFromWeights( w: number[], data: LabData, ): MetricsLite | null { if (!data.assets.length || w.length !== data.assets.length) return null; const m = portfolioMetricsFromWeights(w, data); return { sharpe: m.sharpe, portReturn: m.portReturn, portVol: m.portVol, nActive: m.nActive, }; } export default function SensitivityLabPanel({ data, theme: t, objectiveOptions, sidebar, labContext, }: Props) { const [hypothesisLabel, setHypothesisLabel] = useState(""); const [spec, setSpec] = useState(() => emptySpec(sidebar)); const [weights, setWeights] = useState([]); const [weightsDirty, setWeightsDirty] = useState(false); const [lastSource, setLastSource] = useState<"manual" | "client" | "api">( "manual", ); const [syncDiffChips, setSyncDiffChips] = useState([]); const [lastClientMetrics, setLastClientMetrics] = useState( null, ); const [lastApiMetrics, setLastApiMetrics] = useState(null); const [snapshotA, setSnapshotA] = useState(null); const [snapshotB, setSnapshotB] = useState(null); const [checklistDismissed, setChecklistDismissed] = useState(false); const [quantumMeta, setQuantumMeta] = useState | null>(null); const [error, setError] = useState(null); const [clientBusy, setClientBusy] = useState(false); const [apiBusy, setApiBusy] = useState(false); const n = data?.assets?.length ?? 0; const universeKey = useMemo( () => data?.assets?.map((a) => a.name).join("|") ?? "", [data], ); useEffect(() => { if (!data?.assets?.length) { setWeights([]); setWeightsDirty(false); return; } const nn = data.assets.length; setWeights(Array.from({ length: nn }, () => 1 / nn)); setWeightsDirty(false); }, [universeKey]); const metrics = useMemo(() => { if (!data?.assets?.length || weights.length !== data.assets.length) return null; return portfolioMetricsFromWeights(weights, data); }, [data, weights]); const sharpeCompareNote = useMemo(() => { if (!lastClientMetrics || !lastApiMetrics) return null; const d = Math.abs(lastClientMetrics.sharpe - lastApiMetrics.sharpe); if (d < SHARPE_CLOSE_EPS) { return "Close match on Sharpe — caps may not bind, or client surrogate aligns with the server on this Σ."; } if (d > SHARPE_LARGE_GAP) { return "Large Sharpe gap between quick sim and full optimizer — expected when objectives differ or the client path is a surrogate; trust API for production-style weights."; } return null; }, [lastClientMetrics, lastApiMetrics]); const confirmDestructive = useCallback((message: string) => { if (typeof window === "undefined") return true; return window.confirm(message); }, []); const syncFromSidebar = useCallback(() => { if ( weightsDirty && !confirmDestructive( "Sync from sidebar replaces the bench spec with the sidebar. Current weights stay until you re-run Quick sim or Full optimizer. Continue?", ) ) { return; } const before = spec; const after = emptySpec(sidebar); setSyncDiffChips(specDiff(before, after)); setSpec(after); setLastSource("manual"); setQuantumMeta(null); setWeightsDirty(false); }, [spec, sidebar, weightsDirty, confirmDestructive]); const applyBounds = useCallback(() => { if (!data?.assets?.length) return; if ( weightsDirty && !confirmDestructive( "Clip + normalize will change weights. Continue?", ) ) { return; } setWeights((w) => clipNormalizeWeights(w, spec.weightMin, spec.weightMax), ); setLastSource("manual"); setWeightsDirty(true); }, [data, spec.weightMin, spec.weightMax, weightsDirty, confirmDestructive]); const setEqualWeights = useCallback(() => { if (!data?.assets?.length) return; if ( weightsDirty && !confirmDestructive( "Replace weights with equal weight? Current edits will be overwritten.", ) ) { return; } const nn = data.assets.length; const eq = Array.from({ length: nn }, () => 1 / nn); setWeights(clipNormalizeWeights(eq, spec.weightMin, spec.weightMax)); setLastSource("manual"); setWeightsDirty(true); }, [data, spec.weightMin, spec.weightMax, weightsDirty, confirmDestructive]); const runClient = useCallback(() => { if (!data?.assets?.length) return; setClientBusy(true); setError(null); setQuantumMeta(null); try { const K = spec.K.trim() ? parseInt(spec.K, 10) : NaN; const KScreen = spec.kScreen.trim() ? parseInt(spec.kScreen, 10) : NaN; const KSelect = spec.kSelect.trim() ? parseInt(spec.kSelect, 10) : NaN; const r = runOptimisation(data, { objective: spec.objective, wMax: spec.weightMax, K: Number.isFinite(K) ? K : undefined, KScreen: Number.isFinite(KScreen) ? KScreen : undefined, KSelect: Number.isFinite(KSelect) ? KSelect : undefined, }); const w = [...r.weights]; setWeights(w); const ml = metricsLiteFromWeights(w, data); setLastClientMetrics(ml); setLastSource("client"); setWeightsDirty(false); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setClientBusy(false); } }, [data, spec]); const runApi = useCallback(async () => { if (!data?.assets?.length) return; setApiBusy(true); setError(null); setQuantumMeta(null); try { const returns = data.assets.map((a) => a.annReturn); const covariance = Array.from({ length: n }, (_, i) => Array.from({ length: n }, (_, j) => data.assets[i].annVol * data.assets[j].annVol * data.corr[i][j], ), ); const payload: Record = { returns, covariance, asset_names: data.assets.map((a) => a.name), sectors: data.assets.map((a) => a.sector), objective: spec.objective, weight_min: spec.weightMin, maxWeight: spec.weightMax, seed: spec.seed, n_layers: spec.nLayers, n_restarts: spec.nRestarts, lambda_risk: spec.lambdaRisk, gamma: spec.gamma, }; if (spec.K.trim()) payload.K = parseInt(spec.K, 10); if (spec.kScreen.trim()) payload.K_screen = parseInt(spec.kScreen, 10); if (spec.kSelect.trim()) payload.K_select = parseInt(spec.kSelect, 10); const resp = (await optimizePortfolio(payload)) as Record< string, unknown >; const qsw = (resp.qsw_result || resp) as Record; const raw = (qsw.weights as number[] | undefined) || (resp.weights as number[] | undefined) || []; if (raw.length) { const w = raw.map((x) => Number(x)); setWeights(w); setLastApiMetrics(metricsLiteFromWeights(w, data)); } const qm = resp.quantum_metadata as Record | undefined; setQuantumMeta(qm && typeof qm === "object" ? qm : null); setLastSource("api"); setWeightsDirty(false); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setApiBusy(false); } }, [data, n, spec]); const updateWeight = useCallback((i: number, val: string) => { const x = parseFloat(val); setWeights((prev) => { const next = [...prev]; next[i] = Number.isFinite(x) ? x : 0; return next; }); setLastSource("manual"); setWeightsDirty(true); }, []); const applyPreset = useCallback( (key: "tighter" | "hybrid" | "quantum") => { if (key === "tighter") { setSpec((s) => ({ ...s, weightMax: Math.max(0.05, Math.round(s.weightMax * 0.9 * 1000) / 1000), })); } else if (key === "hybrid") { setSpec((s) => { const parsed = s.kScreen.trim() !== "" ? parseInt(s.kScreen, 10) : NaN; const base = Number.isFinite(parsed) ? parsed : Math.min(15, Math.max(5, n)); return { ...s, objective: "hybrid", kScreen: String(base + 2), }; }); } else if (key === "quantum") { const hasVqe = objectiveOptions.some((o) => o.value === "vqe"); if (hasVqe) setSpec((s) => ({ ...s, objective: "vqe" })); } }, [n, objectiveOptions], ); const saveSnapshot = useCallback( (slot: "A" | "B") => { if (!data) return; const snap: Snapshot = { spec: { ...spec }, weights: [...weights], metrics: metrics ? { sharpe: metrics.sharpe, portReturn: metrics.portReturn, portVol: metrics.portVol, nActive: metrics.nActive, } : null, hypothesisLabel, ts: new Date().toISOString(), }; if (slot === "A") setSnapshotA(snap); else setSnapshotB(snap); }, [data, spec, weights, metrics, hypothesisLabel], ); const abDiff = useMemo(() => { if (!snapshotA || !snapshotB || !data) return null; const specLines = specDiff(snapshotA.spec, snapshotB.spec); const dSharpe = (snapshotA.metrics?.sharpe ?? 0) - (snapshotB.metrics?.sharpe ?? 0); const dRet = (snapshotA.metrics?.portReturn ?? 0) - (snapshotB.metrics?.portReturn ?? 0); const dVol = (snapshotA.metrics?.portVol ?? 0) - (snapshotB.metrics?.portVol ?? 0); const wDelta = maxWeightDelta(snapshotA.weights, snapshotB.weights); return { specLines, dSharpe, dRet, dVol, wDelta }; }, [snapshotA, snapshotB, data]); const exportPayload = useCallback(() => { return { hypothesisLabel, timestamp: new Date().toISOString(), spec, weights, metrics: metrics ? { sharpe: metrics.sharpe, annReturn: metrics.portReturn, annVol: metrics.portVol, nActive: metrics.nActive, } : null, lastQuickSimMetrics: lastClientMetrics, lastApiMetrics, snapshotA, snapshotB, }; }, [ hypothesisLabel, spec, weights, metrics, lastClientMetrics, lastApiMetrics, snapshotA, snapshotB, ]); const copyJson = useCallback(async () => { try { await navigator.clipboard.writeText( JSON.stringify(exportPayload(), null, 2), ); } catch { setError("Could not copy to clipboard"); } }, [exportPayload]); const downloadJson = useCallback(() => { const blob = new Blob([JSON.stringify(exportPayload(), null, 2)], { type: "application/json", }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `scientist-bench-${Date.now()}.json`; a.click(); URL.revokeObjectURL(url); }, [exportPayload]); const downloadCsv = useCallback(() => { if (!data?.assets.length) return; const lines: string[] = []; lines.push("field,value"); lines.push(`hypothesis,${csvEscape(hypothesisLabel)}`); lines.push(`timestamp,${new Date().toISOString()}`); if (metrics) { lines.push(`sharpe,${metrics.sharpe}`); lines.push(`ann_return,${metrics.portReturn}`); lines.push(`ann_vol,${metrics.portVol}`); lines.push(`n_active,${metrics.nActive}`); } lines.push("asset,weight"); data.assets.forEach((a, i) => { lines.push(`${csvEscape(a.name)},${weights[i] ?? 0}`); }); const blob = new Blob([lines.join("\n")], { type: "text/csv" }); const url = URL.createObjectURL(blob); const el = document.createElement("a"); el.href = url; el.download = `scientist-bench-${Date.now()}.csv`; el.click(); URL.revokeObjectURL(url); }, [data, hypothesisLabel, metrics, weights]); const cardStyle: CSSProperties = { padding: 14, borderRadius: 10, border: `1px solid ${t.border}`, background: t.surface, }; const ctx = labContext; if (!data?.assets?.length) { return (
Load a universe (sidebar) to use the scientist bench.
); } return (
How to use this bench
  • Optional: name your hypothesis below.
  • Use Sync from sidebar to align the bench with the left panel; review the change chips.
  • Run Quick sim for fast browser math, then Full optimizer{" "} for Python results on the same spec.
  • Edit weights or use Equal / Clip; metrics update instantly.
  • Save A /{" "} B snapshots to compare scenarios.
  • Hardware / IBM lab runs with metadata live under{" "} Reports — not on this optimize path.
setHypothesisLabel(e.target.value)} style={inputStyle(t)} aria-label="Hypothesis label" /> {syncDiffChips.length > 0 && (
After last sync:
{syncDiffChips.map((c) => ( {c} ))}
)}
{!checklistDismissed && ctx && (

Before you trust API results

  • {ctx.marketMode === "live" && !ctx.isLiveLoaded ? "Live mode: load market data in the sidebar so Σ matches your intent." : "Data: synthetic or live loaded ✓"}
  • MAX_IBM_VQE_ASSETS ? t.accentWarm : t.textMuted }}> {spec.objective === "vqe" && ctx.nAssets > MAX_IBM_VQE_ASSETS ? `VQE on IBM is limited to ~${MAX_IBM_VQE_ASSETS} assets; universe is ${ctx.nAssets}.` : `Universe size ${ctx.nAssets} (OK for typical VQE API path).`}
  • {ctx.ibmConnected ? "IBM token stored — hardware paths use Quantum Engine / lab runs." : "IBM not connected — classical / sim paths only unless you add a token."}
)}

Spec

Presets apply small, documented tweaks. Sync pulls from the sidebar.

setSpec((s) => ({ ...s, weightMin: parseFloat(e.target.value) || 0, })) } style={inputStyle(t)} />
setSpec((s) => ({ ...s, weightMax: parseFloat(e.target.value) || 0.2, })) } style={inputStyle(t)} />
setSpec((s) => ({ ...s, seed: parseInt(e.target.value, 10) || 0, })) } style={inputStyle(t)} />
setSpec((s) => ({ ...s, K: e.target.value }))} placeholder="—" style={inputStyle(t)} />
setSpec((s) => ({ ...s, kScreen: e.target.value })) } placeholder="—" style={inputStyle(t)} />
setSpec((s) => ({ ...s, kSelect: e.target.value })) } placeholder="—" style={inputStyle(t)} />
setSpec((s) => ({ ...s, nLayers: parseInt(e.target.value, 10) || 3, })) } style={inputStyle(t)} />
setSpec((s) => ({ ...s, nRestarts: parseInt(e.target.value, 10) || 8, })) } style={inputStyle(t)} />
setSpec((s) => ({ ...s, lambdaRisk: parseFloat(e.target.value) || 1, })) } style={inputStyle(t)} />
setSpec((s) => ({ ...s, gamma: parseFloat(e.target.value) || 8, })) } style={inputStyle(t)} />

After sync, run Quick sim or Full optimizer to align weights with the new spec.

Run

Same spec; different fidelity.{" "} ?

Last weights from:{" "} {lastSource === "client" ? "Quick sim" : lastSource === "api" ? "Full optimizer" : "Manual / equal"} {weightsDirty ? ( · weights edited ) : null}

{lastClientMetrics && lastApiMetrics && (
Compare last two runs
Quick sim Full opt. Δ
Sharpe {lastClientMetrics.sharpe.toFixed(4)} {lastApiMetrics.sharpe.toFixed(4)} {(lastClientMetrics.sharpe - lastApiMetrics.sharpe).toFixed(4)}
Return {(lastClientMetrics.portReturn * 100).toFixed(2)}% {(lastApiMetrics.portReturn * 100).toFixed(2)}% {( (lastClientMetrics.portReturn - lastApiMetrics.portReturn) * 100 ).toFixed(2)} pp
Vol {(lastClientMetrics.portVol * 100).toFixed(2)}% {(lastApiMetrics.portVol * 100).toFixed(2)}% {( (lastClientMetrics.portVol - lastApiMetrics.portVol) * 100 ).toFixed(2)} pp
{sharpeCompareNote && (

{sharpeCompareNote}

)}
)} {error && (

{error}

)}

Weights

{data.assets.map((a, i) => ( ))}
Asset w
{a.name} updateWeight(i, e.target.value)} style={{ width: "100%", minWidth: 72, padding: "4px 6px", borderRadius: 4, border: `1px solid ${t.border}`, background: t.bg, color: t.text, fontFamily: FONT.mono, fontSize: 11, }} />

Metrics (from current w)

{metrics ? (
Ann. return
{(metrics.portReturn * 100).toFixed(2)}%
Vol
{(metrics.portVol * 100).toFixed(2)}%
Sharpe
{metrics.sharpe.toFixed(4)}
n_active
{metrics.nActive}
) : (

)}
{abDiff && (
A vs B
Δ Sharpe: {abDiff.dSharpe.toFixed(4)}
Δ return: {(abDiff.dRet * 100).toFixed(2)} pp
Δ vol: {(abDiff.dVol * 100).toFixed(2)} pp
Max |Δw|: {abDiff.wDelta.toFixed(4)}
{abDiff.specLines.map((l) => (
{l}
))}
)}

Export

Copy or download spec, weights, metrics, snapshots for tickets or papers.

Quantum / metadata

Standard optimize responses usually have no quantum block. Lab runs with IBM may expose metadata elsewhere (Reports).

{quantumMeta && Object.keys(quantumMeta).length > 0 ? (
              {JSON.stringify(quantumMeta, null, 2)}
            
) : (

Classical / no quantum_metadata on last API response.

)}

Circuit

Circuit visualization requires backend circuit JSON (not yet exposed on optimize).

); } function csvEscape(s: string): string { if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`; return s; } function chipBtn(t: Theme): CSSProperties { return { padding: "5px 10px", borderRadius: 6, border: `1px solid ${t.border}`, background: t.surfaceLight, color: t.text, fontSize: 10, fontFamily: FONT.mono, cursor: "pointer", }; } function labelStyle(t: Theme): CSSProperties { return { display: "block", fontSize: 9, color: t.textMuted, marginBottom: 4, textTransform: "uppercase" as const, letterSpacing: "0.06em", fontFamily: FONT.mono, }; } function inputStyle(t: Theme): CSSProperties { return { width: "100%", boxSizing: "border-box", padding: "6px 8px", marginBottom: 10, borderRadius: 4, border: `1px solid ${t.border}`, background: t.bg, color: t.text, fontSize: 11, fontFamily: FONT.mono, }; } function btnPrimary(t: Theme, disabled: boolean): CSSProperties { return { padding: "8px 14px", borderRadius: 4, border: "none", background: disabled ? t.surfaceLight : t.accent, color: disabled ? t.textDim : t.bg, fontSize: 11, fontWeight: 600, cursor: disabled ? "default" : "pointer", fontFamily: FONT.mono, }; } function btnSecondary(t: Theme): CSSProperties { return { padding: "6px 10px", marginTop: 8, borderRadius: 4, border: `1px solid ${t.border}`, background: "transparent", color: t.accent, fontSize: 10, fontWeight: 600, cursor: "pointer", fontFamily: FONT.mono, }; } function thStyle(t: Theme): CSSProperties { return { textAlign: "left" as const, padding: "6px 8px", color: t.textMuted, borderBottom: `1px solid ${t.border}`, }; } function tdStyle(t: Theme): CSSProperties { return { padding: "4px 8px", color: t.text, borderBottom: `1px solid ${t.border}`, }; }