mishmar 0.2.0__py3-none-any.whl
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.
- mishmar/__init__.py +27 -0
- mishmar/_time.py +31 -0
- mishmar/agentkey.py +188 -0
- mishmar/canon.py +46 -0
- mishmar/client.py +328 -0
- mishmar/proposal.py +268 -0
- mishmar/signer.py +527 -0
- mishmar-0.2.0.dist-info/METADATA +87 -0
- mishmar-0.2.0.dist-info/RECORD +12 -0
- mishmar-0.2.0.dist-info/WHEEL +5 -0
- mishmar-0.2.0.dist-info/licenses/LICENSE +174 -0
- mishmar-0.2.0.dist-info/top_level.txt +1 -0
mishmar/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""The Mishmar client: sign and submit proposals, read decisions, answer the signer channel."""
|
|
2
|
+
|
|
3
|
+
from .agentkey import AgentKey, request_signing_bytes, verify, verify_request
|
|
4
|
+
from .canon import canonical_json
|
|
5
|
+
from .client import DEFAULT_RETRIES, Client, EngineError, Retries
|
|
6
|
+
from .proposal import Proposal, VenueMessage, is_decimal, new_nonce, rfc3339
|
|
7
|
+
from .signer import Signer, download_binary, find_binary
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"DEFAULT_RETRIES",
|
|
11
|
+
"AgentKey",
|
|
12
|
+
"Client",
|
|
13
|
+
"EngineError",
|
|
14
|
+
"Proposal",
|
|
15
|
+
"Retries",
|
|
16
|
+
"Signer",
|
|
17
|
+
"VenueMessage",
|
|
18
|
+
"canonical_json",
|
|
19
|
+
"download_binary",
|
|
20
|
+
"find_binary",
|
|
21
|
+
"is_decimal",
|
|
22
|
+
"new_nonce",
|
|
23
|
+
"request_signing_bytes",
|
|
24
|
+
"rfc3339",
|
|
25
|
+
"verify",
|
|
26
|
+
"verify_request",
|
|
27
|
+
]
|
mishmar/_time.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Times as the contract writes them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def rfc3339(dt: datetime) -> str:
|
|
10
|
+
"""Format a time as the contract wants it: UTC, Z suffix, fraction trimmed."""
|
|
11
|
+
if dt.tzinfo is None:
|
|
12
|
+
raise ValueError("times must be timezone-aware")
|
|
13
|
+
dt = dt.astimezone(UTC)
|
|
14
|
+
text = dt.strftime("%Y-%m-%dT%H:%M:%S")
|
|
15
|
+
if dt.microsecond:
|
|
16
|
+
text += "." + f"{dt.microsecond:06d}".rstrip("0")
|
|
17
|
+
return text + "Z"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_time(value: Any) -> datetime:
|
|
21
|
+
"""A timezone-aware datetime from a datetime or an RFC 3339 string."""
|
|
22
|
+
if isinstance(value, datetime):
|
|
23
|
+
if value.tzinfo is None:
|
|
24
|
+
raise ValueError("times must be timezone-aware")
|
|
25
|
+
return value
|
|
26
|
+
if isinstance(value, str):
|
|
27
|
+
dt = datetime.fromisoformat(value)
|
|
28
|
+
if dt.tzinfo is None:
|
|
29
|
+
raise ValueError(f"time {value!r} has no offset")
|
|
30
|
+
return dt
|
|
31
|
+
raise TypeError(f"not a time: {value!r}")
|
mishmar/agentkey.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""The agent's identity: an Ed25519 key whose public half the engine stores.
|
|
2
|
+
|
|
3
|
+
The engine shows the secret once, as ``msk_<prefix>_<seed>``: a label, then
|
|
4
|
+
the 32-byte seed in base64url without padding. The key id (``key_…``) is
|
|
5
|
+
separate and public.
|
|
6
|
+
|
|
7
|
+
The key signs two things: proposals (see proposal.py) and, under the
|
|
8
|
+
contract's agentAuth scheme, every other agent-facing request: four headers
|
|
9
|
+
carry the key id, the time, a nonce and an Ed25519 signature over the canonical
|
|
10
|
+
JSON of ``{bodySha256, keyId, method, nonce, path, query, signedAt}``. The bytes
|
|
11
|
+
are pinned in spec/vectors/agent-request-signing.json.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import hashlib
|
|
18
|
+
import secrets
|
|
19
|
+
from datetime import UTC, datetime
|
|
20
|
+
|
|
21
|
+
from cryptography.exceptions import InvalidSignature
|
|
22
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
23
|
+
Ed25519PrivateKey,
|
|
24
|
+
Ed25519PublicKey,
|
|
25
|
+
)
|
|
26
|
+
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
|
27
|
+
|
|
28
|
+
from ._time import rfc3339
|
|
29
|
+
from .canon import canonical_json
|
|
30
|
+
|
|
31
|
+
SEED_SIZE = 32
|
|
32
|
+
|
|
33
|
+
HEADER_KEY_ID = "X-Mishmar-Key-Id"
|
|
34
|
+
HEADER_SIGNED_AT = "X-Mishmar-Signed-At"
|
|
35
|
+
HEADER_NONCE = "X-Mishmar-Nonce"
|
|
36
|
+
HEADER_SIGNATURE = "X-Mishmar-Signature"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _b64url_decode(s: str) -> bytes:
|
|
40
|
+
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def new_nonce() -> str:
|
|
44
|
+
"""A fresh nonce: 16 random bytes, hex."""
|
|
45
|
+
return secrets.token_hex(16)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def request_signing_bytes(
|
|
49
|
+
*,
|
|
50
|
+
key_id: str,
|
|
51
|
+
method: str,
|
|
52
|
+
path: str,
|
|
53
|
+
query: str = "",
|
|
54
|
+
body: bytes = b"",
|
|
55
|
+
nonce: str,
|
|
56
|
+
signed_at: str,
|
|
57
|
+
) -> bytes:
|
|
58
|
+
"""What the agent signs for a request. ``path`` is without the server
|
|
59
|
+
prefix (``/agent/decisions``), ``query`` the raw query string without the
|
|
60
|
+
``?``, ``signed_at`` exactly as the header carries it."""
|
|
61
|
+
fields: dict[str, str] = {
|
|
62
|
+
"keyId": key_id,
|
|
63
|
+
"method": method.upper(),
|
|
64
|
+
"nonce": nonce,
|
|
65
|
+
"path": path,
|
|
66
|
+
"signedAt": signed_at,
|
|
67
|
+
}
|
|
68
|
+
if query:
|
|
69
|
+
fields["query"] = query
|
|
70
|
+
if body:
|
|
71
|
+
fields["bodySha256"] = hashlib.sha256(body).hexdigest()
|
|
72
|
+
return canonical_json(fields)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AgentKey:
|
|
76
|
+
"""The agent's private key."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, private: Ed25519PrivateKey):
|
|
79
|
+
self._private = private
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def parse(cls, secret: str) -> AgentKey:
|
|
83
|
+
"""Read the secret the engine showed when the key was issued."""
|
|
84
|
+
# msk_<prefix>_<seed>: the seed is base64url and may itself contain
|
|
85
|
+
# '_', so split on the first two underscores only.
|
|
86
|
+
parts = secret.strip().split("_", 2)
|
|
87
|
+
if len(parts) != 3 or parts[0] != "msk":
|
|
88
|
+
raise ValueError("malformed agent secret: expected msk_<prefix>_<seed>")
|
|
89
|
+
try:
|
|
90
|
+
seed = _b64url_decode(parts[2])
|
|
91
|
+
except ValueError as err:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
"malformed agent secret: the seed is not base64url"
|
|
94
|
+
) from err
|
|
95
|
+
if len(seed) != SEED_SIZE:
|
|
96
|
+
raise ValueError("malformed agent secret: the seed is not 32 bytes")
|
|
97
|
+
return cls.from_seed(seed)
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def from_seed(cls, seed: bytes) -> AgentKey:
|
|
101
|
+
return cls(Ed25519PrivateKey.from_private_bytes(seed))
|
|
102
|
+
|
|
103
|
+
def sign(self, message: bytes) -> str:
|
|
104
|
+
"""The base64 (standard, padded) Ed25519 signature of message."""
|
|
105
|
+
return base64.b64encode(self._private.sign(message)).decode("ascii")
|
|
106
|
+
|
|
107
|
+
def sign_request(
|
|
108
|
+
self,
|
|
109
|
+
key_id: str,
|
|
110
|
+
method: str,
|
|
111
|
+
path: str,
|
|
112
|
+
*,
|
|
113
|
+
query: str = "",
|
|
114
|
+
body: bytes = b"",
|
|
115
|
+
nonce: str | None = None,
|
|
116
|
+
signed_at: datetime | None = None,
|
|
117
|
+
) -> dict[str, str]:
|
|
118
|
+
"""The four agentAuth headers for a request; a nonce and the time are
|
|
119
|
+
made here when not given."""
|
|
120
|
+
nonce = nonce or new_nonce()
|
|
121
|
+
signed_at_text = rfc3339(signed_at or datetime.now(UTC))
|
|
122
|
+
message = request_signing_bytes(
|
|
123
|
+
key_id=key_id,
|
|
124
|
+
method=method,
|
|
125
|
+
path=path,
|
|
126
|
+
query=query,
|
|
127
|
+
body=body,
|
|
128
|
+
nonce=nonce,
|
|
129
|
+
signed_at=signed_at_text,
|
|
130
|
+
)
|
|
131
|
+
return {
|
|
132
|
+
HEADER_KEY_ID: key_id,
|
|
133
|
+
HEADER_SIGNED_AT: signed_at_text,
|
|
134
|
+
HEADER_NONCE: nonce,
|
|
135
|
+
HEADER_SIGNATURE: self.sign(message),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def public_key(self) -> bytes:
|
|
140
|
+
return self._private.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def public_key_b64(self) -> str:
|
|
144
|
+
"""The public half as the API stores and returns it."""
|
|
145
|
+
return base64.b64encode(self.public_key).decode("ascii")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def secret(prefix: str, seed: bytes) -> str:
|
|
149
|
+
"""Render a seed the way the engine shows it."""
|
|
150
|
+
return prefix + "_" + base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def verify(public_key_b64: str, message: bytes, signature_b64: str) -> bool:
|
|
154
|
+
"""Check a base64 signature over message with a public key in the API's form."""
|
|
155
|
+
try:
|
|
156
|
+
public = Ed25519PublicKey.from_public_bytes(
|
|
157
|
+
base64.b64decode(public_key_b64, validate=True)
|
|
158
|
+
)
|
|
159
|
+
public.verify(base64.b64decode(signature_b64, validate=True), message)
|
|
160
|
+
return True
|
|
161
|
+
except (ValueError, InvalidSignature):
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def verify_request(
|
|
166
|
+
public_key_b64: str,
|
|
167
|
+
*,
|
|
168
|
+
key_id: str,
|
|
169
|
+
method: str,
|
|
170
|
+
path: str,
|
|
171
|
+
query: str,
|
|
172
|
+
body: bytes,
|
|
173
|
+
nonce: str,
|
|
174
|
+
signed_at: str,
|
|
175
|
+
signature_b64: str,
|
|
176
|
+
) -> bool:
|
|
177
|
+
"""The engine's side of agentAuth: check the headers against the request.
|
|
178
|
+
Freshness and nonce replay are the caller's checks."""
|
|
179
|
+
message = request_signing_bytes(
|
|
180
|
+
key_id=key_id,
|
|
181
|
+
method=method,
|
|
182
|
+
path=path,
|
|
183
|
+
query=query,
|
|
184
|
+
body=body,
|
|
185
|
+
nonce=nonce,
|
|
186
|
+
signed_at=signed_at,
|
|
187
|
+
)
|
|
188
|
+
return verify(public_key_b64, message, signature_b64)
|
mishmar/canon.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Canonical JSON: the bytes an agent signs and the engine verifies.
|
|
2
|
+
|
|
3
|
+
The rules match Go's encoding/json, which the engine uses (see canon/ in the
|
|
4
|
+
repo root and spec/vectors): one object, keys sorted, no whitespace, keys
|
|
5
|
+
whose value is None left out at every depth, strings with Go's escapes.
|
|
6
|
+
Python's json module agrees on everything but the HTML-safe escapes, which
|
|
7
|
+
are added here. Numbers never appear in what Mishmar signs, so they are
|
|
8
|
+
refused rather than formatted differently from Go.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
# What Go escapes beyond the JSON minimum: HTML-significant characters and
|
|
15
|
+
# the two line separators. Python with ensure_ascii=False writes them raw.
|
|
16
|
+
_GO_ESCAPES = {
|
|
17
|
+
"<": "\\u003c",
|
|
18
|
+
">": "\\u003e",
|
|
19
|
+
"&": "\\u0026",
|
|
20
|
+
"
": "\\u2028",
|
|
21
|
+
"
": "\\u2029",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _strip(value: Any) -> Any:
|
|
26
|
+
if isinstance(value, bool) or value is None or isinstance(value, str):
|
|
27
|
+
return value
|
|
28
|
+
if isinstance(value, dict):
|
|
29
|
+
return {k: _strip(v) for k, v in value.items() if v is not None}
|
|
30
|
+
if isinstance(value, (list, tuple)):
|
|
31
|
+
return [_strip(v) for v in value]
|
|
32
|
+
if isinstance(value, (int, float)):
|
|
33
|
+
raise TypeError(
|
|
34
|
+
f"canonical_json: numbers are not signed by Mishmar; send {value!r} as a decimal string"
|
|
35
|
+
)
|
|
36
|
+
raise TypeError(f"canonical_json: cannot encode {type(value).__name__}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def canonical_json(value: Any) -> bytes:
|
|
40
|
+
"""Return the canonical UTF-8 encoding of value."""
|
|
41
|
+
text = json.dumps(
|
|
42
|
+
_strip(value), sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
|
43
|
+
)
|
|
44
|
+
for raw, escaped in _GO_ESCAPES.items():
|
|
45
|
+
text = text.replace(raw, escaped)
|
|
46
|
+
return text.encode("utf-8")
|
mishmar/client.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""A small HTTP client for the engine, on the standard library only.
|
|
2
|
+
|
|
3
|
+
Proposals go to POST /proposals with the signature in the body. Everything
|
|
4
|
+
else an agent may call (its own decisions, approvals, positions, PnL, trading accounts,
|
|
5
|
+
and the signer channel) is under /agent and signed with the agentAuth
|
|
6
|
+
headers, so a bot can function without an operator token and cannot act on
|
|
7
|
+
the account.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import random
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.parse
|
|
17
|
+
import urllib.request
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from email.utils import parsedate_to_datetime
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from .agentkey import AgentKey
|
|
24
|
+
from .proposal import Proposal
|
|
25
|
+
|
|
26
|
+
RESOLVED_APPROVAL = ("approved", "rejected", "expired")
|
|
27
|
+
|
|
28
|
+
#: The answers that mean the request did nothing and may go again: 503 while
|
|
29
|
+
#: the engine restarts, 429 when this caller sent too many.
|
|
30
|
+
RETRYABLE = (503, 429)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class EngineError(Exception):
|
|
34
|
+
"""The engine answered with an error: ``status`` (HTTP), ``code`` and ``message`` from its body."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, status: int, code: str, message: str):
|
|
37
|
+
super().__init__(f"{status} {code}: {message}")
|
|
38
|
+
self.status, self.code, self.message = status, code, message
|
|
39
|
+
#: The ``Retry-After`` header of a 429, when the engine sent one.
|
|
40
|
+
self.retry_after: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Retries:
|
|
45
|
+
"""How a call the engine did nothing with goes again: 503 ``unavailable``
|
|
46
|
+
while the engine restarts (its drain is 28 s) and 429 ``rate_limited``.
|
|
47
|
+
The same request is sent again after the seconds in ``Retry-After`` when
|
|
48
|
+
the engine names them, otherwise after a wait that doubles from
|
|
49
|
+
``min_wait`` to ``max_wait`` with jitter, until ``budget`` seconds have
|
|
50
|
+
passed since the first attempt; then the last answer is raised. Nothing
|
|
51
|
+
else is retried: a 413 ``too_large`` would come back the same, and any
|
|
52
|
+
other answer means the request was taken. The defaults cover a restart.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
budget: float = 45.0
|
|
56
|
+
min_wait: float = 0.25
|
|
57
|
+
max_wait: float = 8.0
|
|
58
|
+
|
|
59
|
+
def wait(
|
|
60
|
+
self, attempt: int, status: int, retry_after: str | None, elapsed: float
|
|
61
|
+
) -> float | None:
|
|
62
|
+
"""Seconds to wait before attempt ``attempt + 1`` (from 0), or ``None``
|
|
63
|
+
to stop and raise the answer."""
|
|
64
|
+
if status not in RETRYABLE:
|
|
65
|
+
return None
|
|
66
|
+
wait = _parse_retry_after(retry_after)
|
|
67
|
+
if wait is None:
|
|
68
|
+
step = min(self.min_wait * (2 ** min(attempt, 20)), self.max_wait)
|
|
69
|
+
wait = step / 2 + random.uniform(0, step / 2)
|
|
70
|
+
if elapsed + wait > self.budget:
|
|
71
|
+
return None
|
|
72
|
+
return wait
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _parse_retry_after(value: str | None) -> float | None:
|
|
76
|
+
if not value:
|
|
77
|
+
return None
|
|
78
|
+
try:
|
|
79
|
+
secs = int(value)
|
|
80
|
+
return float(secs) if secs >= 0 else None
|
|
81
|
+
except ValueError:
|
|
82
|
+
pass
|
|
83
|
+
try:
|
|
84
|
+
when = parsedate_to_datetime(value)
|
|
85
|
+
except (TypeError, ValueError):
|
|
86
|
+
return None
|
|
87
|
+
return max(when.timestamp() - time.time(), 0.0)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
#: The default policy; see :class:`Retries`.
|
|
91
|
+
DEFAULT_RETRIES = Retries()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Client:
|
|
95
|
+
"""Talks to one engine as one agent. The trading key's share lives in the
|
|
96
|
+
agent's signer, never here: this client proposes, reads, and relays the
|
|
97
|
+
signer channel.
|
|
98
|
+
|
|
99
|
+
``retries`` is how a call the engine did nothing with goes again
|
|
100
|
+
(:class:`Retries`, on by default); ``None`` turns retries off."""
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
engine_url: str,
|
|
105
|
+
agent_key: AgentKey,
|
|
106
|
+
key_id: str,
|
|
107
|
+
*,
|
|
108
|
+
timeout: float = 10.0,
|
|
109
|
+
retries: Retries | None = DEFAULT_RETRIES,
|
|
110
|
+
):
|
|
111
|
+
self.engine_url = engine_url.rstrip("/")
|
|
112
|
+
self.agent_key = agent_key
|
|
113
|
+
self.key_id = key_id
|
|
114
|
+
self.timeout = timeout
|
|
115
|
+
self.retries = retries
|
|
116
|
+
self._sleep: Callable[[float], None] = time.sleep
|
|
117
|
+
|
|
118
|
+
# Proposals
|
|
119
|
+
|
|
120
|
+
def propose(self, proposal: Proposal) -> dict[str, Any]:
|
|
121
|
+
"""Sign the proposal with the agent's key (unless already signed) and
|
|
122
|
+
submit it. Returns the decision as the engine recorded it."""
|
|
123
|
+
if not proposal.key_id:
|
|
124
|
+
proposal.key_id = self.key_id
|
|
125
|
+
if proposal.signature is None:
|
|
126
|
+
proposal.sign(self.agent_key)
|
|
127
|
+
return self._call("POST", "/proposals", body=proposal.to_wire())
|
|
128
|
+
|
|
129
|
+
# Reads, scoped to this agent
|
|
130
|
+
|
|
131
|
+
def decisions(
|
|
132
|
+
self,
|
|
133
|
+
*,
|
|
134
|
+
trading_account_id: str | None = None,
|
|
135
|
+
verdict: str | None = None,
|
|
136
|
+
since: str | None = None,
|
|
137
|
+
until: str | None = None,
|
|
138
|
+
cursor: str | None = None,
|
|
139
|
+
limit: int | None = None,
|
|
140
|
+
) -> dict[str, Any]:
|
|
141
|
+
"""A page of this agent's decisions, newest first."""
|
|
142
|
+
params = {
|
|
143
|
+
"tradingAccountId": trading_account_id,
|
|
144
|
+
"verdict": verdict,
|
|
145
|
+
"from": since,
|
|
146
|
+
"to": until,
|
|
147
|
+
"cursor": cursor,
|
|
148
|
+
"limit": limit,
|
|
149
|
+
}
|
|
150
|
+
return self._agent_call("GET", "/agent/decisions", query=params)
|
|
151
|
+
|
|
152
|
+
def decision(self, decision_id: str) -> dict[str, Any]:
|
|
153
|
+
return self._agent_call(
|
|
154
|
+
"GET", f"/agent/decisions/{urllib.parse.quote(decision_id, safe='')}"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def approvals(self, status: str | None = None) -> list[dict[str, Any]]:
|
|
158
|
+
return self._agent_call("GET", "/agent/approvals", query={"status": status})
|
|
159
|
+
|
|
160
|
+
def positions(self) -> list[dict[str, Any]]:
|
|
161
|
+
return self._agent_call("GET", "/agent/positions")
|
|
162
|
+
|
|
163
|
+
def pnl(self, start: str | None = None, end: str | None = None) -> dict[str, Any]:
|
|
164
|
+
"""Daily PnL of this agent; ``start`` and ``end`` are dates (YYYY-MM-DD)."""
|
|
165
|
+
return self._agent_call("GET", "/agent/pnl", query={"from": start, "to": end})
|
|
166
|
+
|
|
167
|
+
def trading_account(self, trading_account_id: str) -> dict[str, Any]:
|
|
168
|
+
"""A trading account as this agent may see it, its setup without any secret."""
|
|
169
|
+
return self._agent_call(
|
|
170
|
+
"GET",
|
|
171
|
+
f"/agent/trading-accounts/{urllib.parse.quote(trading_account_id, safe='')}",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def venue_rules(
|
|
175
|
+
self, trading_account_id: str, symbol: str | None = None
|
|
176
|
+
) -> dict[str, Any]:
|
|
177
|
+
"""What the trading account's venue accepts per symbol (tick, step,
|
|
178
|
+
minimums), as the engine last read it; ``symbol`` narrows it to one."""
|
|
179
|
+
return self._agent_call(
|
|
180
|
+
"GET",
|
|
181
|
+
f"/agent/trading-accounts/{urllib.parse.quote(trading_account_id, safe='')}/venue-rules",
|
|
182
|
+
query={"symbol": symbol},
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def wait(
|
|
186
|
+
self, decision_id: str, timeout: float = 60.0, interval: float = 1.0
|
|
187
|
+
) -> dict[str, Any]:
|
|
188
|
+
"""Poll a decision until it is no longer waiting for a person, or
|
|
189
|
+
until timeout; returns the last decision read either way."""
|
|
190
|
+
deadline = time.monotonic() + timeout
|
|
191
|
+
while True:
|
|
192
|
+
d = self.decision(decision_id)
|
|
193
|
+
if resolved(d) or time.monotonic() >= deadline:
|
|
194
|
+
return d
|
|
195
|
+
time.sleep(min(interval, max(0.0, deadline - time.monotonic())))
|
|
196
|
+
|
|
197
|
+
# The signer channel
|
|
198
|
+
|
|
199
|
+
def signer_poll(self, wait_seconds: int = 25) -> list[dict[str, Any]]:
|
|
200
|
+
"""Wait up to wait_seconds for requests this agent's signer must sign."""
|
|
201
|
+
return self._agent_call(
|
|
202
|
+
"POST",
|
|
203
|
+
"/agent/signer/poll",
|
|
204
|
+
body={"waitSeconds": wait_seconds},
|
|
205
|
+
timeout=self.timeout + wait_seconds,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def signer_commitments(
|
|
209
|
+
self, venue_key_id: str, count: int | None = None
|
|
210
|
+
) -> dict[str, Any]:
|
|
211
|
+
"""Take up to ``count`` of the engine's single-use FROST commitments for
|
|
212
|
+
one-shot signing with a venue key (CommitmentBatch); by default as many
|
|
213
|
+
as the account allows."""
|
|
214
|
+
body: dict[str, Any] = {"venueKeyId": venue_key_id}
|
|
215
|
+
if count is not None:
|
|
216
|
+
body["count"] = count
|
|
217
|
+
return self._agent_call("POST", "/agent/signer/commitments", body=body)
|
|
218
|
+
|
|
219
|
+
def signer_answer(self, request_id: str, answer: dict[str, Any]) -> None:
|
|
220
|
+
"""Answer a sign request: a share, a signature, a venue response or a refusal (SignerAnswer)."""
|
|
221
|
+
self._agent_call(
|
|
222
|
+
"POST",
|
|
223
|
+
f"/agent/signer/requests/{urllib.parse.quote(request_id, safe='')}/answer",
|
|
224
|
+
body=answer,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Plumbing
|
|
228
|
+
|
|
229
|
+
def _agent_call(
|
|
230
|
+
self,
|
|
231
|
+
method: str,
|
|
232
|
+
path: str,
|
|
233
|
+
*,
|
|
234
|
+
query: dict[str, Any] | None = None,
|
|
235
|
+
body: Any = None,
|
|
236
|
+
timeout: float | None = None,
|
|
237
|
+
) -> Any:
|
|
238
|
+
params = {k: v for k, v in (query or {}).items() if v is not None}
|
|
239
|
+
query_text = urllib.parse.urlencode(params)
|
|
240
|
+
data = b"" if body is None else json.dumps(body).encode("utf-8")
|
|
241
|
+
|
|
242
|
+
# Each attempt is signed anew, with its own nonce and time, so a
|
|
243
|
+
# retry is never a replay.
|
|
244
|
+
def sign() -> dict[str, str]:
|
|
245
|
+
return self.agent_key.sign_request(
|
|
246
|
+
self.key_id, method, path, query=query_text, body=data
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
return self._call(
|
|
250
|
+
method,
|
|
251
|
+
path + ("?" + query_text if query_text else ""),
|
|
252
|
+
body=body,
|
|
253
|
+
sign=sign,
|
|
254
|
+
timeout=timeout,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def _call(
|
|
258
|
+
self,
|
|
259
|
+
method: str,
|
|
260
|
+
path: str,
|
|
261
|
+
*,
|
|
262
|
+
body: Any = None,
|
|
263
|
+
sign: Callable[[], dict[str, str]] | None = None,
|
|
264
|
+
timeout: float | None = None,
|
|
265
|
+
) -> Any:
|
|
266
|
+
"""Send the request, again after an answer that did nothing (``retries``),
|
|
267
|
+
and return the final answer decoded; ``sign`` makes each attempt's headers."""
|
|
268
|
+
data = None if body is None else json.dumps(body).encode("utf-8")
|
|
269
|
+
start = time.monotonic()
|
|
270
|
+
attempt = 0
|
|
271
|
+
while True:
|
|
272
|
+
try:
|
|
273
|
+
return self._once(method, path, data, sign() if sign else {}, timeout)
|
|
274
|
+
except EngineError as err:
|
|
275
|
+
wait = (
|
|
276
|
+
self.retries.wait(
|
|
277
|
+
attempt, err.status, err.retry_after, time.monotonic() - start
|
|
278
|
+
)
|
|
279
|
+
if self.retries is not None
|
|
280
|
+
else None
|
|
281
|
+
)
|
|
282
|
+
if wait is None:
|
|
283
|
+
raise
|
|
284
|
+
self._sleep(wait)
|
|
285
|
+
attempt += 1
|
|
286
|
+
|
|
287
|
+
def _once(
|
|
288
|
+
self,
|
|
289
|
+
method: str,
|
|
290
|
+
path: str,
|
|
291
|
+
data: bytes | None,
|
|
292
|
+
headers: dict[str, str],
|
|
293
|
+
timeout: float | None,
|
|
294
|
+
) -> Any:
|
|
295
|
+
req = urllib.request.Request(self.engine_url + path, data=data, method=method)
|
|
296
|
+
req.add_header("Accept", "application/json")
|
|
297
|
+
if data is not None:
|
|
298
|
+
req.add_header("Content-Type", "application/json")
|
|
299
|
+
for k, v in headers.items():
|
|
300
|
+
req.add_header(k, v)
|
|
301
|
+
try:
|
|
302
|
+
with urllib.request.urlopen(req, timeout=timeout or self.timeout) as res:
|
|
303
|
+
raw = res.read()
|
|
304
|
+
except urllib.error.HTTPError as err:
|
|
305
|
+
raw = err.read()
|
|
306
|
+
code, message = "http_error", err.reason or ""
|
|
307
|
+
try:
|
|
308
|
+
parsed = json.loads(raw)
|
|
309
|
+
code = parsed.get("code", code)
|
|
310
|
+
message = parsed.get("message", message)
|
|
311
|
+
except (ValueError, AttributeError):
|
|
312
|
+
if raw:
|
|
313
|
+
message = raw.decode("utf-8", "replace")[:200]
|
|
314
|
+
failure = EngineError(err.code, str(code), str(message))
|
|
315
|
+
failure.retry_after = (
|
|
316
|
+
err.headers.get("Retry-After") if err.headers else None
|
|
317
|
+
)
|
|
318
|
+
raise failure from None
|
|
319
|
+
return json.loads(raw) if raw else None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def resolved(decision: dict[str, Any]) -> bool:
|
|
323
|
+
"""Whether the decision no longer waits for a person: anything but
|
|
324
|
+
require_approval, or an approval outcome that has been decided."""
|
|
325
|
+
if decision.get("verdict") != "require_approval":
|
|
326
|
+
return True
|
|
327
|
+
approval = decision.get("approval") or {}
|
|
328
|
+
return approval.get("status") in RESOLVED_APPROVAL
|