opentrade-backtest 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opentrade_backtest-0.1.0/PKG-INFO +17 -0
- opentrade_backtest-0.1.0/opentrade_backtest/__init__.py +21 -0
- opentrade_backtest-0.1.0/opentrade_backtest/auth.py +114 -0
- opentrade_backtest-0.1.0/opentrade_backtest/client.py +138 -0
- opentrade_backtest-0.1.0/opentrade_backtest/clock.py +59 -0
- opentrade_backtest-0.1.0/opentrade_backtest/errors.py +111 -0
- opentrade_backtest-0.1.0/opentrade_backtest/export.py +54 -0
- opentrade_backtest-0.1.0/opentrade_backtest/live.py +70 -0
- opentrade_backtest-0.1.0/opentrade_backtest/models.py +525 -0
- opentrade_backtest-0.1.0/opentrade_backtest/session.py +75 -0
- opentrade_backtest-0.1.0/opentrade_backtest/ws.py +290 -0
- opentrade_backtest-0.1.0/opentrade_backtest.egg-info/PKG-INFO +17 -0
- opentrade_backtest-0.1.0/opentrade_backtest.egg-info/SOURCES.txt +16 -0
- opentrade_backtest-0.1.0/opentrade_backtest.egg-info/dependency_links.txt +1 -0
- opentrade_backtest-0.1.0/opentrade_backtest.egg-info/requires.txt +12 -0
- opentrade_backtest-0.1.0/opentrade_backtest.egg-info/top_level.txt +1 -0
- opentrade_backtest-0.1.0/pyproject.toml +37 -0
- opentrade_backtest-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opentrade-backtest
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Open Trade backtest sessions from Python: the live API against a simulated clock
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Project-URL: Documentation, https://opentrade.exchange/api-backtest/
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: requests>=2.31
|
|
9
|
+
Requires-Dist: websocket-client>=1.7
|
|
10
|
+
Provides-Extra: pandas
|
|
11
|
+
Requires-Dist: pandas>=2.0; extra == "pandas"
|
|
12
|
+
Requires-Dist: pyarrow>=14; extra == "pandas"
|
|
13
|
+
Provides-Extra: test
|
|
14
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
15
|
+
Requires-Dist: websockets>=13; extra == "test"
|
|
16
|
+
Requires-Dist: pandas>=2.0; extra == "test"
|
|
17
|
+
Requires-Dist: pyarrow>=14; extra == "test"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from .auth import Credentials, StaticToken, TokenSource
|
|
2
|
+
from .client import BacktestClient
|
|
3
|
+
from .clock import SimClock, WallClock
|
|
4
|
+
from .errors import (ApiError, AuthError, InvalidRequest, NotFound, NotPermitted, QuotaExceeded, RateLimited,
|
|
5
|
+
SessionConflict, SessionEnded, Unauthorized, Unavailable, WrongAudience)
|
|
6
|
+
from .live import cancel_body, live_endpoints, order_body, session_endpoints
|
|
7
|
+
from .models import (Balance, ClientFee, ClockSpec, ClockTick, CreatedSession, Fill, FillModel, Latency, Order,
|
|
8
|
+
Quota, Report, SessionSpec, Status, StepBatch)
|
|
9
|
+
from .session import Session
|
|
10
|
+
from .ws import SessionSocket
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ApiError", "AuthError", "BacktestClient", "Balance", "ClientFee", "ClockSpec", "ClockTick", "CreatedSession",
|
|
16
|
+
"Credentials", "Fill", "FillModel", "InvalidRequest", "Latency", "NotFound", "NotPermitted", "Order",
|
|
17
|
+
"Quota", "QuotaExceeded", "RateLimited", "Report", "Session", "SessionConflict", "SessionEnded",
|
|
18
|
+
"SessionSocket", "SessionSpec", "SimClock", "StaticToken", "Status", "StepBatch", "TokenSource",
|
|
19
|
+
"Unauthorized", "Unavailable", "WallClock", "WrongAudience", "cancel_body", "live_endpoints", "order_body",
|
|
20
|
+
"session_endpoints",
|
|
21
|
+
]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Callable, Optional
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from .errors import AuthError
|
|
13
|
+
|
|
14
|
+
UAT_AUTH_HOST = "https://auth-uat.opentrade.exchange"
|
|
15
|
+
PRODUCTION_AUTH_HOST = "https://auth.opentrade.exchange"
|
|
16
|
+
BACKTEST_SCOPE = "BACKTEST_ACCESS"
|
|
17
|
+
LIVE_SCOPE = "API_ACCESS"
|
|
18
|
+
REFRESH_AT = 0.8
|
|
19
|
+
FALLBACK_LIFETIME_SECONDS = 900.0
|
|
20
|
+
MINIMUM_LIFETIME_SECONDS = 60.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Credentials:
|
|
25
|
+
username: str
|
|
26
|
+
password: str = field(repr=False)
|
|
27
|
+
api_key: str
|
|
28
|
+
secret: str = field(repr=False)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_expiry(value: Optional[str]) -> Optional[datetime]:
|
|
32
|
+
if not value:
|
|
33
|
+
return None
|
|
34
|
+
try:
|
|
35
|
+
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
|
36
|
+
except ValueError:
|
|
37
|
+
return None
|
|
38
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def bearer(token: str) -> str:
|
|
42
|
+
stripped = token.strip().strip('"')
|
|
43
|
+
return stripped if stripped.lower().startswith("bearer ") else f"Bearer {stripped}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _login_refused(response: requests.Response) -> bool:
|
|
47
|
+
if response.status_code >= 400:
|
|
48
|
+
return True
|
|
49
|
+
location = response.headers.get("Location") or ""
|
|
50
|
+
return urlparse(location).path.rstrip("/").endswith("/login")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TokenSource:
|
|
54
|
+
def __init__(self, auth_host: str, creds: Credentials, scope: str = BACKTEST_SCOPE,
|
|
55
|
+
http: Optional[requests.Session] = None, timeout: float = 15.0,
|
|
56
|
+
monotonic: Callable[[], float] = time.monotonic,
|
|
57
|
+
utcnow: Callable[[], datetime] = lambda: datetime.now(timezone.utc)):
|
|
58
|
+
self.auth_host = auth_host.rstrip("/")
|
|
59
|
+
self.creds = creds
|
|
60
|
+
self.scope = scope
|
|
61
|
+
self.timeout = timeout
|
|
62
|
+
self._http_factory = (lambda: http) if http is not None else requests.Session
|
|
63
|
+
self._monotonic = monotonic
|
|
64
|
+
self._utcnow = utcnow
|
|
65
|
+
self._lock = threading.Lock()
|
|
66
|
+
self._token: Optional[str] = None
|
|
67
|
+
self._refresh_at = 0.0
|
|
68
|
+
self.expires_at: Optional[datetime] = None
|
|
69
|
+
self.granted_scope: Optional[str] = None
|
|
70
|
+
|
|
71
|
+
def token(self) -> str:
|
|
72
|
+
with self._lock:
|
|
73
|
+
if self._token is None or self._monotonic() >= self._refresh_at:
|
|
74
|
+
self._mint()
|
|
75
|
+
return self._token
|
|
76
|
+
|
|
77
|
+
def invalidate(self) -> None:
|
|
78
|
+
with self._lock:
|
|
79
|
+
self._token = None
|
|
80
|
+
|
|
81
|
+
def _mint(self) -> None:
|
|
82
|
+
http = self._http_factory()
|
|
83
|
+
login = http.post(f"{self.auth_host}/login",
|
|
84
|
+
data={"username": self.creds.username, "password": self.creds.password},
|
|
85
|
+
allow_redirects=False, timeout=self.timeout)
|
|
86
|
+
if _login_refused(login):
|
|
87
|
+
raise AuthError("LOGIN_REFUSED", f"login was refused (HTTP {login.status_code})",
|
|
88
|
+
login.status_code)
|
|
89
|
+
minted = http.post(f"{self.auth_host}/auth/jwt/clients/{self.creds.api_key}/token",
|
|
90
|
+
params={"scopes": self.scope},
|
|
91
|
+
headers={"accept": "*/*", "clientSecret": self.creds.secret},
|
|
92
|
+
data="", timeout=self.timeout)
|
|
93
|
+
if minted.status_code != 200 or not minted.text.strip():
|
|
94
|
+
raise AuthError("TOKEN_REFUSED", f"the token request was refused (HTTP {minted.status_code})",
|
|
95
|
+
minted.status_code)
|
|
96
|
+
issued = self._monotonic()
|
|
97
|
+
self._token = bearer(minted.text)
|
|
98
|
+
self.granted_scope = minted.headers.get("jwt-scope")
|
|
99
|
+
self.expires_at = parse_expiry(minted.headers.get("jwt-expire-at"))
|
|
100
|
+
lifetime = FALLBACK_LIFETIME_SECONDS
|
|
101
|
+
if self.expires_at is not None:
|
|
102
|
+
lifetime = max(MINIMUM_LIFETIME_SECONDS, (self.expires_at - self._utcnow()).total_seconds())
|
|
103
|
+
self._refresh_at = issued + REFRESH_AT * lifetime
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class StaticToken:
|
|
107
|
+
def __init__(self, token: str):
|
|
108
|
+
self._token = bearer(token)
|
|
109
|
+
|
|
110
|
+
def token(self) -> str:
|
|
111
|
+
return self._token
|
|
112
|
+
|
|
113
|
+
def invalidate(self) -> None:
|
|
114
|
+
return None
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict, List, Mapping, Optional, Sequence, Union
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from .auth import BACKTEST_SCOPE, PRODUCTION_AUTH_HOST, UAT_AUTH_HOST, Credentials, TokenSource
|
|
9
|
+
from .errors import error_for
|
|
10
|
+
from .models import CreatedSession, Instant, Quota, SessionSpec, Status, instant_text
|
|
11
|
+
from .session import Session
|
|
12
|
+
|
|
13
|
+
UAT_GATEWAY = "https://gateway-public-uat.opentrade.exchange"
|
|
14
|
+
PRODUCTION_GATEWAY = "https://gateway-public.opentrade.exchange"
|
|
15
|
+
SERVICE_PATH = "/hermes-backtest"
|
|
16
|
+
CONTROL_PATH = SERVICE_PATH + "/api/backtest"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _day(value: Instant) -> str:
|
|
20
|
+
return instant_text(value)[:10]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _socket_url(http_url: str) -> str:
|
|
24
|
+
scheme, rest = http_url.split("://", 1)
|
|
25
|
+
return ("wss" if scheme == "https" else "ws") + "://" + rest
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BacktestClient:
|
|
29
|
+
def __init__(self, gateway: str, token_source: Any, http: Optional[requests.Session] = None,
|
|
30
|
+
timeout: float = 30.0):
|
|
31
|
+
self.gateway = gateway.rstrip("/")
|
|
32
|
+
self.token_source = token_source
|
|
33
|
+
self.timeout = timeout
|
|
34
|
+
self._http = http or requests.Session()
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def uat(cls, creds: Credentials, **options: Any) -> "BacktestClient":
|
|
38
|
+
return cls(UAT_GATEWAY, TokenSource(UAT_AUTH_HOST, creds, scope=BACKTEST_SCOPE), **options)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def production(cls, creds: Credentials, **options: Any) -> "BacktestClient":
|
|
42
|
+
return cls(PRODUCTION_GATEWAY, TokenSource(PRODUCTION_AUTH_HOST, creds, scope=BACKTEST_SCOPE), **options)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def control_base(self) -> str:
|
|
46
|
+
return self.gateway + CONTROL_PATH
|
|
47
|
+
|
|
48
|
+
def session_base(self, session_id: int) -> str:
|
|
49
|
+
return f"{self.gateway}{SERVICE_PATH}/s/{session_id}"
|
|
50
|
+
|
|
51
|
+
def _send(self, method: str, url: str, params: Optional[Mapping[str, Any]] = None,
|
|
52
|
+
json: Any = None, stream: bool = False) -> requests.Response:
|
|
53
|
+
response = None
|
|
54
|
+
for attempt in range(2):
|
|
55
|
+
headers = {"Authorization": self.token_source.token(), "Accept": "application/json"}
|
|
56
|
+
response = self._http.request(method, url, params=params, json=json, headers=headers,
|
|
57
|
+
timeout=self.timeout, stream=stream)
|
|
58
|
+
if response.status_code != 401 or attempt == 1:
|
|
59
|
+
break
|
|
60
|
+
self.token_source.invalidate()
|
|
61
|
+
if response.status_code >= 400:
|
|
62
|
+
try:
|
|
63
|
+
body = response.json()
|
|
64
|
+
except ValueError:
|
|
65
|
+
body = {}
|
|
66
|
+
raise error_for(response.status_code, body, response.headers)
|
|
67
|
+
return response
|
|
68
|
+
|
|
69
|
+
def _absolute(self, method: str, url: str, params: Optional[Mapping[str, Any]] = None,
|
|
70
|
+
json: Any = None) -> Any:
|
|
71
|
+
response = self._send(method, url, params=params, json=json)
|
|
72
|
+
if response.status_code == 204 or not response.content:
|
|
73
|
+
return None
|
|
74
|
+
return response.json()
|
|
75
|
+
|
|
76
|
+
def _control(self, method: str, path: str, params: Optional[Mapping[str, Any]] = None,
|
|
77
|
+
json: Any = None) -> Any:
|
|
78
|
+
return self._absolute(method, self.control_base + path, params=params, json=json)
|
|
79
|
+
|
|
80
|
+
def create_session(self, spec: Union[SessionSpec, Mapping[str, Any]]) -> Session:
|
|
81
|
+
body = spec.to_json() if isinstance(spec, SessionSpec) else dict(spec)
|
|
82
|
+
created = CreatedSession.from_json(self._control("POST", "/sessions", json=body))
|
|
83
|
+
return Session(self, created.session_id, created.base_url, created.rest_base, created.ws_url, created)
|
|
84
|
+
|
|
85
|
+
def session(self, session_id: int) -> Session:
|
|
86
|
+
base = self.session_base(session_id)
|
|
87
|
+
return Session(self, session_id, base, base + "/api/rest", _socket_url(base) + "/ws")
|
|
88
|
+
|
|
89
|
+
def sessions(self, active: bool = True, page: int = 0, size: int = 100) -> List[Status]:
|
|
90
|
+
answer = self._control("GET", "/sessions",
|
|
91
|
+
params={"state": "active" if active else "all", "page": page, "size": size})
|
|
92
|
+
return [Status.from_json(row) for row in (answer or {}).get("sessions") or []]
|
|
93
|
+
|
|
94
|
+
def runs(self, page: int = 0, size: int = 50) -> Dict[str, Any]:
|
|
95
|
+
return self._control("GET", "/runs", params={"page": page, "size": size})
|
|
96
|
+
|
|
97
|
+
def run(self, run_id: int) -> Dict[str, Any]:
|
|
98
|
+
return self._control("GET", f"/runs/{run_id}")
|
|
99
|
+
|
|
100
|
+
def run_orders(self, run_id: int, page: int = 0) -> Dict[str, Any]:
|
|
101
|
+
return self._control("GET", f"/runs/{run_id}/orders", params={"page": page})
|
|
102
|
+
|
|
103
|
+
def run_fills(self, run_id: int, page: int = 0) -> Dict[str, Any]:
|
|
104
|
+
return self._control("GET", f"/runs/{run_id}/fills", params={"page": page})
|
|
105
|
+
|
|
106
|
+
def compare(self, ids: Sequence[int]) -> Dict[str, Any]:
|
|
107
|
+
return self._control("GET", "/runs/compare", params=[("id", run_id) for run_id in ids])
|
|
108
|
+
|
|
109
|
+
def update_run(self, run_id: int, name: Optional[str] = None, pinned: Optional[bool] = None) -> Any:
|
|
110
|
+
body = {key: value for key, value in (("name", name), ("pinned", pinned)) if value is not None}
|
|
111
|
+
return self._control("PUT", f"/runs/{run_id}", json=body)
|
|
112
|
+
|
|
113
|
+
def delete_run(self, run_id: int) -> None:
|
|
114
|
+
self._control("DELETE", f"/runs/{run_id}")
|
|
115
|
+
|
|
116
|
+
def quota(self) -> Quota:
|
|
117
|
+
return Quota.from_json(self._control("GET", "/quota"))
|
|
118
|
+
|
|
119
|
+
def coverage(self, dataset: str, start: Instant, end: Instant, venue: Optional[str] = None,
|
|
120
|
+
pair: Optional[str] = None, period: Optional[str] = None) -> Dict[str, Any]:
|
|
121
|
+
params = {"dataset": dataset, "from": _day(start), "to": _day(end), "venue": venue, "pair": pair,
|
|
122
|
+
"period": period}
|
|
123
|
+
return self._control("GET", "/coverage", params={k: v for k, v in params.items() if v is not None})
|
|
124
|
+
|
|
125
|
+
def download(self, dataset: str, venue: Optional[str], pair: Optional[str], start: Instant, end: Instant,
|
|
126
|
+
fmt: str = "parquet", period: Optional[str] = None,
|
|
127
|
+
path: Optional[Union[str, Path]] = None) -> Union[bytes, Path]:
|
|
128
|
+
params = {"from": _day(start), "to": _day(end), "venue": venue, "pair": pair, "period": period,
|
|
129
|
+
"format": fmt}
|
|
130
|
+
response = self._send("GET", f"{self.control_base}/data/{dataset}",
|
|
131
|
+
params={k: v for k, v in params.items() if v is not None}, stream=path is not None)
|
|
132
|
+
if path is None:
|
|
133
|
+
return response.content
|
|
134
|
+
target = Path(path)
|
|
135
|
+
with target.open("wb") as out:
|
|
136
|
+
for chunk in response.iter_content(chunk_size=1 << 16):
|
|
137
|
+
out.write(chunk)
|
|
138
|
+
return target
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any, Callable, Mapping, Optional
|
|
6
|
+
|
|
7
|
+
from .models import ClockTick
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WallClock:
|
|
11
|
+
def now(self) -> float:
|
|
12
|
+
return time.time()
|
|
13
|
+
|
|
14
|
+
def now_ms(self) -> int:
|
|
15
|
+
return int(time.time() * 1000)
|
|
16
|
+
|
|
17
|
+
def utcnow(self) -> datetime:
|
|
18
|
+
return datetime.now(timezone.utc)
|
|
19
|
+
|
|
20
|
+
def sleep(self, seconds: float) -> None:
|
|
21
|
+
time.sleep(seconds)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SimClock:
|
|
25
|
+
def __init__(self, stepper: Optional[Callable[[int], None]] = None):
|
|
26
|
+
self._stepper = stepper
|
|
27
|
+
self._millis: Optional[int] = None
|
|
28
|
+
self.last: Optional[ClockTick] = None
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def started(self) -> bool:
|
|
32
|
+
return self._millis is not None
|
|
33
|
+
|
|
34
|
+
def feed(self, data: Mapping[str, Any]) -> ClockTick:
|
|
35
|
+
tick = ClockTick.from_json(data)
|
|
36
|
+
self.last = tick
|
|
37
|
+
self._millis = tick.sim_time_ms
|
|
38
|
+
return tick
|
|
39
|
+
|
|
40
|
+
def _require(self) -> int:
|
|
41
|
+
if self._millis is None:
|
|
42
|
+
raise RuntimeError("the simulated clock has not ticked yet; start the session and read a step first")
|
|
43
|
+
return self._millis
|
|
44
|
+
|
|
45
|
+
def now(self) -> float:
|
|
46
|
+
return self._require() / 1000.0
|
|
47
|
+
|
|
48
|
+
def now_ms(self) -> int:
|
|
49
|
+
return self._require()
|
|
50
|
+
|
|
51
|
+
def utcnow(self) -> datetime:
|
|
52
|
+
return datetime.fromtimestamp(self._require() / 1000.0, tz=timezone.utc)
|
|
53
|
+
|
|
54
|
+
def sleep(self, seconds: float) -> None:
|
|
55
|
+
if self._stepper is None:
|
|
56
|
+
raise RuntimeError("this clock is not attached to a session socket")
|
|
57
|
+
target = self.now_ms() + int(round(seconds * 1000))
|
|
58
|
+
while self.now_ms() < target:
|
|
59
|
+
self._stepper(target)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Mapping, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ApiError(Exception):
|
|
7
|
+
def __init__(self, code: Optional[str], message: str, status: Optional[int] = None,
|
|
8
|
+
body: Optional[Mapping[str, Any]] = None):
|
|
9
|
+
super().__init__(f"{code}: {message}" if code else message)
|
|
10
|
+
self.code = code
|
|
11
|
+
self.message = message
|
|
12
|
+
self.status = status
|
|
13
|
+
self.body = dict(body or {})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthError(ApiError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Unauthorized(ApiError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvalidRequest(ApiError):
|
|
25
|
+
@property
|
|
26
|
+
def field(self) -> Optional[str]:
|
|
27
|
+
return self.body.get("field")
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def missing(self) -> list:
|
|
31
|
+
return list(self.body.get("missing") or [])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class WrongAudience(ApiError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NotPermitted(ApiError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class NotFound(ApiError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SessionConflict(ApiError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class QuotaExceeded(ApiError):
|
|
51
|
+
def __init__(self, code: Optional[str], message: str, status: Optional[int] = None,
|
|
52
|
+
body: Optional[Mapping[str, Any]] = None):
|
|
53
|
+
super().__init__(code, message, status, body)
|
|
54
|
+
self.quota = self.body.get("quota")
|
|
55
|
+
self.limit = self.body.get("limit")
|
|
56
|
+
self.used = self.body.get("used")
|
|
57
|
+
self.resets_at = self.body.get("resetsAt")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class RateLimited(ApiError):
|
|
61
|
+
def __init__(self, code: Optional[str], message: str, status: Optional[int] = None,
|
|
62
|
+
body: Optional[Mapping[str, Any]] = None, retry_after: Optional[float] = None):
|
|
63
|
+
super().__init__(code, message, status, body)
|
|
64
|
+
self.retry_after = retry_after
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Unavailable(ApiError):
|
|
68
|
+
def __init__(self, code: Optional[str], message: str, status: Optional[int] = None,
|
|
69
|
+
body: Optional[Mapping[str, Any]] = None, retry_after: Optional[float] = None):
|
|
70
|
+
super().__init__(code, message, status, body)
|
|
71
|
+
self.retry_after = retry_after
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class SessionEnded(ApiError):
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _retry_after(headers: Mapping[str, str]) -> Optional[float]:
|
|
79
|
+
value = headers.get("Retry-After") if headers is not None else None
|
|
80
|
+
if value is None:
|
|
81
|
+
return None
|
|
82
|
+
try:
|
|
83
|
+
return float(value)
|
|
84
|
+
except ValueError:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def error_for(status: int, body: Any, headers: Optional[Mapping[str, str]] = None) -> ApiError:
|
|
89
|
+
payload = body if isinstance(body, dict) else {}
|
|
90
|
+
code = payload.get("code")
|
|
91
|
+
message = str(payload.get("message") or payload.get("error") or f"HTTP {status}")
|
|
92
|
+
retry_after = _retry_after(headers or {})
|
|
93
|
+
if status == 400:
|
|
94
|
+
return InvalidRequest(code, message, status, payload)
|
|
95
|
+
if status == 401:
|
|
96
|
+
return Unauthorized(code, message, status, payload)
|
|
97
|
+
if status == 403:
|
|
98
|
+
if code == "WRONG_AUDIENCE":
|
|
99
|
+
return WrongAudience(code, message, status, payload)
|
|
100
|
+
return NotPermitted(code, message, status, payload)
|
|
101
|
+
if status == 404:
|
|
102
|
+
return NotFound(code, message, status, payload)
|
|
103
|
+
if status == 409:
|
|
104
|
+
return SessionConflict(code, message, status, payload)
|
|
105
|
+
if status == 429:
|
|
106
|
+
if code == "QUOTA_EXCEEDED":
|
|
107
|
+
return QuotaExceeded(code, message, status, payload)
|
|
108
|
+
return RateLimited(code, message, status, payload, retry_after)
|
|
109
|
+
if status == 503:
|
|
110
|
+
return Unavailable(code, message, status, payload, retry_after)
|
|
111
|
+
return ApiError(code, message, status, payload)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from .models import Instant, Report
|
|
7
|
+
|
|
8
|
+
NAV_COLUMNS = ["time", "nav", "hold"]
|
|
9
|
+
FILL_COLUMNS = ["order_id", "client_order_id", "venue", "pair", "time", "side", "price", "quantity", "liquidity",
|
|
10
|
+
"venue_fee", "venue_fee_asset", "client_fee", "client_fee_asset"]
|
|
11
|
+
ORDER_COLUMNS = ["order_id", "client_order_id", "parent_order_id", "request_type", "venue", "pair", "order_type",
|
|
12
|
+
"time_in_force", "side", "price", "trigger_price", "quantity", "filled_quantity", "avg_fill_price",
|
|
13
|
+
"status", "reject_reason", "sent_at", "arrived_at", "closed_at"]
|
|
14
|
+
METRIC_COLUMNS = ["scope", "scope_key", "metric", "value", "asset"]
|
|
15
|
+
|
|
16
|
+
TIME_COLUMNS = {"time", "sent_at", "arrived_at", "closed_at"}
|
|
17
|
+
NUMBER_COLUMNS = {"nav", "hold", "price", "quantity", "venue_fee", "client_fee", "trigger_price", "filled_quantity",
|
|
18
|
+
"avg_fill_price", "value"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _pandas():
|
|
22
|
+
try:
|
|
23
|
+
import pandas
|
|
24
|
+
except ImportError as missing:
|
|
25
|
+
raise ImportError("pandas is needed for frames; install opentrade-backtest[pandas]") from missing
|
|
26
|
+
return pandas
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _frame(rows: List[Any], columns: List[str]):
|
|
30
|
+
pandas = _pandas()
|
|
31
|
+
records = [{column: getattr(row, column) for column in columns} for row in rows]
|
|
32
|
+
frame = pandas.DataFrame.from_records(records, columns=columns)
|
|
33
|
+
for column in columns:
|
|
34
|
+
if column in TIME_COLUMNS:
|
|
35
|
+
frame[column] = pandas.to_datetime(frame[column], utc=True, format="ISO8601")
|
|
36
|
+
elif column in NUMBER_COLUMNS:
|
|
37
|
+
frame[column] = pandas.to_numeric(frame[column].map(lambda v: None if v is None else float(v)))
|
|
38
|
+
return frame
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def report_frames(report: Report) -> Dict[str, Any]:
|
|
42
|
+
return {
|
|
43
|
+
"nav": _frame(list(report.nav), NAV_COLUMNS),
|
|
44
|
+
"fills": _frame(list(report.fills), FILL_COLUMNS),
|
|
45
|
+
"orders": _frame(list(report.orders), ORDER_COLUMNS),
|
|
46
|
+
"metrics": _frame(list(report.metrics), METRIC_COLUMNS),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def download_frame(client: Any, dataset: str, venue: Optional[str], pair: Optional[str], start: Instant,
|
|
51
|
+
end: Instant, period: Optional[str] = None):
|
|
52
|
+
pandas = _pandas()
|
|
53
|
+
content = client.download(dataset, venue, pair, start, end, fmt="parquet", period=period)
|
|
54
|
+
return pandas.read_parquet(io.BytesIO(content))
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Dict, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
from .models import Amount, to_decimal
|
|
7
|
+
|
|
8
|
+
LIVE_ENDPOINTS: Dict[str, Tuple[str, str]] = {
|
|
9
|
+
"uat": ("https://gateway-public-uat.opentrade.exchange/hermes-ws-gateway/api/rest",
|
|
10
|
+
"wss://uat.opentrade.exchange/ws"),
|
|
11
|
+
"production": ("https://gateway-public.opentrade.exchange/hermes-ws-gateway/api/rest",
|
|
12
|
+
"wss://opentrade.exchange/ws"),
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _number(value: Amount) -> float:
|
|
17
|
+
return float(to_decimal(value))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def order_body(pair: str, direction: str, order_type: str, amount: Amount, price: Optional[Amount] = None,
|
|
21
|
+
time_in_force: str = "GTC", client_order_id: Optional[str] = None,
|
|
22
|
+
exchange: Optional[str] = None) -> Dict[str, Any]:
|
|
23
|
+
body: Dict[str, Any] = {
|
|
24
|
+
"class": "Order",
|
|
25
|
+
"globalInstrumentCd": pair,
|
|
26
|
+
"direction": direction.upper(),
|
|
27
|
+
"orderType": order_type.upper(),
|
|
28
|
+
"timeInForce": time_in_force.upper(),
|
|
29
|
+
}
|
|
30
|
+
if price is not None:
|
|
31
|
+
body["price"] = _number(price)
|
|
32
|
+
body["amount"] = _number(amount)
|
|
33
|
+
if client_order_id is not None:
|
|
34
|
+
body["clientOrderId"] = str(client_order_id)
|
|
35
|
+
if exchange is not None and exchange.upper() != "OPENTRADE":
|
|
36
|
+
body["exchange"] = exchange.upper()
|
|
37
|
+
body["requestType"] = "DIRECT"
|
|
38
|
+
return body
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cancel_body(pair: str, client_order_id: Optional[str] = None, exchange_order_id: Optional[str] = None,
|
|
42
|
+
exchange: Optional[str] = None) -> Dict[str, Any]:
|
|
43
|
+
if not client_order_id and not exchange_order_id:
|
|
44
|
+
raise ValueError("a cancel without clientOrderId or exchangeOrderId cancels every order on the pair; "
|
|
45
|
+
"name the order")
|
|
46
|
+
body: Dict[str, Any] = {"class": "Order", "action": "Cancel", "globalInstrumentCd": pair}
|
|
47
|
+
if client_order_id:
|
|
48
|
+
body["clientOrderId"] = str(client_order_id)
|
|
49
|
+
if exchange_order_id:
|
|
50
|
+
body["exchangeOrderId"] = str(exchange_order_id)
|
|
51
|
+
if exchange is not None and exchange.upper() != "OPENTRADE":
|
|
52
|
+
body["exchange"] = exchange.upper()
|
|
53
|
+
body["requestType"] = "DIRECT"
|
|
54
|
+
return body
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def wire(message: Any) -> str:
|
|
58
|
+
return json.dumps(message, separators=(",", ":"))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def live_endpoints(env: str, ws_url: Optional[str] = None) -> Tuple[str, str]:
|
|
62
|
+
try:
|
|
63
|
+
rest_base, default_ws = LIVE_ENDPOINTS[env.lower()]
|
|
64
|
+
except KeyError:
|
|
65
|
+
raise ValueError(f"unknown environment {env!r}; use one of {sorted(LIVE_ENDPOINTS)}") from None
|
|
66
|
+
return rest_base, ws_url or default_ws
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def session_endpoints(session: Any) -> Tuple[str, str]:
|
|
70
|
+
return session.rest_base, session.ws_url
|