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