tests.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  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_bollinger(self):
  115. result = compute_indicator(CANDLES, "bollinger", {"period": 20, "multiplier": 2})
  116. assert result["indicator"] == "bollinger"
  117. assert {"upper", "middle", "lower"}.issubset(result["value"].keys())
  118. def test_vwap(self):
  119. result = compute_indicator(CANDLES, "vwap", {})
  120. assert result["indicator"] == "vwap"
  121. assert isinstance(result["value"], float)
  122. def test_unsupported(self):
  123. with pytest.raises(UnsupportedIndicatorError):
  124. compute_indicator(CANDLES, "madeup", {})
  125. def test_case_insensitive(self):
  126. result = compute_indicator(CANDLES, "RSI", {"period": 14})
  127. assert result["indicator"] == "rsi"
  128. # ---------------------------------------------------------------------------
  129. # TTL Cache
  130. # ---------------------------------------------------------------------------
  131. class TestTTLCache:
  132. def test_set_get(self):
  133. cache = TTLCache()
  134. cache.set("k", {"x": 1}, ttl=60)
  135. assert cache.get("k") == {"x": 1}
  136. def test_expiry(self):
  137. cache = TTLCache()
  138. cache.set("k", "val", ttl=1)
  139. time.sleep(1.1)
  140. assert cache.get("k") is None
  141. def test_missing_key(self):
  142. cache = TTLCache()
  143. assert cache.get("nonexistent") is None
  144. def test_overwrite(self):
  145. cache = TTLCache()
  146. cache.set("k", "a", ttl=60)
  147. cache.set("k", "b", ttl=60)
  148. assert cache.get("k") == "b"
  149. def test_delete(self):
  150. cache = TTLCache()
  151. cache.set("k", "v", ttl=60)
  152. cache.delete("k")
  153. assert cache.get("k") is None
  154. def test_stats(self):
  155. cache = TTLCache()
  156. cache.set("a", 1, ttl=60)
  157. cache.set("b", 2, ttl=60)
  158. stats = cache.stats()
  159. assert stats["alive_keys"] == 2
  160. # ---------------------------------------------------------------------------
  161. # HTTP / MCP surface
  162. # ---------------------------------------------------------------------------
  163. class TestHTTPMCP:
  164. def test_root_returns_tools(self):
  165. resp = client.get("/")
  166. assert resp.status_code == 200
  167. body = resp.json()
  168. assert body["status"] == "ok"
  169. assert "tools" in body
  170. def test_health(self):
  171. resp = client.get("/health")
  172. assert resp.status_code == 200
  173. body = resp.json()
  174. assert body["status"] == "ok"
  175. assert "cache" in body
  176. def test_mcp_mount_path(self):
  177. resp = client.get("/")
  178. assert resp.status_code == 200
  179. assert resp.json().get("mount") == "/mcp"
  180. if __name__ == "__main__":
  181. pytest.main([__file__, "-v"])