herzogflorian c057f8efb7 Kubernetes deployment: shared data directory, write lock, probes
A third deployment next to the two on the DGX Spark, which stay as they
are: deploy/k8s/ has its own image and Kustomize manifests. The API is a
Deployment with probes, the data a PersistentVolumeClaim, the nightly
update a CronJob; overlays/gpu adds cuOpt on a GPU of the cluster.

Five assumptions of the single-container setup did not hold for pods, so
the service changed (on the Spark nothing changes that one would notice):

- probes: GET /health/live (async, no I/O) and GET /health/ready (data
  directory readable); neither needs cuOpt or market data, so a cuOpt
  outage never gets the API restarted. /health is unchanged
- DataStore replaces every file atomically (temporary file renamed into
  place) and saves the manifest last; the API reloads its market data
  when the manifest changes, so it serves what a CronJob wrote
- one writer at a time across processes and pods: an flock on
  /data/.write.lock, taken by API jobs and the CLI's data commands
  (setup, reselect, update, clean, snapshot); a second writer gets 409 or
  exit status 3, with the holder's name. `clean` now updates the manifest
- job records live in /data/jobs/ and are written at every status
  change: every replica answers GET /jobs/{id}, and a job whose process
  has gone is reported failed ("interrupted")
- the image runs as uid 10001 under a read-only root file system, with
  the library versions pinned to deploy/apptainer/constraints.txt (SciPy
  1.18 releases the GIL while HiGHS solves, so the liveness probe is
  answered during an optimisation)

docs/KUBERNETES.md is the guide (build, deploy, load the data, operate,
troubleshooting); README, USAGE and ARCHITECTURE point to it.

Both kustomizations render (kubectl kustomize); the k3s test of the
deployment is recorded in docs/KUBERNETES.md. 149 tests (148 pass, the
cuOpt round trip skipped without a server).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:27:43 +01:00

249 lines
15 KiB
Python

