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,320 @@
1
+ """Swap command group — on-chain DEX swap execution (Jupiter, Uniswap, SushiSwap)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+
9
+ import click
10
+
11
+ from ..client import FastBooksClient
12
+ from ..output import emit, emit_error, format_table, require_experimental
13
+
14
+ # ── Solana token address registry ────────────────────────────────────────
15
+
16
+ SOLANA_TOKENS = {
17
+ "SOL": "So11111111111111111111111111111111111111112",
18
+ "USDC": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
19
+ "USDT": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB",
20
+ "BONK": "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263",
21
+ "JUP": "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN",
22
+ "RAY": "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R",
23
+ "ORCA": "orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE",
24
+ "MSOL": "mSoLzYCxHdYgdzU16g5QSh3i5K3z3KZK7ytfqcJm7So",
25
+ "JITOSOL": "J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn",
26
+ "WIF": "EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm",
27
+ }
28
+
29
+ # ── EVM token address registry (Ethereum mainnet) ───────────────────────
30
+
31
+ EVM_TOKENS = {
32
+ "ETH": "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", # native
33
+ "WETH": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
34
+ "USDC": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
35
+ "USDT": "0xdAC17F958D2ee523a2206206994597C13D831ec7",
36
+ "DAI": "0x6B175474E89094C44Da98b954EedeAC495271d0F",
37
+ "WBTC": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599",
38
+ "UNI": "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984",
39
+ "LINK": "0x514910771AF9Ca656af840dff83E8264EcF986CA",
40
+ "AAVE": "0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9",
41
+ "ARB": "0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1",
42
+ }
43
+
44
+ CHAINS = ["solana", "ethereum", "arbitrum", "polygon", "base", "optimism"]
45
+
46
+
47
+ @click.group()
48
+ @click.pass_context
49
+ def swap(ctx):
50
+ """[experimental] On-chain DEX swaps: Jupiter (Solana), Uniswap (EVM), SushiSwap.
51
+
52
+ NOTE: requires an on-chain swap router not enabled on standard FastBooks
53
+ deployments. Gated behind FASTBOOKS_EXPERIMENTAL=1.
54
+
55
+ Execute token swaps directly on-chain through DEX aggregators.
56
+ Requires a wallet private key (stored via `fastbooks wallet add`).
57
+
58
+ Supported DEX aggregators:
59
+ Jupiter: Solana tokens (5000+). Best price routing.
60
+ Uniswap: Ethereum/L2 tokens. V3 concentrated liquidity.
61
+ SushiSwap: Multi-chain (Ethereum, Arbitrum, Polygon).
62
+ """
63
+ require_experimental(ctx, "swap")
64
+
65
+
66
+ # ── Jupiter swap (Solana) ───────────────────────────────────────────────
67
+
68
+ @swap.command("jupiter")
69
+ @click.argument("input_token", type=str)
70
+ @click.argument("output_token", type=str)
71
+ @click.argument("amount", type=float)
72
+ @click.option("--slippage", type=float, default=1.0, help="Max slippage in percent (default: 1%).")
73
+ @click.option("--wallet-key", envvar="SOLANA_PRIVATE_KEY", default=None, help="Solana private key (or set SOLANA_PRIVATE_KEY).")
74
+ @click.option("--rpc-url", envvar="SOLANA_RPC_URL", default="https://api.mainnet-beta.solana.com", help="Solana RPC URL.")
75
+ @click.option("--quote-only", is_flag=True, help="Only get quote, don't execute swap.")
76
+ @click.option("--priority-fee", type=int, default=50000, help="Priority fee in micro-lamports.")
77
+ @click.pass_context
78
+ def jupiter_swap(ctx, input_token, output_token, amount, slippage, wallet_key, rpc_url, quote_only, priority_fee):
79
+ """Swap tokens on Solana via Jupiter aggregator.
80
+
81
+ INPUT_TOKEN: Token to sell (symbol like SOL, USDC, or mint address).
82
+ OUTPUT_TOKEN: Token to buy (symbol or mint address).
83
+ AMOUNT: Amount of input token to swap.
84
+
85
+ Examples:
86
+ fastbooks swap jupiter SOL USDC 1.5
87
+ fastbooks swap jupiter USDC BONK 100 --slippage 2.0
88
+ fastbooks swap jupiter SOL JUP 10 --quote-only
89
+ fastbooks swap jupiter So111...112 EPjF...1v 5.0
90
+ """
91
+ client: FastBooksClient = ctx.obj
92
+
93
+ input_mint = SOLANA_TOKENS.get(input_token.upper(), input_token)
94
+ output_mint = SOLANA_TOKENS.get(output_token.upper(), output_token)
95
+ slippage_bps = int(slippage * 100)
96
+
97
+ try:
98
+ # Get quote from Jupiter
99
+ quote = client.jupiter_quote(input_mint, output_mint, amount, slippage_bps)
100
+
101
+ if quote_only:
102
+ emit(ctx, quote, lambda d: _format_jupiter_quote(d, input_token, output_token, amount))
103
+ return
104
+
105
+ if not wallet_key:
106
+ emit_error(ctx, "Wallet key required for swap execution. Use --wallet-key or set SOLANA_PRIVATE_KEY.")
107
+ ctx.exit(1)
108
+ return
109
+
110
+ # Confirm before executing
111
+ if not ctx.find_root().params.get("json_output", False):
112
+ out_amount = quote.get("outAmount", quote.get("expectedOutput", "?"))
113
+ click.echo(f"\n Swap: {amount} {input_token} -> {out_amount} {output_token}")
114
+ click.echo(f" Slippage: {slippage}% Priority fee: {priority_fee} uL")
115
+ if not click.confirm("\n Execute swap?"):
116
+ click.echo(" Cancelled.")
117
+ return
118
+
119
+ result = client.jupiter_swap_execute(
120
+ input_mint=input_mint,
121
+ output_mint=output_mint,
122
+ amount=amount,
123
+ slippage_bps=slippage_bps,
124
+ wallet_key=wallet_key,
125
+ priority_fee=priority_fee,
126
+ )
127
+ emit(ctx, result, lambda d: _format_swap_result(d, "Jupiter"))
128
+
129
+ except Exception as e:
130
+ emit_error(ctx, str(e))
131
+ ctx.exit(1)
132
+
133
+
134
+ def _format_jupiter_quote(data: dict, input_tok: str, output_tok: str, amount: float) -> str:
135
+ lines = [
136
+ "",
137
+ " Jupiter Quote",
138
+ " =============",
139
+ f" Input: {amount} {input_tok}",
140
+ f" Output: {data.get('outAmount', data.get('expectedOutput', '?'))} {output_tok}",
141
+ f" Price Impact: {data.get('priceImpactPct', '?')}%",
142
+ ]
143
+ routes = data.get("routePlan", [])
144
+ if routes:
145
+ lines.append(f" Route: {' -> '.join(r.get('swapInfo', {}).get('label', '?') for r in routes)}")
146
+ return "\n".join(lines)
147
+
148
+
149
+ # ── Uniswap swap (EVM) ──────────────────────────────────────────────────
150
+
151
+ @swap.command("uniswap")
152
+ @click.argument("input_token", type=str)
153
+ @click.argument("output_token", type=str)
154
+ @click.argument("amount", type=float)
155
+ @click.option("--chain", type=click.Choice(["ethereum", "arbitrum", "polygon", "base", "optimism"]), default="ethereum", help="EVM chain.")
156
+ @click.option("--slippage", type=float, default=0.5, help="Max slippage in percent.")
157
+ @click.option("--wallet-key", envvar="EVM_PRIVATE_KEY", default=None, help="EVM private key (or set EVM_PRIVATE_KEY).")
158
+ @click.option("--rpc-url", envvar="EVM_RPC_URL", default=None, help="EVM RPC URL.")
159
+ @click.option("--quote-only", is_flag=True, help="Only get quote, don't execute.")
160
+ @click.option("--gas-limit", type=int, default=None, help="Gas limit override.")
161
+ @click.pass_context
162
+ def uniswap_swap(ctx, input_token, output_token, amount, chain, slippage, wallet_key, rpc_url, quote_only, gas_limit):
163
+ """Swap tokens on EVM chains via Uniswap V3 / compatible routers.
164
+
165
+ INPUT_TOKEN: Token to sell (symbol like ETH, USDC, or contract address).
166
+ OUTPUT_TOKEN: Token to buy (symbol or contract address).
167
+ AMOUNT: Amount of input token to swap.
168
+
169
+ Examples:
170
+ fastbooks swap uniswap ETH USDC 1.0
171
+ fastbooks swap uniswap USDC WBTC 5000 --chain ethereum
172
+ fastbooks swap uniswap ETH ARB 2.0 --chain arbitrum --slippage 1.0
173
+ fastbooks swap uniswap USDC DAI 1000 --quote-only
174
+ """
175
+ client: FastBooksClient = ctx.obj
176
+
177
+ input_address = EVM_TOKENS.get(input_token.upper(), input_token)
178
+ output_address = EVM_TOKENS.get(output_token.upper(), output_token)
179
+ slippage_bps = int(slippage * 100)
180
+
181
+ try:
182
+ quote = client.uniswap_quote(
183
+ input_address, output_address, amount,
184
+ chain=chain, slippage_bps=slippage_bps,
185
+ )
186
+
187
+ if quote_only:
188
+ emit(ctx, quote, lambda d: _format_uniswap_quote(d, input_token, output_token, amount, chain))
189
+ return
190
+
191
+ if not wallet_key:
192
+ emit_error(ctx, "Wallet key required. Use --wallet-key or set EVM_PRIVATE_KEY.")
193
+ ctx.exit(1)
194
+ return
195
+
196
+ if not ctx.find_root().params.get("json_output", False):
197
+ out_amount = quote.get("outputAmount", quote.get("expectedOutput", "?"))
198
+ gas_est = quote.get("gasEstimate", "?")
199
+ click.echo(f"\n Swap: {amount} {input_token} -> {out_amount} {output_token}")
200
+ click.echo(f" Chain: {chain} Slippage: {slippage}% Gas: ~{gas_est}")
201
+ if not click.confirm("\n Execute swap?"):
202
+ click.echo(" Cancelled.")
203
+ return
204
+
205
+ result = client.uniswap_swap_execute(
206
+ input_address=input_address,
207
+ output_address=output_address,
208
+ amount=amount,
209
+ chain=chain,
210
+ slippage_bps=slippage_bps,
211
+ wallet_key=wallet_key,
212
+ gas_limit=gas_limit,
213
+ )
214
+ emit(ctx, result, lambda d: _format_swap_result(d, "Uniswap"))
215
+
216
+ except Exception as e:
217
+ emit_error(ctx, str(e))
218
+ ctx.exit(1)
219
+
220
+
221
+ def _format_uniswap_quote(data: dict, input_tok: str, output_tok: str, amount: float, chain: str) -> str:
222
+ return "\n".join([
223
+ "",
224
+ f" Uniswap Quote ({chain})",
225
+ " ====================",
226
+ f" Input: {amount} {input_tok}",
227
+ f" Output: {data.get('outputAmount', '?')} {output_tok}",
228
+ f" Gas Est: {data.get('gasEstimate', '?')} gwei",
229
+ f" Price Impact: {data.get('priceImpact', '?')}%",
230
+ f" Route: {data.get('route', '?')}",
231
+ ])
232
+
233
+
234
+ # ── SushiSwap (multi-chain) ─────────────────────────────────────────────
235
+
236
+ @swap.command("sushi")
237
+ @click.argument("input_token", type=str)
238
+ @click.argument("output_token", type=str)
239
+ @click.argument("amount", type=float)
240
+ @click.option("--chain", type=click.Choice(["ethereum", "arbitrum", "polygon", "avalanche"]), default="ethereum")
241
+ @click.option("--slippage", type=float, default=0.5, help="Max slippage percent.")
242
+ @click.option("--wallet-key", envvar="EVM_PRIVATE_KEY", default=None)
243
+ @click.option("--quote-only", is_flag=True)
244
+ @click.pass_context
245
+ def sushi_swap(ctx, input_token, output_token, amount, chain, slippage, wallet_key, quote_only):
246
+ """Swap tokens via SushiSwap on multiple EVM chains.
247
+
248
+ Examples:
249
+ fastbooks swap sushi ETH USDC 1.0 --chain ethereum
250
+ fastbooks swap sushi USDC WBTC 5000 --chain arbitrum
251
+ fastbooks swap sushi ETH DAI 2.0 --quote-only
252
+ """
253
+ client: FastBooksClient = ctx.obj
254
+
255
+ input_address = EVM_TOKENS.get(input_token.upper(), input_token)
256
+ output_address = EVM_TOKENS.get(output_token.upper(), output_token)
257
+
258
+ try:
259
+ result = client.sushi_swap(
260
+ input_address=input_address,
261
+ output_address=output_address,
262
+ amount=amount,
263
+ chain=chain,
264
+ slippage_bps=int(slippage * 100),
265
+ wallet_key=wallet_key,
266
+ quote_only=quote_only,
267
+ )
268
+ emit(ctx, result, lambda d: _format_swap_result(d, f"SushiSwap ({chain})"))
269
+ except Exception as e:
270
+ emit_error(ctx, str(e))
271
+ ctx.exit(1)
272
+
273
+
274
+ # ── token lookup ─────────────────────────────────────────────────────────
275
+
276
+ @swap.command("tokens")
277
+ @click.option("--chain", type=click.Choice(CHAINS), default="solana", help="Chain to list tokens for.")
278
+ @click.pass_context
279
+ def list_tokens(ctx, chain):
280
+ """List known token addresses for a chain.
281
+
282
+ Examples:
283
+ fastbooks swap tokens --chain solana
284
+ fastbooks swap tokens --chain ethereum
285
+ """
286
+ if chain == "solana":
287
+ data = {"chain": chain, "tokens": [{"symbol": k, "address": v} for k, v in SOLANA_TOKENS.items()]}
288
+ else:
289
+ data = {"chain": chain, "tokens": [{"symbol": k, "address": v} for k, v in EVM_TOKENS.items()]}
290
+
291
+ emit(ctx, data, lambda d: _format_token_list(d))
292
+
293
+
294
+ def _format_token_list(data: dict) -> str:
295
+ lines = [f"\n Known Tokens ({data['chain']})", " " + "=" * 30]
296
+ for t in data["tokens"]:
297
+ lines.append(f" {t['symbol']:>10} {t['address']}")
298
+ return "\n".join(lines)
299
+
300
+
301
+ # ── shared formatters ────────────────────────────────────────────────────
302
+
303
+ def _format_swap_result(data: dict, dex_name: str) -> str:
304
+ lines = [
305
+ "",
306
+ f" {dex_name} Swap Result",
307
+ " " + "=" * 25,
308
+ ]
309
+ if data.get("txHash") or data.get("signature"):
310
+ tx = data.get("txHash") or data.get("signature")
311
+ lines.append(f" Tx: {tx}")
312
+ if data.get("inputAmount"):
313
+ lines.append(f" Input: {data['inputAmount']} {data.get('inputToken', '')}")
314
+ if data.get("outputAmount"):
315
+ lines.append(f" Output: {data['outputAmount']} {data.get('outputToken', '')}")
316
+ if data.get("gasUsed"):
317
+ lines.append(f" Gas: {data['gasUsed']}")
318
+ status = data.get("status", "unknown")
319
+ lines.append(f" Status: {status}")
320
+ return "\n".join(lines)