quantum-hybrid-portfolio / web /src /components /SensitivityLabPanel.tsx
rocRevyAreGoals15's picture
Initial HF Space deployment
9e89154
Raw
History Blame Contribute Delete
43.7 kB
"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<string, string>;
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<BenchSpec>(() => emptySpec(sidebar));
const [weights, setWeights] = useState<number[]>([]);
const [weightsDirty, setWeightsDirty] = useState(false);
const [lastSource, setLastSource] = useState<"manual" | "client" | "api">(
"manual",
);
const [syncDiffChips, setSyncDiffChips] = useState<string[]>([]);
const [lastClientMetrics, setLastClientMetrics] = useState<MetricsLite | null>(
null,
);
const [lastApiMetrics, setLastApiMetrics] = useState<MetricsLite | null>(null);
const [snapshotA, setSnapshotA] = useState<Snapshot | null>(null);
const [snapshotB, setSnapshotB] = useState<Snapshot | null>(null);
const [checklistDismissed, setChecklistDismissed] = useState(false);
const [quantumMeta, setQuantumMeta] = useState<Record<
string,
unknown
> | null>(null);
const [error, setError] = useState<string | null>(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<string, unknown> = {
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<string, unknown>;
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<string, unknown> | 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 (
<div style={{ ...cardStyle, color: t.textDim, fontSize: 12 }}>
Load a universe (sidebar) to use the scientist bench.
</div>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<details
style={{
...cardStyle,
padding: "10px 14px",
}}
>
<summary
style={{
cursor: "pointer",
fontSize: 12,
fontWeight: 700,
color: t.text,
fontFamily: FONT.sans,
}}
>
How to use this bench
</summary>
<ul
style={{
margin: "10px 0 0",
paddingLeft: 18,
fontSize: 10,
color: t.textMuted,
lineHeight: 1.55,
}}
>
<li>Optional: name your hypothesis below.</li>
<li>
Use <strong style={{ color: t.text }}>Sync from sidebar</strong> to
align the bench with the left panel; review the change chips.
</li>
<li>
Run <strong style={{ color: t.text }}>Quick sim</strong> for fast
browser math, then <strong style={{ color: t.text }}>Full optimizer</strong>{" "}
for Python results on the same spec.
</li>
<li>Edit weights or use Equal / Clip; metrics update instantly.</li>
<li>
Save <strong style={{ color: t.text }}>A</strong> /{" "}
<strong style={{ color: t.text }}>B</strong> snapshots to compare
scenarios.
</li>
<li>
Hardware / IBM lab runs with metadata live under{" "}
<Link
href="/reports"
style={{ color: t.accent, fontWeight: 600 }}
>
Reports
</Link>
— not on this optimize path.
</li>
</ul>
</details>
<div style={{ ...cardStyle }}>
<label style={labelStyle(t)}>Hypothesis label</label>
<input
type="text"
placeholder="e.g. Tighter cap vs baseline"
value={hypothesisLabel}
onChange={(e) => setHypothesisLabel(e.target.value)}
style={inputStyle(t)}
aria-label="Hypothesis label"
/>
{syncDiffChips.length > 0 && (
<div style={{ marginTop: 8 }} aria-live="polite">
<span style={{ fontSize: 9, color: t.textMuted, fontFamily: FONT.mono }}>
After last sync:
</span>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 6,
marginTop: 6,
}}
>
{syncDiffChips.map((c) => (
<span
key={c}
style={{
fontSize: 9,
fontFamily: FONT.mono,
padding: "3px 8px",
borderRadius: 999,
background: t.accentDim,
color: t.accent,
border: `1px solid ${t.border}`,
}}
>
{c}
</span>
))}
</div>
</div>
)}
</div>
{!checklistDismissed && ctx && (
<div
style={{
...cardStyle,
background: t.surfaceLight,
position: "relative",
}}
>
<button
type="button"
aria-label="Dismiss checklist"
onClick={() => setChecklistDismissed(true)}
style={{
position: "absolute",
top: 8,
right: 8,
border: "none",
background: "transparent",
color: t.textDim,
cursor: "pointer",
fontSize: 14,
}}
>
×
</button>
<h4
style={{
margin: "0 0 8px",
fontSize: 11,
fontWeight: 700,
color: t.text,
}}
>
Before you trust API results
</h4>
<ul
style={{
margin: 0,
paddingLeft: 18,
fontSize: 10,
color: t.textMuted,
lineHeight: 1.5,
}}
>
<li style={{ color: ctx.marketMode === "live" && !ctx.isLiveLoaded ? t.accentWarm : t.textMuted }}>
{ctx.marketMode === "live" && !ctx.isLiveLoaded
? "Live mode: load market data in the sidebar so Σ matches your intent."
: "Data: synthetic or live loaded ✓"}
</li>
<li style={{ color: spec.objective === "vqe" && ctx.nAssets > 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).`}
</li>
<li style={{ color: !ctx.ibmConnected ? t.textDim : t.textMuted }}>
{ctx.ibmConnected
? "IBM token stored — hardware paths use Quantum Engine / lab runs."
: "IBM not connected — classical / sim paths only unless you add a token."}
</li>
</ul>
</div>
)}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 340px), 1fr))",
gap: 16,
alignItems: "start",
}}
>
<section style={cardStyle} aria-label="Experiment spec">
<h3
style={{
margin: "0 0 10px",
fontSize: 13,
fontWeight: 700,
color: t.text,
fontFamily: FONT.sans,
}}
>
Spec
</h3>
<p
style={{
fontSize: 10,
color: t.textMuted,
marginBottom: 10,
lineHeight: 1.45,
}}
>
Presets apply small, documented tweaks. Sync pulls from the sidebar.
</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 12 }}>
<button
type="button"
onClick={() => applyPreset("tighter")}
style={chipBtn(t)}
title="Reduce w_max by ~10%"
>
Tighter cap
</button>
<button
type="button"
onClick={() => applyPreset("hybrid")}
style={chipBtn(t)}
title="Objective hybrid, slightly larger K_screen"
>
More hybrid screening
</button>
<button
type="button"
onClick={() => applyPreset("quantum")}
disabled={!objectiveOptions.some((o) => o.value === "vqe")}
style={{
...chipBtn(t),
opacity: objectiveOptions.some((o) => o.value === "vqe") ? 1 : 0.45,
}}
title="Set objective to VQE if available"
>
Quantum-style (VQE)
</button>
</div>
<label style={labelStyle(t)}>Objective</label>
<select
value={spec.objective}
onChange={(e) =>
setSpec((s) => ({ ...s, objective: e.target.value }))
}
style={inputStyle(t)}
>
{objectiveOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label} ({o.group})
</option>
))}
</select>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<div>
<label style={labelStyle(t)}>w_min</label>
<input
type="number"
step={0.001}
min={0}
max={1}
value={spec.weightMin}
onChange={(e) =>
setSpec((s) => ({
...s,
weightMin: parseFloat(e.target.value) || 0,
}))
}
style={inputStyle(t)}
/>
</div>
<div>
<label style={labelStyle(t)}>w_max</label>
<input
type="number"
step={0.01}
min={0}
max={1}
value={spec.weightMax}
onChange={(e) =>
setSpec((s) => ({
...s,
weightMax: parseFloat(e.target.value) || 0.2,
}))
}
style={inputStyle(t)}
/>
</div>
</div>
<label style={labelStyle(t)}>Seed</label>
<input
type="number"
value={spec.seed}
onChange={(e) =>
setSpec((s) => ({
...s,
seed: parseInt(e.target.value, 10) || 0,
}))
}
style={inputStyle(t)}
/>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8 }}>
<div>
<label style={labelStyle(t)}>K (optional)</label>
<input
value={spec.K}
onChange={(e) => setSpec((s) => ({ ...s, K: e.target.value }))}
placeholder="—"
style={inputStyle(t)}
/>
</div>
<div>
<label style={labelStyle(t)}>K_screen</label>
<input
value={spec.kScreen}
onChange={(e) =>
setSpec((s) => ({ ...s, kScreen: e.target.value }))
}
placeholder="—"
style={inputStyle(t)}
/>
</div>
<div>
<label style={labelStyle(t)}>K_select</label>
<input
value={spec.kSelect}
onChange={(e) =>
setSpec((s) => ({ ...s, kSelect: e.target.value }))
}
placeholder="—"
style={inputStyle(t)}
/>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<div>
<label style={labelStyle(t)}>n_layers (API)</label>
<input
type="number"
min={1}
value={spec.nLayers}
onChange={(e) =>
setSpec((s) => ({
...s,
nLayers: parseInt(e.target.value, 10) || 3,
}))
}
style={inputStyle(t)}
/>
</div>
<div>
<label style={labelStyle(t)}>n_restarts (API)</label>
<input
type="number"
min={1}
value={spec.nRestarts}
onChange={(e) =>
setSpec((s) => ({
...s,
nRestarts: parseInt(e.target.value, 10) || 8,
}))
}
style={inputStyle(t)}
/>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<div>
<label style={labelStyle(t)}>λ_risk (API)</label>
<input
type="number"
step={0.1}
value={spec.lambdaRisk}
onChange={(e) =>
setSpec((s) => ({
...s,
lambdaRisk: parseFloat(e.target.value) || 1,
}))
}
style={inputStyle(t)}
/>
</div>
<div>
<label style={labelStyle(t)}>γ (API)</label>
<input
type="number"
step={0.5}
value={spec.gamma}
onChange={(e) =>
setSpec((s) => ({
...s,
gamma: parseFloat(e.target.value) || 8,
}))
}
style={inputStyle(t)}
/>
</div>
</div>
<button type="button" onClick={syncFromSidebar} style={btnSecondary(t)}>
Sync from sidebar
</button>
<p style={{ fontSize: 9, color: t.textDim, marginTop: 8, lineHeight: 1.4 }}>
After sync, run Quick sim or Full optimizer to align weights with the new spec.
</p>
</section>
<section style={cardStyle} aria-label="Run optimization">
<h3
style={{
margin: "0 0 10px",
fontSize: 13,
fontWeight: 700,
color: t.text,
fontFamily: FONT.sans,
}}
>
Run
</h3>
<p style={{ fontSize: 10, color: t.textMuted, marginBottom: 8 }}>
Same spec; different fidelity.{" "}
<abbr
title="Quick sim runs in-browser surrogates. Full optimizer calls the Python service (network latency, authoritative math)."
style={{ cursor: "help", color: t.accent }}
>
?
</abbr>
</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
<button
type="button"
onClick={runClient}
disabled={clientBusy}
style={btnPrimary(t, clientBusy)}
title="Fast browser simulation — good for iteration"
>
{clientBusy ? "Running…" : "Quick sim (browser)"}
</button>
<button
type="button"
onClick={() => void runApi()}
disabled={apiBusy}
style={btnPrimary(t, apiBusy)}
title="Python /api/portfolio/optimize — authoritative"
>
{apiBusy ? "Running…" : "Full optimizer (server)"}
</button>
</div>
<p style={{ fontSize: 10, color: t.textDim, marginTop: 10 }}>
Last weights from:{" "}
<strong style={{ color: t.accent }}>
{lastSource === "client"
? "Quick sim"
: lastSource === "api"
? "Full optimizer"
: "Manual / equal"}
</strong>
{weightsDirty ? (
<span style={{ color: t.accentWarm }}> · weights edited</span>
) : null}
</p>
{lastClientMetrics && lastApiMetrics && (
<div
style={{
marginTop: 12,
padding: 10,
borderRadius: 8,
background: t.bg,
border: `1px solid ${t.border}`,
}}
>
<div
style={{
fontSize: 10,
fontWeight: 700,
color: t.text,
marginBottom: 8,
}}
>
Compare last two runs
</div>
<table style={{ width: "100%", fontSize: 10, fontFamily: FONT.mono }}>
<thead>
<tr>
<th style={{ textAlign: "left", color: t.textMuted }} />
<th style={{ color: t.textMuted }}>Quick sim</th>
<th style={{ color: t.textMuted }}>Full opt.</th>
<th style={{ color: t.textMuted }}>Δ</th>
</tr>
</thead>
<tbody>
<tr>
<td style={{ color: t.textDim }}>Sharpe</td>
<td>{lastClientMetrics.sharpe.toFixed(4)}</td>
<td>{lastApiMetrics.sharpe.toFixed(4)}</td>
<td
style={{
color:
Math.abs(lastClientMetrics.sharpe - lastApiMetrics.sharpe) <
SHARPE_CLOSE_EPS
? t.green
: t.text,
}}
>
{(lastClientMetrics.sharpe - lastApiMetrics.sharpe).toFixed(4)}
</td>
</tr>
<tr>
<td style={{ color: t.textDim }}>Return</td>
<td>{(lastClientMetrics.portReturn * 100).toFixed(2)}%</td>
<td>{(lastApiMetrics.portReturn * 100).toFixed(2)}%</td>
<td>
{(
(lastClientMetrics.portReturn - lastApiMetrics.portReturn) *
100
).toFixed(2)}
pp
</td>
</tr>
<tr>
<td style={{ color: t.textDim }}>Vol</td>
<td>{(lastClientMetrics.portVol * 100).toFixed(2)}%</td>
<td>{(lastApiMetrics.portVol * 100).toFixed(2)}%</td>
<td>
{(
(lastClientMetrics.portVol - lastApiMetrics.portVol) *
100
).toFixed(2)}
pp
</td>
</tr>
</tbody>
</table>
{sharpeCompareNote && (
<p style={{ fontSize: 9, color: t.textMuted, marginTop: 8, lineHeight: 1.4 }}>
{sharpeCompareNote}
</p>
)}
</div>
)}
{error && (
<p
style={{
fontSize: 10,
color: t.red,
marginTop: 8,
fontFamily: FONT.mono,
}}
>
{error}
</p>
)}
</section>
<section style={cardStyle} aria-label="Weights">
<h3
style={{
margin: "0 0 10px",
fontSize: 13,
fontWeight: 700,
color: t.text,
fontFamily: FONT.sans,
}}
>
Weights
</h3>
<div style={{ display: "flex", gap: 8, marginBottom: 10, flexWrap: "wrap" }}>
<button type="button" onClick={setEqualWeights} style={btnSecondary(t)}>
Equal weight
</button>
<button type="button" onClick={applyBounds} style={btnSecondary(t)}>
Clip + normalize
</button>
</div>
<div style={{ maxHeight: 280, overflowY: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 11,
fontFamily: FONT.mono,
}}
>
<thead>
<tr>
<th style={thStyle(t)}>Asset</th>
<th style={thStyle(t)}>w</th>
</tr>
</thead>
<tbody>
{data.assets.map((a, i) => (
<tr key={a.name}>
<td style={tdStyle(t)}>{a.name}</td>
<td style={tdStyle(t)}>
<input
type="number"
step={0.001}
min={0}
max={1}
value={weights[i] ?? 0}
onChange={(e) => 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,
}}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section style={cardStyle} aria-label="Metrics">
<h3
style={{
margin: "0 0 10px",
fontSize: 13,
fontWeight: 700,
color: t.text,
fontFamily: FONT.sans,
}}
>
Metrics (from current w)
</h3>
{metrics ? (
<dl
style={{
display: "grid",
gridTemplateColumns: "auto 1fr",
gap: "6px 12px",
fontSize: 11,
fontFamily: FONT.mono,
margin: 0,
}}
>
<dt style={{ color: t.textMuted }}>Ann. return</dt>
<dd style={{ margin: 0 }}>
{(metrics.portReturn * 100).toFixed(2)}%
</dd>
<dt style={{ color: t.textMuted }}>Vol</dt>
<dd style={{ margin: 0 }}>{(metrics.portVol * 100).toFixed(2)}%</dd>
<dt style={{ color: t.textMuted }}>Sharpe</dt>
<dd style={{ margin: 0 }}>{metrics.sharpe.toFixed(4)}</dd>
<dt style={{ color: t.textMuted }}>n_active</dt>
<dd style={{ margin: 0 }}>{metrics.nActive}</dd>
</dl>
) : (
<p style={{ color: t.textDim, fontSize: 11 }}></p>
)}
<div style={{ marginTop: 12, display: "flex", flexWrap: "wrap", gap: 8 }}>
<button
type="button"
onClick={() => saveSnapshot("A")}
style={btnSecondary(t)}
>
Save snapshot A
</button>
<button
type="button"
onClick={() => saveSnapshot("B")}
style={btnSecondary(t)}
>
Save snapshot B
</button>
</div>
{abDiff && (
<div style={{ marginTop: 10, fontSize: 9, fontFamily: FONT.mono, color: t.textMuted }}>
<div style={{ fontWeight: 700, color: t.text, marginBottom: 4 }}>
A vs B
</div>
<div>Δ Sharpe: {abDiff.dSharpe.toFixed(4)}</div>
<div>Δ return: {(abDiff.dRet * 100).toFixed(2)} pp</div>
<div>Δ vol: {(abDiff.dVol * 100).toFixed(2)} pp</div>
<div>Max |Δw|: {abDiff.wDelta.toFixed(4)}</div>
<div style={{ marginTop: 6 }}>
{abDiff.specLines.map((l) => (
<div key={l}>{l}</div>
))}
</div>
</div>
)}
</section>
<section style={cardStyle} aria-label="Export">
<h3
style={{
margin: "0 0 10px",
fontSize: 13,
fontWeight: 700,
color: t.text,
fontFamily: FONT.sans,
}}
>
Export
</h3>
<p style={{ fontSize: 10, color: t.textMuted, marginBottom: 8 }}>
Copy or download spec, weights, metrics, snapshots for tickets or
papers.
</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
<button type="button" onClick={() => void copyJson()} style={btnSecondary(t)}>
Copy JSON
</button>
<button type="button" onClick={downloadJson} style={btnSecondary(t)}>
Download JSON
</button>
<button type="button" onClick={downloadCsv} style={btnSecondary(t)}>
Download CSV
</button>
</div>
</section>
<section style={cardStyle} aria-label="Quantum">
<h3
style={{
margin: "0 0 10px",
fontSize: 13,
fontWeight: 700,
color: t.text,
fontFamily: FONT.sans,
}}
>
Quantum / metadata
</h3>
<p style={{ fontSize: 10, color: t.textMuted, lineHeight: 1.45 }}>
Standard optimize responses usually have no quantum block. Lab runs
with IBM may expose metadata elsewhere (Reports).
</p>
{quantumMeta && Object.keys(quantumMeta).length > 0 ? (
<pre
style={{
fontSize: 9,
fontFamily: FONT.mono,
color: t.text,
whiteSpace: "pre-wrap",
wordBreak: "break-all",
margin: "10px 0 0",
padding: 8,
borderRadius: 6,
background: t.bg,
border: `1px solid ${t.border}`,
}}
>
{JSON.stringify(quantumMeta, null, 2)}
</pre>
) : (
<p style={{ fontSize: 10, color: t.textDim, marginTop: 8 }}>
Classical / no quantum_metadata on last API response.
</p>
)}
</section>
<section style={cardStyle} aria-label="Circuit placeholder">
<h3
style={{
margin: "0 0 10px",
fontSize: 13,
fontWeight: 700,
color: t.text,
fontFamily: FONT.sans,
}}
>
Circuit
</h3>
<p style={{ fontSize: 10, color: t.textDim, lineHeight: 1.5 }}>
Circuit visualization requires backend circuit JSON (not yet exposed
on optimize).
</p>
</section>
</div>
</div>
);
}
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}`,
};
}