blockfill 0.1.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.
- blockfill/__init__.py +24 -0
- blockfill/client.py +218 -0
- blockfill/config.py +48 -0
- blockfill/daemon.py +90 -0
- blockfill/exceptions.py +29 -0
- blockfill/installer.py +42 -0
- blockfill/models.py +38 -0
- blockfill/rpc.py +23 -0
- blockfill-0.1.0.dist-info/METADATA +299 -0
- blockfill-0.1.0.dist-info/RECORD +12 -0
- blockfill-0.1.0.dist-info/WHEEL +5 -0
- blockfill-0.1.0.dist-info/top_level.txt +1 -0
blockfill/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from .client import Blockfill
|
|
2
|
+
from .exceptions import (
|
|
3
|
+
BinaryNotFound,
|
|
4
|
+
CredentialsError,
|
|
5
|
+
DaemonNotRunning,
|
|
6
|
+
DaemonStartTimeout,
|
|
7
|
+
InstallError,
|
|
8
|
+
RpcError,
|
|
9
|
+
TicketNotFound,
|
|
10
|
+
)
|
|
11
|
+
from .models import DaemonStatus, Ticket
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Blockfill",
|
|
15
|
+
"Ticket",
|
|
16
|
+
"DaemonStatus",
|
|
17
|
+
"BinaryNotFound",
|
|
18
|
+
"CredentialsError",
|
|
19
|
+
"DaemonNotRunning",
|
|
20
|
+
"DaemonStartTimeout",
|
|
21
|
+
"InstallError",
|
|
22
|
+
"RpcError",
|
|
23
|
+
"TicketNotFound",
|
|
24
|
+
]
|
blockfill/client.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Self
|
|
4
|
+
|
|
5
|
+
from . import config as _config
|
|
6
|
+
from . import daemon as _daemon
|
|
7
|
+
from . import installer as _installer
|
|
8
|
+
from . import rpc
|
|
9
|
+
from .exceptions import BinaryNotFound, DaemonNotRunning, TicketNotFound
|
|
10
|
+
from .models import DaemonStatus, Ticket
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parse_ticket(raw: dict, defaults: dict | None = None) -> Ticket:
|
|
14
|
+
# Daemon may return a partial response (place returns only ticket_id/status/exchange/symbol/target_position).
|
|
15
|
+
# Merge in any caller-supplied defaults for fields not present in the response.
|
|
16
|
+
d = {**(defaults or {}), **raw}
|
|
17
|
+
return Ticket(
|
|
18
|
+
ticket_id=d["ticket_id"],
|
|
19
|
+
status=d["status"],
|
|
20
|
+
exchange=d["exchange"],
|
|
21
|
+
symbol=d["symbol"],
|
|
22
|
+
strategy=d.get("strategy", ""),
|
|
23
|
+
target_position=d["target_position"],
|
|
24
|
+
executed_position=d.get("executed_position"),
|
|
25
|
+
time_constraint_ms=d.get("time_constraint_ms", 0),
|
|
26
|
+
start_time_ms=d.get("start_time_ms"),
|
|
27
|
+
last_update_time_ms=d.get("last_update_time_ms"),
|
|
28
|
+
is_expired=d.get("is_expired", False),
|
|
29
|
+
cancel_reason=d.get("cancel_reason"),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Blockfill:
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
data_dir: str | Path = "~/.blockfill",
|
|
37
|
+
binary_path: str | Path | None = None,
|
|
38
|
+
timeout_s: float = 10.0,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._data_dir = Path(data_dir).expanduser()
|
|
41
|
+
self._timeout_s = timeout_s
|
|
42
|
+
|
|
43
|
+
if binary_path is not None:
|
|
44
|
+
self._binary = Path(binary_path).expanduser()
|
|
45
|
+
else:
|
|
46
|
+
self._binary = self._data_dir / "bin" / "blockfill"
|
|
47
|
+
|
|
48
|
+
self._config_path = self._data_dir / "config.toml"
|
|
49
|
+
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
# Internal helpers
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def _sock(self) -> Path:
|
|
55
|
+
return _daemon.sock_path(self._data_dir)
|
|
56
|
+
|
|
57
|
+
def _require_binary(self) -> None:
|
|
58
|
+
if not self._binary.exists():
|
|
59
|
+
raise BinaryNotFound(
|
|
60
|
+
f"blockfill binary not found at {self._binary}. Run install() first."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def _require_daemon(self) -> None:
|
|
64
|
+
if not self._sock().exists():
|
|
65
|
+
raise DaemonNotRunning(f"daemon socket not found at {self._sock()}")
|
|
66
|
+
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
# Install & version
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def install(self, version: str = "latest", force: bool = False) -> str:
|
|
72
|
+
return _installer.install(self._binary, version=version, force=force)
|
|
73
|
+
|
|
74
|
+
def version(self) -> str:
|
|
75
|
+
self._require_binary()
|
|
76
|
+
return _installer.version_from_binary(self._binary)
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
# Credentials & config
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def set_credentials(
|
|
83
|
+
self,
|
|
84
|
+
exchange: str,
|
|
85
|
+
api_key: str,
|
|
86
|
+
api_secret: str,
|
|
87
|
+
api_passphrase: str | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
_config.set_credentials(
|
|
90
|
+
self._config_path,
|
|
91
|
+
exchange=exchange,
|
|
92
|
+
api_key=api_key,
|
|
93
|
+
api_secret=api_secret,
|
|
94
|
+
api_passphrase=api_passphrase,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def set_qtex_endpoint(self, endpoint: str) -> None:
|
|
98
|
+
_config.set_qtex_endpoint(self._config_path, endpoint)
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
# Daemon management
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def is_running(self) -> bool:
|
|
105
|
+
return _daemon.is_running(self._data_dir, timeout_s=2.0)
|
|
106
|
+
|
|
107
|
+
def start(
|
|
108
|
+
self,
|
|
109
|
+
wait_timeout_s: float = 10.0,
|
|
110
|
+
env: dict | None = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
self._require_binary()
|
|
113
|
+
_daemon.start(self._binary, self._data_dir, wait_timeout_s=wait_timeout_s, env=env)
|
|
114
|
+
|
|
115
|
+
def stop(self, wait_timeout_s: float = 5.0) -> None:
|
|
116
|
+
_daemon.stop(self._data_dir, wait_timeout_s=wait_timeout_s)
|
|
117
|
+
|
|
118
|
+
def restart(self) -> None:
|
|
119
|
+
self.stop()
|
|
120
|
+
self.start()
|
|
121
|
+
|
|
122
|
+
def health(self) -> DaemonStatus:
|
|
123
|
+
return _daemon.health(self._data_dir, timeout_s=self._timeout_s)
|
|
124
|
+
|
|
125
|
+
def run_foreground(self, env: dict | None = None) -> None:
|
|
126
|
+
self._require_binary()
|
|
127
|
+
import os
|
|
128
|
+
merged_env = {**os.environ, **(env or {})}
|
|
129
|
+
subprocess.run(
|
|
130
|
+
[str(self._binary), "--data-dir", str(self._data_dir), "run"],
|
|
131
|
+
env=merged_env,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------
|
|
135
|
+
# Ticket operations (UDS RPC)
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def place(
|
|
139
|
+
self,
|
|
140
|
+
exchange: str,
|
|
141
|
+
symbol: str,
|
|
142
|
+
strategy: str,
|
|
143
|
+
target_position: float,
|
|
144
|
+
time_constraint_ms: int,
|
|
145
|
+
client_ticket_id: str | None = None,
|
|
146
|
+
) -> Ticket:
|
|
147
|
+
self._require_daemon()
|
|
148
|
+
params: dict = {
|
|
149
|
+
"exchange": exchange,
|
|
150
|
+
"symbol": symbol,
|
|
151
|
+
"strategy": strategy,
|
|
152
|
+
"target_position": target_position,
|
|
153
|
+
"time_constraint_ms": time_constraint_ms,
|
|
154
|
+
}
|
|
155
|
+
if client_ticket_id is not None:
|
|
156
|
+
params["client_ticket_id"] = client_ticket_id
|
|
157
|
+
|
|
158
|
+
result = rpc.call(self._sock(), "ticket.place", params, timeout_s=self._timeout_s)
|
|
159
|
+
return _parse_ticket(result, defaults=params)
|
|
160
|
+
|
|
161
|
+
def query(
|
|
162
|
+
self,
|
|
163
|
+
status: str | None = None,
|
|
164
|
+
symbol: str | None = None,
|
|
165
|
+
ticket_id: str | None = None,
|
|
166
|
+
from_ms: int | None = None,
|
|
167
|
+
to_ms: int | None = None,
|
|
168
|
+
limit: int = 100,
|
|
169
|
+
) -> list[Ticket]:
|
|
170
|
+
self._require_daemon()
|
|
171
|
+
params: dict = {"limit": limit}
|
|
172
|
+
if status is not None:
|
|
173
|
+
params["status"] = status
|
|
174
|
+
if symbol is not None:
|
|
175
|
+
params["symbol"] = symbol
|
|
176
|
+
if ticket_id is not None:
|
|
177
|
+
params["ticket_id"] = ticket_id
|
|
178
|
+
if from_ms is not None:
|
|
179
|
+
params["from_ms"] = from_ms
|
|
180
|
+
if to_ms is not None:
|
|
181
|
+
params["to_ms"] = to_ms
|
|
182
|
+
|
|
183
|
+
result = rpc.call(self._sock(), "ticket.query", params, timeout_s=self._timeout_s)
|
|
184
|
+
tickets = result if isinstance(result, list) else result.get("tickets", [])
|
|
185
|
+
return [_parse_ticket(t) for t in tickets]
|
|
186
|
+
|
|
187
|
+
def cancel(self, ticket_id: str) -> None:
|
|
188
|
+
self._require_daemon()
|
|
189
|
+
try:
|
|
190
|
+
rpc.call(
|
|
191
|
+
self._sock(),
|
|
192
|
+
"ticket.cancel",
|
|
193
|
+
{"ticket_id": ticket_id},
|
|
194
|
+
timeout_s=self._timeout_s,
|
|
195
|
+
)
|
|
196
|
+
except Exception as e:
|
|
197
|
+
# Surface TicketNotFound if the daemon signals it
|
|
198
|
+
msg = str(e).lower()
|
|
199
|
+
if "not found" in msg or "404" in msg:
|
|
200
|
+
raise TicketNotFound(ticket_id) from e
|
|
201
|
+
raise
|
|
202
|
+
|
|
203
|
+
def cancel_all(self) -> int:
|
|
204
|
+
self._require_daemon()
|
|
205
|
+
result = rpc.call(self._sock(), "ticket.cancel_all", timeout_s=self._timeout_s)
|
|
206
|
+
if isinstance(result, int):
|
|
207
|
+
return result
|
|
208
|
+
return result.get("canceled", 0)
|
|
209
|
+
|
|
210
|
+
# ------------------------------------------------------------------
|
|
211
|
+
# Context manager
|
|
212
|
+
# ------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
def __enter__(self) -> Self:
|
|
215
|
+
return self
|
|
216
|
+
|
|
217
|
+
def __exit__(self, *_) -> None:
|
|
218
|
+
self.stop()
|
blockfill/config.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import stat
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import toml
|
|
6
|
+
|
|
7
|
+
from .exceptions import CredentialsError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _load_toml(config_path: Path) -> dict:
|
|
11
|
+
if config_path.exists():
|
|
12
|
+
return toml.loads(config_path.read_text())
|
|
13
|
+
return {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _save_toml(config_path: Path, data: dict) -> None:
|
|
17
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
config_path.write_text(toml.dumps(data))
|
|
19
|
+
os.chmod(config_path, stat.S_IRUSR | stat.S_IWUSR)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def set_credentials(
|
|
23
|
+
config_path: Path,
|
|
24
|
+
exchange: str,
|
|
25
|
+
api_key: str,
|
|
26
|
+
api_secret: str,
|
|
27
|
+
api_passphrase: str | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
valid_exchanges = {"binance-futures", "okx-swap"}
|
|
30
|
+
if exchange not in valid_exchanges:
|
|
31
|
+
raise CredentialsError(f"Unknown exchange: {exchange!r}. Valid: {valid_exchanges}")
|
|
32
|
+
|
|
33
|
+
data = _load_toml(config_path)
|
|
34
|
+
if "exchanges" not in data:
|
|
35
|
+
data["exchanges"] = {}
|
|
36
|
+
entry: dict = {"api_key": api_key, "api_secret": api_secret}
|
|
37
|
+
if api_passphrase is not None:
|
|
38
|
+
entry["api_passphrase"] = api_passphrase
|
|
39
|
+
data["exchanges"][exchange] = entry
|
|
40
|
+
_save_toml(config_path, data)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def set_qtex_endpoint(config_path: Path, endpoint: str) -> None:
|
|
44
|
+
data = _load_toml(config_path)
|
|
45
|
+
if "qtex" not in data:
|
|
46
|
+
data["qtex"] = {}
|
|
47
|
+
data["qtex"]["endpoint"] = endpoint
|
|
48
|
+
_save_toml(config_path, data)
|
blockfill/daemon.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from . import rpc
|
|
7
|
+
from .exceptions import DaemonNotRunning, DaemonStartTimeout
|
|
8
|
+
from .models import DaemonStatus
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def sock_path(data_dir: Path) -> Path:
|
|
12
|
+
return data_dir / "runtime" / "daemon.sock"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_running(data_dir: Path, timeout_s: float = 2.0) -> bool:
|
|
16
|
+
sp = sock_path(data_dir)
|
|
17
|
+
if not sp.exists():
|
|
18
|
+
return False
|
|
19
|
+
try:
|
|
20
|
+
rpc.call(sp, "system.status", timeout_s=timeout_s)
|
|
21
|
+
return True
|
|
22
|
+
except Exception:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def start(
|
|
27
|
+
binary_path: Path,
|
|
28
|
+
data_dir: Path,
|
|
29
|
+
wait_timeout_s: float = 10.0,
|
|
30
|
+
env: dict | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
if is_running(data_dir):
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
merged_env = {**os.environ, **(env or {})}
|
|
36
|
+
subprocess.Popen(
|
|
37
|
+
[str(binary_path), "--data-dir", str(data_dir), "run"],
|
|
38
|
+
env=merged_env,
|
|
39
|
+
stdout=subprocess.DEVNULL,
|
|
40
|
+
stderr=subprocess.DEVNULL,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
deadline = time.monotonic() + wait_timeout_s
|
|
44
|
+
while time.monotonic() < deadline:
|
|
45
|
+
if is_running(data_dir, timeout_s=1.0):
|
|
46
|
+
return
|
|
47
|
+
time.sleep(0.25)
|
|
48
|
+
|
|
49
|
+
raise DaemonStartTimeout(
|
|
50
|
+
f"daemon did not become ready within {wait_timeout_s}s "
|
|
51
|
+
f"(sock: {sock_path(data_dir)})"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def stop(data_dir: Path, wait_timeout_s: float = 5.0) -> None:
|
|
56
|
+
sp = sock_path(data_dir)
|
|
57
|
+
if not sp.exists():
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
rpc.call(sp, "system.shutdown", timeout_s=3.0)
|
|
62
|
+
except Exception:
|
|
63
|
+
# If daemon is already gone or RPC fails, proceed to wait for socket removal
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
deadline = time.monotonic() + wait_timeout_s
|
|
67
|
+
while time.monotonic() < deadline:
|
|
68
|
+
if not sp.exists():
|
|
69
|
+
return
|
|
70
|
+
time.sleep(0.25)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def health(data_dir: Path, timeout_s: float = 10.0) -> DaemonStatus:
|
|
74
|
+
sp = sock_path(data_dir)
|
|
75
|
+
if not sp.exists():
|
|
76
|
+
raise DaemonNotRunning(f"daemon socket not found at {sp}")
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
result = rpc.call(sp, "system.status", timeout_s=timeout_s)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
raise DaemonNotRunning(f"cannot connect to daemon: {e}") from e
|
|
82
|
+
|
|
83
|
+
return DaemonStatus(
|
|
84
|
+
running=result.get("running", True),
|
|
85
|
+
pid=result.get("pid", 0),
|
|
86
|
+
exchanges=result.get("exchanges", []),
|
|
87
|
+
active_tickets=result.get("active_tickets", 0),
|
|
88
|
+
uptime_s=result.get("uptime_s", 0),
|
|
89
|
+
version=result.get("version", ""),
|
|
90
|
+
)
|
blockfill/exceptions.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
class BinaryNotFound(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class DaemonNotRunning(Exception):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DaemonStartTimeout(Exception):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RpcError(Exception):
|
|
14
|
+
def __init__(self, code: int, message: str) -> None:
|
|
15
|
+
super().__init__(f"RPC error {code}: {message}")
|
|
16
|
+
self.code = code
|
|
17
|
+
self.message = message
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TicketNotFound(Exception):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CredentialsError(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InstallError(Exception):
|
|
29
|
+
pass
|
blockfill/installer.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import platform
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .exceptions import BinaryNotFound, InstallError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _detect_platform() -> str:
|
|
9
|
+
system = platform.system().lower()
|
|
10
|
+
machine = platform.machine().lower()
|
|
11
|
+
|
|
12
|
+
if system == "linux":
|
|
13
|
+
if machine in ("x86_64", "amd64"):
|
|
14
|
+
return "linux-amd64"
|
|
15
|
+
elif machine in ("aarch64", "arm64"):
|
|
16
|
+
return "linux-arm64"
|
|
17
|
+
elif system == "darwin":
|
|
18
|
+
return "darwin-arm64"
|
|
19
|
+
|
|
20
|
+
raise InstallError(f"Unsupported platform: {system}/{machine}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def install(binary_path: Path, version: str = "latest", force: bool = False) -> str:
|
|
24
|
+
if binary_path.exists() and not force:
|
|
25
|
+
# Already installed — return current version without downloading
|
|
26
|
+
return version_from_binary(binary_path)
|
|
27
|
+
|
|
28
|
+
_detect_platform() # validate platform before attempting download
|
|
29
|
+
raise NotImplementedError("download not yet implemented")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def version_from_binary(binary_path: Path) -> str:
|
|
33
|
+
if not binary_path.exists():
|
|
34
|
+
raise BinaryNotFound(f"blockfill binary not found at {binary_path}. Run install() first.")
|
|
35
|
+
result = subprocess.run(
|
|
36
|
+
[str(binary_path), "version"],
|
|
37
|
+
capture_output=True,
|
|
38
|
+
text=True,
|
|
39
|
+
)
|
|
40
|
+
if result.returncode != 0:
|
|
41
|
+
raise BinaryNotFound(f"blockfill version failed: {result.stderr.strip()}")
|
|
42
|
+
return result.stdout.strip()
|
blockfill/models.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class DaemonStatus:
|
|
6
|
+
running: bool
|
|
7
|
+
pid: int
|
|
8
|
+
exchanges: list[str]
|
|
9
|
+
active_tickets: int
|
|
10
|
+
uptime_s: int
|
|
11
|
+
version: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Ticket:
|
|
16
|
+
ticket_id: str
|
|
17
|
+
status: str
|
|
18
|
+
exchange: str
|
|
19
|
+
symbol: str
|
|
20
|
+
strategy: str
|
|
21
|
+
target_position: float
|
|
22
|
+
executed_position: float | None
|
|
23
|
+
time_constraint_ms: int
|
|
24
|
+
start_time_ms: int | None
|
|
25
|
+
last_update_time_ms: int | None
|
|
26
|
+
is_expired: bool
|
|
27
|
+
cancel_reason: str | None
|
|
28
|
+
|
|
29
|
+
def __str__(self) -> str:
|
|
30
|
+
parts = [f"{self.ticket_id} [{self.status}] {self.exchange}/{self.symbol}",
|
|
31
|
+
f"target={self.target_position}"]
|
|
32
|
+
if self.executed_position is not None:
|
|
33
|
+
parts.append(f"executed={self.executed_position}")
|
|
34
|
+
if self.cancel_reason:
|
|
35
|
+
parts.append(f"cancel_reason={self.cancel_reason}")
|
|
36
|
+
if self.is_expired:
|
|
37
|
+
parts.append("EXPIRED")
|
|
38
|
+
return " ".join(parts)
|
blockfill/rpc.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import socket
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .exceptions import RpcError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def call(sock_path: Path | str, method: str, params: dict | None = None, timeout_s: float = 10.0) -> dict:
|
|
9
|
+
req = json.dumps({"jsonrpc": "2.0", "method": method, "params": params or {}, "id": 1}) + "\n"
|
|
10
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
|
11
|
+
s.settimeout(timeout_s)
|
|
12
|
+
s.connect(str(sock_path))
|
|
13
|
+
s.sendall(req.encode())
|
|
14
|
+
data = b""
|
|
15
|
+
while not data.endswith(b"\n"):
|
|
16
|
+
chunk = s.recv(4096)
|
|
17
|
+
if not chunk:
|
|
18
|
+
break
|
|
19
|
+
data += chunk
|
|
20
|
+
resp = json.loads(data.decode())
|
|
21
|
+
if "error" in resp:
|
|
22
|
+
raise RpcError(resp["error"]["code"], resp["error"]["message"])
|
|
23
|
+
return resp["result"]
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: blockfill
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the blockfill execution daemon
|
|
5
|
+
Author-email: Mavri-X <robot@mavri-x.ai>
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://github.com/mavri-x/blockfill
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: toml
|
|
11
|
+
|
|
12
|
+
# blockfill Python SDK
|
|
13
|
+
|
|
14
|
+
Python wrapper for the [blockfill](../executor/) execution daemon. Covers the full lifecycle: install binary, configure credentials, manage daemon, place/query/cancel tickets.
|
|
15
|
+
|
|
16
|
+
## Requirements
|
|
17
|
+
|
|
18
|
+
- Python 3.10+
|
|
19
|
+
- blockfill binary (see [Install](#install))
|
|
20
|
+
- blockfill daemon running (see [Daemon](#daemon))
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install blockfill
|
|
26
|
+
# or from source
|
|
27
|
+
pip install -e /path/to/python-sdk
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quickstart
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from blockfill import Blockfill
|
|
34
|
+
|
|
35
|
+
bf = Blockfill(
|
|
36
|
+
binary_path="/path/to/blockfill", # or ~/.blockfill/bin/blockfill
|
|
37
|
+
data_dir="~/.blockfill",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# First-time setup
|
|
41
|
+
bf.set_credentials("binance-futures", api_key="...", api_secret="...")
|
|
42
|
+
bf.set_qtex_endpoint("https://qtex.example.com")
|
|
43
|
+
|
|
44
|
+
# Start daemon
|
|
45
|
+
bf.start(env={"BLOCKFILL_API_KEY": "..."})
|
|
46
|
+
bf.health()
|
|
47
|
+
|
|
48
|
+
# Place a ticket
|
|
49
|
+
ticket = bf.place(
|
|
50
|
+
exchange="binance-futures",
|
|
51
|
+
symbol="btcusdt",
|
|
52
|
+
strategy="maker_l2",
|
|
53
|
+
target_position=0.1,
|
|
54
|
+
time_constraint_ms=300_000,
|
|
55
|
+
)
|
|
56
|
+
print(ticket.ticket_id, ticket.status) # tkt_xxx NEW
|
|
57
|
+
|
|
58
|
+
# Query
|
|
59
|
+
tickets = bf.query(status="NEW")
|
|
60
|
+
|
|
61
|
+
# Cancel
|
|
62
|
+
bf.cancel(ticket.ticket_id)
|
|
63
|
+
|
|
64
|
+
# Stop daemon
|
|
65
|
+
bf.stop()
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## API Reference
|
|
71
|
+
|
|
72
|
+
### `Blockfill(binary_path, data_dir, timeout_s)`
|
|
73
|
+
|
|
74
|
+
| Parameter | Default | Description |
|
|
75
|
+
| ------------- | ---------------------------- | ------------------------------------- |
|
|
76
|
+
| `binary_path` | `~/.blockfill/bin/blockfill` | Path to blockfill binary |
|
|
77
|
+
| `data_dir` | `~/.blockfill` | Data directory (config, socket, logs) |
|
|
78
|
+
| `timeout_s` | `10.0` | RPC call timeout (seconds) |
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
### Install & Version
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
bf.install(version="latest", force=False) -> str
|
|
86
|
+
# Returns installed version string.
|
|
87
|
+
# If binary already exists and force=False, skips download and returns current version.
|
|
88
|
+
|
|
89
|
+
bf.version() -> str
|
|
90
|
+
# Returns "blockfill 0.1.0"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
### Credentials
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
bf.set_credentials(
|
|
99
|
+
exchange: str, # "binance-futures" | "okx-swap"
|
|
100
|
+
api_key: str,
|
|
101
|
+
api_secret: str,
|
|
102
|
+
api_passphrase: str | None = None, # OKX only
|
|
103
|
+
) -> None
|
|
104
|
+
# Writes to {data_dir}/config.toml (chmod 0600)
|
|
105
|
+
|
|
106
|
+
bf.set_qtex_endpoint(endpoint: str) -> None
|
|
107
|
+
# e.g. bf.set_qtex_endpoint("https://qtex.example.com")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
### Daemon
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
bf.start(wait_timeout_s=10.0, env=None) -> None
|
|
116
|
+
# Starts daemon in background. No-op if already running.
|
|
117
|
+
# env: extra environment variables (e.g. {"BLOCKFILL_API_KEY": "..."})
|
|
118
|
+
|
|
119
|
+
bf.stop(wait_timeout_s=5.0) -> None
|
|
120
|
+
# Graceful shutdown. No-op if not running.
|
|
121
|
+
|
|
122
|
+
bf.restart() -> None
|
|
123
|
+
|
|
124
|
+
bf.is_running() -> bool
|
|
125
|
+
|
|
126
|
+
bf.health() -> DaemonStatus
|
|
127
|
+
# Raises DaemonNotRunning if daemon is not up.
|
|
128
|
+
|
|
129
|
+
bf.run_foreground(env=None) -> None
|
|
130
|
+
# Blocking. For dev/debug.
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`DaemonStatus` fields: `running`, `pid`, `exchanges`, `active_tickets`, `uptime_s`, `version`
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
### Tickets
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
bf.place(
|
|
141
|
+
exchange: str,
|
|
142
|
+
symbol: str,
|
|
143
|
+
strategy: str,
|
|
144
|
+
target_position: float, # positive = buy, negative = sell
|
|
145
|
+
time_constraint_ms: int,
|
|
146
|
+
client_ticket_id: str | None = None, # idempotency key
|
|
147
|
+
) -> Ticket
|
|
148
|
+
|
|
149
|
+
bf.query(
|
|
150
|
+
status: str | None = None, # "NEW" | "OPEN" | "COMPLETE" | "CANCEL"
|
|
151
|
+
symbol: str | None = None,
|
|
152
|
+
ticket_id: str | None = None,
|
|
153
|
+
from_ms: int | None = None,
|
|
154
|
+
to_ms: int | None = None,
|
|
155
|
+
limit: int = 100,
|
|
156
|
+
) -> list[Ticket]
|
|
157
|
+
|
|
158
|
+
bf.cancel(ticket_id: str) -> None
|
|
159
|
+
# Raises TicketNotFound if ticket doesn't exist.
|
|
160
|
+
|
|
161
|
+
bf.cancel_all() -> int
|
|
162
|
+
# Returns number of cancelled tickets.
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`Ticket` fields: `ticket_id`, `status`, `exchange`, `symbol`, `strategy`, `target_position`, `executed_position`, `time_constraint_ms`, `start_time_ms`, `last_update_time_ms`, `is_expired`, `cancel_reason`
|
|
166
|
+
|
|
167
|
+
**Auto-cancel**: placing a new ticket for the same `exchange+symbol` automatically cancels existing `NEW` tickets for that pair.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
### Context Manager
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
with Blockfill(binary_path=..., data_dir=...) as bf:
|
|
175
|
+
bf.start(env={"BLOCKFILL_API_KEY": "..."})
|
|
176
|
+
ticket = bf.place(...)
|
|
177
|
+
# daemon is stopped on exit
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
### Exceptions
|
|
183
|
+
|
|
184
|
+
| Exception | When |
|
|
185
|
+
| ------------------------- | --------------------------------------------- |
|
|
186
|
+
| `BinaryNotFound` | binary not found, call `install()` first |
|
|
187
|
+
| `DaemonNotRunning` | daemon socket not found or unreachable |
|
|
188
|
+
| `DaemonStartTimeout` | `start()` timed out waiting for daemon |
|
|
189
|
+
| `RpcError(code, message)` | daemon returned a JSON-RPC error |
|
|
190
|
+
| `TicketNotFound` | `cancel()` called with non-existent ticket_id |
|
|
191
|
+
| `CredentialsError` | invalid exchange name in `set_credentials()` |
|
|
192
|
+
| `InstallError` | binary download failed |
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## Patterns
|
|
197
|
+
|
|
198
|
+
### Strategy system integration
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
from blockfill import Blockfill, DaemonNotRunning
|
|
202
|
+
|
|
203
|
+
bf = Blockfill(binary_path="/path/to/blockfill")
|
|
204
|
+
|
|
205
|
+
# On startup
|
|
206
|
+
if not bf.is_running():
|
|
207
|
+
bf.start(env={"BLOCKFILL_API_KEY": "..."})
|
|
208
|
+
bf.health()
|
|
209
|
+
|
|
210
|
+
# On each signal
|
|
211
|
+
ticket = bf.place(
|
|
212
|
+
exchange="binance-futures",
|
|
213
|
+
symbol=symbol,
|
|
214
|
+
strategy="maker_l2",
|
|
215
|
+
target_position=position,
|
|
216
|
+
time_constraint_ms=300_000,
|
|
217
|
+
)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Idempotent placement (safe to retry)
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
import uuid
|
|
224
|
+
|
|
225
|
+
key = str(uuid.uuid4()) # generate once, reuse on retry
|
|
226
|
+
|
|
227
|
+
ticket = bf.place(
|
|
228
|
+
exchange="binance-futures",
|
|
229
|
+
symbol="btcusdt",
|
|
230
|
+
strategy="maker_l2",
|
|
231
|
+
target_position=0.1,
|
|
232
|
+
time_constraint_ms=300_000,
|
|
233
|
+
client_ticket_id=key,
|
|
234
|
+
)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Check open positions
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
open_tickets = bf.query(status="NEW") + bf.query(status="OPEN")
|
|
241
|
+
for t in open_tickets:
|
|
242
|
+
print(t.symbol, t.target_position, t.executed_position)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## AI Agent Usage
|
|
248
|
+
|
|
249
|
+
The SDK is designed for programmatic use by AI agents (Claude, GPT, etc.).
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
import sys
|
|
253
|
+
sys.path.insert(0, "/path/to/python-sdk")
|
|
254
|
+
|
|
255
|
+
import os
|
|
256
|
+
os.environ["BLOCKFILL_API_KEY"] = "your_api_key"
|
|
257
|
+
|
|
258
|
+
from blockfill import Blockfill, DaemonNotRunning, TicketNotFound
|
|
259
|
+
|
|
260
|
+
bf = Blockfill(
|
|
261
|
+
binary_path="/path/to/blockfill",
|
|
262
|
+
data_dir="/path/to/data_dir",
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
if not bf.is_running():
|
|
266
|
+
bf.start(env={"BLOCKFILL_API_KEY": os.environ["BLOCKFILL_API_KEY"]})
|
|
267
|
+
|
|
268
|
+
bf.health() # raises if daemon not ready
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Key points for agents:
|
|
272
|
+
|
|
273
|
+
- Always call `bf.health()` before placing tickets
|
|
274
|
+
- Use `client_ticket_id=uuid.uuid4()` for idempotent retries
|
|
275
|
+
- `place()` auto-cancels existing NEW tickets for the same exchange+symbol
|
|
276
|
+
- `query()` returns data from qtex MongoDB (not exchange realtime)
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
## Directory Structure
|
|
281
|
+
|
|
282
|
+
```
|
|
283
|
+
~/.blockfill/
|
|
284
|
+
├── config.toml # credentials + qtex endpoint (chmod 0600)
|
|
285
|
+
├── bin/
|
|
286
|
+
│ └── blockfill # binary
|
|
287
|
+
├── runtime/
|
|
288
|
+
│ ├── daemon.sock # UDS socket (CLI ↔ daemon IPC)
|
|
289
|
+
│ ├── daemon.pid # PID file
|
|
290
|
+
│ └── daemon.log # daemon logs
|
|
291
|
+
└── trading-log/ # Channel B upload retry buffer
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
## Supported Exchanges
|
|
295
|
+
|
|
296
|
+
| Exchange | Value |
|
|
297
|
+
| --------------- | ------------------- |
|
|
298
|
+
| Binance Futures | `"binance-futures"` |
|
|
299
|
+
| OKX Swap | `"okx-swap"` |
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
blockfill/__init__.py,sha256=SRnwufNFyG0daHJUsJB4iZ3JlUazNSKb3T36XWj0YrY,459
|
|
2
|
+
blockfill/client.py,sha256=-88AvxXfHAVIrn2xRqj3-swaNFypaajD2kCDzW4MPBA,7416
|
|
3
|
+
blockfill/config.py,sha256=dOvarHB3_hTh64wQ7c6U2TCmQkpZdSgZHSkzWITwW9U,1347
|
|
4
|
+
blockfill/daemon.py,sha256=fXMbfeJOI2K0g1--tzRaeYExWCez8nPnLUr6atlvBcY,2418
|
|
5
|
+
blockfill/exceptions.py,sha256=5s9-vQ2qh8-YFfIAuLuHyrh_UdxJSemJ2dNt7dVnTxQ,467
|
|
6
|
+
blockfill/installer.py,sha256=SkUgd_kNIRnlALUECJp_O2yGoad52eEMB_eO2pMmLtw,1377
|
|
7
|
+
blockfill/models.py,sha256=JMp0-8JZM5-06HViYX9MKpQhQqWaAySdz8P2Xl3Itvc,971
|
|
8
|
+
blockfill/rpc.py,sha256=9tAyg3F8fTas5DaM12ML0HEoIhP9UEmnYLrZiZv3Src,781
|
|
9
|
+
blockfill-0.1.0.dist-info/METADATA,sha256=nW7skiyIVa-cvb3Uvuzh4TGAmynP7ROKG8ANN_-BfBw,7503
|
|
10
|
+
blockfill-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
blockfill-0.1.0.dist-info/top_level.txt,sha256=vGuWqvvXlW6NdWV0A4YoA5pbLXbUdD-m1QX5hZxgk3M,10
|
|
12
|
+
blockfill-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
blockfill
|