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.
File without changes
@@ -0,0 +1,144 @@
1
+ """FastBooks platform API-key command group — fully self-service.
2
+
3
+ These are the keys that authenticate the FastBooks PUBLIC API (/api/public/v1),
4
+ NOT exchange credentials (see `fastbooks wallet add` for those).
5
+
6
+ Everything here is self-service — no admin token, ever. Anyone can create a
7
+ key; you manage (inspect/revoke) a key by presenting the key itself.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import click
13
+
14
+ from ..client import FastBooksClient
15
+ from ..output import emit, emit_error, format_table
16
+
17
+
18
+ @click.group()
19
+ def apikey():
20
+ """Create and manage FastBooks platform API keys (public API access).
21
+
22
+ Self-service: no admin token. Each key is free, read-only, and rate-limited
23
+ to 5 requests/second. Manage a key by presenting the key itself.
24
+ """
25
+ pass
26
+
27
+
28
+ @apikey.command()
29
+ @click.option("--label", "-l", required=True, help="Human-readable label for the key.")
30
+ @click.option("--user-id", default=None, help="Optional owner id (defaults to an anonymous id).")
31
+ @click.option("--email", default=None, help="Optional owner email.")
32
+ @click.pass_context
33
+ def create(ctx, label, user_id, email):
34
+ """Create a new FastBooks API key (self-service, no admin token).
35
+
36
+ Example:
37
+ fastbooks apikey create --label "my-bot"
38
+ """
39
+ client: FastBooksClient = ctx.obj
40
+ try:
41
+ data = client.create_fastbooks_api_key(label=label, user_id=user_id, email=email)
42
+ emit(ctx, data, lambda d: _format_created(d))
43
+ except Exception as e:
44
+ emit_error(ctx, str(e))
45
+ ctx.exit(1)
46
+
47
+
48
+ def _format_created(d: dict) -> str:
49
+ key = d.get("apiKey") or d.get("key") or d.get("plaintextKey") or "?"
50
+ kid = d.get("id") or d.get("keyId") or "?"
51
+ rate = d.get("rateLimitPerSecond")
52
+ rate_line = f"\n limit: {rate} req/sec" if rate else ""
53
+ return (
54
+ f" FastBooks API key created.\n"
55
+ f" id: {kid}\n"
56
+ f" key: {key}{rate_line}\n"
57
+ f" (store it now — the plaintext key is not retrievable later)"
58
+ )
59
+
60
+
61
+ @apikey.command("limits")
62
+ @click.pass_context
63
+ def limits(ctx):
64
+ """Show the default self-service key limits."""
65
+ client: FastBooksClient = ctx.obj
66
+ try:
67
+ data = client.fastbooks_api_key_limits()
68
+ emit(ctx, data, lambda d: f" Plan: {d.get('plan')} · {d.get('rateLimitPerSecond')} req/sec · {d.get('scope')}")
69
+ except Exception as e:
70
+ emit_error(ctx, str(e))
71
+ ctx.exit(1)
72
+
73
+
74
+ @apikey.command()
75
+ @click.argument("api_key")
76
+ @click.option("--category", default=None, help="Filter markets by category.")
77
+ @click.pass_context
78
+ def verify(ctx, api_key, category):
79
+ """Verify a FastBooks API key by fetching public market data with it.
80
+
81
+ Example:
82
+ fastbooks apikey verify fb_live_xxx --category crypto
83
+ """
84
+ client: FastBooksClient = ctx.obj
85
+ try:
86
+ data = client.public_markets(api_key, category=category)
87
+ emit(ctx, data, lambda d: _format_verify(d))
88
+ except Exception as e:
89
+ emit_error(ctx, str(e))
90
+ ctx.exit(1)
91
+
92
+
93
+ def _format_verify(d: dict) -> str:
94
+ items = d.get("markets", d.get("data", [])) if isinstance(d, dict) else d
95
+ n = len(items) if isinstance(items, list) else d.get("total", "?")
96
+ return f" Key valid — public API returned {n} markets."
97
+
98
+
99
+ @apikey.command()
100
+ @click.argument("api_key")
101
+ @click.pass_context
102
+ def info(ctx, api_key):
103
+ """Show a key's own details (plan, limits, scopes). Self-service.
104
+
105
+ Example:
106
+ fastbooks apikey info fb_live_xxx
107
+ """
108
+ client: FastBooksClient = ctx.obj
109
+ try:
110
+ data = client.fastbooks_api_key_info(api_key)
111
+ emit(ctx, data, lambda d: _format_info(d))
112
+ except Exception as e:
113
+ emit_error(ctx, str(e))
114
+ ctx.exit(1)
115
+
116
+
117
+ def _format_info(d: dict) -> str:
118
+ return (
119
+ f" id: {d.get('id', '?')}\n"
120
+ f" plan: {d.get('plan', '?')}\n"
121
+ f" limit: {d.get('rateLimitPerSecond', '?')} req/sec "
122
+ f"({d.get('rateLimitPerMinute', '?')}/min)"
123
+ )
124
+
125
+
126
+ @apikey.command()
127
+ @click.argument("api_key")
128
+ @click.option("--confirm", is_flag=True, help="Skip the confirmation prompt.")
129
+ @click.pass_context
130
+ def revoke(ctx, api_key, confirm):
131
+ """Revoke a key by presenting it. Self-service — no admin token.
132
+
133
+ Example:
134
+ fastbooks apikey revoke fb_live_xxx
135
+ """
136
+ if not confirm and not click.confirm("Revoke this API key? This cannot be undone."):
137
+ ctx.exit(0)
138
+ client: FastBooksClient = ctx.obj
139
+ try:
140
+ data = client.revoke_fastbooks_api_key(api_key)
141
+ emit(ctx, data, lambda d: " Key revoked." if d.get("revoked") else f" {d}")
142
+ except Exception as e:
143
+ emit_error(ctx, str(e))
144
+ ctx.exit(1)
@@ -0,0 +1,450 @@
1
+ """Backtest command group — run and manage backtests across venues.
2
+
3
+ Supports the full bot configuration from the investor dashboard frontend:
4
+ sleeves, risk controls, trading options, and exchange-specific parameters.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+
11
+ import click
12
+
13
+ from ..client import FastBooksClient
14
+ from ..output import emit, emit_error, format_table
15
+
16
+ VENUES = ["binance", "bybit", "hyperliquid", "alpaca", "ibkr", "okx"]
17
+ BARS = ["1m", "5m", "15m", "1h", "4h", "1d"]
18
+ REBALANCE_FREQ = ["daily", "weekly", "monthly"]
19
+ CURRENCIES = ["USD", "CAD"]
20
+ SLEEVE_TYPES = [
21
+ "dividend_stocks", "cash_reserve", "momentum",
22
+ "sector_focus", "hold_accumulate", "custom",
23
+ ]
24
+
25
+
26
+ @click.group()
27
+ def backtest():
28
+ """Backtesting: run historical simulations across any venue.
29
+
30
+ Supports real data backtests (Alpaca, Hyperliquid) and simulated
31
+ backtests with synthetic OHLCV for rapid prototyping.
32
+
33
+ The ``run`` command accepts the same full bot configuration as the
34
+ investor dashboard frontend (sleeves, risk controls, etc.).
35
+ """
36
+ pass
37
+
38
+
39
+ # ══════════════════════════════════════════════════════════════════════════
40
+ # run
41
+ # ══════════════════════════════════════════════════════════════════════════
42
+
43
+ @backtest.command()
44
+ # ── data source ──────────────────────────────────────────────────────
45
+ @click.option("--venue", "-v", type=click.Choice(VENUES, case_sensitive=False), required=True, help="Data/execution venue.")
46
+ @click.option("--symbols", "-s", type=str, required=True, help="Comma-separated symbols (e.g. BTC/USDT,ETH/USDT or AAPL,MSFT).")
47
+ @click.option("--start", required=True, help="Start date (YYYY-MM-DD).")
48
+ @click.option("--end", required=True, help="End date (YYYY-MM-DD).")
49
+ @click.option("--bar", "-b", type=click.Choice(BARS), default="1h", help="Bar/candle size.")
50
+ # ── capital ──────────────────────────────────────────────────────────
51
+ @click.option("--capital", "-c", type=float, default=50000.0, help="Total allocated capital.")
52
+ @click.option("--currency", type=click.Choice(CURRENCIES, case_sensitive=False), default="USD", help="Quote currency.")
53
+ # ── strategy sleeves ─────────────────────────────────────────────────
54
+ @click.option(
55
+ "--sleeves", type=str, default=None,
56
+ help=(
57
+ "Sleeve allocations as JSON array. Each element: "
58
+ '{"sleeve_type":"momentum","name":"My Momentum","target_percent":30,'
59
+ '"drift_threshold":5,"min_trade_value":500,"config":{}}. '
60
+ "Target percents must total 100."
61
+ ),
62
+ )
63
+ @click.option(
64
+ "--sleeve", multiple=True,
65
+ help=(
66
+ "Quick sleeve shorthand (repeatable): TYPE:PERCENT[:NAME]. "
67
+ "e.g. --sleeve momentum:30 --sleeve dividend_stocks:40 "
68
+ "--sleeve cash_reserve:10 --sleeve hold_accumulate:20"
69
+ ),
70
+ )
71
+ # ── risk controls ────────────────────────────────────────────────────
72
+ @click.option("--max-position-size", type=float, default=10.0, help="Max single position as %% of capital (default 10).")
73
+ @click.option("--max-daily-loss", type=float, default=None, help="Max daily loss in $ before pausing (default 5%% of capital).")
74
+ @click.option("--conviction-threshold", type=float, default=60.0, help="Minimum conviction score 0-100 (default 60).")
75
+ # ── trading options ──────────────────────────────────────────────────
76
+ @click.option("--paper/--live", "is_paper_trading", default=True, help="Paper or live trading mode (default: paper).")
77
+ @click.option("--market-hours-only/--anytime", default=True, help="Only trade during market hours (default: yes).")
78
+ @click.option("--auto-rebalance/--no-auto-rebalance", default=True, help="Auto-rebalance when drift exceeds threshold (default: yes).")
79
+ # ── backtest-specific ────────────────────────────────────────────────
80
+ @click.option("--rebalance", "-r", type=click.Choice(REBALANCE_FREQ), default="daily", help="Rebalance frequency.")
81
+ @click.option("--topk", "-k", type=int, default=10, help="Top K symbols to hold per sleeve.")
82
+ @click.option("--slippage", type=float, default=0.001, help="Slippage factor (0.001 = 0.1%%).")
83
+ @click.option("--commission", type=float, default=0.001, help="Commission rate per trade.")
84
+ # ── full config override ─────────────────────────────────────────────
85
+ @click.option("--config-json", type=str, default=None, help="Full config as JSON (overrides all other options).")
86
+ @click.option("--config-file", type=click.Path(exists=True), default=None, help="Load config from JSON file.")
87
+ # ── bot name / description ───────────────────────────────────────────
88
+ @click.option("--name", "-n", type=str, default=None, help="Backtest name (for saved results).")
89
+ @click.option("--description", type=str, default=None, help="Backtest description.")
90
+ @click.pass_context
91
+ def run(
92
+ ctx, venue, symbols, start, end, bar,
93
+ capital, currency,
94
+ sleeves, sleeve,
95
+ max_position_size, max_daily_loss, conviction_threshold,
96
+ is_paper_trading, market_hours_only, auto_rebalance,
97
+ rebalance, topk, slippage, commission,
98
+ config_json, config_file,
99
+ name, description,
100
+ ):
101
+ """Run a backtest with historical data and full bot configuration.
102
+
103
+ Accepts the same parameters as the investor dashboard bot creation form:
104
+ capital allocation, strategy sleeves, risk controls, and trading options.
105
+
106
+ \b
107
+ SLEEVE SHORTHAND (--sleeve, repeatable):
108
+ --sleeve momentum:30 --sleeve dividend_stocks:40
109
+ --sleeve cash_reserve:10 --sleeve hold_accumulate:20
110
+
111
+ \b
112
+ SLEEVE JSON (--sleeves):
113
+ --sleeves '[{"sleeve_type":"momentum","target_percent":50,...}]'
114
+
115
+ \b
116
+ FULL CONFIG (--config-json or --config-file):
117
+ --config-json '{"allocated_capital":100000,"sleeves":[...]}'
118
+ --config-file my_bot_config.json
119
+
120
+ Examples:
121
+ # Quick: momentum + dividend on IBKR stocks
122
+ fastbooks backtest run --venue ibkr --symbols AAPL,MSFT,GOOGL \\
123
+ --start 2024-01-01 --end 2024-12-31 -c 50000 \\
124
+ --sleeve momentum:40 --sleeve dividend_stocks:40 \\
125
+ --sleeve cash_reserve:20 --conviction-threshold 65
126
+
127
+ # Crypto backtest with custom risk
128
+ fastbooks backtest run --venue hyperliquid --symbols ETH/USDC,BTC/USDC \\
129
+ --start 2025-01-01 --end 2025-03-01 -c 10000 \\
130
+ --max-position-size 20 --max-daily-loss 500
131
+
132
+ # From saved config file (same format as frontend export)
133
+ fastbooks backtest run --venue alpaca --symbols AAPL,TSLA \\
134
+ --start 2024-06-01 --end 2025-01-01 --config-file bot_config.json
135
+
136
+ # Simple (backward-compatible)
137
+ fastbooks backtest run --venue binance --symbols BTC/USDT \\
138
+ --start 2025-01-01 --end 2025-02-01 -b 1d -c 10000
139
+ """
140
+ client: FastBooksClient = ctx.obj
141
+
142
+ # Start with full override if provided
143
+ if config_json:
144
+ try:
145
+ config = json.loads(config_json)
146
+ except json.JSONDecodeError as e:
147
+ emit_error(ctx, f"Invalid JSON: {e}")
148
+ ctx.exit(1)
149
+ return
150
+ elif config_file:
151
+ try:
152
+ with open(config_file) as f:
153
+ config = json.load(f)
154
+ except (json.JSONDecodeError, OSError) as e:
155
+ emit_error(ctx, f"Config file error: {e}")
156
+ ctx.exit(1)
157
+ return
158
+ else:
159
+ config = {}
160
+
161
+ # Build sleeves from --sleeve shorthand or --sleeves JSON
162
+ built_sleeves = config.get("sleeves")
163
+ if sleeves and not built_sleeves:
164
+ try:
165
+ built_sleeves = json.loads(sleeves)
166
+ except json.JSONDecodeError as e:
167
+ emit_error(ctx, f"Invalid --sleeves JSON: {e}")
168
+ ctx.exit(1)
169
+ return
170
+ elif sleeve and not built_sleeves:
171
+ built_sleeves = _parse_sleeve_shorthand(sleeve)
172
+ if isinstance(built_sleeves, str): # error message
173
+ emit_error(ctx, built_sleeves)
174
+ ctx.exit(1)
175
+ return
176
+
177
+ # Validate sleeve totals
178
+ if built_sleeves:
179
+ total = sum(s.get("target_percent", 0) for s in built_sleeves)
180
+ if abs(total - 100) > 0.01:
181
+ emit_error(ctx, f"Sleeve target_percent must total 100 (got {total:.1f}).")
182
+ ctx.exit(1)
183
+ return
184
+
185
+ # Merge everything — CLI flags fill in what config doesn't have
186
+ symbols_list = [s.strip() for s in symbols.split(",")]
187
+
188
+ if max_daily_loss is None:
189
+ max_daily_loss = capital * 0.05 # Default: 5% of capital
190
+
191
+ final = {
192
+ # Data source
193
+ "venue": config.get("venue", venue),
194
+ "symbols": config.get("symbols", symbols_list),
195
+ "start": config.get("start", start),
196
+ "end": config.get("end", end),
197
+ "bar": config.get("bar", bar),
198
+ # Capital (matches frontend BotConfig)
199
+ "allocated_capital": config.get("allocated_capital", capital),
200
+ "currency": config.get("currency", currency),
201
+ # Sleeves (matches frontend SleeveConfig[])
202
+ "sleeves": built_sleeves or config.get("sleeves", _default_sleeves()),
203
+ # Risk controls (matches frontend BotConfig)
204
+ "max_position_size": config.get("max_position_size", max_position_size),
205
+ "max_daily_loss": config.get("max_daily_loss", max_daily_loss),
206
+ "conviction_threshold": config.get("conviction_threshold", conviction_threshold),
207
+ # Trading options (matches frontend BotConfig)
208
+ "is_paper_trading": config.get("is_paper_trading", is_paper_trading),
209
+ "market_hours_only": config.get("market_hours_only", market_hours_only),
210
+ "auto_rebalance": config.get("auto_rebalance", auto_rebalance),
211
+ # Backtest-specific
212
+ "rebalance": config.get("rebalance", rebalance),
213
+ "topk": config.get("topk", topk),
214
+ "slippage": config.get("slippage", slippage),
215
+ "commission": config.get("commission", commission),
216
+ # Backward compat alias
217
+ "cash": config.get("cash", capital),
218
+ }
219
+
220
+ if name:
221
+ final["name"] = name
222
+ elif config.get("name"):
223
+ final["name"] = config["name"]
224
+
225
+ if description:
226
+ final["description"] = description
227
+ elif config.get("description"):
228
+ final["description"] = config["description"]
229
+
230
+ try:
231
+ data = client.run_backtest(final)
232
+ emit(ctx, data, lambda d: _format_backtest_result(d))
233
+ except Exception as e:
234
+ emit_error(ctx, str(e))
235
+ ctx.exit(1)
236
+
237
+
238
+ # ── sleeve helpers ───────────────────────────────────────────────────────
239
+
240
+ def _parse_sleeve_shorthand(items: tuple) -> list | str:
241
+ """Parse --sleeve TYPE:PERCENT[:NAME] tuples into SleeveConfig dicts."""
242
+ result = []
243
+ for item in items:
244
+ parts = item.split(":", 2)
245
+ if len(parts) < 2:
246
+ return f"Invalid sleeve format: '{item}'. Use TYPE:PERCENT[:NAME]."
247
+
248
+ sleeve_type = parts[0].strip()
249
+ if sleeve_type not in SLEEVE_TYPES:
250
+ return (
251
+ f"Unknown sleeve type: '{sleeve_type}'. "
252
+ f"Valid: {', '.join(SLEEVE_TYPES)}"
253
+ )
254
+
255
+ try:
256
+ pct = float(parts[1])
257
+ except ValueError:
258
+ return f"Invalid percent in sleeve '{item}': '{parts[1]}'."
259
+
260
+ name = parts[2].strip() if len(parts) > 2 else _sleeve_display_name(sleeve_type)
261
+
262
+ result.append({
263
+ "sleeve_type": sleeve_type,
264
+ "name": name,
265
+ "target_percent": pct,
266
+ "drift_threshold": 5,
267
+ "min_trade_value": 0 if sleeve_type == "cash_reserve" else 500,
268
+ "config": {},
269
+ })
270
+ return result
271
+
272
+
273
+ def _sleeve_display_name(sleeve_type: str) -> str:
274
+ return {
275
+ "dividend_stocks": "Dividend Stocks",
276
+ "cash_reserve": "Cash Reserve",
277
+ "momentum": "Momentum",
278
+ "sector_focus": "Sector Focus",
279
+ "hold_accumulate": "Hold & Accumulate",
280
+ "custom": "Custom",
281
+ }.get(sleeve_type, sleeve_type)
282
+
283
+
284
+ def _default_sleeves() -> list:
285
+ """Match the frontend's default sleeve allocation."""
286
+ return [
287
+ {"sleeve_type": "dividend_stocks", "name": "Dividend Stocks", "target_percent": 30, "drift_threshold": 5, "min_trade_value": 500, "config": {}},
288
+ {"sleeve_type": "cash_reserve", "name": "Cash Reserve", "target_percent": 10, "drift_threshold": 5, "min_trade_value": 0, "config": {}},
289
+ {"sleeve_type": "momentum", "name": "Momentum", "target_percent": 20, "drift_threshold": 5, "min_trade_value": 500, "config": {}},
290
+ {"sleeve_type": "sector_focus", "name": "Sector Focus", "target_percent": 20, "drift_threshold": 5, "min_trade_value": 500, "config": {}},
291
+ {"sleeve_type": "hold_accumulate", "name": "Hold & Accumulate", "target_percent": 20, "drift_threshold": 5, "min_trade_value": 500, "config": {}},
292
+ ]
293
+
294
+
295
+ # ── formatters ───────────────────────────────────────────────────────────
296
+
297
+ def _format_backtest_result(data: dict) -> str:
298
+ lines = ["", " Backtest Results", " ================"]
299
+ metrics = data.get("metrics", data)
300
+ if isinstance(metrics, dict):
301
+ for key in [
302
+ "total_return", "sharpe_ratio", "max_drawdown", "win_rate",
303
+ "total_trades", "final_equity", "allocated_capital",
304
+ "conviction_threshold", "sleeves_count",
305
+ ]:
306
+ if key in metrics:
307
+ label = key.replace("_", " ").title()
308
+ lines.append(f" {label}: {metrics[key]}")
309
+
310
+ # Sleeve breakdown if available
311
+ sleeve_results = data.get("sleeve_results", [])
312
+ if sleeve_results:
313
+ lines.append("")
314
+ lines.append(" Per-Sleeve Breakdown")
315
+ lines.append(" --------------------")
316
+ for sr in sleeve_results:
317
+ name = sr.get("name", sr.get("sleeve_type", "?"))
318
+ ret = sr.get("return", sr.get("total_return", "?"))
319
+ alloc = sr.get("target_percent", "?")
320
+ lines.append(f" {name} ({alloc}%): {ret}")
321
+
322
+ if not isinstance(metrics, dict) and not sleeve_results:
323
+ lines.append(f" {data}")
324
+ return "\n".join(lines)
325
+
326
+
327
+ # ══════════════════════════════════════════════════════════════════════════
328
+ # forward test
329
+ # ══════════════════════════════════════════════════════════════════════════
330
+
331
+ @backtest.command("forward-test")
332
+ @click.option("--venue", "-v", type=click.Choice(VENUES, case_sensitive=False), required=True)
333
+ @click.option("--asset-type", type=click.Choice(["stock", "crypto"]), default="stock")
334
+ @click.option("--capital", "-c", type=float, default=50000.0, help="Cash allocation.")
335
+ @click.option("--currency", type=click.Choice(CURRENCIES, case_sensitive=False), default="USD")
336
+ @click.option("--max-symbols", type=int, default=20, help="Max symbols in universe.")
337
+ @click.option("--topk", "-k", type=int, default=10, help="Top K to hold.")
338
+ # ── same bot config params ───────────────────────────────────────────
339
+ @click.option("--sleeve", multiple=True, help="Sleeve shorthand (repeatable): TYPE:PERCENT[:NAME].")
340
+ @click.option("--max-position-size", type=float, default=10.0, help="Max position as %% of capital.")
341
+ @click.option("--max-daily-loss", type=float, default=None, help="Max daily loss in $.")
342
+ @click.option("--conviction-threshold", type=float, default=60.0, help="Min conviction score 0-100.")
343
+ @click.option("--paper/--live", "is_paper_trading", default=True, help="Paper or live mode.")
344
+ @click.option("--market-hours-only/--anytime", default=True)
345
+ @click.option("--auto-rebalance/--no-auto-rebalance", default=True)
346
+ @click.pass_context
347
+ def forward_test(
348
+ ctx, venue, asset_type, capital, currency, max_symbols, topk,
349
+ sleeve, max_position_size, max_daily_loss, conviction_threshold,
350
+ is_paper_trading, market_hours_only, auto_rebalance,
351
+ ):
352
+ """Run a single-cycle forward test (no historical data needed).
353
+
354
+ Executes one rebalance cycle with live market data to validate strategy.
355
+ Accepts the same bot configuration params as ``backtest run``.
356
+
357
+ Examples:
358
+ fastbooks backtest forward-test --venue alpaca --capital 50000 \\
359
+ --sleeve momentum:40 --sleeve dividend_stocks:40 --sleeve cash_reserve:20
360
+
361
+ fastbooks backtest forward-test --venue hyperliquid --asset-type crypto \\
362
+ --currency USD --conviction-threshold 70
363
+ """
364
+ client: FastBooksClient = ctx.obj
365
+
366
+ # Parse sleeves
367
+ built_sleeves = None
368
+ if sleeve:
369
+ built_sleeves = _parse_sleeve_shorthand(sleeve)
370
+ if isinstance(built_sleeves, str):
371
+ emit_error(ctx, built_sleeves)
372
+ ctx.exit(1)
373
+ return
374
+
375
+ if max_daily_loss is None:
376
+ max_daily_loss = capital * 0.05
377
+
378
+ config = {
379
+ "venue": venue,
380
+ "asset_type": asset_type,
381
+ "allocated_capital": capital,
382
+ "currency": currency,
383
+ "cash": capital,
384
+ "quote": currency,
385
+ "max_symbols": max_symbols,
386
+ "topk": topk,
387
+ "sleeves": built_sleeves or _default_sleeves(),
388
+ "max_position_size": max_position_size,
389
+ "max_daily_loss": max_daily_loss,
390
+ "conviction_threshold": conviction_threshold,
391
+ "is_paper_trading": is_paper_trading,
392
+ "market_hours_only": market_hours_only,
393
+ "auto_rebalance": auto_rebalance,
394
+ }
395
+ try:
396
+ data = client.forward_test(config)
397
+ emit(ctx, data)
398
+ except Exception as e:
399
+ emit_error(ctx, str(e))
400
+ ctx.exit(1)
401
+
402
+
403
+ # ══════════════════════════════════════════════════════════════════════════
404
+ # list / delete
405
+ # ══════════════════════════════════════════════════════════════════════════
406
+
407
+ @backtest.command("list")
408
+ @click.pass_context
409
+ def list_backtests(ctx):
410
+ """List saved backtest results.
411
+
412
+ Example: fastbooks backtest list
413
+ """
414
+ client: FastBooksClient = ctx.obj
415
+ try:
416
+ data = client.list_backtests()
417
+ emit(ctx, data, lambda d: _format_backtest_list(d))
418
+ except Exception as e:
419
+ emit_error(ctx, str(e))
420
+ ctx.exit(1)
421
+
422
+
423
+ def _format_backtest_list(data) -> str:
424
+ items = data if isinstance(data, list) else data.get("backtests", data.get("data", []))
425
+ if not isinstance(items, list):
426
+ return str(data)
427
+ if not items:
428
+ return " No backtests found."
429
+ return format_table(
430
+ items,
431
+ ["id", "venue", "start", "end", "total_return", "sharpe_ratio", "status"],
432
+ ["ID", "Venue", "Start", "End", "Return", "Sharpe", "Status"],
433
+ )
434
+
435
+
436
+ @backtest.command("delete")
437
+ @click.argument("backtest_id")
438
+ @click.pass_context
439
+ def delete_backtest(ctx, backtest_id):
440
+ """Delete a saved backtest.
441
+
442
+ Example: fastbooks backtest delete bt-abc123
443
+ """
444
+ client: FastBooksClient = ctx.obj
445
+ try:
446
+ data = client.delete_backtest(backtest_id)
447
+ emit(ctx, data, lambda d: f" Backtest {backtest_id} deleted.")
448
+ except Exception as e:
449
+ emit_error(ctx, str(e))
450
+ ctx.exit(1)