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,385 @@
1
+ """REPL mode — interactive session with undo/redo and command history.
2
+
3
+ Following CLI-Anything's ReplSkin pattern: persistent session state,
4
+ structured command execution, and undo/redo for reversible operations.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import copy
10
+ import json
11
+ import os
12
+ import readline
13
+ import shlex
14
+ import sys
15
+ import time
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+
19
+ import click
20
+
21
+ from ..client import FastBooksClient
22
+ from ..output import emit, emit_error
23
+ from .. import __version__
24
+
25
+
26
+ @dataclass
27
+ class SessionState:
28
+ """Tracks REPL session state for undo/redo."""
29
+ active_venue: str | None = None
30
+ active_symbol: str | None = None
31
+ active_bot_id: str | None = None
32
+ history: list[dict] = field(default_factory=list)
33
+ undo_stack: list[dict] = field(default_factory=list)
34
+ redo_stack: list[dict] = field(default_factory=list)
35
+ aliases: dict[str, str] = field(default_factory=dict)
36
+ variables: dict[str, str] = field(default_factory=dict)
37
+
38
+ def snapshot(self) -> dict:
39
+ return {
40
+ "active_venue": self.active_venue,
41
+ "active_symbol": self.active_symbol,
42
+ "active_bot_id": self.active_bot_id,
43
+ }
44
+
45
+ def restore(self, snap: dict) -> None:
46
+ self.active_venue = snap.get("active_venue")
47
+ self.active_symbol = snap.get("active_symbol")
48
+ self.active_bot_id = snap.get("active_bot_id")
49
+
50
+ def push_undo(self, action: dict) -> None:
51
+ self.undo_stack.append({"state": self.snapshot(), "action": action})
52
+ self.redo_stack.clear()
53
+
54
+ def undo(self) -> dict | None:
55
+ if not self.undo_stack:
56
+ return None
57
+ entry = self.undo_stack.pop()
58
+ self.redo_stack.append({"state": self.snapshot(), "action": entry["action"]})
59
+ self.restore(entry["state"])
60
+ return entry["action"]
61
+
62
+ def redo(self) -> dict | None:
63
+ if not self.redo_stack:
64
+ return None
65
+ entry = self.redo_stack.pop()
66
+ self.undo_stack.append({"state": self.snapshot(), "action": entry["action"]})
67
+ self.restore(entry["state"])
68
+ return entry["action"]
69
+
70
+
71
+ # ── REPL built-in commands ───────────────────────────────────────────────
72
+
73
+ BUILTINS_HELP = """
74
+ Built-in Commands:
75
+ ==================
76
+ help / ? Show this help
77
+ exit / quit / q Exit REPL
78
+ undo Undo last state change
79
+ redo Redo last undone change
80
+ history Show command history
81
+ status Show current session state
82
+ use <venue> Set active venue (e.g. 'use binance')
83
+ symbol <sym> Set active symbol (e.g. 'symbol BTC/USDT')
84
+ bot <id> Set active bot ID
85
+ alias <name> <cmd> Create command alias
86
+ unalias <name> Remove alias
87
+ aliases List all aliases
88
+ set <key> <val> Set a session variable
89
+ vars List session variables
90
+ clear Clear screen
91
+ json on/off Toggle JSON output mode
92
+
93
+ CLI Commands:
94
+ =============
95
+ Any 'fastbooks' command works without the 'fastbooks' prefix:
96
+ markets orderbook binance BTC/USDT
97
+ execute balance binance
98
+ bots list
99
+ swap jupiter SOL USDC 1.0
100
+
101
+ Context-Aware Shortcuts:
102
+ ========================
103
+ After 'use binance' + 'symbol BTC/USDT':
104
+ orderbook -> markets orderbook binance BTC/USDT
105
+ balance -> execute balance binance
106
+ order buy market 1 -> execute order binance BTC/USDT buy market 1
107
+ """
108
+
109
+
110
+ def _banner() -> str:
111
+ return f"""
112
+ ╔══════════════════════════════════════════════════╗
113
+ ║ FastBooks REPL v{__version__:<36}║
114
+ ║ Agent-native interactive trading session ║
115
+ ║ Type 'help' for commands, 'exit' to quit ║
116
+ ╚══════════════════════════════════════════════════╝
117
+
118
+ SKILL.md: {os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'SKILL.md'))}
119
+ """
120
+
121
+
122
+ @click.command("repl")
123
+ @click.option("--venue", "-v", default=None, help="Start with an active venue.")
124
+ @click.option("--symbol", "-s", default=None, help="Start with an active symbol.")
125
+ @click.pass_context
126
+ def repl(ctx, venue, symbol):
127
+ """Start an interactive REPL session.
128
+
129
+ Provides a persistent session with context-aware shortcuts,
130
+ undo/redo, command history, and aliases.
131
+
132
+ Examples:
133
+ fastbooks repl
134
+ fastbooks repl --venue binance --symbol BTC/USDT
135
+ """
136
+ from ..main import cli as root_cli
137
+
138
+ client: FastBooksClient = ctx.obj
139
+ state = SessionState(active_venue=venue, active_symbol=symbol)
140
+ json_mode = ctx.find_root().params.get("json_output", False)
141
+
142
+ # Setup readline history
143
+ histfile = os.path.expanduser("~/.fastbooks_history")
144
+ try:
145
+ readline.read_history_file(histfile)
146
+ except FileNotFoundError:
147
+ pass
148
+ readline.set_history_length(1000)
149
+
150
+ click.echo(_banner())
151
+
152
+ if state.active_venue:
153
+ click.echo(f" Active venue: {state.active_venue}")
154
+ if state.active_symbol:
155
+ click.echo(f" Active symbol: {state.active_symbol}")
156
+ click.echo()
157
+
158
+ while True:
159
+ # Build prompt
160
+ prompt_parts = ["fastbooks"]
161
+ if state.active_venue:
162
+ prompt_parts.append(state.active_venue)
163
+ if state.active_symbol:
164
+ prompt_parts.append(state.active_symbol)
165
+ prompt = ":".join(prompt_parts) + "> "
166
+
167
+ try:
168
+ line = input(prompt).strip()
169
+ except (EOFError, KeyboardInterrupt):
170
+ click.echo("\n Goodbye.")
171
+ break
172
+
173
+ if not line:
174
+ continue
175
+
176
+ # Save to readline history
177
+ state.history.append({"command": line, "timestamp": time.time()})
178
+
179
+ # Resolve aliases
180
+ parts = line.split(None, 1)
181
+ cmd = parts[0].lower()
182
+ rest = parts[1] if len(parts) > 1 else ""
183
+
184
+ if cmd in state.aliases:
185
+ line = state.aliases[cmd] + (" " + rest if rest else "")
186
+ parts = line.split(None, 1)
187
+ cmd = parts[0].lower()
188
+ rest = parts[1] if len(parts) > 1 else ""
189
+
190
+ # ── built-ins ────────────────────────────────────────────────
191
+
192
+ if cmd in ("exit", "quit", "q"):
193
+ click.echo(" Goodbye.")
194
+ break
195
+
196
+ elif cmd in ("help", "?"):
197
+ click.echo(BUILTINS_HELP)
198
+ continue
199
+
200
+ elif cmd == "undo":
201
+ action = state.undo()
202
+ if action:
203
+ click.echo(f" Undone: {action.get('description', '?')}")
204
+ else:
205
+ click.echo(" Nothing to undo.")
206
+ continue
207
+
208
+ elif cmd == "redo":
209
+ action = state.redo()
210
+ if action:
211
+ click.echo(f" Redone: {action.get('description', '?')}")
212
+ else:
213
+ click.echo(" Nothing to redo.")
214
+ continue
215
+
216
+ elif cmd == "history":
217
+ for i, h in enumerate(state.history[-20:], 1):
218
+ click.echo(f" {i:3} {h['command']}")
219
+ continue
220
+
221
+ elif cmd == "status":
222
+ click.echo(f" Venue: {state.active_venue or '(none)'}")
223
+ click.echo(f" Symbol: {state.active_symbol or '(none)'}")
224
+ click.echo(f" Bot: {state.active_bot_id or '(none)'}")
225
+ click.echo(f" JSON: {'on' if json_mode else 'off'}")
226
+ click.echo(f" Undo: {len(state.undo_stack)} actions")
227
+ click.echo(f" Redo: {len(state.redo_stack)} actions")
228
+ continue
229
+
230
+ elif cmd == "use":
231
+ old = state.active_venue
232
+ state.push_undo({"description": f"use {old} -> {rest}", "type": "venue"})
233
+ state.active_venue = rest.strip() if rest.strip() else None
234
+ click.echo(f" Venue set to: {state.active_venue or '(none)'}")
235
+ continue
236
+
237
+ elif cmd == "symbol":
238
+ old = state.active_symbol
239
+ state.push_undo({"description": f"symbol {old} -> {rest}", "type": "symbol"})
240
+ state.active_symbol = rest.strip() if rest.strip() else None
241
+ click.echo(f" Symbol set to: {state.active_symbol or '(none)'}")
242
+ continue
243
+
244
+ elif cmd == "bot":
245
+ old = state.active_bot_id
246
+ state.push_undo({"description": f"bot {old} -> {rest}", "type": "bot"})
247
+ state.active_bot_id = rest.strip() if rest.strip() else None
248
+ click.echo(f" Bot set to: {state.active_bot_id or '(none)'}")
249
+ continue
250
+
251
+ elif cmd == "alias":
252
+ alias_parts = rest.split(None, 1)
253
+ if len(alias_parts) < 2:
254
+ click.echo(" Usage: alias <name> <command>")
255
+ continue
256
+ state.aliases[alias_parts[0]] = alias_parts[1]
257
+ click.echo(f" Alias '{alias_parts[0]}' -> '{alias_parts[1]}'")
258
+ continue
259
+
260
+ elif cmd == "unalias":
261
+ name = rest.strip()
262
+ if name in state.aliases:
263
+ del state.aliases[name]
264
+ click.echo(f" Removed alias '{name}'.")
265
+ else:
266
+ click.echo(f" No alias '{name}'.")
267
+ continue
268
+
269
+ elif cmd == "aliases":
270
+ if not state.aliases:
271
+ click.echo(" No aliases defined.")
272
+ else:
273
+ for name, target in state.aliases.items():
274
+ click.echo(f" {name} -> {target}")
275
+ continue
276
+
277
+ elif cmd == "set":
278
+ set_parts = rest.split(None, 1)
279
+ if len(set_parts) < 2:
280
+ click.echo(" Usage: set <key> <value>")
281
+ continue
282
+ state.variables[set_parts[0]] = set_parts[1]
283
+ click.echo(f" {set_parts[0]} = {set_parts[1]}")
284
+ continue
285
+
286
+ elif cmd == "vars":
287
+ if not state.variables:
288
+ click.echo(" No variables set.")
289
+ else:
290
+ for k, v in state.variables.items():
291
+ click.echo(f" {k} = {v}")
292
+ continue
293
+
294
+ elif cmd == "clear":
295
+ click.clear()
296
+ continue
297
+
298
+ elif cmd == "json":
299
+ if rest.strip().lower() in ("on", "true", "1"):
300
+ json_mode = True
301
+ click.echo(" JSON output: on")
302
+ elif rest.strip().lower() in ("off", "false", "0"):
303
+ json_mode = False
304
+ click.echo(" JSON output: off")
305
+ else:
306
+ click.echo(f" JSON output: {'on' if json_mode else 'off'}")
307
+ continue
308
+
309
+ # ── context-aware shortcuts ──────────────────────────────────
310
+
311
+ expanded = _expand_shortcut(line, state)
312
+ if expanded != line:
313
+ click.secho(f" -> {expanded}", fg="cyan")
314
+ line = expanded
315
+
316
+ # ── execute as CLI command ───────────────────────────────────
317
+
318
+ try:
319
+ args = shlex.split(line)
320
+ except ValueError as e:
321
+ click.secho(f" Parse error: {e}", fg="red")
322
+ continue
323
+
324
+ if json_mode and "--json" not in args:
325
+ args = ["--json"] + args
326
+
327
+ try:
328
+ root_cli(args, standalone_mode=False, obj=client)
329
+ except SystemExit:
330
+ pass
331
+ except click.exceptions.UsageError as e:
332
+ click.secho(f" {e}", fg="red")
333
+ except Exception as e:
334
+ click.secho(f" Error: {e}", fg="red")
335
+
336
+ click.echo()
337
+
338
+ # Save history
339
+ try:
340
+ readline.write_history_file(histfile)
341
+ except OSError:
342
+ pass
343
+
344
+
345
+ def _expand_shortcut(line: str, state: SessionState) -> str:
346
+ """Expand context-aware shortcuts using active venue/symbol."""
347
+ parts = line.split()
348
+ if not parts:
349
+ return line
350
+
351
+ cmd = parts[0].lower()
352
+
353
+ # Direct command shortcuts when venue/symbol are set
354
+ if cmd == "orderbook" and state.active_venue and state.active_symbol:
355
+ return f"markets orderbook {state.active_venue} {state.active_symbol}"
356
+
357
+ if cmd == "balance" and state.active_venue:
358
+ extra = " ".join(parts[1:])
359
+ return f"execute balance {state.active_venue}" + (f" {extra}" if extra else "")
360
+
361
+ if cmd == "positions" and state.active_venue:
362
+ return f"execute positions {state.active_venue}"
363
+
364
+ if cmd == "orders" and state.active_venue:
365
+ extra = " ".join(parts[1:])
366
+ return f"execute orders {state.active_venue}" + (f" {extra}" if extra else "")
367
+
368
+ if cmd == "order" and state.active_venue and state.active_symbol and len(parts) >= 4:
369
+ # order buy market 0.01 -> execute order <venue> <symbol> buy market 0.01
370
+ extra = " ".join(parts[1:])
371
+ return f"execute order {state.active_venue} {state.active_symbol} {extra}"
372
+
373
+ if cmd == "ohlcv" and state.active_venue and state.active_symbol:
374
+ extra = " ".join(parts[1:])
375
+ return f"markets ohlcv {state.active_venue} {state.active_symbol}" + (f" {extra}" if extra else "")
376
+
377
+ if cmd == "tickers" and state.active_venue:
378
+ extra = " ".join(parts[1:])
379
+ return f"markets tickers --exchange {state.active_venue}" + (f" {extra}" if extra else "")
380
+
381
+ # Bot shortcuts
382
+ if cmd in ("start", "stop", "pause", "resume", "logs", "events", "report", "rebalance") and state.active_bot_id and len(parts) == 1:
383
+ return f"bots {cmd} {state.active_bot_id}"
384
+
385
+ return line
@@ -0,0 +1,259 @@
1
+ """Strategy command group — run local trading bots from the CLI.
2
+
3
+ Unlike ``bots`` (cloud-managed), strategies are local Python modules
4
+ that run in-process. They are shipped as a separate, optionally-installed
5
+ plugin package (``fastbooks-clistrategies``) and discovered at runtime via
6
+ the ``fastbooks_cli.strategies`` entry point.
7
+
8
+ Usage:
9
+ fastbooks strategy list
10
+ fastbooks strategy info hl-momentum-hunter
11
+ fastbooks strategy run hl-momentum-hunter --private-key 0x... --account 0x...
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import json
18
+ import os
19
+
20
+ import click
21
+
22
+ from ..output import emit, emit_error, format_table
23
+ from ..strategies import REGISTRY
24
+
25
+
26
+ @click.group()
27
+ def strategy():
28
+ """Local trading strategies — run bots directly from the CLI.
29
+
30
+ Strategies are self-contained trading algorithms that run in-process.
31
+ Keys are passed via CLI flags or environment variables — never hardcoded.
32
+
33
+ Examples:
34
+ fastbooks strategy list
35
+ fastbooks strategy info hl-momentum-hunter
36
+ fastbooks strategy run hl-momentum-hunter --private-key $HL_PRIVATE_KEY --account $HL_ACCOUNT
37
+ """
38
+ pass
39
+
40
+
41
+ # ── list ─────────────────────────────────────────────────────────────────
42
+
43
+ @strategy.command("list")
44
+ @click.pass_context
45
+ def strategy_list(ctx):
46
+ """List all available strategies.
47
+
48
+ Example: fastbooks strategy list
49
+ """
50
+ rows = []
51
+ for slug, meta in REGISTRY.items():
52
+ rows.append({
53
+ "slug": slug,
54
+ "name": meta.name,
55
+ "venue": meta.venue,
56
+ "description": meta.description,
57
+ })
58
+
59
+ data = {"strategies": rows}
60
+ emit(ctx, data, lambda d: _format_strategy_list(d))
61
+
62
+
63
+ def _format_strategy_list(data) -> str:
64
+ items = data.get("strategies", [])
65
+ if not items:
66
+ return " No strategies registered."
67
+ return format_table(
68
+ items,
69
+ ["slug", "name", "venue", "description"],
70
+ ["Slug", "Name", "Venue", "Description"],
71
+ )
72
+
73
+
74
+ # ── info ─────────────────────────────────────────────────────────────────
75
+
76
+ @strategy.command("info")
77
+ @click.argument("slug")
78
+ @click.pass_context
79
+ def strategy_info(ctx, slug):
80
+ """Show details for a strategy.
81
+
82
+ Example: fastbooks strategy info hl-momentum-hunter
83
+ """
84
+ meta = REGISTRY.get(slug)
85
+ if not meta:
86
+ emit_error(ctx, f"Unknown strategy: {slug}")
87
+ ctx.exit(1)
88
+ return
89
+
90
+ data = {
91
+ "slug": meta.slug,
92
+ "name": meta.name,
93
+ "venue": meta.venue,
94
+ "description": meta.description,
95
+ "required_keys": meta.required_keys,
96
+ "optional_keys": meta.optional_keys,
97
+ "module": meta.module,
98
+ }
99
+ emit(ctx, data, lambda d: _format_strategy_info(d))
100
+
101
+
102
+ def _format_strategy_info(data) -> str:
103
+ lines = [
104
+ f"\n {data['name']}",
105
+ f" Slug: {data['slug']}",
106
+ f" Venue: {data['venue']}",
107
+ f" Module: {data['module']}",
108
+ f" {data['description']}",
109
+ "",
110
+ " Required keys (via --private-key/--account or env vars):",
111
+ ]
112
+ for k in data["required_keys"]:
113
+ lines.append(f" - {k}")
114
+ if data.get("optional_keys"):
115
+ lines.append(" Optional:")
116
+ for k in data["optional_keys"]:
117
+ lines.append(f" - {k}")
118
+ return "\n".join(lines)
119
+
120
+
121
+ # ── run ──────────────────────────────────────────────────────────────────
122
+
123
+ @strategy.command("run")
124
+ @click.argument("slug")
125
+ @click.option(
126
+ "--private-key", envvar="HL_PRIVATE_KEY", default=None,
127
+ help="Wallet private key (or set HL_PRIVATE_KEY).",
128
+ )
129
+ @click.option(
130
+ "--account", envvar="HL_ACCOUNT_ADDRESS", default=None,
131
+ help="Main account address (or set HL_ACCOUNT_ADDRESS).",
132
+ )
133
+ @click.option("--testnet", is_flag=True, envvar="HL_TESTNET", help="Use testnet.")
134
+ @click.option(
135
+ "--config-json", type=str, default=None,
136
+ help="Override strategy params as JSON string.",
137
+ )
138
+ @click.option(
139
+ "--config-file", type=click.Path(exists=True), default=None,
140
+ help="Override strategy params from a JSON file.",
141
+ )
142
+ @click.option(
143
+ "--confirm/--no-confirm", default=True,
144
+ help="Require confirmation before live trading.",
145
+ )
146
+ @click.pass_context
147
+ def strategy_run(ctx, slug, private_key, account, testnet, config_json, config_file, confirm):
148
+ """Run a strategy.
149
+
150
+ Keys are passed via flags or environment variables. Strategy-specific
151
+ parameters can be overridden with --config-json or --config-file.
152
+
153
+ Examples:
154
+ fastbooks strategy run hl-momentum-hunter \\
155
+ --private-key 0x... --account 0x...
156
+
157
+ # With env vars:
158
+ export HL_PRIVATE_KEY=0x...
159
+ export HL_ACCOUNT_ADDRESS=0x...
160
+ fastbooks strategy run hl-momentum-hunter
161
+
162
+ # Testnet + custom params:
163
+ fastbooks strategy run hl-momentum-hunter --testnet \\
164
+ --config-json '{"max_order_usd": 100, "max_leverage": 3}'
165
+
166
+ # From config file:
167
+ fastbooks strategy run hl-momentum-hunter \\
168
+ --config-file my_settings.json
169
+ """
170
+ meta = REGISTRY.get(slug)
171
+ if not meta:
172
+ emit_error(ctx, f"Unknown strategy: {slug}")
173
+ if REGISTRY:
174
+ emit_error(ctx, f"Available: {', '.join(REGISTRY.keys())}")
175
+ else:
176
+ emit_error(ctx, "No strategies installed. Install the plugin package: pip install fastbooks-clistrategies")
177
+ ctx.exit(1)
178
+ return
179
+
180
+ # Build config dict
181
+ cfg: dict = {}
182
+
183
+ # Load config file first (lowest priority)
184
+ if config_file:
185
+ try:
186
+ with open(config_file) as f:
187
+ cfg.update(json.load(f))
188
+ except (json.JSONDecodeError, OSError) as e:
189
+ emit_error(ctx, f"Config file error: {e}")
190
+ ctx.exit(1)
191
+ return
192
+
193
+ # Overlay JSON string (higher priority)
194
+ if config_json:
195
+ try:
196
+ cfg.update(json.loads(config_json))
197
+ except json.JSONDecodeError as e:
198
+ emit_error(ctx, f"Invalid JSON: {e}")
199
+ ctx.exit(1)
200
+ return
201
+
202
+ # Auth keys (highest priority — CLI flags / env vars)
203
+ if private_key:
204
+ cfg["private_key"] = private_key
205
+ if account:
206
+ cfg["account_address"] = account
207
+ cfg["use_testnet"] = testnet
208
+
209
+ # Validate required keys
210
+ missing = []
211
+ key_map = {
212
+ "HL_PRIVATE_KEY": "private_key",
213
+ "HL_ACCOUNT_ADDRESS": "account_address",
214
+ }
215
+ for env_name in meta.required_keys:
216
+ cfg_key = key_map.get(env_name, env_name.lower())
217
+ if not cfg.get(cfg_key):
218
+ # Try env var as fallback
219
+ env_val = os.environ.get(env_name)
220
+ if env_val:
221
+ cfg[cfg_key] = env_val
222
+ else:
223
+ missing.append(env_name)
224
+
225
+ if missing:
226
+ emit_error(ctx, f"Missing required keys: {', '.join(missing)}")
227
+ emit_error(ctx, "Pass via CLI flags or set as environment variables.")
228
+ ctx.exit(1)
229
+ return
230
+
231
+ # Safety confirmation for mainnet
232
+ if not testnet and confirm:
233
+ click.echo(f"\n Strategy: {meta.name}")
234
+ click.echo(f" Venue: {meta.venue}")
235
+ click.echo(f" Network: MAINNET")
236
+ click.echo(f" Account: {cfg.get('account_address', '?')}")
237
+ if not click.confirm("\n Start live trading?"):
238
+ click.echo(" Cancelled.")
239
+ return
240
+
241
+ # Run
242
+ import importlib
243
+
244
+ try:
245
+ mod = importlib.import_module(meta.module)
246
+ except ImportError as e:
247
+ emit_error(ctx, f"Cannot import strategy: {e}")
248
+ emit_error(
249
+ ctx,
250
+ "Install the strategy plugin and its deps: pip install fastbooks-clistrategies",
251
+ )
252
+ ctx.exit(1)
253
+ return
254
+
255
+ click.echo(f"\n Starting {meta.name}...")
256
+ try:
257
+ asyncio.run(mod.run(cfg))
258
+ except KeyboardInterrupt:
259
+ click.echo("\n Strategy stopped.")