Files
Adaptive-Barrier-Monitor/webapp/static/app.js
T
ps e5d785f511 Polish web demo tests and refresh notebooks
- add README live-demo badge
- tighten engine validation for jump grids and detector indices
- replace flaky FastAPI TestClient web tests with direct handler tests
- remove unused httpx dev dependency
- clean stale frontend comments
- re-execute notebooks with clean sequential outputs
2026-08-03 08:15:22 -04:00

374 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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 qualitycost 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 parameter cards. */
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);
});