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.
- irl_gateway-0.2.0/LICENSE +21 -0
- irl_gateway-0.2.0/PKG-INFO +141 -0
- irl_gateway-0.2.0/README.md +120 -0
- irl_gateway-0.2.0/pyproject.toml +53 -0
- irl_gateway-0.2.0/setup.cfg +4 -0
- irl_gateway-0.2.0/src/irl_gateway/__init__.py +4 -0
- irl_gateway-0.2.0/src/irl_gateway/__main__.py +3 -0
- irl_gateway-0.2.0/src/irl_gateway/brokers.py +226 -0
- irl_gateway-0.2.0/src/irl_gateway/config.py +114 -0
- irl_gateway-0.2.0/src/irl_gateway/gateway.py +303 -0
- irl_gateway-0.2.0/src/irl_gateway/irl.py +200 -0
- irl_gateway-0.2.0/src/irl_gateway/journal.py +72 -0
- irl_gateway-0.2.0/src/irl_gateway/server.py +158 -0
- irl_gateway-0.2.0/src/irl_gateway.egg-info/PKG-INFO +141 -0
- irl_gateway-0.2.0/src/irl_gateway.egg-info/SOURCES.txt +21 -0
- irl_gateway-0.2.0/src/irl_gateway.egg-info/dependency_links.txt +1 -0
- irl_gateway-0.2.0/src/irl_gateway.egg-info/entry_points.txt +2 -0
- irl_gateway-0.2.0/src/irl_gateway.egg-info/requires.txt +12 -0
- irl_gateway-0.2.0/src/irl_gateway.egg-info/top_level.txt +1 -0
- irl_gateway-0.2.0/tests/test_components.py +209 -0
- irl_gateway-0.2.0/tests/test_gateway.py +201 -0
- irl_gateway-0.2.0/tests/test_irl_client.py +133 -0
- irl_gateway-0.2.0/tests/test_server.py +100 -0
|
@@ -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,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
|