irl-gateway 0.2.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MacroPulse Lab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.4
2
+ Name: irl-gateway
3
+ Version: 0.2.0
4
+ Summary: MCP trading gateway for AI agents: IRL pre-trade policy, sealed rationale, and verifiable fills.
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.12
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: mcp<3,>=2.3
10
+ Requires-Dist: aiohttp>=3.9
11
+ Requires-Dist: ccxt>=4.3
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8.0; extra == "dev"
14
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
15
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
16
+ Requires-Dist: black>=24.0; extra == "dev"
17
+ Requires-Dist: isort>=5.13; extra == "dev"
18
+ Requires-Dist: ruff>=0.4; extra == "dev"
19
+ Requires-Dist: mypy>=1.9; extra == "dev"
20
+ Dynamic: license-file
21
+
22
+ # IRL Gateway
23
+
24
+ <!-- mcp-name: io.github.macropulse-lab/irl-gateway -->
25
+
26
+ **Give your AI agent a trading account it can't misuse, and a record of every decision it can't rewrite.**
27
+
28
+ IRL Gateway is an [MCP](https://modelcontextprotocol.io) server that sits between an AI agent (Claude, ChatGPT, or your own) and an exchange account. Every order the agent places goes through the [IRL Engine](https://irl.macropulse.live):
29
+
30
+ 1. **Policy before execution.** IRL checks the order against the agent's mandate (active status, notional cap, allowed assets and venues) before anything reaches the exchange. Out of mandate means no order.
31
+ 2. **The rationale is sealed.** The agent must say why it is trading. The gateway hashes that rationale together with the trade inputs and seals the hash into IRL's tamper-evident trace, anchored daily to Bitcoin. The plaintext stays in your local journal.
32
+ 3. **Intent is reconciled with the fill.** After the exchange fills the order, IRL compares what was authorized with what executed and records `MATCHED` or `DIVERGENT`.
33
+
34
+ When something goes wrong, you can prove what the agent was allowed to do, what it said it was doing, and what actually happened.
35
+
36
+ ```
37
+ AI agent ── MCP ──> irl-gateway ──> IRL: authorize (policy + sealed rationale)
38
+ │
39
+ ├──────> exchange: market order (client id = sealed intent)
40
+ │
41
+ └──────> IRL: bind fill -> MATCHED / DIVERGENT
42
+ ```
43
+
44
+ ## Tools
45
+
46
+ | Tool | What it does |
47
+ | --- | --- |
48
+ | `execute_trade(symbol, side, rationale, quantity \| notional)` | The only tool that moves money. Spot market order through authorize → place → bind. Returns `filled`, `denied`, `blocked` or `failed`, with the IRL `trace_id` and verdict. |
49
+ | `get_policy()` | The agent's mandate as IRL enforces it, plus the local kill-switch state. |
50
+ | `get_quote(symbol)` | Last price on the gateway's venue. |
51
+ | `get_balances()` | Free balances (paper or exchange). |
52
+ | `get_trace(trace_id)` | IRL's sealed record of one trade. |
53
+ | `list_recent_trades(limit)` | Local journal: rationale, context hash, trace id and outcome per trade. |
54
+
55
+ Behaviour the agent can rely on:
56
+
57
+ - **Fail closed.** If IRL is unreachable or denies the intent, no order is sent.
58
+ - **Kill switch.** Create the file `~/.irl-gateway/KILL` and every trade is refused before IRL is even called. Delete it to resume.
59
+ - **No silent fills.** If the exchange fills but the IRL bind fails, the result still reports the fill and flags it for reconciliation.
60
+
61
+ ## Quick start (paper trading)
62
+
63
+ You need an IRL server and an agent registered on it. Paper trading is the default: fills are simulated at live public Binance prices, and no exchange keys are needed.
64
+
65
+ ```bash
66
+ pip install irl-gateway # or run it without installing: uvx irl-gateway
67
+ ```
68
+
69
+ Register the agent once, with its mandate:
70
+
71
+ ```bash
72
+ curl -X POST "$IRL_BASE_URL/irl/agents" -H "Authorization: Bearer $IRL_API_TOKEN" \
73
+ -H "Content-Type: application/json" -d '{
74
+ "name": "my-claude-trader",
75
+ "model_hash_hex": "<sha256 of your agent config>",
76
+ "max_notional": 100,
77
+ "allowed_assets": ["BTC/USDT", "ETH/USDT"],
78
+ "allowed_venues": ["paper-binance"]
79
+ }'
80
+ ```
81
+
82
+ Then add the gateway to your MCP client, for example Claude Code or Claude Desktop:
83
+
84
+ ```json
85
+ {
86
+ "mcpServers": {
87
+ "irl-gateway": {
88
+ "command": "uvx",
89
+ "args": ["irl-gateway"],
90
+ "env": {
91
+ "IRL_BASE_URL": "https://irl.example.com",
92
+ "IRL_API_TOKEN": "…",
93
+ "IRL_AGENT_ID": "<agent_id from registration>",
94
+ "IRL_MODEL_HASH": "<the same model_hash_hex>",
95
+ "AGENT_MODEL_ID": "claude-opus-5-5",
96
+ "PAPER_BALANCES": "USDT=1000"
97
+ }
98
+ }
99
+ }
100
+ }
101
+ ```
102
+
103
+ Ask the agent to check `get_policy`, then trade.
104
+
105
+ ## Configuration
106
+
107
+ | Variable | Default | Meaning |
108
+ | --- | --- | --- |
109
+ | `IRL_BASE_URL`, `IRL_API_TOKEN` | required | IRL server and bearer token |
110
+ | `IRL_AGENT_ID`, `IRL_MODEL_HASH` | required | The registered agent and its model hash |
111
+ | `AGENT_MODEL_ID` | `unspecified-model` | Model name sealed into each trace (the agent can override it per trade) |
112
+ | `AGENT_CONFIG_CHECKSUM` | `none` | Optional checksum of the agent's configuration, sealed into each trace |
113
+ | `IRL_L2_MODE` | `off` | `regime` if your IRL server requires Layer 2 regime binding |
114
+ | `GATEWAY_BROKER` | `paper` | `paper` or `exchange` |
115
+ | `EXCHANGE_ID` | `binance` | Any ccxt exchange id; also the price source for paper trading |
116
+ | `EXCHANGE_API_KEY`, `EXCHANGE_API_SECRET` | | Required for `exchange` |
117
+ | `EXCHANGE_TESTNET` | `true` | Use the exchange's testnet |
118
+ | `PAPER_BALANCES` | `USDT=1000` | Starting paper balances (used only until `paper_state.json` exists; the paper account then persists across restarts) |
119
+ | `IRL_GATEWAY_HOME` | `~/.irl-gateway` | Journal (`journal.jsonl`), kill switch (`KILL`) and paper account (`paper_state.json`) location |
120
+
121
+ The venue IRL sees is the exchange id (`binance`), or `paper-<exchange>` for paper trading, so a mandate can allow paper trading while denying the real account.
122
+
123
+ ## How the rationale is sealed
124
+
125
+ For each trade the gateway builds a context of the rationale, symbol, side, quantity, reference price, venue, model id and client order id. It hashes that context as canonical JSON (sorted keys, no whitespace) with SHA-256 and sends the hash to IRL as `prompt_version = "ctx-sha256:<hex>"`, which IRL seals into the trace's `reasoning_hash`.
126
+
127
+ The journal stores the full context next to its hash, so anyone holding a journal line can recompute the hash and match it to the sealed trace. IRL itself never sees the rationale's text.
128
+
129
+ ## Development
130
+
131
+ ```bash
132
+ python -m venv .venv && .venv/bin/pip install -e ".[dev]"
133
+ pytest --cov=irl_gateway
134
+ ruff check src tests && black --check src tests && isort --check-only src tests && mypy src
135
+ ```
136
+
137
+ ## Status
138
+
139
+ Early (0.1). Spot market orders only. Paper trading and ccxt exchanges are supported; Alpaca is next. Not investment advice, and no strategy is included: the gateway controls and records what your agent does, it does not decide.
140
+
141
+ MIT licensed.
@@ -0,0 +1,120 @@
1
+ # IRL Gateway
2
+
3
+ <!-- mcp-name: io.github.macropulse-lab/irl-gateway -->
4
+
5
+ **Give your AI agent a trading account it can't misuse, and a record of every decision it can't rewrite.**
6
+
7
+ IRL Gateway is an [MCP](https://modelcontextprotocol.io) server that sits between an AI agent (Claude, ChatGPT, or your own) and an exchange account. Every order the agent places goes through the [IRL Engine](https://irl.macropulse.live):
8
+
9
+ 1. **Policy before execution.** IRL checks the order against the agent's mandate (active status, notional cap, allowed assets and venues) before anything reaches the exchange. Out of mandate means no order.
10
+ 2. **The rationale is sealed.** The agent must say why it is trading. The gateway hashes that rationale together with the trade inputs and seals the hash into IRL's tamper-evident trace, anchored daily to Bitcoin. The plaintext stays in your local journal.
11
+ 3. **Intent is reconciled with the fill.** After the exchange fills the order, IRL compares what was authorized with what executed and records `MATCHED` or `DIVERGENT`.
12
+
13
+ When something goes wrong, you can prove what the agent was allowed to do, what it said it was doing, and what actually happened.
14
+
15
+ ```
16
+ AI agent ── MCP ──> irl-gateway ──> IRL: authorize (policy + sealed rationale)
17
+ │
18
+ ├──────> exchange: market order (client id = sealed intent)
19
+ │
20
+ └──────> IRL: bind fill -> MATCHED / DIVERGENT
21
+ ```
22
+
23
+ ## Tools
24
+
25
+ | Tool | What it does |
26
+ | --- | --- |
27
+ | `execute_trade(symbol, side, rationale, quantity \| notional)` | The only tool that moves money. Spot market order through authorize → place → bind. Returns `filled`, `denied`, `blocked` or `failed`, with the IRL `trace_id` and verdict. |
28
+ | `get_policy()` | The agent's mandate as IRL enforces it, plus the local kill-switch state. |
29
+ | `get_quote(symbol)` | Last price on the gateway's venue. |
30
+ | `get_balances()` | Free balances (paper or exchange). |
31
+ | `get_trace(trace_id)` | IRL's sealed record of one trade. |
32
+ | `list_recent_trades(limit)` | Local journal: rationale, context hash, trace id and outcome per trade. |
33
+
34
+ Behaviour the agent can rely on:
35
+
36
+ - **Fail closed.** If IRL is unreachable or denies the intent, no order is sent.
37
+ - **Kill switch.** Create the file `~/.irl-gateway/KILL` and every trade is refused before IRL is even called. Delete it to resume.
38
+ - **No silent fills.** If the exchange fills but the IRL bind fails, the result still reports the fill and flags it for reconciliation.
39
+
40
+ ## Quick start (paper trading)
41
+
42
+ You need an IRL server and an agent registered on it. Paper trading is the default: fills are simulated at live public Binance prices, and no exchange keys are needed.
43
+
44
+ ```bash
45
+ pip install irl-gateway # or run it without installing: uvx irl-gateway
46
+ ```
47
+
48
+ Register the agent once, with its mandate:
49
+
50
+ ```bash
51
+ curl -X POST "$IRL_BASE_URL/irl/agents" -H "Authorization: Bearer $IRL_API_TOKEN" \
52
+ -H "Content-Type: application/json" -d '{
53
+ "name": "my-claude-trader",
54
+ "model_hash_hex": "<sha256 of your agent config>",
55
+ "max_notional": 100,
56
+ "allowed_assets": ["BTC/USDT", "ETH/USDT"],
57
+ "allowed_venues": ["paper-binance"]
58
+ }'
59
+ ```
60
+
61
+ Then add the gateway to your MCP client, for example Claude Code or Claude Desktop:
62
+
63
+ ```json
64
+ {
65
+ "mcpServers": {
66
+ "irl-gateway": {
67
+ "command": "uvx",
68
+ "args": ["irl-gateway"],
69
+ "env": {
70
+ "IRL_BASE_URL": "https://irl.example.com",
71
+ "IRL_API_TOKEN": "…",
72
+ "IRL_AGENT_ID": "<agent_id from registration>",
73
+ "IRL_MODEL_HASH": "<the same model_hash_hex>",
74
+ "AGENT_MODEL_ID": "claude-opus-5-5",
75
+ "PAPER_BALANCES": "USDT=1000"
76
+ }
77
+ }
78
+ }
79
+ }
80
+ ```
81
+
82
+ Ask the agent to check `get_policy`, then trade.
83
+
84
+ ## Configuration
85
+
86
+ | Variable | Default | Meaning |
87
+ | --- | --- | --- |
88
+ | `IRL_BASE_URL`, `IRL_API_TOKEN` | required | IRL server and bearer token |
89
+ | `IRL_AGENT_ID`, `IRL_MODEL_HASH` | required | The registered agent and its model hash |
90
+ | `AGENT_MODEL_ID` | `unspecified-model` | Model name sealed into each trace (the agent can override it per trade) |
91
+ | `AGENT_CONFIG_CHECKSUM` | `none` | Optional checksum of the agent's configuration, sealed into each trace |
92
+ | `IRL_L2_MODE` | `off` | `regime` if your IRL server requires Layer 2 regime binding |
93
+ | `GATEWAY_BROKER` | `paper` | `paper` or `exchange` |
94
+ | `EXCHANGE_ID` | `binance` | Any ccxt exchange id; also the price source for paper trading |
95
+ | `EXCHANGE_API_KEY`, `EXCHANGE_API_SECRET` | | Required for `exchange` |
96
+ | `EXCHANGE_TESTNET` | `true` | Use the exchange's testnet |
97
+ | `PAPER_BALANCES` | `USDT=1000` | Starting paper balances (used only until `paper_state.json` exists; the paper account then persists across restarts) |
98
+ | `IRL_GATEWAY_HOME` | `~/.irl-gateway` | Journal (`journal.jsonl`), kill switch (`KILL`) and paper account (`paper_state.json`) location |
99
+
100
+ The venue IRL sees is the exchange id (`binance`), or `paper-<exchange>` for paper trading, so a mandate can allow paper trading while denying the real account.
101
+
102
+ ## How the rationale is sealed
103
+
104
+ For each trade the gateway builds a context of the rationale, symbol, side, quantity, reference price, venue, model id and client order id. It hashes that context as canonical JSON (sorted keys, no whitespace) with SHA-256 and sends the hash to IRL as `prompt_version = "ctx-sha256:<hex>"`, which IRL seals into the trace's `reasoning_hash`.
105
+
106
+ The journal stores the full context next to its hash, so anyone holding a journal line can recompute the hash and match it to the sealed trace. IRL itself never sees the rationale's text.
107
+
108
+ ## Development
109
+
110
+ ```bash
111
+ python -m venv .venv && .venv/bin/pip install -e ".[dev]"
112
+ pytest --cov=irl_gateway
113
+ ruff check src tests && black --check src tests && isort --check-only src tests && mypy src
114
+ ```
115
+
116
+ ## Status
117
+
118
+ Early (0.1). Spot market orders only. Paper trading and ccxt exchanges are supported; Alpaca is next. Not investment advice, and no strategy is included: the gateway controls and records what your agent does, it does not decide.
119
+
120
+ MIT licensed.
@@ -0,0 +1,53 @@
1
+ [project]
2
+ name = "irl-gateway"
3
+ version = "0.2.0"
4
+ description = "MCP trading gateway for AI agents: IRL pre-trade policy, sealed rationale, and verifiable fills."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.12"
8
+ dependencies = [
9
+ "mcp>=2.3,<3",
10
+ "aiohttp>=3.9",
11
+ "ccxt>=4.3",
12
+ ]
13
+
14
+ [project.scripts]
15
+ irl-gateway = "irl_gateway.server:main"
16
+
17
+ [project.optional-dependencies]
18
+ dev = [
19
+ "pytest>=8.0",
20
+ "pytest-asyncio>=0.23",
21
+ "pytest-cov>=5.0",
22
+ "black>=24.0",
23
+ "isort>=5.13",
24
+ "ruff>=0.4",
25
+ "mypy>=1.9",
26
+ ]
27
+
28
+ [build-system]
29
+ requires = ["setuptools>=68"]
30
+ build-backend = "setuptools.build_meta"
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["src"]
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
37
+ asyncio_mode = "auto"
38
+
39
+ [tool.black]
40
+ target-version = ["py312"]
41
+ line-length = 100
42
+
43
+ [tool.isort]
44
+ profile = "black"
45
+ line_length = 100
46
+
47
+ [tool.ruff]
48
+ line-length = 100
49
+
50
+ [tool.mypy]
51
+ python_version = "3.12"
52
+ ignore_missing_imports = true
53
+ strict = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ """IRL gateway: an MCP server that puts IRL's pre-trade policy and sealed
2
+ audit trail between an AI agent and its exchange account."""
3
+
4
+ __version__ = "0.2.0"
@@ -0,0 +1,3 @@
1
+ from irl_gateway.server import main
2
+
3
+ main()
@@ -0,0 +1,226 @@
1
+ """Venues the gateway can execute on, behind one small async interface.
2
+
3
+ Symbols use ccxt's unified 'BASE/QUOTE' form (e.g. 'BTC/USDT'). Only spot
4
+ market orders are supported: the gateway's job is controlled, audited
5
+ execution, not order-type coverage.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import uuid
13
+ from abc import ABC, abstractmethod
14
+ from collections.abc import Awaitable, Callable
15
+ from dataclasses import dataclass
16
+ from enum import Enum
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import ccxt.async_support as ccxt_async
21
+
22
+
23
+ class Side(str, Enum):
24
+ BUY = "buy"
25
+ SELL = "sell"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Fill:
30
+ order_id: str
31
+ symbol: str
32
+ side: Side
33
+ quantity: float
34
+ price: float
35
+ fee: float
36
+ fee_asset: str | None
37
+
38
+
39
+ class Broker(ABC):
40
+ venue_id: str
41
+
42
+ @abstractmethod
43
+ async def get_price(self, symbol: str) -> float: ...
44
+
45
+ @abstractmethod
46
+ async def get_balances(self) -> dict[str, float]:
47
+ """Free balance per asset, non-zero only."""
48
+
49
+ @abstractmethod
50
+ async def place_market_order(
51
+ self, symbol: str, side: Side, quantity: float, *, client_order_id: str
52
+ ) -> Fill: ...
53
+
54
+ @abstractmethod
55
+ async def close(self) -> None: ...
56
+
57
+
58
+ def split_symbol(symbol: str) -> tuple[str, str]:
59
+ base, sep, quote = symbol.partition("/")
60
+ if not sep or not base or not quote:
61
+ raise ValueError(f"symbol must look like 'BASE/QUOTE', got {symbol!r}")
62
+ return base.upper(), quote.upper()
63
+
64
+
65
+ class CcxtBroker(Broker):
66
+ """A real exchange account (or its testnet) through ccxt."""
67
+
68
+ def __init__(self, exchange: Any):
69
+ self._exchange = exchange
70
+ self.venue_id = str(exchange.id)
71
+
72
+ @classmethod
73
+ def create(
74
+ cls, exchange_id: str, api_key: str, api_secret: str, *, testnet: bool
75
+ ) -> CcxtBroker:
76
+ exchange_class = getattr(ccxt_async, exchange_id)
77
+ exchange = exchange_class(
78
+ {"apiKey": api_key, "secret": api_secret, "enableRateLimit": True}
79
+ )
80
+ if testnet:
81
+ exchange.set_sandbox_mode(True)
82
+ return cls(exchange)
83
+
84
+ async def get_price(self, symbol: str) -> float:
85
+ ticker = await self._exchange.fetch_ticker(symbol)
86
+ return float(ticker["last"])
87
+
88
+ async def get_balances(self) -> dict[str, float]:
89
+ balance = await self._exchange.fetch_balance()
90
+ return {k: float(v) for k, v in balance.get("free", {}).items() if v}
91
+
92
+ async def place_market_order(
93
+ self, symbol: str, side: Side, quantity: float, *, client_order_id: str
94
+ ) -> Fill:
95
+ # clientOrderId is ccxt's unified param (newClientOrderId on Binance),
96
+ # which links the exchange order to the sealed IRL intent.
97
+ order = await self._exchange.create_order(
98
+ symbol, "market", side.value, quantity, None, {"clientOrderId": client_order_id}
99
+ )
100
+ fee = order.get("fee") or {}
101
+ return Fill(
102
+ order_id=str(order.get("id", "")),
103
+ symbol=symbol,
104
+ side=side,
105
+ quantity=float(order.get("filled") or quantity),
106
+ price=float(order.get("average") or order.get("price") or 0.0),
107
+ fee=float(fee.get("cost") or 0.0),
108
+ fee_asset=fee.get("currency"),
109
+ )
110
+
111
+ async def close(self) -> None:
112
+ await self._exchange.close()
113
+
114
+
115
+ PriceSource = Callable[[str], Awaitable[float]]
116
+
117
+
118
+ class PaperBroker(Broker):
119
+ """Simulated fills at live prices. No order ever leaves the process.
120
+
121
+ With ``state_path`` the balance sheet survives restarts: it is loaded from
122
+ that file when present (``balances`` then only seeds a new account) and
123
+ rewritten atomically after every fill. A state file that can't be read
124
+ is an error, never a silent reset to the starting balances.
125
+ """
126
+
127
+ def __init__(
128
+ self,
129
+ price_source: PriceSource,
130
+ balances: dict[str, float],
131
+ *,
132
+ venue_id: str = "paper",
133
+ fee_bps: float = 10.0,
134
+ slippage_bps: float = 5.0,
135
+ on_close: Callable[[], Awaitable[None]] | None = None,
136
+ state_path: str | os.PathLike[str] | None = None,
137
+ ):
138
+ self._price_source = price_source
139
+ self._state_path = Path(state_path) if state_path else None
140
+ if self._state_path is not None and self._state_path.exists():
141
+ self._balances = _load_balances(self._state_path)
142
+ else:
143
+ self._balances = {k.upper(): float(v) for k, v in balances.items()}
144
+ self.venue_id = venue_id
145
+ self._fee = fee_bps / 10_000
146
+ self._slip = slippage_bps / 10_000
147
+ self._on_close = on_close
148
+
149
+ async def get_price(self, symbol: str) -> float:
150
+ return await self._price_source(symbol)
151
+
152
+ async def get_balances(self) -> dict[str, float]:
153
+ return {k: v for k, v in self._balances.items() if v}
154
+
155
+ async def place_market_order(
156
+ self, symbol: str, side: Side, quantity: float, *, client_order_id: str
157
+ ) -> Fill:
158
+ if quantity <= 0:
159
+ raise ValueError("quantity must be positive")
160
+ base, quote = split_symbol(symbol)
161
+ mid = await self._price_source(symbol)
162
+ price = mid * (1 + self._slip) if side is Side.BUY else mid * (1 - self._slip)
163
+ gross = quantity * price
164
+ fee = gross * self._fee
165
+ if side is Side.BUY:
166
+ self._debit(quote, gross + fee)
167
+ self._credit(base, quantity)
168
+ else:
169
+ self._debit(base, quantity)
170
+ self._credit(quote, gross - fee)
171
+ self._save()
172
+ return Fill(
173
+ order_id=f"paper-{uuid.uuid4().hex[:16]}",
174
+ symbol=symbol,
175
+ side=side,
176
+ quantity=quantity,
177
+ price=price,
178
+ fee=fee,
179
+ fee_asset=quote,
180
+ )
181
+
182
+ def _debit(self, asset: str, amount: float) -> None:
183
+ available = self._balances.get(asset, 0.0)
184
+ if amount > available + 1e-12:
185
+ raise ValueError(f"insufficient {asset}: need {amount:.8f}, have {available:.8f}")
186
+ self._balances[asset] = available - amount
187
+
188
+ def _credit(self, asset: str, amount: float) -> None:
189
+ self._balances[asset] = self._balances.get(asset, 0.0) + amount
190
+
191
+ async def close(self) -> None:
192
+ if self._on_close is not None:
193
+ await self._on_close()
194
+
195
+ def _save(self) -> None:
196
+ if self._state_path is None:
197
+ return
198
+ self._state_path.parent.mkdir(parents=True, exist_ok=True)
199
+ tmp = self._state_path.with_name(self._state_path.name + ".tmp")
200
+ tmp.write_text(json.dumps({"balances": self._balances}, sort_keys=True), encoding="utf-8")
201
+ os.replace(tmp, self._state_path)
202
+
203
+
204
+ def _load_balances(path: Path) -> dict[str, float]:
205
+ try:
206
+ raw = json.loads(path.read_text(encoding="utf-8"))
207
+ balances = raw["balances"]
208
+ parsed = {str(k).upper(): float(v) for k, v in balances.items()}
209
+ except (OSError, ValueError, KeyError, TypeError, AttributeError) as exc:
210
+ raise ValueError(f"paper state {path} is unreadable; fix or remove it: {exc}") from exc
211
+ if any(v < 0 for v in parsed.values()):
212
+ raise ValueError(f"paper state {path} has negative balances")
213
+ return parsed
214
+
215
+
216
+ def public_price_source(
217
+ exchange_id: str = "binance",
218
+ ) -> tuple[PriceSource, Callable[[], Awaitable[None]]]:
219
+ """Live last-trade prices from an exchange's public API (no keys)."""
220
+ exchange = getattr(ccxt_async, exchange_id)({"enableRateLimit": True})
221
+
222
+ async def price(symbol: str) -> float:
223
+ ticker = await exchange.fetch_ticker(symbol)
224
+ return float(ticker["last"])
225
+
226
+ return price, exchange.close