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.
- fastbooks_cli/__init__.py +3 -0
- fastbooks_cli/__main__.py +6 -0
- fastbooks_cli/client.py +1169 -0
- fastbooks_cli/commands/__init__.py +0 -0
- fastbooks_cli/commands/apikey.py +144 -0
- fastbooks_cli/commands/backtest.py +450 -0
- fastbooks_cli/commands/bots.py +479 -0
- fastbooks_cli/commands/config.py +294 -0
- fastbooks_cli/commands/dex.py +507 -0
- fastbooks_cli/commands/direct.py +150 -0
- fastbooks_cli/commands/execution.py +298 -0
- fastbooks_cli/commands/init_cmd.py +149 -0
- fastbooks_cli/commands/markets.py +270 -0
- fastbooks_cli/commands/mcp_cmd.py +27 -0
- fastbooks_cli/commands/onchain.py +123 -0
- fastbooks_cli/commands/polymarket.py +2215 -0
- fastbooks_cli/commands/repl.py +385 -0
- fastbooks_cli/commands/strategy.py +259 -0
- fastbooks_cli/commands/swap.py +320 -0
- fastbooks_cli/commands/wallet.py +447 -0
- fastbooks_cli/config_store.py +112 -0
- fastbooks_cli/direct.py +209 -0
- fastbooks_cli/main.py +88 -0
- fastbooks_cli/mcp_server.py +171 -0
- fastbooks_cli/output.py +86 -0
- fastbooks_cli/strategies/__init__.py +87 -0
- trading_cli-0.3.0.dist-info/METADATA +309 -0
- trading_cli-0.3.0.dist-info/RECORD +32 -0
- trading_cli-0.3.0.dist-info/WHEEL +5 -0
- trading_cli-0.3.0.dist-info/entry_points.txt +4 -0
- trading_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
- trading_cli-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
}
|