|
@@ -0,0 +1,238 @@
|
|
|
|
|
+"""
|
|
|
|
|
+Tests for indicators and cache logic.
|
|
|
|
|
+Run with: python -m pytest tests.py -v
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+import sys, os
|
|
|
|
|
+sys.path.insert(0, os.path.dirname(__file__))
|
|
|
|
|
+
|
|
|
|
|
+import pytest
|
|
|
|
|
+import time
|
|
|
|
|
+from fastapi.testclient import TestClient
|
|
|
|
|
+from main import app
|
|
|
|
|
+from indicators import rsi, ema, macd, sma, compute_indicator
|
|
|
|
|
+from cache import TTLCache
|
|
|
|
|
+from errors import InsufficientDataError, UnsupportedIndicatorError
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# Fixtures
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+def make_candles(closes: list[float]) -> list:
|
|
|
|
|
+ """Create minimal candles from a close price list."""
|
|
|
|
|
+ return [[i * 3600, c * 0.99, c * 1.01, c * 0.98, c, 1000.0] for i, c in enumerate(closes)]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# Realistic-ish BTC close prices (50 candles)
|
|
|
|
|
+SAMPLE_CLOSES = [
|
|
|
|
|
+ 67000, 67200, 66800, 67500, 68000, 67800, 68200, 68500, 68300, 68700,
|
|
|
|
|
+ 69000, 68900, 69200, 69500, 69100, 68800, 68600, 68400, 68200, 68000,
|
|
|
|
|
+ 67800, 67600, 67400, 67200, 67000, 66800, 67200, 67500, 67800, 68100,
|
|
|
|
|
+ 68400, 68700, 69000, 69300, 69600, 69900, 70200, 70500, 70800, 71000,
|
|
|
|
|
+ 71200, 71000, 70800, 70600, 70400, 70200, 70000, 69800, 69600, 69400,
|
|
|
|
|
+]
|
|
|
|
|
+CANDLES = make_candles(SAMPLE_CLOSES)
|
|
|
|
|
+client = TestClient(app)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# EMA
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+class TestEMA:
|
|
|
|
|
+ def test_basic(self):
|
|
|
|
|
+ val = ema(CANDLES, period=20)
|
|
|
|
|
+ assert isinstance(val, float)
|
|
|
|
|
+ assert 60000 < val < 80000
|
|
|
|
|
+
|
|
|
|
|
+ def test_period_1(self):
|
|
|
|
|
+ val = ema(CANDLES, period=1)
|
|
|
|
|
+ # EMA(1) == last close
|
|
|
|
|
+ assert val == pytest.approx(SAMPLE_CLOSES[-1], rel=1e-4)
|
|
|
|
|
+
|
|
|
|
|
+ def test_insufficient_data(self):
|
|
|
|
|
+ with pytest.raises(InsufficientDataError):
|
|
|
|
|
+ ema(make_candles([100.0] * 5), period=10)
|
|
|
|
|
+
|
|
|
|
|
+ def test_ema_50_needs_more_than_20(self):
|
|
|
|
|
+ val_20 = ema(CANDLES, period=20)
|
|
|
|
|
+ val_50 = ema(CANDLES, period=50)
|
|
|
|
|
+ # With uptrend ending, EMA50 should be lower than EMA20
|
|
|
|
|
+ assert isinstance(val_50, float)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# RSI
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+class TestRSI:
|
|
|
|
|
+ def test_range(self):
|
|
|
|
|
+ val = rsi(CANDLES, period=14)
|
|
|
|
|
+ assert 0 <= val <= 100
|
|
|
|
|
+
|
|
|
|
|
+ def test_all_up(self):
|
|
|
|
|
+ # Consistently rising prices → RSI near 100
|
|
|
|
|
+ candles = make_candles([float(i) for i in range(1, 50)])
|
|
|
|
|
+ val = rsi(candles, period=14)
|
|
|
|
|
+ assert val > 80
|
|
|
|
|
+
|
|
|
|
|
+ def test_all_down(self):
|
|
|
|
|
+ # Consistently falling prices → RSI near 0
|
|
|
|
|
+ candles = make_candles([float(50 - i) for i in range(50)])
|
|
|
|
|
+ val = rsi(candles, period=14)
|
|
|
|
|
+ assert val < 20
|
|
|
|
|
+
|
|
|
|
|
+ def test_insufficient_data(self):
|
|
|
|
|
+ with pytest.raises(InsufficientDataError):
|
|
|
|
|
+ rsi(make_candles([100.0] * 5), period=14)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# MACD
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+class TestMACD:
|
|
|
|
|
+ def test_structure(self):
|
|
|
|
|
+ result = macd(CANDLES)
|
|
|
|
|
+ assert "macd" in result
|
|
|
|
|
+ assert "signal" in result
|
|
|
|
|
+ assert "histogram" in result
|
|
|
|
|
+
|
|
|
|
|
+ def test_histogram_math(self):
|
|
|
|
|
+ result = macd(CANDLES)
|
|
|
|
|
+ assert result["histogram"] == pytest.approx(
|
|
|
|
|
+ result["macd"] - result["signal"], rel=1e-5
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def test_insufficient_data(self):
|
|
|
|
|
+ with pytest.raises(InsufficientDataError):
|
|
|
|
|
+ macd(make_candles([100.0] * 10))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# SMA
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+class TestSMA:
|
|
|
|
|
+ def test_basic(self):
|
|
|
|
|
+ # SMA of constant values == that value
|
|
|
|
|
+ candles = make_candles([500.0] * 30)
|
|
|
|
|
+ val = sma(candles, period=20)
|
|
|
|
|
+ assert val == pytest.approx(500.0, rel=1e-6)
|
|
|
|
|
+
|
|
|
|
|
+ def test_insufficient_data(self):
|
|
|
|
|
+ with pytest.raises(InsufficientDataError):
|
|
|
|
|
+ sma(make_candles([100.0] * 5), period=20)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# Compute Indicator Dispatcher
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+class TestDispatcher:
|
|
|
|
|
+ def test_rsi(self):
|
|
|
|
|
+ result = compute_indicator(CANDLES, "rsi", {"period": 14})
|
|
|
|
|
+ assert result["indicator"] == "rsi"
|
|
|
|
|
+ assert 0 <= result["value"] <= 100
|
|
|
|
|
+
|
|
|
|
|
+ def test_ema(self):
|
|
|
|
|
+ result = compute_indicator(CANDLES, "ema", {"period": 20})
|
|
|
|
|
+ assert result["indicator"] == "ema"
|
|
|
|
|
+
|
|
|
|
|
+ def test_macd(self):
|
|
|
|
|
+ result = compute_indicator(CANDLES, "macd", {})
|
|
|
|
|
+ assert result["indicator"] == "macd"
|
|
|
|
|
+ assert "histogram" in result["value"]
|
|
|
|
|
+
|
|
|
|
|
+ def test_unsupported(self):
|
|
|
|
|
+ with pytest.raises(UnsupportedIndicatorError):
|
|
|
|
|
+ compute_indicator(CANDLES, "bollinger", {})
|
|
|
|
|
+
|
|
|
|
|
+ def test_case_insensitive(self):
|
|
|
|
|
+ result = compute_indicator(CANDLES, "RSI", {"period": 14})
|
|
|
|
|
+ assert result["indicator"] == "rsi"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# TTL Cache
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+class TestTTLCache:
|
|
|
|
|
+ def test_set_get(self):
|
|
|
|
|
+ cache = TTLCache()
|
|
|
|
|
+ cache.set("k", {"x": 1}, ttl=60)
|
|
|
|
|
+ assert cache.get("k") == {"x": 1}
|
|
|
|
|
+
|
|
|
|
|
+ def test_expiry(self):
|
|
|
|
|
+ cache = TTLCache()
|
|
|
|
|
+ cache.set("k", "val", ttl=1)
|
|
|
|
|
+ time.sleep(1.1)
|
|
|
|
|
+ assert cache.get("k") is None
|
|
|
|
|
+
|
|
|
|
|
+ def test_missing_key(self):
|
|
|
|
|
+ cache = TTLCache()
|
|
|
|
|
+ assert cache.get("nonexistent") is None
|
|
|
|
|
+
|
|
|
|
|
+ def test_overwrite(self):
|
|
|
|
|
+ cache = TTLCache()
|
|
|
|
|
+ cache.set("k", "a", ttl=60)
|
|
|
|
|
+ cache.set("k", "b", ttl=60)
|
|
|
|
|
+ assert cache.get("k") == "b"
|
|
|
|
|
+
|
|
|
|
|
+ def test_delete(self):
|
|
|
|
|
+ cache = TTLCache()
|
|
|
|
|
+ cache.set("k", "v", ttl=60)
|
|
|
|
|
+ cache.delete("k")
|
|
|
|
|
+ assert cache.get("k") is None
|
|
|
|
|
+
|
|
|
|
|
+ def test_stats(self):
|
|
|
|
|
+ cache = TTLCache()
|
|
|
|
|
+ cache.set("a", 1, ttl=60)
|
|
|
|
|
+ cache.set("b", 2, ttl=60)
|
|
|
|
|
+ stats = cache.stats()
|
|
|
|
|
+ assert stats["alive_keys"] == 2
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# HTTP / MCP surface
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+class TestHTTPMCP:
|
|
|
|
|
+ def test_root_returns_tools(self):
|
|
|
|
|
+ resp = client.get("/")
|
|
|
|
|
+ assert resp.status_code == 200
|
|
|
|
|
+ body = resp.json()
|
|
|
|
|
+ assert body["jsonrpc"] == "2.0"
|
|
|
|
|
+ assert "tools" in body["result"]
|
|
|
|
|
+
|
|
|
|
|
+ def test_health(self):
|
|
|
|
|
+ resp = client.get("/health")
|
|
|
|
|
+ assert resp.status_code == 200
|
|
|
|
|
+ body = resp.json()
|
|
|
|
|
+ assert body["status"] == "ok"
|
|
|
|
|
+ assert "cache" in body
|
|
|
|
|
+
|
|
|
|
|
+ def test_initialize_rpc(self):
|
|
|
|
|
+ resp = client.post(
|
|
|
|
|
+ "/mcp",
|
|
|
|
|
+ json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"clientInfo": {"name": "pytest", "version": "1.0"}}},
|
|
|
|
|
+ )
|
|
|
|
|
+ assert resp.status_code == 200
|
|
|
|
|
+ body = resp.json()
|
|
|
|
|
+ assert body["jsonrpc"] == "2.0"
|
|
|
|
|
+ assert body["id"] == 1
|
|
|
|
|
+ assert "sessionId" in body["result"]
|
|
|
|
|
+
|
|
|
|
|
+ def test_tools_list_rpc(self):
|
|
|
|
|
+ resp = client.post(
|
|
|
|
|
+ "/mcp",
|
|
|
|
|
+ json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
|
|
|
|
|
+ )
|
|
|
|
|
+ assert resp.status_code == 200
|
|
|
|
|
+ body = resp.json()
|
|
|
|
|
+ assert len(body["result"]["tools"]) >= 1
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == "__main__":
|
|
|
|
|
+ pytest.main([__file__, "-v"])
|