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,209 @@
1
+ """Standalone (backend-free) exchange access via ccxt.
2
+
3
+ This module lets the CLI — and the MCP server — read public market data and place
4
+ orders on 100+ exchanges DIRECTLY, with NO FastBooks backend running. Public data
5
+ (tickers / ohlcv / order book / markets) needs NO API key. Trading reads per-exchange
6
+ credentials from explicit args or the environment:
7
+
8
+ CCXT_<EXCHANGE>_API_KEY / _SECRET / _PASSWORD (preferred)
9
+ <EXCHANGE>_API_KEY / _SECRET / _PASSWORD (fallback)
10
+
11
+ e.g. CCXT_BINANCE_API_KEY, CCXT_BINANCE_SECRET.
12
+
13
+ ccxt is an OPTIONAL dependency — install standalone support with:
14
+ pip install "trading-cli[standalone]" (or simply: pip install ccxt)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ from typing import Any
21
+
22
+
23
+ class CcxtNotInstalled(RuntimeError):
24
+ """Raised when a direct/standalone feature is used but ccxt isn't installed."""
25
+
26
+
27
+ def _ccxt():
28
+ try:
29
+ import ccxt # type: ignore
30
+ except ImportError as e: # pragma: no cover - exercised only without the extra
31
+ raise CcxtNotInstalled(
32
+ "ccxt is not installed — standalone mode is unavailable. Install it with: "
33
+ "pip install 'trading-cli[standalone]' (or: pip install ccxt)"
34
+ ) from e
35
+ return ccxt
36
+
37
+
38
+ def list_exchanges() -> list[str]:
39
+ """Every exchange id ccxt supports (100+)."""
40
+ return list(_ccxt().exchanges)
41
+
42
+
43
+ def _load_keys(
44
+ exchange: str,
45
+ api_key: str | None = None,
46
+ secret: str | None = None,
47
+ password: str | None = None,
48
+ ) -> dict[str, Any]:
49
+ ex = exchange.upper()
50
+
51
+ def env(*names: str) -> str | None:
52
+ for n in names:
53
+ v = os.environ.get(n)
54
+ if v:
55
+ return v
56
+ return None
57
+
58
+ # Precedence: explicit args > env (CCXT_<EX>_*) > the persisted config store (fastbooks init).
59
+ stored: dict = {}
60
+ try:
61
+ from . import config_store
62
+ stored = config_store.get_credentials(exchange)
63
+ except Exception:
64
+ stored = {}
65
+
66
+ api_key = api_key or env(f"CCXT_{ex}_API_KEY", f"{ex}_API_KEY") or stored.get("apiKey")
67
+ secret = secret or env(f"CCXT_{ex}_SECRET", f"{ex}_SECRET", f"{ex}_API_SECRET") or stored.get("secret")
68
+ password = password or env(f"CCXT_{ex}_PASSWORD", f"{ex}_PASSWORD", f"{ex}_PASSPHRASE") or stored.get("password")
69
+ cfg: dict[str, Any] = {"enableRateLimit": True}
70
+ if api_key:
71
+ cfg["apiKey"] = api_key
72
+ if secret:
73
+ cfg["secret"] = secret
74
+ if password:
75
+ cfg["password"] = password
76
+ return cfg
77
+
78
+
79
+ def get_exchange(
80
+ exchange: str,
81
+ api_key: str | None = None,
82
+ secret: str | None = None,
83
+ password: str | None = None,
84
+ sandbox: bool = False,
85
+ ):
86
+ """Instantiate a ccxt exchange (keyless unless creds are supplied/in env)."""
87
+ ccxt = _ccxt()
88
+ name = exchange.lower()
89
+ if not hasattr(ccxt, name):
90
+ raise ValueError(f"Unknown exchange '{exchange}'. Run `fastbooks direct exchanges` to list.")
91
+ inst = getattr(ccxt, name)(_load_keys(name, api_key, secret, password))
92
+ if sandbox:
93
+ try:
94
+ inst.set_sandbox_mode(True)
95
+ except Exception:
96
+ pass
97
+ return inst
98
+
99
+
100
+ def _has_creds(ex) -> bool:
101
+ return bool(getattr(ex, "apiKey", None) and getattr(ex, "secret", None))
102
+
103
+
104
+ def _needs_key(exchange: str) -> PermissionError:
105
+ ex = exchange.upper()
106
+ return PermissionError(
107
+ f"No API credentials for '{exchange}'. Set CCXT_{ex}_API_KEY and CCXT_{ex}_SECRET "
108
+ f"(and CCXT_{ex}_PASSWORD if the exchange requires a passphrase)."
109
+ )
110
+
111
+
112
+ # ── public market data (keyless) ──────────────────────────────────────────
113
+
114
+ def ticker(exchange: str, symbol: str) -> dict:
115
+ t = get_exchange(exchange).fetch_ticker(symbol)
116
+ return {
117
+ "exchange": exchange,
118
+ "symbol": symbol,
119
+ "last": t.get("last"),
120
+ "bid": t.get("bid"),
121
+ "ask": t.get("ask"),
122
+ "high": t.get("high"),
123
+ "low": t.get("low"),
124
+ "baseVolume": t.get("baseVolume"),
125
+ "quoteVolume": t.get("quoteVolume"),
126
+ "changePct": t.get("percentage"),
127
+ "timestamp": t.get("timestamp"),
128
+ }
129
+
130
+
131
+ def ohlcv(exchange: str, symbol: str, timeframe: str = "1h", limit: int = 100) -> dict:
132
+ ex = get_exchange(exchange)
133
+ if not ex.has.get("fetchOHLCV"):
134
+ raise ValueError(f"{exchange} does not provide OHLCV via ccxt.")
135
+ rows = ex.fetch_ohlcv(symbol, timeframe=timeframe, limit=limit)
136
+ return {
137
+ "exchange": exchange,
138
+ "symbol": symbol,
139
+ "timeframe": timeframe,
140
+ "candles": [
141
+ {"ts": c[0], "open": c[1], "high": c[2], "low": c[3], "close": c[4], "volume": c[5]}
142
+ for c in rows
143
+ ],
144
+ }
145
+
146
+
147
+ def orderbook(exchange: str, symbol: str, depth: int = 10) -> dict:
148
+ ob = get_exchange(exchange).fetch_order_book(symbol, limit=depth)
149
+ return {
150
+ "exchange": exchange,
151
+ "symbol": symbol,
152
+ "bids": ob.get("bids", [])[:depth],
153
+ "asks": ob.get("asks", [])[:depth],
154
+ "timestamp": ob.get("timestamp"),
155
+ }
156
+
157
+
158
+ def markets(exchange: str, quote: str | None = None, limit: int = 200) -> dict:
159
+ syms = list(get_exchange(exchange).load_markets().keys())
160
+ if quote:
161
+ q = quote.upper()
162
+ syms = [s for s in syms if s.upper().endswith(f"/{q}")]
163
+ return {"exchange": exchange, "count": len(syms), "symbols": sorted(syms)[:limit]}
164
+
165
+
166
+ # ── trading (needs credentials) ───────────────────────────────────────────
167
+
168
+ def balance(exchange: str, **keys) -> dict:
169
+ ex = get_exchange(exchange, **keys)
170
+ if not _has_creds(ex):
171
+ raise _needs_key(exchange)
172
+ total = {k: v for k, v in (ex.fetch_balance().get("total") or {}).items() if v}
173
+ return {"exchange": exchange, "balances": total}
174
+
175
+
176
+ def positions(exchange: str, **keys) -> dict:
177
+ ex = get_exchange(exchange, **keys)
178
+ if not _has_creds(ex):
179
+ raise _needs_key(exchange)
180
+ if not ex.has.get("fetchPositions"):
181
+ raise ValueError(f"{exchange} does not support positions via ccxt.")
182
+ return {"exchange": exchange, "positions": ex.fetch_positions()}
183
+
184
+
185
+ def place_order(
186
+ exchange: str,
187
+ symbol: str,
188
+ side: str,
189
+ order_type: str = "market",
190
+ amount: float = 0.0,
191
+ price: float | None = None,
192
+ sandbox: bool = False,
193
+ **keys,
194
+ ) -> dict:
195
+ ex = get_exchange(exchange, sandbox=sandbox, **keys)
196
+ if not _has_creds(ex):
197
+ raise _needs_key(exchange)
198
+ o = ex.create_order(symbol, order_type.lower(), side.lower(), amount, price)
199
+ return {
200
+ "exchange": exchange,
201
+ "id": o.get("id"),
202
+ "symbol": o.get("symbol"),
203
+ "side": o.get("side"),
204
+ "type": o.get("type"),
205
+ "amount": o.get("amount"),
206
+ "price": o.get("price"),
207
+ "status": o.get("status"),
208
+ "filled": o.get("filled"),
209
+ }
fastbooks_cli/main.py ADDED
@@ -0,0 +1,88 @@
1
+ """FastBooks CLI — agent-native trading interface.
2
+
3
+ Following CLI-Anything methodology: structured, self-describing commands
4
+ with dual output (JSON for agents, tables for humans).
5
+
6
+ Usage:
7
+ fastbooks [--json] [--url URL] COMMAND [ARGS...]
8
+
9
+ Examples:
10
+ fastbooks markets orderbook binance BTC/USDT
11
+ fastbooks execute order binance BTC/USDT buy market 0.01
12
+ fastbooks bots list
13
+ fastbooks backtest run --venue hyperliquid --symbols ETH/USDC --start 2025-01-01 --end 2025-03-01
14
+ fastbooks --json execute balance binance
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import click
20
+
21
+ from . import __version__
22
+ from .client import FastBooksClient
23
+ from .commands.markets import markets
24
+ from .commands.execution import execute
25
+ from .commands.bots import bots
26
+ from .commands.backtest import backtest
27
+ from .commands.wallet import wallet
28
+ from .commands.config import config
29
+ from .commands.swap import swap
30
+ from .commands.dex import dex
31
+ from .commands.repl import repl
32
+ from .commands.polymarket import polymarket
33
+ from .commands.strategy import strategy
34
+ from .commands.apikey import apikey
35
+ from .commands.direct import direct
36
+ from .commands.mcp_cmd import mcp_cmd
37
+ from .commands.onchain import onchain
38
+ from .commands.init_cmd import init_cmd
39
+
40
+ pass_client = click.make_pass_decorator(FastBooksClient)
41
+
42
+
43
+ @click.group()
44
+ @click.version_option(__version__, prog_name="fastbooks")
45
+ @click.option("--json", "json_output", is_flag=True, help="Output JSON (for agents).")
46
+ @click.option("--url", "routing_url", envvar="FASTBOOKS_ROUTING_URL", default=None, help="Routing API URL.")
47
+ @click.option("--bots-url", envvar="FASTBOOKS_BOTS_URL", default=None, help="Bots API URL.")
48
+ @click.option("--token", envvar="FASTBOOKS_API_TOKEN", default=None, help="JWT for authenticated routes (api-keys, saved backtests, onchain).")
49
+ @click.option("--api-key", "api_key", envvar="FASTBOOKS_API_KEY", default=None, help="FastBooks public-API key (fb_live_...). Sent as x-api-key so your bot is metered/throttled. Mint one: `fastbooks apikey create`.")
50
+ @click.option("--timeout", type=int, default=30, help="Request timeout in seconds.")
51
+ @click.pass_context
52
+ def cli(ctx: click.Context, json_output: bool, routing_url: str | None, bots_url: str | None, token: str | None, api_key: str | None, timeout: int):
53
+ """FastBooks CLI — trade any exchange from the command line.
54
+
55
+ Supports CEX (Binance, Bybit, OKX, Kraken, Coinbase, KuCoin),
56
+ perpetuals (Hyperliquid), traditional brokers (IBKR, Alpaca),
57
+ DEX (Jupiter, Uniswap), and prediction markets (Polymarket, Limitless).
58
+
59
+ Use --json for machine-readable output suitable for AI agents.
60
+ """
61
+ ctx.ensure_object(dict)
62
+ ctx.obj = FastBooksClient(routing_url=routing_url, bots_url=bots_url, timeout=timeout, token=token, api_key=api_key)
63
+
64
+
65
+ cli.add_command(markets)
66
+ cli.add_command(execute)
67
+ cli.add_command(bots)
68
+ cli.add_command(backtest)
69
+ cli.add_command(wallet)
70
+ cli.add_command(config)
71
+ cli.add_command(swap)
72
+ cli.add_command(dex)
73
+ cli.add_command(repl)
74
+ cli.add_command(polymarket)
75
+ cli.add_command(strategy)
76
+ cli.add_command(apikey)
77
+ cli.add_command(init_cmd)
78
+ cli.add_command(direct)
79
+ cli.add_command(mcp_cmd)
80
+ cli.add_command(onchain)
81
+
82
+
83
+ def main():
84
+ cli()
85
+
86
+
87
+ if __name__ == "__main__":
88
+ main()
@@ -0,0 +1,171 @@
1
+ """FastBooks MCP server — exposes the CLI's capabilities as agent-callable tools.
2
+
3
+ Two tiers of tools:
4
+ • STANDALONE (ccxt): market data + trading on 100+ exchanges with NO backend.
5
+ Public data is keyless; trading reads CCXT_<EXCHANGE>_API_KEY/_SECRET/_PASSWORD from env.
6
+ • PLATFORM (FastBooks backend): aggregated multi-asset data, the 15-venue execution
7
+ layer (stocks/forex/prediction/perps), and managed bots — used when a backend is reachable
8
+ (FASTBOOKS_ROUTING_URL / FASTBOOKS_BOTS_URL, default localhost:3002/8003). These degrade to
9
+ a structured {"error": ...} when the backend is down, so the server never crashes.
10
+
11
+ Run: fastbooks-mcp (or: fastbooks mcp) — speaks MCP over stdio.
12
+ Requires: pip install "trading-cli[mcp]" (and [standalone] for the ccxt tools).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ from . import direct as d
20
+ from .client import FastBooksClient
21
+
22
+
23
+ def _safe(fn):
24
+ """Wrap a tool body so backend/network errors return structured JSON, not a crash."""
25
+ try:
26
+ return fn()
27
+ except Exception as e: # noqa: BLE001
28
+ return {"error": str(e), "type": e.__class__.__name__}
29
+
30
+
31
+ def build_server():
32
+ try:
33
+ from mcp.server.fastmcp import FastMCP
34
+ except ImportError as e: # pragma: no cover
35
+ raise RuntimeError(
36
+ "MCP SDK not installed. Install with: pip install 'trading-cli[mcp]'"
37
+ ) from e
38
+
39
+ mcp = FastMCP("fastbooks")
40
+
41
+ # ── STANDALONE (ccxt) — no backend needed ────────────────────────────
42
+
43
+ @mcp.tool()
44
+ def list_exchanges(search: str = "") -> dict:
45
+ """List the 100+ exchanges available for standalone (backend-free) data & trading. Optional substring filter."""
46
+ return _safe(lambda: (lambda xs: {"count": len(xs), "exchanges": xs})(
47
+ [x for x in d.list_exchanges() if not search or search.lower() in x.lower()]
48
+ ))
49
+
50
+ @mcp.tool()
51
+ def get_ticker(exchange: str, symbol: str) -> dict:
52
+ """Live ticker for a symbol on any ccxt exchange (keyless). e.g. exchange='binance', symbol='BTC/USDT'."""
53
+ return _safe(lambda: d.ticker(exchange, symbol))
54
+
55
+ @mcp.tool()
56
+ def get_ohlcv(exchange: str, symbol: str, timeframe: str = "1h", limit: int = 100) -> dict:
57
+ """OHLCV candles for a symbol (keyless). timeframe e.g. 1m,5m,15m,1h,4h,1d."""
58
+ return _safe(lambda: d.ohlcv(exchange, symbol, timeframe, limit))
59
+
60
+ @mcp.tool()
61
+ def get_orderbook(exchange: str, symbol: str, depth: int = 10) -> dict:
62
+ """Order book (bids/asks) for a symbol on any exchange (keyless)."""
63
+ return _safe(lambda: d.orderbook(exchange, symbol, depth))
64
+
65
+ @mcp.tool()
66
+ def get_markets(exchange: str, quote: str = "", limit: int = 200) -> dict:
67
+ """List tradable symbols on an exchange (keyless). Optional quote-asset filter (e.g. 'USDT')."""
68
+ return _safe(lambda: d.markets(exchange, quote or None, limit))
69
+
70
+ @mcp.tool()
71
+ def get_balance(exchange: str) -> dict:
72
+ """Account balance on an exchange. Needs CCXT_<EXCHANGE>_API_KEY/_SECRET in the server's env."""
73
+ return _safe(lambda: d.balance(exchange))
74
+
75
+ @mcp.tool()
76
+ def place_order(exchange: str, symbol: str, side: str, order_type: str = "market",
77
+ amount: float = 0.0, price: float | None = None, sandbox: bool = False) -> dict:
78
+ """Place an order DIRECTLY on an exchange via ccxt (needs CCXT_<EXCHANGE>_* creds). side=buy|sell, order_type=market|limit. sandbox=true uses testnet where supported."""
79
+ return _safe(lambda: d.place_order(exchange, symbol, side, order_type, amount, price, sandbox=sandbox))
80
+
81
+ # ── PLATFORM (FastBooks backend) — richer venues/assets when reachable ─
82
+
83
+ def _client() -> FastBooksClient:
84
+ return FastBooksClient()
85
+
86
+ @mcp.tool()
87
+ def platform_venues() -> Any:
88
+ """FastBooks execution venues (Hyperliquid perps, Alpaca/IBKR stocks, OANDA/Saxo forex, Polymarket prediction, CEXs, paper). Requires the FastBooks backend."""
89
+ return _safe(lambda: _client().venues())
90
+
91
+ @mcp.tool()
92
+ def platform_tickers(limit: int = 50, category: str = "", search: str = "") -> Any:
93
+ """FastBooks aggregated tickers across ALL asset classes (crypto, stocks, forex, index, commodities, dex, prediction). Requires the backend. category e.g. crypto|stock|forex|prediction."""
94
+ return _safe(lambda: _client().tickers(limit=limit, category=category or None, search=search or None))
95
+
96
+ @mcp.tool()
97
+ def platform_place_order(venue: str, symbol: str, side: str, order_type: str = "market",
98
+ amount: float = 0.0, price: float | None = None) -> Any:
99
+ """Place an order via the FastBooks execution layer (stocks/forex/prediction/perps across 15 venues). Requires the backend + configured keys. Use venue='paper' for KEYLESS simulation."""
100
+ return _safe(lambda: _client().place_order(venue, symbol, side, order_type, amount, price))
101
+
102
+ @mcp.tool()
103
+ def platform_balance(venue: str) -> Any:
104
+ """Balance on a FastBooks venue. Requires the backend."""
105
+ return _safe(lambda: _client().balance(venue))
106
+
107
+ @mcp.tool()
108
+ def platform_positions(venue: str) -> Any:
109
+ """Open positions on a FastBooks venue. Requires the backend."""
110
+ return _safe(lambda: _client().positions(venue))
111
+
112
+ @mcp.tool()
113
+ def platform_paper_init(balance: float = 10000.0) -> Any:
114
+ """Initialize a KEYLESS paper-trading account on the FastBooks backend (no exchange keys needed)."""
115
+ return _safe(lambda: _client().paper_init(balance=balance))
116
+
117
+ @mcp.tool()
118
+ def list_bots() -> Any:
119
+ """List FastBooks managed trading bots. Requires the bots service (:8003)."""
120
+ return _safe(lambda: _client().list_bots())
121
+
122
+ @mcp.tool()
123
+ def create_public_api_key(label: str = "mcp-agent") -> Any:
124
+ """Self-service mint a free read-only FastBooks public-API key (fb_live_...). Set it as FASTBOOKS_API_KEY so this agent is metered/throttled per key on platform data. Requires the backend."""
125
+ return _safe(lambda: _client().create_fastbooks_api_key(label))
126
+
127
+ # ── FastBots ON-CHAIN (Solana/EVM engine via /api/onchain) ──────────
128
+ # Needs FASTBOOKS_API_TOKEN (a JWT on the engine's email allowlist) in the server env.
129
+
130
+ @mcp.tool()
131
+ def onchain_status() -> Any:
132
+ """Live status of on-chain (Solana/EVM) bots — which are running. Requires backend + JWT."""
133
+ return _safe(lambda: _client().onchain_live_status())
134
+
135
+ @mcp.tool()
136
+ def onchain_stats(by_bot: bool = False) -> Any:
137
+ """Realized PnL / win-rate for on-chain bots (per-bot if by_bot=true). Requires backend + JWT."""
138
+ return _safe(lambda: _client().onchain_stats(by_bot=by_bot))
139
+
140
+ @mcp.tool()
141
+ def onchain_strategies() -> Any:
142
+ """List deployable on-chain strategies + params. Requires backend + JWT."""
143
+ return _safe(lambda: _client().onchain_strategies())
144
+
145
+ @mcp.tool()
146
+ def onchain_start(strategy_id: str, alloc_sol: float | None = None,
147
+ slippage_bps: int | None = None, max_safety_risk: int | None = None) -> Any:
148
+ """Start a LIVE on-chain bot (REAL funds) on the Solana/EVM engine. Requires backend + JWT. alloc_sol=maxAllocationSol; max_safety_risk=40 allows the caution band."""
149
+ opts: dict = {}
150
+ if alloc_sol is not None:
151
+ opts["maxAllocationSol"] = alloc_sol
152
+ if slippage_bps is not None:
153
+ opts["realSlippageBps"] = slippage_bps
154
+ if max_safety_risk is not None:
155
+ opts["maxSafetyRisk"] = max_safety_risk
156
+ return _safe(lambda: _client().onchain_live_start(strategy_id, opts))
157
+
158
+ @mcp.tool()
159
+ def onchain_stop(strategy_id: str) -> Any:
160
+ """Stop a live on-chain bot. Requires backend + JWT."""
161
+ return _safe(lambda: _client().onchain_live_stop(strategy_id))
162
+
163
+ return mcp
164
+
165
+
166
+ def main():
167
+ build_server().run()
168
+
169
+
170
+ if __name__ == "__main__":
171
+ main()
@@ -0,0 +1,86 @@
1
+ """Dual output formatter — JSON for agents, rich tables for humans."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from typing import Any
9
+
10
+ import click
11
+
12
+
13
+ def require_experimental(ctx: click.Context, group: str) -> None:
14
+ """Gate an experimental command group behind FASTBOOKS_EXPERIMENTAL=1.
15
+
16
+ These groups call backend routers that are NOT enabled on standard FastBooks
17
+ deployments, so by default we refuse with a clear message instead of emitting
18
+ a confusing connection/404 error. Showing `--help` (no subcommand) is allowed.
19
+ """
20
+ if ctx.invoked_subcommand is None:
21
+ return # `fastbooks <group>` / `--help` — let Click show help
22
+ if os.environ.get("FASTBOOKS_EXPERIMENTAL", "").lower() in ("1", "true", "yes"):
23
+ return
24
+ emit_error(
25
+ ctx,
26
+ f"`{group}` is experimental and disabled. It requires the {group} backend "
27
+ f"router, which is not enabled on standard FastBooks deployments. "
28
+ f"Set FASTBOOKS_EXPERIMENTAL=1 to enable it anyway.",
29
+ )
30
+ ctx.exit(2)
31
+
32
+
33
+ def _is_json_mode(ctx: click.Context) -> bool:
34
+ """Check if --json flag was passed at the root level."""
35
+ root = ctx.find_root()
36
+ return root.params.get("json_output", False)
37
+
38
+
39
+ def emit(ctx: click.Context, data: Any, human_formatter=None) -> None:
40
+ """Emit data in JSON or human-readable format.
41
+
42
+ Args:
43
+ ctx: Click context (used to check --json flag).
44
+ data: The data payload (dict, list, or primitive).
45
+ human_formatter: Optional callable(data) -> str for human output.
46
+ If None, falls back to pretty-printed JSON.
47
+ """
48
+ if _is_json_mode(ctx):
49
+ click.echo(json.dumps(data, indent=2, default=str))
50
+ elif human_formatter:
51
+ click.echo(human_formatter(data))
52
+ else:
53
+ click.echo(json.dumps(data, indent=2, default=str))
54
+
55
+
56
+ def emit_error(ctx: click.Context, message: str, details: Any = None) -> None:
57
+ """Emit an error in JSON or human format."""
58
+ if _is_json_mode(ctx):
59
+ err = {"error": message}
60
+ if details:
61
+ err["details"] = details
62
+ click.echo(json.dumps(err, indent=2, default=str), err=True)
63
+ else:
64
+ click.secho(f"Error: {message}", fg="red", err=True)
65
+ if details:
66
+ click.echo(str(details), err=True)
67
+
68
+
69
+ def format_table(rows: list[dict], columns: list[str], headers: list[str] | None = None) -> str:
70
+ """Format a list of dicts as an aligned text table."""
71
+ if not rows:
72
+ return "(no data)"
73
+ hdrs = headers or columns
74
+ col_widths = [len(h) for h in hdrs]
75
+ str_rows = []
76
+ for row in rows:
77
+ vals = [str(row.get(c, "")) for c in columns]
78
+ str_rows.append(vals)
79
+ for i, v in enumerate(vals):
80
+ col_widths[i] = max(col_widths[i], len(v))
81
+
82
+ fmt = " ".join(f"{{:<{w}}}" for w in col_widths)
83
+ lines = [fmt.format(*hdrs), fmt.format(*("-" * w for w in col_widths))]
84
+ for vals in str_rows:
85
+ lines.append(fmt.format(*vals))
86
+ return "\n".join(lines)
@@ -0,0 +1,87 @@
1
+ """Local trading strategy registry (loader only — no strategies bundled here).
2
+
3
+ Strategies are shipped as SEPARATE, optionally-installed packages so the
4
+ open-source CLI contains the runner but not the trading logic. A strategy
5
+ provider exposes:
6
+
7
+ STRATEGIES = [ { module, name, slug, description, venue,
8
+ required_keys, optional_keys }, ... ]
9
+
10
+ and each referenced ``module`` exposes ``run(cfg)`` (async) plus
11
+ ``NAME``/``DESCRIPTION``/``VENUE``/``REQUIRED_KEYS``/``OPTIONAL_KEYS``.
12
+
13
+ Discovery order:
14
+ 1. `fastbooks_cli.strategies` entry points (installed plugins) — zero config.
15
+ 2. Fallback: a top-level ``clistrategies`` package on the path.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass, fields
21
+ from typing import Dict, List
22
+
23
+
24
+ @dataclass
25
+ class StrategyMeta:
26
+ """Lightweight descriptor — no heavy imports."""
27
+ module: str # e.g. "clistrategies.hl_momentum_hunter"
28
+ name: str # human name
29
+ slug: str # CLI identifier (lowercase, hyphens)
30
+ description: str
31
+ venue: str
32
+ required_keys: List[str]
33
+ optional_keys: List[str]
34
+
35
+
36
+ REGISTRY: Dict[str, StrategyMeta] = {}
37
+
38
+ _META_FIELDS = {f.name for f in fields(StrategyMeta)}
39
+
40
+
41
+ def _register(meta: StrategyMeta) -> None:
42
+ REGISTRY[meta.slug] = meta
43
+
44
+
45
+ def _register_from_dicts(items) -> None:
46
+ for d in items or []:
47
+ try:
48
+ meta = StrategyMeta(**{k: v for k, v in d.items() if k in _META_FIELDS})
49
+ except TypeError:
50
+ continue # skip malformed descriptors rather than crash the CLI
51
+ _register(meta)
52
+
53
+
54
+ def _discover() -> None:
55
+ """Populate REGISTRY from installed strategy plugins. Best-effort, never raises."""
56
+ found = False
57
+
58
+ # 1. Entry points advertised by installed plugin packages.
59
+ try:
60
+ from importlib.metadata import entry_points
61
+
62
+ eps = entry_points()
63
+ group = (
64
+ eps.select(group="fastbooks_cli.strategies")
65
+ if hasattr(eps, "select")
66
+ else eps.get("fastbooks_cli.strategies", []) # py3.9 shim
67
+ )
68
+ for ep in group:
69
+ try:
70
+ provider = ep.load()
71
+ except Exception:
72
+ continue
73
+ _register_from_dicts(getattr(provider, "STRATEGIES", None))
74
+ found = True
75
+ except Exception:
76
+ pass
77
+
78
+ # 2. Fallback: a top-level `clistrategies` package on PYTHONPATH.
79
+ if not found and not REGISTRY:
80
+ try:
81
+ import clistrategies # type: ignore
82
+ _register_from_dicts(getattr(clistrategies, "STRATEGIES", None))
83
+ except Exception:
84
+ pass
85
+
86
+
87
+ _discover()