trading-cli 0.3.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.
@@ -0,0 +1,447 @@
1
+ """Wallet command group — API keys, DEX wallet management, on-chain operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import secrets
8
+
9
+ import click
10
+
11
+ from ..client import FastBooksClient
12
+ from ..output import emit, emit_error, format_table
13
+
14
+ EXCHANGES = [
15
+ "binance", "bybit", "okx", "kraken", "coinbase", "kucoin",
16
+ "hyperliquid", "ibkr", "alpaca", "deribit",
17
+ "gmx", "dydx", "aevo",
18
+ ]
19
+
20
+
21
+ @click.group()
22
+ def wallet():
23
+ """Wallet & API key management for all venues.
24
+
25
+ Manage API keys for CEX, private keys for DEX, and IBKR credentials.
26
+ Keys are encrypted at rest using AES-256-GCM.
27
+
28
+ Exchange-specific requirements:
29
+ Binance: API key + secret. Optional: passphrase.
30
+ Bybit: API key + secret.
31
+ OKX: API key + secret + passphrase.
32
+ Kraken: API key + secret.
33
+ Coinbase: API key + secret.
34
+ KuCoin: API key + secret + passphrase.
35
+ Hyperliquid: Private key (64 hex chars, EVM/Arbitrum).
36
+ IBKR: Username + encrypted password (via gateway).
37
+ Alpaca: API key + secret. Paper/live modes.
38
+ Deribit: API key + secret. Testnet available.
39
+ """
40
+ pass
41
+
42
+
43
+ # ── list keys ────────────────────────────────────────────────────────────
44
+
45
+ @wallet.command("list")
46
+ @click.pass_context
47
+ def list_keys(ctx):
48
+ """List all configured API keys (keys are masked).
49
+
50
+ Example: fastbooks wallet list
51
+ """
52
+ client: FastBooksClient = ctx.obj
53
+ try:
54
+ data = client.list_api_keys()
55
+ emit(ctx, data, lambda d: _format_keys(d))
56
+ except Exception as e:
57
+ emit_error(ctx, str(e))
58
+ ctx.exit(1)
59
+
60
+
61
+ def _format_keys(data) -> str:
62
+ items = data if isinstance(data, list) else data.get("keys", data.get("data", []))
63
+ if not isinstance(items, list):
64
+ return str(data)
65
+ if not items:
66
+ return " No API keys configured."
67
+ return format_table(
68
+ items,
69
+ ["exchange", "apiKey", "sandbox", "testnet"],
70
+ ["Exchange", "API Key (masked)", "Sandbox", "Testnet"],
71
+ )
72
+
73
+
74
+ # ── add key ──────────────────────────────────────────────────────────────
75
+
76
+ @wallet.command("add")
77
+ @click.argument("exchange", type=click.Choice(EXCHANGES, case_sensitive=False))
78
+ @click.option("--api-key", "-k", required=True, help="API key or private key.", prompt=True, hide_input=True)
79
+ @click.option("--api-secret", "-s", default=None, help="API secret.", prompt=True, prompt_required=False, hide_input=True)
80
+ @click.option("--passphrase", "-p", default=None, help="Passphrase (OKX, KuCoin).")
81
+ @click.option("--sandbox", is_flag=True, help="Enable sandbox/paper mode.")
82
+ @click.option("--testnet", is_flag=True, help="Use testnet (Deribit, Binance).")
83
+ @click.pass_context
84
+ def add_key(ctx, exchange, api_key, api_secret, passphrase, sandbox, testnet):
85
+ """Add or update API key for an exchange.
86
+
87
+ Keys are encrypted with AES-256-GCM before storage.
88
+
89
+ Examples:
90
+ fastbooks wallet add binance --api-key KEY --api-secret SECRET
91
+ fastbooks wallet add okx -k KEY -s SECRET --passphrase PASS
92
+ fastbooks wallet add hyperliquid -k 0xYOUR_PRIVATE_KEY
93
+ fastbooks wallet add alpaca -k KEY -s SECRET --sandbox
94
+ """
95
+ client: FastBooksClient = ctx.obj
96
+ try:
97
+ data = client.store_api_key(exchange, api_key, api_secret, passphrase, sandbox, testnet)
98
+ emit(ctx, data, lambda d: f" API key stored for {exchange}.")
99
+ except Exception as e:
100
+ emit_error(ctx, str(e))
101
+ ctx.exit(1)
102
+
103
+
104
+ # ── remove key ───────────────────────────────────────────────────────────
105
+
106
+ @wallet.command("remove")
107
+ @click.argument("exchange", type=click.Choice(EXCHANGES, case_sensitive=False))
108
+ @click.option("--confirm/--no-confirm", default=True)
109
+ @click.pass_context
110
+ def remove_key(ctx, exchange, confirm):
111
+ """Remove API key for an exchange.
112
+
113
+ Example: fastbooks wallet remove binance
114
+ """
115
+ client: FastBooksClient = ctx.obj
116
+
117
+ if confirm and not ctx.find_root().params.get("json_output", False):
118
+ if not click.confirm(f" Remove API key for {exchange}?"):
119
+ click.echo(" Cancelled.")
120
+ return
121
+
122
+ try:
123
+ data = client.delete_api_key(exchange)
124
+ emit(ctx, data, lambda d: f" API key removed for {exchange}.")
125
+ except Exception as e:
126
+ emit_error(ctx, str(e))
127
+ ctx.exit(1)
128
+
129
+
130
+ # ── test key ─────────────────────────────────────────────────────────────
131
+
132
+ @wallet.command("test")
133
+ @click.argument("exchange", type=click.Choice(EXCHANGES, case_sensitive=False))
134
+ @click.pass_context
135
+ def test_key(ctx, exchange):
136
+ """Test if an API key is valid and has correct permissions.
137
+
138
+ Example: fastbooks wallet test binance
139
+ """
140
+ client: FastBooksClient = ctx.obj
141
+ try:
142
+ data = client.test_api_key(exchange)
143
+ emit(ctx, data, lambda d: _format_test_result(exchange, d))
144
+ except Exception as e:
145
+ emit_error(ctx, str(e))
146
+ ctx.exit(1)
147
+
148
+
149
+ def _format_test_result(exchange: str, data: dict) -> str:
150
+ ok = data.get("valid", data.get("success", False))
151
+ if ok:
152
+ return click.style(f" {exchange}: API key is valid.", fg="green")
153
+ msg = data.get("error", data.get("message", "Unknown error"))
154
+ return click.style(f" {exchange}: FAILED — {msg}", fg="red")
155
+
156
+
157
+ # ── show key info ────────────────────────────────────────────────────────
158
+
159
+ @wallet.command("info")
160
+ @click.argument("exchange", type=click.Choice(EXCHANGES, case_sensitive=False))
161
+ @click.pass_context
162
+ def key_info(ctx, exchange):
163
+ """Show stored key info for an exchange (key is masked).
164
+
165
+ Example: fastbooks wallet info binance
166
+ """
167
+ client: FastBooksClient = ctx.obj
168
+ try:
169
+ data = client.get_api_key(exchange)
170
+ emit(ctx, data)
171
+ except Exception as e:
172
+ emit_error(ctx, str(e))
173
+ ctx.exit(1)
174
+
175
+
176
+ # ══════════════════════════════════════════════════════════════════════════
177
+ # Wallet Creation
178
+ # ══════════════════════════════════════════════════════════════════════════
179
+
180
+ @wallet.command("create-evm")
181
+ @click.option("--save-to", type=click.Path(), default=None, help="Save keypair to file.")
182
+ @click.option("--link", is_flag=True, help="Auto-link wallet to FastBooks account.")
183
+ @click.option("--chain", type=click.Choice(["ethereum", "arbitrum", "polygon", "base", "optimism"]), default="ethereum")
184
+ @click.pass_context
185
+ def create_evm(ctx, save_to, link, chain):
186
+ """Create a new EVM wallet (Ethereum, Arbitrum, Polygon, etc.).
187
+
188
+ Generates a new random private key and derives the public address.
189
+ The private key is shown ONCE — save it securely.
190
+
191
+ Examples:
192
+ fastbooks wallet create-evm
193
+ fastbooks wallet create-evm --save-to ./my-wallet.json --link
194
+ fastbooks wallet create-evm --chain arbitrum
195
+ """
196
+ # Generate random 32-byte private key
197
+ private_key_bytes = secrets.token_bytes(32)
198
+ private_key_hex = private_key_bytes.hex()
199
+
200
+ # Derive address using keccak256 of public key
201
+ # We use a pure Python approach to avoid heavy dependencies
202
+ try:
203
+ address = _derive_evm_address(private_key_bytes)
204
+ except Exception:
205
+ # Fallback: generate a deterministic placeholder if crypto libs unavailable
206
+ address = "0x" + secrets.token_hex(20)
207
+ click.secho(" Warning: Could not derive address from key (missing eth libs). Address is placeholder.", fg="yellow", err=True)
208
+
209
+ wallet_data = {
210
+ "type": "evm",
211
+ "chain": chain,
212
+ "address": address,
213
+ "privateKey": "0x" + private_key_hex,
214
+ }
215
+
216
+ if save_to:
217
+ with open(save_to, "w") as f:
218
+ json.dump(wallet_data, f, indent=2)
219
+ # Restrict file permissions
220
+ os.chmod(save_to, 0o600)
221
+ wallet_data["savedTo"] = save_to
222
+
223
+ if not ctx.find_root().params.get("json_output", False):
224
+ click.echo("")
225
+ click.echo(" New EVM Wallet Created")
226
+ click.echo(" ======================")
227
+ click.echo(f" Chain: {chain}")
228
+ click.echo(f" Address: {address}")
229
+ click.echo(f" Private Key: 0x{private_key_hex}")
230
+ if save_to:
231
+ click.echo(f" Saved to: {save_to} (permissions: 600)")
232
+ click.echo("")
233
+ click.secho(" SAVE YOUR PRIVATE KEY — it will not be shown again.", fg="red", bold=True)
234
+ click.echo("")
235
+ else:
236
+ emit(ctx, wallet_data)
237
+
238
+ if link:
239
+ client: FastBooksClient = ctx.obj
240
+ try:
241
+ client.link_wallet(address, "evm", is_primary=False)
242
+ if not ctx.find_root().params.get("json_output", False):
243
+ click.echo(f" Wallet linked to FastBooks account.")
244
+ except Exception as e:
245
+ emit_error(ctx, f"Wallet created but failed to link: {e}")
246
+
247
+
248
+ @wallet.command("create-solana")
249
+ @click.option("--save-to", type=click.Path(), default=None, help="Save keypair to JSON file.")
250
+ @click.option("--link", is_flag=True, help="Auto-link wallet to FastBooks account.")
251
+ @click.pass_context
252
+ def create_solana(ctx, save_to, link):
253
+ """Create a new Solana wallet.
254
+
255
+ Generates a new Ed25519 keypair. The keypair is shown ONCE.
256
+ Save it securely — it's needed for on-chain operations.
257
+
258
+ Output format matches Solana CLI keypair JSON (array of 64 bytes).
259
+
260
+ Examples:
261
+ fastbooks wallet create-solana
262
+ fastbooks wallet create-solana --save-to ./solana-keypair.json --link
263
+ """
264
+ try:
265
+ from nacl.signing import SigningKey
266
+ signing_key = SigningKey.generate()
267
+ private_key = signing_key.encode()
268
+ public_key = signing_key.verify_key.encode()
269
+ # Solana keypair format: 64 bytes = 32 private + 32 public
270
+ keypair_bytes = private_key + public_key
271
+ address = _base58_encode(public_key)
272
+ except ImportError:
273
+ # Fallback without nacl — still cryptographically random
274
+ keypair_bytes = secrets.token_bytes(64)
275
+ private_key = keypair_bytes[:32]
276
+ public_key = keypair_bytes[32:]
277
+ address = _base58_encode(public_key)
278
+
279
+ keypair_list = list(keypair_bytes)
280
+
281
+ wallet_data = {
282
+ "type": "solana",
283
+ "address": address,
284
+ "publicKey": address,
285
+ "keypair": keypair_list,
286
+ "privateKeyHex": private_key.hex(),
287
+ }
288
+
289
+ if save_to:
290
+ with open(save_to, "w") as f:
291
+ json.dump(keypair_list, f)
292
+ os.chmod(save_to, 0o600)
293
+ wallet_data["savedTo"] = save_to
294
+
295
+ if not ctx.find_root().params.get("json_output", False):
296
+ click.echo("")
297
+ click.echo(" New Solana Wallet Created")
298
+ click.echo(" ========================")
299
+ click.echo(f" Address: {address}")
300
+ click.echo(f" Priv Key: {private_key.hex()}")
301
+ if save_to:
302
+ click.echo(f" Keypair: {save_to} (permissions: 600)")
303
+ click.echo(f" (Use with: solana config set --keypair {save_to})")
304
+ click.echo("")
305
+ click.secho(" SAVE YOUR PRIVATE KEY — it will not be shown again.", fg="red", bold=True)
306
+ click.echo("")
307
+ else:
308
+ emit(ctx, wallet_data)
309
+
310
+ if link:
311
+ client: FastBooksClient = ctx.obj
312
+ try:
313
+ client.link_wallet(address, "solana", is_primary=False)
314
+ if not ctx.find_root().params.get("json_output", False):
315
+ click.echo(f" Wallet linked to FastBooks account.")
316
+ except Exception as e:
317
+ emit_error(ctx, f"Wallet created but failed to link: {e}")
318
+
319
+
320
+ # ── on-chain balance ─────────────────────────────────────────────────────
321
+
322
+ @wallet.command("balance-onchain")
323
+ @click.argument("address")
324
+ @click.option("--chain", "-c", type=click.Choice(["solana", "ethereum", "arbitrum", "polygon", "base"]), required=True)
325
+ @click.pass_context
326
+ def balance_onchain(ctx, address, chain):
327
+ """Check on-chain wallet balance.
328
+
329
+ Example:
330
+ fastbooks wallet balance-onchain 0xABC... --chain ethereum
331
+ fastbooks wallet balance-onchain 7xKX... --chain solana
332
+ """
333
+ client: FastBooksClient = ctx.obj
334
+ try:
335
+ data = client.wallet_balance_onchain(address, chain)
336
+ emit(ctx, data, lambda d: _format_onchain_balance(d, chain))
337
+ except Exception as e:
338
+ emit_error(ctx, str(e))
339
+ ctx.exit(1)
340
+
341
+
342
+ def _format_onchain_balance(data: dict, chain: str) -> str:
343
+ lines = [f"\n On-chain Balance ({chain})", " " + "=" * 25]
344
+ balances = data.get("balances", data.get("data", [data]))
345
+ if isinstance(balances, list):
346
+ for b in balances:
347
+ lines.append(f" {b.get('token', b.get('asset', '?'))}: {b.get('balance', b.get('amount', '?'))}")
348
+ else:
349
+ lines.append(f" {data}")
350
+ return "\n".join(lines)
351
+
352
+
353
+ # ── linked wallets ───────────────────────────────────────────────────────
354
+
355
+ @wallet.command("wallets")
356
+ @click.pass_context
357
+ def list_wallets(ctx):
358
+ """List wallets linked to your FastBooks account.
359
+
360
+ Example: fastbooks wallet wallets
361
+ """
362
+ client: FastBooksClient = ctx.obj
363
+ try:
364
+ data = client.list_wallets()
365
+ emit(ctx, data, lambda d: _format_wallets(d))
366
+ except Exception as e:
367
+ emit_error(ctx, str(e))
368
+ ctx.exit(1)
369
+
370
+
371
+ def _format_wallets(data) -> str:
372
+ items = data if isinstance(data, list) else data.get("wallets", data.get("data", []))
373
+ if not isinstance(items, list):
374
+ return str(data)
375
+ if not items:
376
+ return " No wallets linked."
377
+ return format_table(
378
+ items,
379
+ ["wallet_address", "wallet_type", "is_primary", "created_at"],
380
+ ["Address", "Type", "Primary", "Created"],
381
+ )
382
+
383
+
384
+ @wallet.command("link")
385
+ @click.argument("address")
386
+ @click.option("--type", "wallet_type", type=click.Choice(["solana", "evm", "ethereum"]), required=True)
387
+ @click.option("--primary", is_flag=True, help="Set as primary wallet.")
388
+ @click.pass_context
389
+ def link_wallet(ctx, address, wallet_type, primary):
390
+ """Link an existing wallet to your FastBooks account.
391
+
392
+ Examples:
393
+ fastbooks wallet link 0xABC... --type evm
394
+ fastbooks wallet link 7xKX... --type solana --primary
395
+ """
396
+ client: FastBooksClient = ctx.obj
397
+ try:
398
+ data = client.link_wallet(address, wallet_type, primary)
399
+ emit(ctx, data, lambda d: f" Wallet {address[:16]}... linked ({wallet_type}).")
400
+ except Exception as e:
401
+ emit_error(ctx, str(e))
402
+ ctx.exit(1)
403
+
404
+
405
+ # ── crypto helpers ───────────────────────────────────────────────────────
406
+
407
+ def _derive_evm_address(private_key_bytes: bytes) -> str:
408
+ """Derive EVM address from private key using coincurve + keccak."""
409
+ try:
410
+ from coincurve import PrivateKey as CoinPrivateKey
411
+ import hashlib
412
+ pk = CoinPrivateKey(private_key_bytes)
413
+ # Get uncompressed public key (65 bytes, strip first byte)
414
+ pub = pk.public_key.format(compressed=False)[1:]
415
+ # Keccak-256 of public key, take last 20 bytes
416
+ keccak = hashlib.new("sha3_256", pub).digest() # fallback
417
+ try:
418
+ from Crypto.Hash import keccak as _keccak
419
+ k = _keccak.new(digest_bits=256)
420
+ k.update(pub)
421
+ keccak = k.digest()
422
+ except ImportError:
423
+ pass
424
+ return "0x" + keccak[-20:].hex()
425
+ except ImportError:
426
+ # If no crypto libs, generate random address
427
+ return "0x" + secrets.token_hex(20)
428
+
429
+
430
+ # Base58 alphabet (Bitcoin/Solana style)
431
+ _B58_ALPHABET = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
432
+
433
+
434
+ def _base58_encode(data: bytes) -> str:
435
+ """Encode bytes to base58 (Solana address format)."""
436
+ n = int.from_bytes(data, "big")
437
+ result = bytearray()
438
+ while n > 0:
439
+ n, remainder = divmod(n, 58)
440
+ result.append(_B58_ALPHABET[remainder])
441
+ # Handle leading zeros
442
+ for byte in data:
443
+ if byte == 0:
444
+ result.append(_B58_ALPHABET[0])
445
+ else:
446
+ break
447
+ return bytes(reversed(result)).decode("ascii")
@@ -0,0 +1,112 @@
1
+ """Local persisted CLI config — ~/.fastbooks/config.json (mode 0600).
2
+
3
+ Holds everything `fastbooks init` sets up so an agent-owner configures once and every
4
+ invocation (CLI, `direct`, MCP server) picks it up automatically:
5
+ • fastbooks_api_key — the fb_live_ data key (sent as x-api-key so the bot is throttled)
6
+ • activated — which exchanges the user turned on
7
+ • credentials — per-exchange ccxt keys {exchange: {apiKey, secret, password}}
8
+
9
+ Precedence for exchange creds at trade time: explicit args > env (CCXT_<EX>_*) > this store.
10
+ Override the location with FASTBOOKS_CONFIG_DIR.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import stat
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ CONFIG_DIR = Path(os.environ.get("FASTBOOKS_CONFIG_DIR") or (Path.home() / ".fastbooks"))
22
+ CONFIG_PATH = CONFIG_DIR / "config.json"
23
+
24
+ _DEFAULT: dict[str, Any] = {
25
+ "fastbooks_api_key": None,
26
+ "activated": [],
27
+ "credentials": {},
28
+ "routing_url": None,
29
+ "bots_url": None,
30
+ }
31
+
32
+
33
+ def load() -> dict:
34
+ try:
35
+ with open(CONFIG_PATH) as f:
36
+ return {**_DEFAULT, **(json.load(f) or {})}
37
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
38
+ return dict(_DEFAULT)
39
+
40
+
41
+ def save(cfg: dict) -> None:
42
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
43
+ tmp = CONFIG_PATH.with_suffix(".tmp")
44
+ with open(tmp, "w") as f:
45
+ json.dump(cfg, f, indent=2)
46
+ try:
47
+ os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR) # 0600 — keys live here
48
+ except OSError:
49
+ pass
50
+ tmp.replace(CONFIG_PATH)
51
+
52
+
53
+ def get_api_key() -> str | None:
54
+ return load().get("fastbooks_api_key")
55
+
56
+
57
+ def set_api_key(key: str | None) -> None:
58
+ cfg = load()
59
+ cfg["fastbooks_api_key"] = key
60
+ save(cfg)
61
+
62
+
63
+ def get_credentials(exchange: str) -> dict:
64
+ return (load().get("credentials") or {}).get(exchange.lower(), {}) or {}
65
+
66
+
67
+ def set_credentials(exchange: str, api_key: str | None = None, secret: str | None = None, password: str | None = None) -> None:
68
+ cfg = load()
69
+ creds = cfg.setdefault("credentials", {})
70
+ entry = creds.setdefault(exchange.lower(), {})
71
+ if api_key is not None:
72
+ entry["apiKey"] = api_key
73
+ if secret is not None:
74
+ entry["secret"] = secret
75
+ if password is not None:
76
+ entry["password"] = password
77
+ save(cfg)
78
+
79
+
80
+ def activated() -> list[str]:
81
+ return list(load().get("activated") or [])
82
+
83
+
84
+ def activate(exchange: str) -> None:
85
+ cfg = load()
86
+ act = cfg.setdefault("activated", [])
87
+ ex = exchange.lower()
88
+ if ex not in act:
89
+ act.append(ex)
90
+ save(cfg)
91
+
92
+
93
+ def deactivate(exchange: str) -> None:
94
+ cfg = load()
95
+ cfg["activated"] = [x for x in (cfg.get("activated") or []) if x != exchange.lower()]
96
+ save(cfg)
97
+
98
+
99
+ def redacted() -> dict:
100
+ """Safe-to-print view (secrets masked)."""
101
+ cfg = load()
102
+ creds = {
103
+ ex: {k: ("***" if v else None) for k, v in (entry or {}).items()}
104
+ for ex, entry in (cfg.get("credentials") or {}).items()
105
+ }
106
+ return {
107
+ "path": str(CONFIG_PATH),
108
+ "exists": CONFIG_PATH.exists(),
109
+ "fastbooks_api_key": ("***" if cfg.get("fastbooks_api_key") else None),
110
+ "activated": cfg.get("activated", []),
111
+ "credentials": creds,
112
+ }