import json
import numpy as np
import pytest
from fastapi.testclient import TestClient
import quantcore.api.app as app_module
from quantcore.config import Settings
from quantcore.data.store import DataStore
@pytest.fixture(scope="module")
def client(settings, store):
app_module.state = app_module.State(settings)
with TestClient(app_module.app) as c:
yield c
def test_health_and_universe(client):
h = client.get("/health").json()
assert h["status"] == "ok" and h["store"]["prices"] is True and h["cuopt"]["reachable"] is False
u = client.get("/universe", params={"region": "Europe"}).json()
assert len(u) == 6 and all(row["region"] == "Europe" for row in u)
assert client.get("/universe/S0").json()["symbol"] == "S0"
assert client.get("/universe/NOPE").status_code == 404
def test_prices_returns_and_market(client):
p = client.get("/prices", params={"symbols": "S0,S1", "freq": "weekly", "start": "2020-01-01"}).json()
assert p["columns"] == ["S0", "S1"] and len(p["index"]) > 100
r = client.get("/returns", params={"symbols": "S0", "currency": "local"}).json()
assert r["kind"] == "log_returns"
m = client.get("/market/index", params={"freq": "weekly"}).json()
assert "mean_ann" in m and m["n"] > 100
w = client.get("/market/weights").json()["weights"]
assert abs(sum(w.values()) - 1) < 1e-6
def test_risk_and_performance_endpoints(client):
body = {"symbols": ["S0", "S1", "S2"], "horizon": "week", "confidence": [0.95, 0.99]}
rows = client.post("/risk/assets", json=body).json()
assert len(rows) == 3 and rows[0]["cvar99_hist"] > rows[0]["var99_hist"]
corr = client.post("/risk/correlation", json={"symbols": ["S0", "S1"], "method": "ewm", "lam": 0.99}).json()
assert corr["data"][0][0] == pytest.approx(1.0)
pr = client.post("/risk/portfolio", json={"holdings": {"S0": 10, "S3": 5}, "cash": 500, "horizon": "month"}).json()
assert pr["confidence"]["0.95"]["cvar_money"] > 0 and pr["horizon_periods"] == 21
series = client.post("/risk/series", json={"symbol": "S0", "window": 100}).json()
assert "vol_ewm_ann" in series["columns"]
perf = client.post("/performance/assets", json={"symbols": ["S0"], "freq": "weekly"}).json()
assert "sharpe" in perf[0]
pp = client.post("/performance/portfolio", json={"holdings": {"S0": 10, "S1": 20}}).json()
assert "max_drawdown" in pp
roll = client.post("/performance/rolling", json={"symbol": "S1", "window": 60}).json()
assert "sharpe" in roll["columns"]
assert client.post("/risk/assets", json={"symbols": ["NOPE"]}).status_code == 422
def test_models_and_optimize(client):
er = client.post("/model/expected-returns", json={"symbols": ["S0", "S1", "S2", "S3"],
"views": [{"symbol": "S1", "sentiment": 0.8, "confidence": 0.6}]}).json()
assert er["posterior_ann"]["S1"] > er["prior_ann"]["S1"] and len(er["views"]) == 1
sc = client.post("/model/scenarios", json={"symbols": ["S0", "S1"], "model": "gbm", "n_scenarios": 500}).json()
assert sc["description"]["n_scenarios"] == 512 and sc["description"]["n_requested"] == 500 # Sobol': power of two
assert sc["description"]["sampling"] == "sobol" and sc["description"]["moment_matched"] and len(sc["summary"]) == 2
assert len(sc["description"]["hash"]) == 16
body = {"mode": "new", "cash": 200000, "symbols": ["S0", "S1", "S2", "S3", "S4", "S5"], "cvar_limit": 8000,
"n_scenarios": 400, "scenario_model": "gbm", "solver": "highs", "horizon": "week",
"importance_sampling": "off"}
res = client.post("/optimize", json=body).json()
assert res["status"] == "Optimal" and res["solver"] == "highs" and res["reproducible"] is True
assert res["cvar"] <= 8000 + 1e-3 and res["cash_end"] >= 0
assert all(p["shares"] >= 0 for p in res["positions"])
# identical request -> identical scenario hash and identical portfolio
again = client.post("/optimize", json=body).json()
assert again["scenario_hash"] == res["scenario_hash"] and again["positions"] == res["positions"]
# importance sampling and the exact Gaussian mode are accepted and give proposals of the same kind
tilted = client.post("/optimize", json={**body, "importance_sampling": "market"}).json()
assert tilted["status"] == "Optimal" and tilted["model"]["scenarios"]["weighted"] is True
assert tilted["model"]["scenarios"]["importance_sampling"]["direction"] == "market"
adaptive = client.post("/optimize", json={**body, "importance_sampling": "adaptive"}).json()
assert adaptive["status"] == "Optimal"
assert adaptive["solver_info"]["importance_sampling"]["stage1_status"] == "Optimal"
# the market tilt does not depend on the risk limit: same scenario key and hash at another limit
other = client.post("/optimize", json={**body, "importance_sampling": "market", "cvar_limit": 5000}).json()
assert other["scenario_key"]["id"] == tilted["scenario_key"]["id"]
assert other["scenario_hash"] == tilted["scenario_hash"] and other["cvar"] <= 5000 + 1e-3
exact = client.post("/optimize", json={**body, "scenario_model": "gaussian_exact"}).json()
assert exact["status"] == "Optimal" and exact["reproducible"] and exact["scenario_hash"] is None
assert exact["solver_info"]["outer_approximation"]["converged"] and exact["cvar"] <= 8000 * (1 + 1e-5)
hold = {p["symbol"]: p["shares"] for p in res["positions"]}
reb = client.post("/optimize", json={**body, "mode": "rebalance", "holdings": hold, "cash": res["cash_end"],
"cvar_limit": 5000}).json()
assert reb["status"] == "Optimal" and reb["mode"] == "rebalance"
# the starting portfolio is reported on the same scenarios, so before and after are comparable
before = reb["before"]
assert res["before"] is None # a new portfolio has nothing to compare against
assert before["n_positions"] == sum(1 for v in hold.values() if v > 0)
assert before["invested"] == pytest.approx(res["invested"], rel=1e-9)
assert before["cvar"] > 5000 > reb["cvar"] # the rebalancing is what brings the risk under the new limit
assert before["expected_pnl"] > reb["expected_pnl"] # ... at the cost of expected return
assert sum(before["sector_exposure"].values()) == pytest.approx(before["invested"] / reb["wealth"], rel=1e-6)
def test_probes(client):
assert client.get("/health/live").json() == {"status": "ok"}
assert client.get("/health/ready").json() == {"status": "ready", "market_data": True}
def test_reloads_data_written_by_another_process(client, store):
md = app_module.state.md
assert app_module.state.md is md # nothing written: the loaded data are kept
store.update_manifest() # what a CronJob or the CLI does last after writing new data
assert app_module.state.md is not md
def test_job_records_live_in_the_data_directory(tmp_path, monkeypatch):
"""What another replica, or the pod after a restart, sees of a job: its record in jobs_dir."""
settings = Settings(data_dir=tmp_path, cuopt_url="http://127.0.0.1:9")
monkeypatch.setattr(app_module, "state", app_module.State(settings))
c = TestClient(app_module.app)
settings.jobs_dir.mkdir(parents=True)
record = {"id": "abc123", "kind": "update", "status": "running", "created_at": "2026-10-22T08:00:00+00:00"}
(settings.jobs_dir / "abc123.json").write_text(json.dumps(record))
with DataStore(settings).write_lock("update job abc123"): # its process is alive
assert c.get("/jobs/abc123").json()["status"] == "running"
busy = c.post("/jobs/update")
assert busy.status_code == 409 and "update job abc123" in busy.json()["detail"]
gone = c.get("/jobs/abc123").json() # lock released, no final record: the process has gone
assert gone["status"] == "failed" and gone["error"].startswith("interrupted")
# a job of this process: runs, fails (no data), leaves its record and frees the lock
job = c.post("/jobs/update").json()
final = c.get(f"/jobs/{job['id']}").json()
assert final["status"] == "failed" and "setup" in final["error"]
assert json.loads((settings.jobs_dir / f"{job['id']}.json").read_text())["status"] == "failed"
assert [j["id"] for j in c.get("/jobs").json()] == ["abc123", job["id"]]
assert c.post("/jobs/update").status_code == 202
def test_jobs_and_snapshots(client):
snaps = client.get("/snapshots").json()
assert isinstance(snaps, list)
created = client.post("/snapshots", json={"name": "test-snap"})
assert created.status_code == 201
assert "test-snap" in client.get("/snapshots").json()
assert client.get("/jobs").json() == []
assert client.get("/jobs/nope").status_code == 404
def test_asset_risk_in_value_space(client):
body = {"symbols": ["S0", "S1"], "horizon": "month", "confidence": [0.95, 0.99]}
log = client.post("/risk/assets", json=body).json()
val = client.post("/risk/assets", json={**body, "space": "value"}).json()
for a, b in zip(log, val):
# a VaR is a quantile: it carries over through 1 - e^(-x) (up to the interpolation between
# order statistics, which does not commute with a non-linear map)
assert b["var95_hist"] < a["var95_hist"]
assert b["var95_hist"] == pytest.approx(1 - np.exp(-a["var95_hist"]), rel=1e-4)
assert b["var95_normal_ewm"] == pytest.approx(1 - np.exp(-a["var95_normal_ewm"]), rel=1e-8)
# a CVaR is a tail mean: computed in value space it is strictly below the naive conversion
for col in ("cvar95_normal_ewm", "cvar99_normal_ewm"):
assert b[col] < 1 - np.exp(-a[col])
# this fixture drifts upwards, so at a monthly horizon the estimated mean can push the
# parametric VaR below zero -- the reason the UI asks for drift="zero"
assert a["var95_normal_ewm"] < 0
assert b["cvar99_hist"] < 1.0 # a share of the value can never reach 100 %
assert b["vol_ewm_ann"] == a["vol_ewm_ann"] # only the loss columns change
# the market-risk convention: no drift extrapolated into the horizon
zero = client.post("/risk/assets", json={**body, "drift": "zero"}).json()
for a, z in zip(log, zero):
assert z["var95_normal_ewm"] != a["var95_normal_ewm"]
assert z["var95_hist"] == a["var95_hist"] # historical columns keep their window
def test_symbol_table_and_facets(client):
t = client.get("/data/symbols", params={"limit": 5}).json()
assert t["n_universe"] == 12 and t["n_matched"] == 12 and len(t["symbols"]) == 5
caps = [row["market_cap"] for row in t["symbols"]]
assert caps == sorted(caps, reverse=True) # largest first
assert set(t["facets"]["regions"]) == {"US", "Europe"} and sum(t["facets"]["regions"].values()) == 12
row = t["symbols"][0]
assert row["price"] > 0 and 0 < row["market_weight"] <= 1 and row["sector"]
eu = client.get("/data/symbols", params={"region": "Europe"}).json()
assert eu["n_matched"] == 6 and all(r["region"] == "Europe" for r in eu["symbols"])
one = client.get("/data/symbols", params={"search": "S1"}).json()
assert {r["symbol"] for r in one["symbols"]} >= {"S1", "S10", "S11"}
def test_model_covariance_endpoint(client):
body = {"symbols": ["S0", "S1", "S2", "S3"], "freq": "daily", "horizon": "week",
"covariance": {"method": "ewm", "lam": 0.99, "shrinkage": "auto"}}
c = client.post("/model/covariance", json=body).json()
assert c["correlation"]["columns"] == body["symbols"]
assert c["correlation"]["data"][0][0] == pytest.approx(1.0)
assert 0 <= c["shrinkage_applied"] <= 1 and c["conditioning"]["n_assets"] == 4
assert c["conditioning"]["rank_deficient"] is False
assert sum(c["eigenvalues"]) == pytest.approx(1.0)
# the covariance is scaled to the horizon, the volatility to the year
vol_h = c["volatility_horizon"]["S0"]
assert c["covariance"]["data"][0][0] == pytest.approx(vol_h ** 2, rel=1e-9)
assert c["volatility_ann"]["S0"] == pytest.approx(vol_h * (252 / 5) ** 0.5, rel=1e-9)
def test_scenario_distribution_of_a_portfolio(client):
body = {"symbols": ["S0", "S1", "S2"], "model": "gbm", "n_scenarios": 1024, "horizon": "week",
"weights": {"S0": 0.5, "S1": 0.3, "S2": 0.2}, "value": 1_000_000, "bins": 30,
"focus": ["S0"], "importance_sampling": "off"}
d = client.post("/model/scenarios", json=body).json()
p = d["portfolio"]
assert p["value"] == 1_000_000 and p["invested"] == pytest.approx(1_000_000)
assert len(p["histogram"]["mass"]) == 30 and sum(p["histogram"]["mass"]) == pytest.approx(1.0)
assert p["risk"]["0.99"]["cvar"] > p["risk"]["0.95"]["cvar"] > p["risk"]["0.95"]["var"]
# the quantiles and the VaR are the same statistic seen from two sides (the VaR is the order
# statistic the Rockafellar-Uryasev rows use, the quantile interpolates - one rank apart)
assert p["quantiles"]["0.05"] == pytest.approx(-p["risk"]["0.95"]["var"], rel=1e-2)
assert p["min"] <= p["quantiles"]["0.01"] <= p["quantiles"]["0.5"] <= p["quantiles"]["0.99"] <= p["max"]
assert sum(d["asset_histograms"]["S0"]["mass"]) == pytest.approx(1.0)
# a tilted set reports the same model quantities, with probability weights
tilted = client.post("/model/scenarios", json={**body, "importance_sampling": "market"}).json()
assert tilted["description"]["weighted"] is True
assert tilted["description"]["effective_sample_size"] < tilted["description"]["n_scenarios"]
assert tilted["portfolio"]["risk"]["0.95"]["cvar"] == pytest.approx(p["risk"]["0.95"]["cvar"], rel=0.05)
# shares instead of weights
shares = client.post("/model/scenarios", json={**body, "weights": {}, "holdings": {"S0": 10, "S1": 5}}).json()
assert shares["portfolio"]["invested"] > 0 and len(shares["portfolio"]["exposure"]) == 2
def test_the_ui_is_served(client):
page = client.get("/ui/")
assert page.status_code == 200 and "quantcore" in page.text
assert client.get("/ui/js/main.js").status_code == 200
assert client.get("/", follow_redirects=False).status_code in (307, 308)
def test_the_ui_can_be_switched_off(settings):
"""``QUANTCORE_ENABLE_UI=false`` ships the same image with the JSON API and /docs only."""
from fastapi import FastAPI
off = FastAPI()
assert app_module.mount_test_bench(off, settings.model_copy(update={"enable_ui": False})) is False
with TestClient(off) as c:
assert c.get("/ui/").status_code == 404
assert c.get("/", follow_redirects=False).status_code == 404
on = FastAPI()
assert app_module.mount_test_bench(on, settings.model_copy(update={"enable_ui": True})) is True
with TestClient(on) as c:
assert c.get("/ui/").status_code == 200
assert c.get("/", follow_redirects=False).headers["location"] == "/ui/"