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
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
# Adaptive Barrier Monitor
|
# Adaptive Barrier Monitor
|
||||||
|
|
||||||
|
[](https://abm.pawelsarkowicz.xyz)
|
||||||
|
|
||||||
A five-notebook quantitative-finance project connecting random walks, Brownian
|
A five-notebook quantitative-finance project connecting random walks, Brownian
|
||||||
motion, geometric Brownian motion, first-passage times, Brownian bridges, and
|
motion, geometric Brownian motion, first-passage times, Brownian bridges, and
|
||||||
state-dependent monitoring.
|
state-dependent monitoring.
|
||||||
@@ -19,7 +21,7 @@ appear in a practical monitoring problem.
|
|||||||
Under geometric Brownian motion,
|
Under geometric Brownian motion,
|
||||||
|
|
||||||
$$
|
$$
|
||||||
\frac{dS_t}{S_t}=\mu\,dt+\sigma\,dW_t,
|
\frac{dS_t}{S_t}=\mu dt+\sigma dW_t,
|
||||||
$$
|
$$
|
||||||
|
|
||||||
the relative log-price $X_t=\log(S_t/S_0)$ is arithmetic Brownian motion. A
|
the relative log-price $X_t=\log(S_t/S_0)$ is arithmetic Brownian motion. A
|
||||||
@@ -28,7 +30,7 @@ the relative log-price $X_t=\log(S_t/S_0)$ is arithmetic Brownian motion. A
|
|||||||
For zero drift, the probability of touching the barrier by time $T$ is
|
For zero drift, the probability of touching the barrier by time $T$ is
|
||||||
|
|
||||||
$$
|
$$
|
||||||
P(\tau_B\leq T)=2\Phi\!\left(\frac{B}{\sigma\sqrt{T}}\right).
|
P(\tau_B\leq T)=2\Phi\left(\frac{B}{\sigma\sqrt{T}}\right).
|
||||||
$$
|
$$
|
||||||
|
|
||||||
At 30% annualised volatility, a 10% move in five trading minutes is roughly a
|
At 30% annualised volatility, a 10% move in five trading minutes is roughly a
|
||||||
@@ -41,7 +43,7 @@ that the hidden path crossed the barrier is
|
|||||||
|
|
||||||
$$
|
$$
|
||||||
P_{\mathrm{cross}}
|
P_{\mathrm{cross}}
|
||||||
=\exp\!\left(
|
=\exp\left(
|
||||||
-\frac{2(x_0-B)(x_T-B)}{\sigma^2\Delta t}
|
-\frac{2(x_0-B)(x_T-B)}{\sigma^2\Delta t}
|
||||||
\right).
|
\right).
|
||||||
$$
|
$$
|
||||||
@@ -188,8 +190,7 @@ adaptive-barrier-monitor/
|
|||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
Python, NumPy, SciPy, pandas, SymPy, Matplotlib, FastAPI, Pydantic, Uvicorn,
|
Python, NumPy, SciPy, pandas, SymPy, Matplotlib, FastAPI, Pydantic, Uvicorn,
|
||||||
Plotly.js, Docker, pytest, and GitHub Actions.
|
Plotly.js, Docker, pytest.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT — see [`LICENSE`](LICENSE).
|
MIT — see [`LICENSE`](LICENSE).
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -28,7 +28,6 @@ webapp = [
|
|||||||
]
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8,<9",
|
"pytest>=8,<9",
|
||||||
"httpx>=0.27,<1",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -178,6 +178,8 @@ def merton_jump_diffusion(
|
|||||||
) -> tuple[np.ndarray, np.ndarray]:
|
) -> tuple[np.ndarray, np.ndarray]:
|
||||||
"""Sample a Merton jump diffusion with annualised Poisson intensity."""
|
"""Sample a Merton jump diffusion with annualised Poisson intensity."""
|
||||||
_require_positive("S0", S0)
|
_require_positive("S0", S0)
|
||||||
|
_require_positive("T", T)
|
||||||
|
_require_positive_int("n_steps", n_steps)
|
||||||
_require_positive("sigma", sigma)
|
_require_positive("sigma", sigma)
|
||||||
_require_positive_int("n_paths", n_paths)
|
_require_positive_int("n_paths", n_paths)
|
||||||
if not np.isfinite(mu):
|
if not np.isfinite(mu):
|
||||||
@@ -472,8 +474,24 @@ def detect_barrier_event(
|
|||||||
"""
|
"""
|
||||||
if breach_idx is None:
|
if breach_idx is None:
|
||||||
return False, None, None
|
return False, None, None
|
||||||
indices = np.asarray(sample_indices, dtype=int).ravel()
|
raw_indices = np.asarray(sample_indices)
|
||||||
|
if np.issubdtype(raw_indices.dtype, np.bool_):
|
||||||
|
raise ValueError("sample_indices must contain integer grid indices")
|
||||||
|
index_values = np.asarray(sample_indices, dtype=float).ravel()
|
||||||
|
if np.any(~np.isfinite(index_values)) or np.any(index_values != np.floor(index_values)):
|
||||||
|
raise ValueError("sample_indices must contain integer grid indices")
|
||||||
|
indices = index_values.astype(int)
|
||||||
path = np.asarray(values, dtype=float).ravel()
|
path = np.asarray(values, dtype=float).ravel()
|
||||||
|
if path.size == 0 or np.any(~np.isfinite(path)) or not np.isfinite(barrier):
|
||||||
|
raise ValueError("values and barrier must be finite")
|
||||||
|
if np.any(indices < 0) or np.any(indices >= len(path)):
|
||||||
|
raise ValueError("sample_indices must lie within the path")
|
||||||
|
if indices.size > 1 and np.any(np.diff(indices) < 0):
|
||||||
|
raise ValueError("sample_indices must be sorted in ascending order")
|
||||||
|
if isinstance(breach_idx, bool) or int(breach_idx) != breach_idx:
|
||||||
|
raise ValueError("breach_idx must be an integer or None")
|
||||||
|
if breach_idx < 0 or breach_idx >= len(path):
|
||||||
|
raise ValueError("breach_idx must lie within the path")
|
||||||
if direction not in {"down", "up"}:
|
if direction not in {"down", "up"}:
|
||||||
raise ValueError("direction must be 'down' or 'up'")
|
raise ValueError("direction must be 'down' or 'up'")
|
||||||
if max_lag_steps is not None:
|
if max_lag_steps is not None:
|
||||||
|
|||||||
@@ -52,6 +52,16 @@ def test_detection_deadline_is_enforced():
|
|||||||
assert detect_barrier_event(samples, values, 1, 90.0, "down", 3) == (True, 3, 4)
|
assert detect_barrier_event(samples, values, 1, 90.0, "down", 3) == (True, 3, 4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_detection_rejects_invalid_indices():
|
||||||
|
values = np.array([100.0, 89.0, 88.0])
|
||||||
|
with pytest.raises(ValueError, match="within the path"):
|
||||||
|
detect_barrier_event(np.array([0, 3]), values, 1, 90.0, "down", 2)
|
||||||
|
with pytest.raises(ValueError, match="ascending"):
|
||||||
|
detect_barrier_event(np.array([2, 1]), values, 1, 90.0, "down", 2)
|
||||||
|
with pytest.raises(ValueError, match="integer grid indices"):
|
||||||
|
detect_barrier_event(np.array([0.5, 1.0]), values, 1, 90.0, "down", 2)
|
||||||
|
|
||||||
|
|
||||||
def test_fixed_indices_have_exact_requested_count():
|
def test_fixed_indices_have_exact_requested_count():
|
||||||
for n in (2, 10, 101):
|
for n in (2, 10, 101):
|
||||||
for k in range(1, n + 1):
|
for k in range(1, n + 1):
|
||||||
@@ -74,6 +84,13 @@ def test_zero_jump_intensity_matches_gbm_for_same_seed():
|
|||||||
assert np.array_equal(gbm, jump)
|
assert np.array_equal(gbm, jump)
|
||||||
|
|
||||||
|
|
||||||
|
def test_merton_jump_diffusion_validates_time_grid_before_sampling():
|
||||||
|
with pytest.raises(ValueError, match="T"):
|
||||||
|
merton_jump_diffusion(100.0, 0.0, 100)
|
||||||
|
with pytest.raises(ValueError, match="n_steps"):
|
||||||
|
merton_jump_diffusion(100.0, 5 / TRADING_MINUTES_PER_YEAR, 0)
|
||||||
|
|
||||||
|
|
||||||
def test_simulation_baseline_has_exact_per_path_budget():
|
def test_simulation_baseline_has_exact_per_path_budget():
|
||||||
result = run_monte_carlo_simulation(
|
result = run_monte_carlo_simulation(
|
||||||
n_paths=12,
|
n_paths=12,
|
||||||
|
|||||||
+35
-39
@@ -1,36 +1,30 @@
|
|||||||
from fastapi.testclient import TestClient
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from webapp.app import app
|
from webapp.app import SimulateRequest, app, health, simulate
|
||||||
|
|
||||||
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
|
|
||||||
def test_health_endpoint():
|
def test_health_endpoint():
|
||||||
response = client.get("/api/health")
|
payload = health()
|
||||||
assert response.status_code == 200
|
|
||||||
payload = response.json()
|
|
||||||
assert payload["ok"] is True
|
assert payload["ok"] is True
|
||||||
assert payload["version"] == "0.4.7"
|
assert payload["version"] == "0.4.7"
|
||||||
|
|
||||||
|
|
||||||
def test_simulation_endpoint_preserves_equal_budget():
|
def test_simulation_endpoint_preserves_equal_budget():
|
||||||
response = client.post(
|
payload = simulate(
|
||||||
"/api/simulate",
|
SimulateRequest(
|
||||||
json={
|
rng_seed=3,
|
||||||
"rng_seed": 3,
|
n_paths=5,
|
||||||
"n_paths": 5,
|
n_steps=100,
|
||||||
"n_steps": 100,
|
drop_fraction=0.05,
|
||||||
"drop_fraction": 0.05,
|
rise_fraction=0.08,
|
||||||
"rise_fraction": 0.08,
|
max_detection_lag_steps=3,
|
||||||
"max_detection_lag_steps": 3,
|
)
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
|
||||||
payload = response.json()
|
|
||||||
assert payload["adaptive_total_samples"] == payload["fixed_total_samples"]
|
assert payload["adaptive_total_samples"] == payload["fixed_total_samples"]
|
||||||
assert payload["max_detection_lag_steps"] == 3
|
assert payload["max_detection_lag_steps"] == 3
|
||||||
|
|
||||||
|
|
||||||
def test_webapp_imports_from_source_checkout_without_installed_package(tmp_path):
|
def test_webapp_imports_from_source_checkout_without_installed_package(tmp_path):
|
||||||
"""The documented direct uvicorn command must resolve the src-layout package."""
|
"""The documented direct uvicorn command must resolve the src-layout package."""
|
||||||
import os
|
import os
|
||||||
@@ -53,30 +47,32 @@ def test_webapp_imports_from_source_checkout_without_installed_package(tmp_path)
|
|||||||
assert "Adaptive Barrier Monitor" in completed.stdout
|
assert "Adaptive Barrier Monitor" in completed.stdout
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def test_simulation_endpoint_supports_fixed_cadence():
|
def test_simulation_endpoint_supports_fixed_cadence():
|
||||||
response = client.post(
|
payload = simulate(
|
||||||
"/api/simulate",
|
SimulateRequest(
|
||||||
json={
|
rng_seed=3,
|
||||||
"rng_seed": 3,
|
n_paths=5,
|
||||||
"n_paths": 5,
|
n_steps=200,
|
||||||
"n_steps": 200,
|
window_minutes=200.0,
|
||||||
"window_minutes": 200.0,
|
dt_cap_minutes=2.0,
|
||||||
"dt_cap_minutes": 2.0,
|
comparison_mode="fixed_cadence",
|
||||||
"comparison_mode": "fixed_cadence",
|
fixed_cadence_minutes=20.0,
|
||||||
"fixed_cadence_minutes": 20.0,
|
)
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
|
||||||
payload = response.json()
|
|
||||||
assert payload["comparison_mode"] == "fixed_cadence"
|
assert payload["comparison_mode"] == "fixed_cadence"
|
||||||
assert payload["fixed_cadence_minutes"] == 20.0
|
assert payload["fixed_cadence_minutes"] == 20.0
|
||||||
assert payload["adaptive_total_samples"] != payload["fixed_total_samples"]
|
assert payload["adaptive_total_samples"] != payload["fixed_total_samples"]
|
||||||
|
|
||||||
|
|
||||||
def test_simulation_endpoint_rejects_unknown_comparison_mode():
|
def test_simulation_endpoint_rejects_unknown_comparison_mode():
|
||||||
response = client.post(
|
with pytest.raises(ValidationError):
|
||||||
"/api/simulate",
|
SimulateRequest.model_validate({"comparison_mode": "unknown"})
|
||||||
json={"comparison_mode": "unknown"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
def test_app_registers_expected_routes():
|
||||||
|
route_methods = {
|
||||||
|
route.path: getattr(route, "methods", set())
|
||||||
|
for route in app.routes
|
||||||
|
}
|
||||||
|
assert "GET" in route_methods["/api/health"]
|
||||||
|
assert "POST" in route_methods["/api/simulate"]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* Theme tokens and page structure intentionally mirror ClimbingBoardGPT. */
|
/* Theme tokens and page structure for the Adaptive Barrier Monitor demo. */
|
||||||
:root {
|
:root {
|
||||||
--base00: #1A1B26;
|
--base00: #1A1B26;
|
||||||
--base01: #16161E;
|
--base01: #16161E;
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ async function runMonteCarlo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Initialize collapsible cards to match the ClimbingBoardGPT interaction. */
|
/** Initialize collapsible parameter cards. */
|
||||||
function initCollapsibleCards() {
|
function initCollapsibleCards() {
|
||||||
document.querySelectorAll(".card.collapsible > h2").forEach((heading) => {
|
document.querySelectorAll(".card.collapsible > h2").forEach((heading) => {
|
||||||
const card = heading.parentElement;
|
const card = heading.parentElement;
|
||||||
|
|||||||
@@ -159,7 +159,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Left column, row 2: supporting information, mirroring ClimbingBoardGPT. -->
|
<!-- Left column, row 2: supporting information. -->
|
||||||
<section class="controls" id="col-info">
|
<section class="controls" id="col-info">
|
||||||
<div class="card explain">
|
<div class="card explain">
|
||||||
<h2>How to read it</h2>
|
<h2>How to read it</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user