probable-trader 1.0.0
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.
- package/README.md +118 -0
- package/bin/probable-trader.js +64 -0
- package/lib/setup.js +125 -0
- package/package.json +30 -0
- package/requirements.txt +6 -0
- package/schemas/action-output.schema.json +50 -0
- package/schemas/config.schema.json +49 -0
- package/schemas/order-intent.schema.json +47 -0
- package/scripts/lib/__init__.py +1 -0
- package/scripts/lib/client_wrapper.py +241 -0
- package/scripts/lib/config.py +150 -0
- package/scripts/lib/db.py +176 -0
- package/scripts/lib/onboard.py +177 -0
- package/scripts/lib/report.py +105 -0
- package/scripts/lib/safety.py +76 -0
- package/scripts/lib/ws_client.py +109 -0
- package/scripts/prob.py +474 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Probable Markets onboarding: L1 auth, API key generation, credential storage.
|
|
2
|
+
|
|
3
|
+
Full flow:
|
|
4
|
+
1. Get nonce from /public/api/v1/auth/nonce
|
|
5
|
+
2. EIP-712 sign auth message (domain: "Prob", chain 56)
|
|
6
|
+
3. Login at /public/api/v1/auth/login
|
|
7
|
+
4. Generate API key at /public/api/v1/auth/api-key/{chainId} using L1 headers
|
|
8
|
+
5. Store credentials in .probable/config.json
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac as hmac_mod
|
|
14
|
+
import json
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import asdict, dataclass
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
from eth_account import Account
|
|
23
|
+
|
|
24
|
+
from .config import CONFIG_DIR, CONFIG_FILE, ProbableConfig, load_config
|
|
25
|
+
from .safety import ActionResult
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ApiCreds:
|
|
30
|
+
api_key: str
|
|
31
|
+
api_secret: str
|
|
32
|
+
api_passphrase: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── EIP-712 Signing (ClobAuthDomain) ────────────────────────────
|
|
36
|
+
|
|
37
|
+
def _create_l1_headers(private_key: str, chain_id: int = 56, nonce: int = 0) -> dict:
|
|
38
|
+
"""Create L1 auth headers using ClobAuthDomain EIP-712 signing.
|
|
39
|
+
|
|
40
|
+
Uses py_clob_client's proven signing logic with prob_* header names.
|
|
41
|
+
"""
|
|
42
|
+
from py_clob_client.signer import Signer
|
|
43
|
+
from py_clob_client.headers.headers import create_level_1_headers
|
|
44
|
+
|
|
45
|
+
signer = Signer(private_key, chain_id)
|
|
46
|
+
poly_headers = create_level_1_headers(signer, nonce=nonce)
|
|
47
|
+
|
|
48
|
+
# Probable Markets uses prob_* headers (not POLY_*)
|
|
49
|
+
return {
|
|
50
|
+
"prob_address": poly_headers["POLY_ADDRESS"],
|
|
51
|
+
"prob_signature": poly_headers["POLY_SIGNATURE"],
|
|
52
|
+
"prob_timestamp": poly_headers["POLY_TIMESTAMP"],
|
|
53
|
+
"prob_nonce": poly_headers["POLY_NONCE"],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ── HMAC Signing (for L2 auth) ───────────────────────────────────
|
|
58
|
+
|
|
59
|
+
def build_hmac_signature(secret: str, timestamp: str, method: str, request_path: str, body=None) -> str:
|
|
60
|
+
"""Build HMAC-SHA256 signature for L2 authenticated requests."""
|
|
61
|
+
secret_bytes = base64.urlsafe_b64decode(secret)
|
|
62
|
+
message = str(timestamp) + str(method) + str(request_path)
|
|
63
|
+
if body:
|
|
64
|
+
if isinstance(body, dict):
|
|
65
|
+
message += json.dumps(body, separators=(",", ":"), ensure_ascii=False)
|
|
66
|
+
else:
|
|
67
|
+
message += str(body).replace("'", '"')
|
|
68
|
+
h = hmac_mod.new(secret_bytes, message.encode("utf-8"), hashlib.sha256)
|
|
69
|
+
return base64.urlsafe_b64encode(h.digest()).decode("utf-8")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ── API Calls ────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
def _api_url(host: str, path: str) -> str:
|
|
75
|
+
return f"{host.rstrip('/')}{path}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_nonce(host: str) -> dict:
|
|
79
|
+
"""Step 1: Get nonce from API."""
|
|
80
|
+
url = _api_url(host, "/public/api/v1/auth/nonce")
|
|
81
|
+
resp = httpx.get(url, timeout=15)
|
|
82
|
+
resp.raise_for_status()
|
|
83
|
+
return resp.json()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def generate_api_key(host: str, private_key: str, chain_id: int = 56) -> dict:
|
|
87
|
+
"""Generate L2 API key using L1 ClobAuth headers."""
|
|
88
|
+
url = _api_url(host, f"/public/api/v1/auth/api-key/{chain_id}")
|
|
89
|
+
headers = _create_l1_headers(private_key, chain_id, nonce=0)
|
|
90
|
+
resp = httpx.post(url, headers=headers, timeout=15)
|
|
91
|
+
resp.raise_for_status()
|
|
92
|
+
return resp.json()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ── Credential Storage ───────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
def save_creds(cfg: ProbableConfig, api_key: str, api_secret: str = "", api_passphrase: str = ""):
|
|
98
|
+
"""Save API credentials to .probable/config.json."""
|
|
99
|
+
config_dir = Path(CONFIG_DIR)
|
|
100
|
+
config_dir.mkdir(exist_ok=True)
|
|
101
|
+
config_path = config_dir / CONFIG_FILE
|
|
102
|
+
|
|
103
|
+
# Load existing or create new
|
|
104
|
+
data = {}
|
|
105
|
+
if config_path.exists():
|
|
106
|
+
try:
|
|
107
|
+
data = json.loads(config_path.read_text())
|
|
108
|
+
except (json.JSONDecodeError, OSError):
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
data["api_key"] = api_key
|
|
112
|
+
if api_secret:
|
|
113
|
+
data["api_secret"] = api_secret
|
|
114
|
+
if api_passphrase:
|
|
115
|
+
data["api_passphrase"] = api_passphrase
|
|
116
|
+
data["host"] = cfg.host
|
|
117
|
+
data["chain_id"] = cfg.chain_id
|
|
118
|
+
if cfg.multi_sig_addr:
|
|
119
|
+
data["multi_sig_addr"] = cfg.multi_sig_addr
|
|
120
|
+
|
|
121
|
+
config_path.write_text(json.dumps(data, indent=2))
|
|
122
|
+
return config_path
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ── Full Onboarding Orchestration ────────────────────────────────
|
|
126
|
+
|
|
127
|
+
def run_onboard(cfg: ProbableConfig) -> ActionResult:
|
|
128
|
+
"""Run complete onboarding: L1 ClobAuth sign → generate API key → save.
|
|
129
|
+
|
|
130
|
+
Requires: PROB_PRIVATE_KEY set in config.
|
|
131
|
+
"""
|
|
132
|
+
steps = {}
|
|
133
|
+
|
|
134
|
+
if not cfg.private_key:
|
|
135
|
+
return ActionResult(
|
|
136
|
+
success=False, action="onboard",
|
|
137
|
+
error="PROB_PRIVATE_KEY is required. Set it as env var or in .probable/config.json"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Derive EOA address from private key
|
|
141
|
+
try:
|
|
142
|
+
acct = Account.from_key(cfg.private_key)
|
|
143
|
+
eoa_address = acct.address
|
|
144
|
+
steps["eoa_address"] = eoa_address
|
|
145
|
+
except Exception as e:
|
|
146
|
+
return ActionResult(success=False, action="onboard", error=f"Invalid private key: {e}")
|
|
147
|
+
|
|
148
|
+
# Generate API key (one step: L1 ClobAuth headers → POST api-key)
|
|
149
|
+
try:
|
|
150
|
+
api_resp = generate_api_key(cfg.host, cfg.private_key, cfg.chain_id)
|
|
151
|
+
api_key = api_resp.get("apiKey", "")
|
|
152
|
+
api_secret = api_resp.get("secret", "")
|
|
153
|
+
api_passphrase = api_resp.get("passphrase", "")
|
|
154
|
+
|
|
155
|
+
if not api_key:
|
|
156
|
+
return ActionResult(success=False, action="onboard",
|
|
157
|
+
error=f"API key not found in response: {api_resp}", data=steps)
|
|
158
|
+
|
|
159
|
+
steps["api_key_generated"] = True
|
|
160
|
+
steps["api_key_preview"] = api_key[:12] + "..."
|
|
161
|
+
except Exception as e:
|
|
162
|
+
return ActionResult(success=False, action="onboard", error=f"Failed to generate API key: {e}", data=steps)
|
|
163
|
+
|
|
164
|
+
# Save credentials
|
|
165
|
+
try:
|
|
166
|
+
path = save_creds(cfg, api_key, api_secret, api_passphrase)
|
|
167
|
+
steps["saved_to"] = str(path)
|
|
168
|
+
except Exception as e:
|
|
169
|
+
return ActionResult(success=False, action="onboard", error=f"Failed to save credentials: {e}", data=steps)
|
|
170
|
+
|
|
171
|
+
steps["next_steps"] = [
|
|
172
|
+
"Run: python3 scripts/prob.py doctor --json (verify connectivity)",
|
|
173
|
+
"Run: python3 scripts/prob.py setup --confirm (approve tokens for trading)",
|
|
174
|
+
"Run: python3 scripts/prob.py market list --json (browse markets)",
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
return ActionResult(success=True, action="onboard", data=steps)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Daily trading report generator for Probable Markets Skill."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from .db import ProbableDB
|
|
8
|
+
from .safety import ActionResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def generate_daily_report(db: ProbableDB, fmt: str = "markdown", hours: int = 24) -> ActionResult:
|
|
12
|
+
"""Generate a trading report for the past N hours."""
|
|
13
|
+
trades = db.get_recent_trades(hours)
|
|
14
|
+
orders = db.get_recent_orders(hours)
|
|
15
|
+
audit = db.get_audit_log(hours)
|
|
16
|
+
|
|
17
|
+
# Compute stats
|
|
18
|
+
total_trades = len(trades)
|
|
19
|
+
total_orders = len(orders)
|
|
20
|
+
total_actions = len(audit)
|
|
21
|
+
|
|
22
|
+
buy_trades = [t for t in trades if t.get("side", "").upper() == "BUY"]
|
|
23
|
+
sell_trades = [t for t in trades if t.get("side", "").upper() == "SELL"]
|
|
24
|
+
|
|
25
|
+
orders_by_status = {}
|
|
26
|
+
for o in orders:
|
|
27
|
+
s = o.get("status", "unknown")
|
|
28
|
+
orders_by_status[s] = orders_by_status.get(s, 0) + 1
|
|
29
|
+
|
|
30
|
+
# Build report data
|
|
31
|
+
report_data = {
|
|
32
|
+
"period_hours": hours,
|
|
33
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
34
|
+
"summary": {
|
|
35
|
+
"total_trades": total_trades,
|
|
36
|
+
"buy_trades": len(buy_trades),
|
|
37
|
+
"sell_trades": len(sell_trades),
|
|
38
|
+
"total_orders": total_orders,
|
|
39
|
+
"orders_by_status": orders_by_status,
|
|
40
|
+
"total_audit_actions": total_actions,
|
|
41
|
+
},
|
|
42
|
+
"trades": trades,
|
|
43
|
+
"orders": orders,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if fmt == "json":
|
|
47
|
+
return ActionResult(success=True, action="report.daily", data=report_data)
|
|
48
|
+
|
|
49
|
+
# Markdown format
|
|
50
|
+
md_lines = [
|
|
51
|
+
f"# Probable Markets Daily Report",
|
|
52
|
+
f"",
|
|
53
|
+
f"**Period:** Last {hours} hours | **Generated:** {report_data['generated_at']}",
|
|
54
|
+
f"",
|
|
55
|
+
f"## Summary",
|
|
56
|
+
f"",
|
|
57
|
+
f"| Metric | Value |",
|
|
58
|
+
f"|--------|-------|",
|
|
59
|
+
f"| Total Trades | {total_trades} |",
|
|
60
|
+
f"| Buy Trades | {len(buy_trades)} |",
|
|
61
|
+
f"| Sell Trades | {len(sell_trades)} |",
|
|
62
|
+
f"| Total Orders | {total_orders} |",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
for status, count in orders_by_status.items():
|
|
66
|
+
md_lines.append(f"| Orders ({status}) | {count} |")
|
|
67
|
+
|
|
68
|
+
md_lines.append(f"| Audit Actions | {total_actions} |")
|
|
69
|
+
|
|
70
|
+
if trades:
|
|
71
|
+
md_lines.extend([
|
|
72
|
+
f"",
|
|
73
|
+
f"## Recent Trades",
|
|
74
|
+
f"",
|
|
75
|
+
f"| Trade ID | Market | Side | Price | Amount | Time |",
|
|
76
|
+
f"|----------|--------|------|-------|--------|------|",
|
|
77
|
+
])
|
|
78
|
+
for t in trades[:20]: # Limit to 20
|
|
79
|
+
md_lines.append(
|
|
80
|
+
f"| {t.get('trade_id', '-')[:8]} | {t.get('market_id', '-')} | "
|
|
81
|
+
f"{t.get('side', '-')} | {t.get('price', '-')} | {t.get('amount', '-')} | "
|
|
82
|
+
f"{t.get('timestamp', '-')} |"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if orders:
|
|
86
|
+
md_lines.extend([
|
|
87
|
+
f"",
|
|
88
|
+
f"## Recent Orders",
|
|
89
|
+
f"",
|
|
90
|
+
f"| Order ID | Market | Side | Price | Amount | Status |",
|
|
91
|
+
f"|----------|--------|------|-------|--------|--------|",
|
|
92
|
+
])
|
|
93
|
+
for o in orders[:20]:
|
|
94
|
+
md_lines.append(
|
|
95
|
+
f"| {o.get('order_id', '-')[:8]} | {o.get('market_id', '-')} | "
|
|
96
|
+
f"{o.get('side', '-')} | {o.get('price', '-')} | {o.get('amount', '-')} | "
|
|
97
|
+
f"{o.get('status', '-')} |"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
report_data["markdown"] = "\n".join(md_lines)
|
|
101
|
+
|
|
102
|
+
if fmt == "both":
|
|
103
|
+
return ActionResult(success=True, action="report.daily", data=report_data)
|
|
104
|
+
else: # markdown
|
|
105
|
+
return ActionResult(success=True, action="report.daily", data={"markdown": report_data["markdown"]})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Safety layer: dry-run, confirmation, secret masking, action result envelope."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import asdict, dataclass, field
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ActionResult:
|
|
14
|
+
success: bool
|
|
15
|
+
action: str
|
|
16
|
+
data: Any = None
|
|
17
|
+
error: Optional[str] = None
|
|
18
|
+
dry_run: bool = False
|
|
19
|
+
request_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
|
|
20
|
+
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> dict:
|
|
23
|
+
return asdict(self)
|
|
24
|
+
|
|
25
|
+
def to_json(self) -> str:
|
|
26
|
+
return json.dumps(self.to_dict(), indent=2, default=str)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Patterns that look like private keys or secrets
|
|
30
|
+
_SECRET_PATTERNS = [
|
|
31
|
+
re.compile(r"0x[0-9a-fA-F]{64}"), # Hex private key
|
|
32
|
+
re.compile(r"[0-9a-fA-F]{64}"), # Hex private key without 0x
|
|
33
|
+
re.compile(r"(?i)(api[_-]?key|secret|passphrase|password|private[_-]?key)\s*[:=]\s*\S+"),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def mask_secrets(text: str) -> str:
|
|
38
|
+
"""Redact private keys and secrets from text."""
|
|
39
|
+
result = text
|
|
40
|
+
for pat in _SECRET_PATTERNS:
|
|
41
|
+
result = pat.sub("***REDACTED***", result)
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def require_confirm(action_name: str, details: dict, confirmed: bool, dry_run: bool) -> Optional[ActionResult]:
|
|
46
|
+
"""Check if action is confirmed. Returns ActionResult if blocked, None if OK."""
|
|
47
|
+
if dry_run:
|
|
48
|
+
return ActionResult(
|
|
49
|
+
success=True,
|
|
50
|
+
action=action_name,
|
|
51
|
+
data=details,
|
|
52
|
+
dry_run=True,
|
|
53
|
+
)
|
|
54
|
+
if not confirmed:
|
|
55
|
+
return ActionResult(
|
|
56
|
+
success=False,
|
|
57
|
+
action=action_name,
|
|
58
|
+
error=f"Action '{action_name}' requires --confirm flag. Use --dry-run to preview.",
|
|
59
|
+
data=details,
|
|
60
|
+
dry_run=False,
|
|
61
|
+
)
|
|
62
|
+
return None # Proceed
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def output_result(result: ActionResult, json_mode: bool = False):
|
|
66
|
+
"""Print ActionResult to stdout."""
|
|
67
|
+
if json_mode:
|
|
68
|
+
print(result.to_json())
|
|
69
|
+
else:
|
|
70
|
+
status = "OK" if result.success else "FAILED"
|
|
71
|
+
dry = " [DRY-RUN]" if result.dry_run else ""
|
|
72
|
+
print(f"[{status}]{dry} {result.action}")
|
|
73
|
+
if result.error:
|
|
74
|
+
print(f" Error: {result.error}")
|
|
75
|
+
if result.data:
|
|
76
|
+
print(json.dumps(result.data, indent=2, default=str))
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""WebSocket client for Probable Markets — book updates and execution reports."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
WS_BASE = "wss://ws.probable.markets"
|
|
10
|
+
WS_MARKET_PATH = "/ws/market"
|
|
11
|
+
WS_USER_PATH = "/ws/user"
|
|
12
|
+
PING_INTERVAL = 10
|
|
13
|
+
RECONNECT_BASE_DELAY = 1
|
|
14
|
+
RECONNECT_MAX_DELAY = 30
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _print_event(event: dict, json_mode: bool):
|
|
18
|
+
"""Print a WebSocket event as JSON line."""
|
|
19
|
+
if json_mode:
|
|
20
|
+
print(json.dumps(event, default=str), flush=True)
|
|
21
|
+
else:
|
|
22
|
+
etype = event.get("type", event.get("event", "unknown"))
|
|
23
|
+
print(f"[WS] {etype}: {json.dumps(event, default=str)}", flush=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def _connect_and_stream(url: str, subscribe_msg: Optional[dict], duration: int,
|
|
27
|
+
json_mode: bool, on_event=None):
|
|
28
|
+
"""Connect to WebSocket, send subscription, stream events for duration seconds."""
|
|
29
|
+
try:
|
|
30
|
+
import websockets
|
|
31
|
+
except ImportError:
|
|
32
|
+
print("Error: 'websockets' package required. Install with: pip install websockets", file=sys.stderr)
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
deadline = time.time() + duration
|
|
36
|
+
delay = RECONNECT_BASE_DELAY
|
|
37
|
+
|
|
38
|
+
while time.time() < deadline:
|
|
39
|
+
try:
|
|
40
|
+
async with websockets.connect(url) as ws:
|
|
41
|
+
delay = RECONNECT_BASE_DELAY # Reset on successful connect
|
|
42
|
+
|
|
43
|
+
if subscribe_msg:
|
|
44
|
+
await ws.send(json.dumps(subscribe_msg))
|
|
45
|
+
|
|
46
|
+
# Ping task
|
|
47
|
+
async def ping_loop():
|
|
48
|
+
while True:
|
|
49
|
+
await asyncio.sleep(PING_INTERVAL)
|
|
50
|
+
try:
|
|
51
|
+
await ws.send("PING")
|
|
52
|
+
except Exception:
|
|
53
|
+
break
|
|
54
|
+
|
|
55
|
+
ping_task = asyncio.create_task(ping_loop())
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
while time.time() < deadline:
|
|
59
|
+
try:
|
|
60
|
+
msg = await asyncio.wait_for(ws.recv(), timeout=min(5, deadline - time.time()))
|
|
61
|
+
except asyncio.TimeoutError:
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
if msg == "PONG" or msg == "pong":
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
event = json.loads(msg)
|
|
69
|
+
except json.JSONDecodeError:
|
|
70
|
+
event = {"raw": msg}
|
|
71
|
+
|
|
72
|
+
_print_event(event, json_mode)
|
|
73
|
+
if on_event:
|
|
74
|
+
on_event(event)
|
|
75
|
+
finally:
|
|
76
|
+
ping_task.cancel()
|
|
77
|
+
try:
|
|
78
|
+
await ping_task
|
|
79
|
+
except asyncio.CancelledError:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
except Exception as e:
|
|
83
|
+
if time.time() >= deadline:
|
|
84
|
+
break
|
|
85
|
+
_print_event({"type": "reconnecting", "error": str(e), "delay": delay}, json_mode)
|
|
86
|
+
await asyncio.sleep(delay)
|
|
87
|
+
delay = min(delay * 2, RECONNECT_MAX_DELAY)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def subscribe_book(token_id: str, duration: int = 60, json_mode: bool = False):
|
|
91
|
+
"""Subscribe to orderbook updates for a token."""
|
|
92
|
+
url = f"{WS_BASE}{WS_MARKET_PATH}"
|
|
93
|
+
sub_msg = {
|
|
94
|
+
"assets_ids": [token_id],
|
|
95
|
+
"type": "market",
|
|
96
|
+
}
|
|
97
|
+
_print_event({"type": "subscribing", "url": url, "token_id": token_id, "duration": duration}, json_mode)
|
|
98
|
+
await _connect_and_stream(url, sub_msg, duration, json_mode)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def subscribe_user(cfg, duration: int = 60, json_mode: bool = False):
|
|
102
|
+
"""Subscribe to user execution reports (authenticated)."""
|
|
103
|
+
url = f"{WS_BASE}{WS_USER_PATH}"
|
|
104
|
+
sub_msg = {
|
|
105
|
+
"type": "user",
|
|
106
|
+
"apiKey": cfg.api_key,
|
|
107
|
+
}
|
|
108
|
+
_print_event({"type": "subscribing", "url": url, "duration": duration}, json_mode)
|
|
109
|
+
await _connect_and_stream(url, sub_msg, duration, json_mode)
|