Kaynağa Gözat

Add global Bitstamp request throttling

Lukas Goldschmidt 1 ay önce
ebeveyn
işleme
47b25f0d7e

+ 6 - 0
src/exec_mcp/bitstamp.py

@@ -4,6 +4,8 @@ import inspect
 import json
 from dataclasses import dataclass
 
+from .bitstamp_rate_limit import throttle_bitstamp_request
+
 try:
     from bitstamp.client import BitstampError, Public, Trading
 except ModuleNotFoundError:  # optional in tests
@@ -26,6 +28,10 @@ class LG_Trading(Trading):
     def __init__(self, username, key, secret, *args, **kwargs):
         super(LG_Trading, self).__init__(username=username, key=key, secret=secret, *args, **kwargs)
 
+    def _post(self, url, data=None, return_json=True, version=2, **kwargs):
+        throttle_bitstamp_request()
+        return super()._post(url, data=data, return_json=return_json, version=version, **kwargs)
+
     def order_status_v2(self, order_id, client_order_id=None, omit_transactions=None):
         data = {'id': order_id}
         if client_order_id is not None:

+ 2 - 0
src/exec_mcp/bitstamp_fx.py

@@ -4,6 +4,7 @@ from datetime import datetime, timezone
 
 import requests
 
+from .bitstamp_rate_limit import throttle_bitstamp_request
 from .storage import get_connection
 
 BITSTAMP_BASE_URL = "https://www.bitstamp.net"
@@ -11,6 +12,7 @@ FX_REFRESH_SECONDS = 15 * 60
 
 
 def fetch_eur_usd() -> dict:
+    throttle_bitstamp_request()
     response = requests.get(f"{BITSTAMP_BASE_URL}/api/v2/eur_usd/", timeout=30)
     response.raise_for_status()
     return response.json()

+ 3 - 0
src/exec_mcp/bitstamp_metadata.py

@@ -2,6 +2,7 @@ from __future__ import annotations
 
 import requests
 
+from .bitstamp_rate_limit import throttle_bitstamp_request
 from .storage import get_connection
 
 BITSTAMP_BASE_URL = "https://www.bitstamp.net"
@@ -9,12 +10,14 @@ METADATA_REFRESH_SECONDS = 24 * 60 * 60
 
 
 def fetch_currencies() -> list[dict]:
+    throttle_bitstamp_request()
     response = requests.get(f"{BITSTAMP_BASE_URL}/api/v2/currencies/", timeout=30)
     response.raise_for_status()
     return response.json()
 
 
 def fetch_markets() -> list[dict]:
+    throttle_bitstamp_request()
     response = requests.get(f"{BITSTAMP_BASE_URL}/api/v2/markets/", timeout=30)
     response.raise_for_status()
     return response.json()

+ 45 - 0
src/exec_mcp/bitstamp_rate_limit.py

@@ -0,0 +1,45 @@
+from __future__ import annotations
+
+import os
+import threading
+import time
+from dataclasses import dataclass
+
+
+@dataclass(slots=True)
+class _LimiterState:
+    next_allowed_at: float = 0.0
+
+
+class GlobalRateLimiter:
+    def __init__(self, default_delay_ms: int = 250):
+        self._default_delay_ms = max(int(default_delay_ms), 0)
+        self._lock = threading.Lock()
+        self._state = _LimiterState()
+
+    def _delay_seconds(self) -> float:
+        try:
+            return max(int(os.getenv("BITSTAMP_CALL_DELAY_MS", str(self._default_delay_ms))) / 1000.0, 0.0)
+        except Exception:
+            return self._default_delay_ms / 1000.0
+
+    def acquire(self) -> float:
+        delay = self._delay_seconds()
+        if delay <= 0:
+            return 0.0
+
+        with self._lock:
+            now = time.monotonic()
+            wait_for = max(0.0, self._state.next_allowed_at - now)
+            self._state.next_allowed_at = max(self._state.next_allowed_at, now) + delay
+
+        if wait_for > 0:
+            time.sleep(wait_for)
+        return wait_for
+
+
+BITSTAMP_RATE_LIMITER = GlobalRateLimiter()
+
+
+def throttle_bitstamp_request() -> float:
+    return BITSTAMP_RATE_LIMITER.acquire()

+ 51 - 0
tests/test_bitstamp_rate_limit.py

@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+from exec_mcp import bitstamp_fx, bitstamp_metadata, bitstamp_rate_limit
+
+
+class _FakeResponse:
+    def __init__(self, payload):
+        self._payload = payload
+
+    def raise_for_status(self):
+        return None
+
+    def json(self):
+        return self._payload
+
+
+def test_global_rate_limiter_spaces_calls(monkeypatch):
+    limiter = bitstamp_rate_limit.GlobalRateLimiter(default_delay_ms=250)
+    monkeypatch.setenv("BITSTAMP_CALL_DELAY_MS", "250")
+
+    now = [100.0]
+    sleeps: list[float] = []
+
+    monkeypatch.setattr(bitstamp_rate_limit.time, "monotonic", lambda: now[0])
+    monkeypatch.setattr(bitstamp_rate_limit.time, "sleep", lambda seconds: sleeps.append(seconds))
+
+    first = limiter.acquire()
+    second = limiter.acquire()
+
+    assert first == 0.0
+    assert second == 0.25
+    assert sleeps == [0.25]
+
+
+def test_bitstamp_public_helpers_call_throttle(monkeypatch):
+    calls: list[str] = []
+    monkeypatch.setattr(bitstamp_metadata, "throttle_bitstamp_request", lambda: calls.append("meta"))
+    monkeypatch.setattr(bitstamp_fx, "throttle_bitstamp_request", lambda: calls.append("fx"))
+
+    def fake_get(url, *args, **kwargs):
+        if url.endswith("/currencies/"):
+            return _FakeResponse([{"code": "USD"}])
+        if url.endswith("/eur_usd/"):
+            return _FakeResponse({"buy": "1.0", "sell": "1.0"})
+        raise AssertionError(f"unexpected url: {url}")
+
+    monkeypatch.setattr(bitstamp_metadata.requests, "get", fake_get)
+
+    assert bitstamp_metadata.fetch_currencies() == [{"code": "USD"}]
+    assert bitstamp_fx.fetch_eur_usd() == {"buy": "1.0", "sell": "1.0"}
+    assert calls == ["meta", "fx"]