tests.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. """
  2. Tests for indicators and cache logic.
  3. Run with: python -m pytest tests.py -v
  4. """
  5. import sys, os
  6. sys.path.insert(0, os.path.dirname(__file__))
  7. import pytest
  8. import time
  9. from fastapi.testclient import TestClient
  10. from server_fastmcp import app
  11. from indicators import rsi, ema, macd, sma, compute_indicator
  12. from cache import TTLCache
  13. from errors import InsufficientDataError, UnsupportedIndicatorError
  14. # ---------------------------------------------------------------------------
  15. # Fixtures
  16. # ---------------------------------------------------------------------------
  17. def make_candles(closes: list[float]) -> list:
  18. """Create minimal candles from a close price list."""
  19. return [[i * 3600, c * 0.99, c * 1.01, c * 0.98, c, 1000.0] for i, c in enumerate(closes)]
  20. # Realistic-ish BTC close prices (50 candles)
  21. SAMPLE_CLOSES = [
  22. 67000, 67200, 66800, 67500, 68000, 67800, 68200, 68500, 68300, 68700,
  23. 69000, 68900, 69200, 69500, 69100, 68800, 68600, 68400, 68200, 68000,
  24. 67800, 67600, 67400, 67200, 67000, 66800, 67200, 67500, 67800, 68100,
  25. 68400, 68700, 69000, 69300, 69600, 69900, 70200, 70500, 70800, 71000,
  26. 71200, 71000, 70800, 70600, 70400, 70200, 70000, 69800, 69600, 69400,
  27. ]
  28. CANDLES = make_candles(SAMPLE_CLOSES)
  29. client = TestClient(app)
  30. # ---------------------------------------------------------------------------
  31. # EMA
  32. # ---------------------------------------------------------------------------
  33. class TestEMA:
  34. def test_basic(self):
  35. val = ema(CANDLES, period=20)
  36. assert isinstance(val, float)
  37. assert 60000 < val < 80000
  38. def test_period_1(self):
  39. val = ema(CANDLES, period=1)
  40. # EMA(1) == last close
  41. assert val == pytest.approx(SAMPLE_CLOSES[-1], rel=1e-4)
  42. def test_insufficient_data(self):
  43. with pytest.raises(InsufficientDataError):
  44. ema(make_candles([100.0] * 5), period=10)
  45. def test_ema_50_needs_more_than_20(self):
  46. val_20 = ema(CANDLES, period=20)
  47. val_50 = ema(CANDLES, period=50)
  48. # With uptrend ending, EMA50 should be lower than EMA20
  49. assert isinstance(val_50, float)
  50. # ---------------------------------------------------------------------------
  51. # RSI
  52. # ---------------------------------------------------------------------------
  53. class TestRSI:
  54. def test_range(self):
  55. val = rsi(CANDLES, period=14)
  56. assert 0 <= val <= 100
  57. def test_all_up(self):
  58. # Consistently rising prices → RSI near 100
  59. candles = make_candles([float(i) for i in range(1, 50)])
  60. val = rsi(candles, period=14)
  61. assert val > 80
  62. def test_all_down(self):
  63. # Consistently falling prices → RSI near 0
  64. candles = make_candles([float(50 - i) for i in range(50)])
  65. val = rsi(candles, period=14)
  66. assert val < 20
  67. def test_insufficient_data(self):
  68. with pytest.raises(InsufficientDataError):
  69. rsi(make_candles([100.0] * 5), period=14)
  70. # ---------------------------------------------------------------------------
  71. # MACD
  72. # ---------------------------------------------------------------------------
  73. class TestMACD:
  74. def test_structure(self):
  75. result = macd(CANDLES)
  76. assert "macd" in result
  77. assert "signal" in result
  78. assert "histogram" in result
  79. def test_histogram_math(self):
  80. result = macd(CANDLES)
  81. assert result["histogram"] == pytest.approx(
  82. result["macd"] - result["signal"], rel=1e-5
  83. )
  84. def test_insufficient_data(self):
  85. with pytest.raises(InsufficientDataError):
  86. macd(make_candles([100.0] * 10))
  87. # ---------------------------------------------------------------------------
  88. # SMA
  89. # ---------------------------------------------------------------------------
  90. class TestSMA:
  91. def test_basic(self):
  92. # SMA of constant values == that value
  93. candles = make_candles([500.0] * 30)
  94. val = sma(candles, period=20)
  95. assert val == pytest.approx(500.0, rel=1e-6)
  96. def test_insufficient_data(self):
  97. with pytest.raises(InsufficientDataError):
  98. sma(make_candles([100.0] * 5), period=20)
  99. # ---------------------------------------------------------------------------
  100. # Compute Indicator Dispatcher
  101. # ---------------------------------------------------------------------------
  102. class TestDispatcher:
  103. def test_rsi(self):
  104. result = compute_indicator(CANDLES, "rsi", {"period": 14})
  105. assert result["indicator"] == "rsi"
  106. assert 0 <= result["value"] <= 100
  107. def test_ema(self):
  108. result = compute_indicator(CANDLES, "ema", {"period": 20})
  109. assert result["indicator"] == "ema"
  110. def test_macd(self):
  111. result = compute_indicator(CANDLES, "macd", {})
  112. assert result["indicator"] == "macd"
  113. assert "histogram" in result["value"]
  114. def test_unsupported(self):
  115. with pytest.raises(UnsupportedIndicatorError):
  116. compute_indicator(CANDLES, "bollinger", {})
  117. def test_case_insensitive(self):
  118. result = compute_indicator(CANDLES, "RSI", {"period": 14})
  119. assert result["indicator"] == "rsi"
  120. # ---------------------------------------------------------------------------
  121. # TTL Cache
  122. # ---------------------------------------------------------------------------
  123. class TestTTLCache:
  124. def test_set_get(self):
  125. cache = TTLCache()
  126. cache.set("k", {"x": 1}, ttl=60)
  127. assert cache.get("k") == {"x": 1}
  128. def test_expiry(self):
  129. cache = TTLCache()
  130. cache.set("k", "val", ttl=1)
  131. time.sleep(1.1)
  132. assert cache.get("k") is None
  133. def test_missing_key(self):
  134. cache = TTLCache()
  135. assert cache.get("nonexistent") is None
  136. def test_overwrite(self):
  137. cache = TTLCache()
  138. cache.set("k", "a", ttl=60)
  139. cache.set("k", "b", ttl=60)
  140. assert cache.get("k") == "b"
  141. def test_delete(self):
  142. cache = TTLCache()
  143. cache.set("k", "v", ttl=60)
  144. cache.delete("k")
  145. assert cache.get("k") is None
  146. def test_stats(self):
  147. cache = TTLCache()
  148. cache.set("a", 1, ttl=60)
  149. cache.set("b", 2, ttl=60)
  150. stats = cache.stats()
  151. assert stats["alive_keys"] == 2
  152. # ---------------------------------------------------------------------------
  153. # HTTP / MCP surface
  154. # ---------------------------------------------------------------------------
  155. class TestHTTPMCP:
  156. def test_root_returns_tools(self):
  157. resp = client.get("/")
  158. assert resp.status_code == 200
  159. body = resp.json()
  160. assert body["status"] == "ok"
  161. assert "tools" in body
  162. def test_health(self):
  163. resp = client.get("/health")
  164. assert resp.status_code == 200
  165. body = resp.json()
  166. assert body["status"] == "ok"
  167. assert "cache" in body
  168. def test_mcp_mount_path(self):
  169. resp = client.get("/")
  170. assert resp.status_code == 200
  171. assert resp.json().get("mount") == "/mcp"
  172. if __name__ == "__main__":
  173. pytest.main([__file__, "-v"])