| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238 |
- """
- 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"])
|