374 lines
15 KiB
JavaScript
374 lines
15 KiB
JavaScript
/*
|
||
* Browser-side controller for the Adaptive Barrier Monitor demo (Monte Carlo showcase).
|
||
*
|
||
* Runs a simulation and renders each path with adaptive sampling (cyan) and
|
||
* either an equal-budget or independently fixed-cadence baseline (grey).
|
||
*/
|
||
|
||
const state = { lastResult: null };
|
||
|
||
// ---- helpers ----
|
||
|
||
function $(id) { return document.getElementById(id); }
|
||
|
||
async function fetchJson(url, options = {}) {
|
||
const resp = await fetch(url, options);
|
||
const text = await resp.text();
|
||
let payload;
|
||
try { payload = text ? JSON.parse(text) : {}; } catch { payload = { detail: text }; }
|
||
if (!resp.ok) {
|
||
const detail = payload.detail ?? payload;
|
||
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail, null, 2));
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
function setBusy(button, busy) {
|
||
button.disabled = busy;
|
||
button.textContent = busy ? "Working…" : (button.dataset.label || button.textContent);
|
||
}
|
||
|
||
function showWarnings(warnings) {
|
||
const box = $("warning-box");
|
||
const norm = (warnings || []).filter(Boolean).map(String);
|
||
if (norm.length === 0) { box.hidden = true; box.textContent = ""; return; }
|
||
box.hidden = false;
|
||
box.textContent = norm.join("\n");
|
||
}
|
||
|
||
// ---- slider ↔ number sync ----
|
||
|
||
function bindRange(id) {
|
||
const range = $(id);
|
||
const num = $(id + "-val");
|
||
if (!range || !num) return;
|
||
range.addEventListener("input", () => { num.value = parseFloat(range.value); });
|
||
num.addEventListener("change", () => { range.value = parseFloat(num.value); });
|
||
}
|
||
|
||
function bindLogRange(id) {
|
||
const range = $(id);
|
||
const num = $(id + "-val");
|
||
if (!range || !num) return;
|
||
range.addEventListener("input", () => { num.value = Number(range.value).toExponential(4); });
|
||
num.addEventListener("change", () => { range.value = parseFloat(num.value); });
|
||
}
|
||
|
||
function updateComparisonModeCopy() {
|
||
const mode = $("mc-comparison-mode").value;
|
||
const fixedCadence = mode === "fixed_cadence";
|
||
$("mc-fixed-cadence-row").hidden = !fixedCadence;
|
||
|
||
if (fixedCadence) {
|
||
$("fixed-explanation").innerHTML =
|
||
'A <strong>fixed-cadence</strong> sampler that observes every chosen number of minutes, independently of the adaptive sample count.';
|
||
$("headline-explanation").textContent =
|
||
'The schedules use independent sample counts. Compare detections, lag, and total observations to see the quality–cost trade-off.';
|
||
$("comparison-note").innerHTML =
|
||
'The fixed monitor samples at the selected cadence. The experiment compares <strong>detection quality and observation cost</strong>.';
|
||
$("allocation-method").innerHTML =
|
||
'<strong>Allocate samples:</strong> adaptive intervals shrink near the closest barrier; the fixed monitor samples at the selected cadence.';
|
||
$("method-conclusion").textContent =
|
||
'The headline compares detections, mean lag, and total observations under independent schedules.';
|
||
} else {
|
||
$("fixed-explanation").innerHTML =
|
||
'An exactly equal-budget <strong>fixed-rate</strong> sampler: each path receives the same number of observations as its adaptive counterpart, spread uniformly.';
|
||
$("headline-explanation").textContent =
|
||
'Same paths and exactly the same sample budget. The comparison reports which schedule confirms more lower and upper barrier events before the detection deadline.';
|
||
$("comparison-note").innerHTML =
|
||
'The adaptive and fixed monitors receive the <strong>same number of samples on every path</strong>. The experiment therefore compares sample placement—not computational cost.';
|
||
$("allocation-method").innerHTML =
|
||
'<strong>Allocate samples:</strong> adaptive intervals shrink near the closest barrier; the fixed monitor receives the exact same sample count, spaced uniformly.';
|
||
$("method-conclusion").textContent =
|
||
'The headline compares detections and mean lag under an equal sample budget.';
|
||
}
|
||
}
|
||
|
||
// ---- Plotly helpers ----
|
||
|
||
const PLOTLY_CONFIG = {
|
||
displayModeBar: true,
|
||
modeBarButtonsToRemove: ["lasso2d", "select2d"],
|
||
displaylogo: false,
|
||
responsive: true,
|
||
};
|
||
|
||
const PLOTLY_LAYOUT = {
|
||
font: { color: "#A9B1D6", family: "Inter, sans-serif" },
|
||
paper_bgcolor: "#1A1B26",
|
||
plot_bgcolor: "#1A1B26",
|
||
xaxis: { gridcolor: "rgba(120,124,153,0.15)", zerolinecolor: "rgba(120,124,153,0.3)" },
|
||
yaxis: { gridcolor: "rgba(120,124,153,0.15)", zerolinecolor: "rgba(120,124,153,0.3)" },
|
||
margin: { l: 60, r: 30, t: 20, b: 50 },
|
||
legend: { font: { color: "#A9B1D6" }, x: 0.01, y: 0.99, bgcolor: "rgba(26,27,38,0.6)" },
|
||
};
|
||
|
||
function renderPlot(data, layoutOverrides = {}) {
|
||
Plotly.newPlot("chart-stage", data, { ...PLOTLY_LAYOUT, ...layoutOverrides }, PLOTLY_CONFIG);
|
||
}
|
||
|
||
// ---- Monte Carlo ----
|
||
|
||
async function runMonteCarlo() {
|
||
const button = $("mc-run-btn");
|
||
setBusy(button, true);
|
||
showWarnings([]);
|
||
$("result-subtitle").textContent = "Running simulation…";
|
||
|
||
try {
|
||
const thresholdPct = parseFloat($("mc-threshold").value);
|
||
const risePct = parseFloat($("mc-rise-threshold").value);
|
||
const dtcapRaw = $("mc-dtcap").value.trim();
|
||
const seedRaw = $("mc-seed").value.trim();
|
||
const comparisonMode = $("mc-comparison-mode").value;
|
||
const payload = {
|
||
S0: 100.0,
|
||
sigma_annual: parseFloat($("mc-sigma").value),
|
||
mu_annual: parseFloat($("mc-mu").value),
|
||
window_minutes: parseFloat($("mc-window").value),
|
||
n_paths: parseInt($("mc-paths").value),
|
||
n_steps: parseInt($("mc-steps").value),
|
||
use_jumps: $("mc-jumps").checked,
|
||
jump_intensity: 25.0,
|
||
jump_mean: -0.02,
|
||
jump_sigma: 0.05,
|
||
eps: parseFloat($("mc-eps").value),
|
||
drop_fraction: thresholdPct / 100.0,
|
||
rise_fraction: risePct / 100.0,
|
||
dt_cap_minutes: dtcapRaw ? parseFloat(dtcapRaw) : null,
|
||
rng_seed: seedRaw ? parseInt(seedRaw) : null,
|
||
max_detection_lag_steps: parseInt($("mc-max-lag").value),
|
||
comparison_mode: comparisonMode,
|
||
fixed_cadence_minutes: parseFloat($("mc-fixed-cadence").value),
|
||
};
|
||
const r = await fetchJson("/api/simulate", {
|
||
method: "POST", headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
state.lastResult = r;
|
||
|
||
const colors = ["#2AC3DE", "#BB9AF7", "#9ECE6A", "#0DB9D7", "#B4F9F8",
|
||
"#FF9E64", "#7DCFFF", "#C0CAF5", "#73DACA", "#F7768E"];
|
||
const t = r.times_minutes;
|
||
const t0 = t[0], t1 = t[t.length - 1];
|
||
const traces = [];
|
||
|
||
for (let i = 0; i < r.paths.length; i++) {
|
||
const p = r.paths[i];
|
||
const c = colors[i % colors.length];
|
||
traces.push({
|
||
x: t, y: p.prices, type: "scatter", mode: "lines",
|
||
line: { color: c, width: 0.8, opacity: 0.5 }, name: `Path ${i + 1}`, showlegend: i < 5,
|
||
});
|
||
if (p.sample_times.length) {
|
||
traces.push({
|
||
x: p.sample_times, y: p.sample_prices, type: "scatter", mode: "markers",
|
||
marker: { color: "#2AC3DE", size: 6, symbol: "circle-open", opacity: 0.9 },
|
||
name: "adaptive", showlegend: i === 0,
|
||
});
|
||
}
|
||
if (p.fixed_sample_times.length) {
|
||
traces.push({
|
||
x: p.fixed_sample_times, y: p.fixed_sample_prices, type: "scatter", mode: "markers",
|
||
marker: { color: "#787C99", size: 3, opacity: 0.5 },
|
||
name: r.comparison_mode === "fixed_cadence" ? "fixed cadence" : "fixed equal-budget", showlegend: i === 0,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Lower barrier (drop)
|
||
traces.push({
|
||
x: [t0, t1], y: [r.lower_barrier_price, r.lower_barrier_price], type: "scatter", mode: "lines",
|
||
line: { color: "#F7768E", width: 2, dash: "dash" }, name: `−${thresholdPct}% lower`,
|
||
});
|
||
|
||
// Upper barrier (rise)
|
||
traces.push({
|
||
x: [t0, t1], y: [r.upper_barrier_price, r.upper_barrier_price], type: "scatter", mode: "lines",
|
||
line: { color: "#9ECE6A", width: 2, dash: "dash" }, name: `+${risePct}% upper`,
|
||
});
|
||
|
||
// Lower breach markers (red X)
|
||
const bxDown = [], byDown = [];
|
||
for (const p of r.paths) {
|
||
if (p.lower_breach_idx !== null) { bxDown.push(t[p.lower_breach_idx]); byDown.push(p.prices[p.lower_breach_idx]); }
|
||
}
|
||
if (bxDown.length) {
|
||
traces.push({
|
||
x: bxDown, y: byDown, type: "scatter", mode: "markers",
|
||
marker: { color: "#F7768E", size: 13, symbol: "x", line: { width: 3 } },
|
||
name: `↓ breaches (${bxDown.length})`,
|
||
});
|
||
}
|
||
|
||
// Upper breach markers (green triangles)
|
||
const bxUp = [], byUp = [];
|
||
for (const p of r.paths) {
|
||
if (p.upper_breach_idx !== null) { bxUp.push(t[p.upper_breach_idx]); byUp.push(p.prices[p.upper_breach_idx]); }
|
||
}
|
||
if (bxUp.length) {
|
||
traces.push({
|
||
x: bxUp, y: byUp, type: "scatter", mode: "markers",
|
||
marker: { color: "#9ECE6A", size: 13, symbol: "triangle-up", line: { width: 3 } },
|
||
name: `↑ breaches (${bxUp.length})`,
|
||
});
|
||
}
|
||
|
||
renderPlot(traces, {
|
||
xaxis: { title: "Time (minutes)" },
|
||
yaxis: { title: "Price ($)" },
|
||
showlegend: r.paths.length <= 10,
|
||
});
|
||
|
||
// Headline tiles — use explicit event counts and method labels.
|
||
// A barrier event is the first lower-barrier or upper-barrier crossing on a path.
|
||
// One path can therefore contribute up to two events.
|
||
const nEvents = r.n_barrier_events ?? r.n_breaches;
|
||
const nLowerEvents = r.n_lower_events ?? r.n_lower_breaches;
|
||
const nUpperEvents = r.n_upper_events ?? r.n_upper_breaches;
|
||
const number = new Intl.NumberFormat();
|
||
|
||
const directionSummary = (lowerCaught, upperCaught) => {
|
||
const lower = nLowerEvents > 0
|
||
? `↓ ${lowerCaught} of ${nLowerEvents} lower`
|
||
: "↓ no lower events";
|
||
const upper = nUpperEvents > 0
|
||
? `↑ ${upperCaught} of ${nUpperEvents} upper`
|
||
: "↑ no upper events";
|
||
return `${lower} · ${upper}`;
|
||
};
|
||
|
||
$("hl-adaptive").textContent = nEvents > 0
|
||
? `${r.adaptive_detections} of ${nEvents}` : "No events";
|
||
$("hl-fixed").textContent = nEvents > 0
|
||
? `${r.fixed_detections} of ${nEvents}` : "No events";
|
||
$("hl-adaptive-sub").textContent = directionSummary(
|
||
r.adaptive_lower_detections,
|
||
r.adaptive_upper_detections,
|
||
);
|
||
$("hl-fixed-sub").textContent = directionSummary(
|
||
r.fixed_lower_detections,
|
||
r.fixed_upper_detections,
|
||
);
|
||
|
||
const equalBudgetMode = r.comparison_mode === "equal_budget";
|
||
$("hl-samples").textContent = equalBudgetMode
|
||
? `${number.format(r.adaptive_total_samples)} each`
|
||
: `A ${number.format(r.adaptive_total_samples)} · F ${number.format(r.fixed_total_samples)}`;
|
||
$("hl-samples-sub").textContent = equalBudgetMode
|
||
? "same count on every path"
|
||
: `adaptive · fixed every ${number.format(r.fixed_cadence_minutes)} min`;
|
||
|
||
const adaptiveLag = (r.mean_detection_lag !== null && r.mean_detection_lag !== undefined)
|
||
? r.mean_detection_lag.toFixed(1) : "–";
|
||
const fixedLag = (r.mean_fixed_detection_lag !== null && r.mean_fixed_detection_lag !== undefined)
|
||
? r.mean_fixed_detection_lag.toFixed(1) : "–";
|
||
$("hl-lag").textContent = `${adaptiveLag} vs ${fixedLag}`;
|
||
|
||
const breachedPaths = r.n_paths_with_any_breach ?? r.paths.filter(
|
||
(path) => path.lower_breach_idx !== null || path.upper_breach_idx !== null,
|
||
).length;
|
||
$("result-subtitle").textContent =
|
||
`${nEvents} barrier events across ${breachedPaths} of ${r.n_paths} paths` +
|
||
` · ${nLowerEvents} lower, ${nUpperEvents} upper` +
|
||
` · deadline ≤${r.max_detection_lag_steps} grid steps` +
|
||
(r.comparison_mode === "fixed_cadence"
|
||
? ` · fixed every ${r.fixed_cadence_minutes} min`
|
||
: " · equal budget") +
|
||
(r.use_jumps ? " · Merton jumps" : " · pure GBM");
|
||
|
||
$("stats-json").textContent = JSON.stringify({
|
||
threshold_pct: thresholdPct,
|
||
rise_pct: risePct,
|
||
seed: payload.rng_seed,
|
||
n_paths: r.n_paths,
|
||
n_paths_with_any_breach: r.n_paths_with_any_breach,
|
||
n_barrier_events: r.n_barrier_events ?? r.n_breaches,
|
||
n_lower_events: r.n_lower_events ?? r.n_lower_breaches,
|
||
n_upper_events: r.n_upper_events ?? r.n_upper_breaches,
|
||
adaptive_detections: r.adaptive_detections,
|
||
adaptive_lower_detections: r.adaptive_lower_detections,
|
||
adaptive_upper_detections: r.adaptive_upper_detections,
|
||
fixed_detections: r.fixed_detections,
|
||
fixed_lower_detections: r.fixed_lower_detections,
|
||
fixed_upper_detections: r.fixed_upper_detections,
|
||
adaptive_total_samples: r.adaptive_total_samples,
|
||
fixed_total_samples: r.fixed_total_samples,
|
||
mean_detection_lag: r.mean_detection_lag,
|
||
mean_lower_detection_lag: r.mean_lower_detection_lag,
|
||
mean_upper_detection_lag: r.mean_upper_detection_lag,
|
||
sigma_annual: r.sigma_annual,
|
||
mu_annual: r.mu_annual,
|
||
eps: r.eps,
|
||
drop_fraction: r.drop_fraction,
|
||
rise_fraction: r.rise_fraction,
|
||
dt_cap_minutes: r.dt_cap_minutes,
|
||
max_detection_lag_steps: r.max_detection_lag_steps,
|
||
comparison_mode: r.comparison_mode,
|
||
fixed_cadence_minutes: r.fixed_cadence_minutes,
|
||
grid_step_minutes: r.grid_step_minutes,
|
||
model_scope: r.model_scope,
|
||
}, null, 2);
|
||
|
||
const warnings = [];
|
||
if (r.use_jumps) {
|
||
warnings.push("Jump stress test: ε is derived from a continuous diffusion and does not bound missed jump events.");
|
||
}
|
||
if (r.comparison_mode === "fixed_cadence" && r.fixed_cadence_minutes < r.grid_step_minutes) {
|
||
warnings.push(`The requested fixed cadence (${r.fixed_cadence_minutes} min) is finer than the simulation grid (${r.grid_step_minutes.toFixed(2)} min), so it is limited to one sample per grid point.`);
|
||
}
|
||
if (r.n_breaches === 0) {
|
||
warnings.push("No barrier events occurred in this run. Increase the horizon/volatility, lower the thresholds, or choose another seed.");
|
||
}
|
||
showWarnings(warnings);
|
||
} catch (err) {
|
||
showWarnings([err.message]);
|
||
} finally {
|
||
setBusy(button, false);
|
||
}
|
||
}
|
||
|
||
|
||
/** Initialize collapsible cards to match the ClimbingBoardGPT interaction. */
|
||
function initCollapsibleCards() {
|
||
document.querySelectorAll(".card.collapsible > h2").forEach((heading) => {
|
||
const card = heading.parentElement;
|
||
heading.addEventListener("click", () => {
|
||
card.classList.toggle("collapsed");
|
||
});
|
||
});
|
||
}
|
||
|
||
// ---- init ----
|
||
|
||
async function init() {
|
||
$("mc-run-btn").dataset.label = "Run simulation";
|
||
|
||
try {
|
||
await fetchJson("/api/health");
|
||
$("health").textContent = "ready";
|
||
} catch {
|
||
$("health").textContent = "offline";
|
||
}
|
||
|
||
bindRange("mc-sigma");
|
||
bindLogRange("mc-eps");
|
||
updateComparisonModeCopy();
|
||
initCollapsibleCards();
|
||
|
||
$("mc-comparison-mode").addEventListener("change", async () => {
|
||
updateComparisonModeCopy();
|
||
await runMonteCarlo();
|
||
});
|
||
$("mc-run-btn").addEventListener("click", runMonteCarlo);
|
||
|
||
// Enter opens on a compelling scenario immediately.
|
||
await runMonteCarlo();
|
||
}
|
||
|
||
init().catch(err => {
|
||
$("health").textContent = `Error: ${err.message}`;
|
||
showWarnings(["Initialization error: " + err.message]);
|
||
console.error(err);
|
||
});
|