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
fastbooks_cli/direct.py
ADDED
|
@@ -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()
|
fastbooks_cli/output.py
ADDED
|
@@ -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()
|