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.
@@ -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