import numpy as np import pytest from fastapi.testclient import TestClient import quantcore.api.app as app_module @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_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/"