"""Integration tests against a local Postgres (ktrade_test) and Redis db 15. Kraken calls are mocked.""" import os import httpx import pytest import respx from ktrade.api.auth import hash_password from ktrade.db.pool import dsn, migrate pytestmark = pytest.mark.skipif(os.environ.get("KTRADE_SKIP_DB") == "1", reason="db tests disabled") @pytest.fixture async def client(monkeypatch): import asyncpg from ktrade.config import get_settings get_settings.cache_clear() monkeypatch.setenv("KTRADE_OWNER_PASSWORD_HASH", hash_password("hunter2")) monkeypatch.setenv("KTRADE_APP_BASE_URL", "http://testserver") url = os.environ["KTRADE_DATABASE_URL"] conn = await asyncpg.connect(dsn(url)) await conn.execute("DROP SCHEMA public CASCADE; CREATE SCHEMA public") await conn.close() await migrate(url) from ktrade.api.main import create_app app = create_app() async with app.router.lifespan_context(app): async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as c: yield c, app get_settings.cache_clear() async def test_health_and_auth_flow(client): c, app = client r = await c.get("/api/health") assert r.status_code == 200 and r.json()["db"] == "ok" assert (await c.get("/api/pairs")).status_code == 401 r = await c.post("/api/auth/login", json={"password": "wrong"}, headers={"origin": "http://testserver"}) assert r.status_code == 401 r = await c.post("/api/auth/login", json={"password": "hunter2"}, headers={"origin": "http://testserver"}) assert r.status_code == 200 and "ktrade_session" in r.cookies assert (await c.get("/api/auth/me")).json()["authenticated"] is True # CSRF: mutation without same-origin header is rejected r = await c.post("/api/risk/kill-switch", json={"active": True}) assert r.status_code == 403 r = await c.post("/api/risk/kill-switch", json={"active": True, "reason": "test"}, headers={"origin": "http://testserver"}) assert r.status_code == 200 and r.json()["active"] is True audit = (await c.get("/api/audit")).json() assert audit["chain_valid"] and any(a["action"] == "risk.kill_switch" for a in audit["items"]) async def test_backtest_end_to_end(client): import asyncio from tests.conftest import synthetic_candles c, app = client st = app.state.ktrade await c.post("/api/auth/login", json={"password": "hunter2"}, headers={"origin": "http://testserver"}) df = synthetic_candles(n=1200, seed=21) await st.pool.execute("INSERT INTO pairs (pair_id, altname, wsname, base, quote, status, in_universe, lot_decimals, ordermin, costmin) VALUES ('XXBTZUSD','XBTUSD','BTC/USD','XXBT','ZUSD','online',true,8,0.0001,0.5)") await st.pool.executemany("INSERT INTO candles (symbol, interval_min, ts, open, high, low, close, vwap, volume, count, closed) VALUES ('BTC/USD',60,$1,$2,$3,$4,$5,$6,$7,$8,true)", [(int(r.ts), r.open, r.high, r.low, r.close, r.vwap, r.volume, int(r.count)) for r in df.itertuples()]) r = await c.get("/api/market/candles", params={"symbol": "BTC/USD", "interval": 60, "limit": 10}) assert len(r.json()["items"]) == 10 r = await c.post("/api/backtests", json={"strategy": "tsmom", "symbol": "BTC/USD", "interval_min": 60, "holdout_frac": 0.2, "sensitivity": {"x": "lookback", "x_values": [24, 48], "y": "ema_slow", "y_values": [60, 80]}}, headers={"origin": "http://testserver"}) assert r.status_code == 202 rid = r.json()["id"] for _ in range(100): await asyncio.sleep(0.2) run = (await c.get(f"/api/backtests/{rid}")).json() if run["status"] in ("done", "failed"): break assert run["status"] == "done", run.get("error") assert run["metrics"]["bars"] > 0 and "sharpe" in run["metrics"] assert run["provenance"]["dataset_hash"] and run["artifacts"]["benchmarks"]["buy_and_hold"] assert run["artifacts"]["holdout"]["metrics"]["bars"] > 0 assert run["artifacts"]["sensitivity"]["z"] and run["artifacts"]["stress"]["fees_x2"] r = await c.get(f"/api/backtests/{rid}/trades.csv") assert r.status_code == 200 r = await c.post("/api/backtests", json={"strategy": "tsmom", "symbol": "BTC/USD", "params": {"nope": 1}}, headers={"origin": "http://testserver"}) assert r.status_code == 422 @respx.mock async def test_rest_client_parsing(): from ktrade.kraken.rest import KrakenREST respx.get("https://api.kraken.com/0/public/OHLC").mock(return_value=httpx.Response(200, json={"error": [], "result": {"XXBTZUSD": [[1700000000, "1", "2", "0.5", "1.5", "1.2", "10", 3]], "last": 1700000000}})) respx.get("https://api.kraken.com/0/public/AssetPairs").mock(return_value=httpx.Response(200, json={"error": [], "result": {"XXBTZUSD": {"altname": "XBTUSD", "wsname": "XBT/USD", "base": "XXBT", "quote": "ZUSD", "ordermin": "0.0001", "costmin": "0.5", "tick_size": "0.1", "pair_decimals": 1, "lot_decimals": 8, "status": "online"}}})) respx.get("https://api.kraken.com/0/public/Time").mock(return_value=httpx.Response(200, json={"error": ["EAPI:Rate limit exceeded"]})) rest = KrakenREST(public_min_interval=0) candles, last = await rest.ohlc("XXBTZUSD", 1) assert candles[0].close == 1.5 and last == 1700000000 and candles[0].closed_at(1) == 1700000060 pairs = await rest.asset_pairs() assert pairs["XXBTZUSD"].wsname == "BTC/USD" and float(pairs["XXBTZUSD"].ordermin) == 0.0001 with pytest.raises(Exception, match="Rate limit"): await rest.time() await rest.aclose()