backtest360-mcp 0.1.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.
- backtest360_mcp-0.1.0/.gitignore +23 -0
- backtest360_mcp-0.1.0/CHANGELOG.md +32 -0
- backtest360_mcp-0.1.0/LICENSE +21 -0
- backtest360_mcp-0.1.0/PKG-INFO +139 -0
- backtest360_mcp-0.1.0/README.md +109 -0
- backtest360_mcp-0.1.0/pyproject.toml +78 -0
- backtest360_mcp-0.1.0/src/backtest360_mcp/__init__.py +13 -0
- backtest360_mcp-0.1.0/src/backtest360_mcp/engine_client.py +255 -0
- backtest360_mcp-0.1.0/src/backtest360_mcp/errors.py +59 -0
- backtest360_mcp-0.1.0/src/backtest360_mcp/py.typed +0 -0
- backtest360_mcp-0.1.0/src/backtest360_mcp/server.py +54 -0
- backtest360_mcp-0.1.0/src/backtest360_mcp/settings.py +53 -0
- backtest360_mcp-0.1.0/src/backtest360_mcp/shaping.py +404 -0
- backtest360_mcp-0.1.0/src/backtest360_mcp/tools/__init__.py +42 -0
- backtest360_mcp-0.1.0/src/backtest360_mcp/tools/analysis.py +67 -0
- backtest360_mcp-0.1.0/src/backtest360_mcp/tools/backtest.py +190 -0
- backtest360_mcp-0.1.0/src/backtest360_mcp/tools/catalogs.py +178 -0
- backtest360_mcp-0.1.0/src/backtest360_mcp/tools/data.py +80 -0
- backtest360_mcp-0.1.0/src/backtest360_mcp/tools/strategy.py +45 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
.eggs/
|
|
8
|
+
|
|
9
|
+
# Environments
|
|
10
|
+
.venv/
|
|
11
|
+
venv/
|
|
12
|
+
|
|
13
|
+
# Tooling
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
.mypy_cache/
|
|
16
|
+
.ruff_cache/
|
|
17
|
+
.coverage
|
|
18
|
+
htmlcov/
|
|
19
|
+
|
|
20
|
+
# Editors
|
|
21
|
+
.idea/
|
|
22
|
+
.vscode/
|
|
23
|
+
*.swp
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to backtest360-mcp are documented here.
|
|
4
|
+
|
|
5
|
+
## 0.1.0
|
|
6
|
+
|
|
7
|
+
Initial release.
|
|
8
|
+
|
|
9
|
+
- MCP server over stdio exposing the Backtest360 engine API as 12 tools:
|
|
10
|
+
`engine_info`, `get_catalog`, `list_indicators`, `get_strategy_schema`,
|
|
11
|
+
`validate_strategy`, `run_backtest`, `get_latest_signal`, `compare_backtests`,
|
|
12
|
+
`compute_stats`, `search_tickers`, `list_tickers`, `get_data_range`.
|
|
13
|
+
- Reference catalogs additionally published as MCP resources
|
|
14
|
+
(`backtest360://catalog/{name}`, `backtest360://schema/strategy`).
|
|
15
|
+
- Response shaping for backtest results (`summary` / `stats` / `full`, add-on
|
|
16
|
+
`include` blocks, series downsampling, trade pagination) with an explicit
|
|
17
|
+
`truncated_by_mcp` marker on any size-capped result.
|
|
18
|
+
- Discovery calls stay within a client's context budget: `list_tickers` returns
|
|
19
|
+
a bounded, explicitly marked result instead of the full ticker universe;
|
|
20
|
+
`list_indicators` returns a single consolidated object (`{"indicators": [...],
|
|
21
|
+
"count": N}`); oversized catalogs and ticker-search results are size-capped and
|
|
22
|
+
marked `truncated_by_mcp`; and the catalog and strategy-schema resources return
|
|
23
|
+
a readable error instead of failing hard. `list_indicators` returns the same
|
|
24
|
+
result shape on every supported Python version.
|
|
25
|
+
- Agent-oriented error semantics: failed validations and request rejections are
|
|
26
|
+
returned as readable results; capacity and permission errors carry explicit
|
|
27
|
+
retry/recovery guidance and the engine request id.
|
|
28
|
+
- Environment-driven configuration (`BACKTEST360_API_KEY`,
|
|
29
|
+
`BACKTEST360_ENGINE_URL`, timeout, output cap).
|
|
30
|
+
- Example MCP client configuration (`examples/mcp.json`).
|
|
31
|
+
- Releases are published automatically from version tags via OIDC trusted
|
|
32
|
+
publishing — no stored tokens.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Backtest360
|
|
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,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: backtest360-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server exposing the Backtest360 engine API as tools for AI agents
|
|
5
|
+
Project-URL: Homepage, https://backtest360.com
|
|
6
|
+
Project-URL: Repository, https://github.com/Backtest360/backtest360-mcp
|
|
7
|
+
Project-URL: API Reference, https://api.backtest360.com/docs
|
|
8
|
+
Project-URL: Changelog, https://github.com/Backtest360/backtest360-mcp/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/Backtest360/backtest360-mcp/issues
|
|
10
|
+
Author-email: Backtest360 <hello@backtest360.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: ai-agents,api-client,backtesting,mcp,model-context-protocol,quantitative-finance,trading
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: httpx>=0.27
|
|
25
|
+
Requires-Dist: mcp>=1.27
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# backtest360-mcp
|
|
32
|
+
|
|
33
|
+
MCP server exposing the [Backtest360](https://backtest360.com) engine API as tools for AI agents.
|
|
34
|
+
|
|
35
|
+
Connect any MCP-capable AI client and drive real backtests conversationally: discover
|
|
36
|
+
indicators, build and validate strategies, run backtests, and read the results — all
|
|
37
|
+
against the deterministic Backtest360 engine. The server contains no AI and computes no
|
|
38
|
+
numbers of its own; it is a thin, faithful adapter over the engine HTTP API. Your engine
|
|
39
|
+
API key and its plan govern everything (permissions, rate limits, data access).
|
|
40
|
+
|
|
41
|
+
**Status: pre-release (v0.1.x).** Local stdio transport. Remote (HTTP) deployment is planned.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install backtest360-mcp # or, from a clone: pip install -e .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Requires Python 3.10+ and a Backtest360 API key — create one at
|
|
50
|
+
[backtest360.com](https://backtest360.com).
|
|
51
|
+
|
|
52
|
+
## Configuration
|
|
53
|
+
|
|
54
|
+
Everything is environment-driven:
|
|
55
|
+
|
|
56
|
+
| Variable | Required | Default | Purpose |
|
|
57
|
+
|---|---|---|---|
|
|
58
|
+
| `BACKTEST360_API_KEY` | yes | — | Engine API key, sent as `X-API-Key` |
|
|
59
|
+
| `BACKTEST360_ENGINE_URL` | no | `https://api.backtest360.com` | Engine base URL |
|
|
60
|
+
| `BACKTEST360_MCP_TIMEOUT` | no | `300` | Per-request timeout (seconds) |
|
|
61
|
+
| `BACKTEST360_MCP_MAX_OUTPUT_BYTES` | no | `100000` | Hard cap on a single tool result |
|
|
62
|
+
|
|
63
|
+
## Connect an MCP client
|
|
64
|
+
|
|
65
|
+
Add the server to your MCP client's configuration (the common `mcpServers` shape):
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"mcpServers": {
|
|
70
|
+
"backtest360": {
|
|
71
|
+
"command": "backtest360-mcp",
|
|
72
|
+
"env": {
|
|
73
|
+
"BACKTEST360_API_KEY": "b360_..."
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Prefer not to put the key in a config file? Point `command` at a small wrapper script
|
|
81
|
+
that exports the key from your secrets manager and then runs `backtest360-mcp`. A
|
|
82
|
+
minimal example config is in [`examples/mcp.json`](examples/mcp.json).
|
|
83
|
+
|
|
84
|
+
## Tools
|
|
85
|
+
|
|
86
|
+
| Tool | What it does |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `engine_info` | Engine version, API contract, health |
|
|
89
|
+
| `get_catalog` | Reference catalogs: operators, execution modes, stop types, sizing methods, bar frequencies, metric sections |
|
|
90
|
+
| `list_indicators` | Indicator discovery; per-indicator parameter schemas |
|
|
91
|
+
| `get_strategy_schema` | JSON Schema for strategy documents |
|
|
92
|
+
| `validate_strategy` | Validate a strategy without running it — returns structured, locatable errors |
|
|
93
|
+
| `run_backtest` | Run a historical backtest |
|
|
94
|
+
| `get_latest_signal` | Evaluate the most recent bar only (no P&L) |
|
|
95
|
+
| `compare_backtests` | Run several strategies on the same data, side by side |
|
|
96
|
+
| `compute_stats` | Compute the metric set from an externally produced returns series |
|
|
97
|
+
| `search_tickers` / `list_tickers` | Asset discovery for server-side data fetch |
|
|
98
|
+
| `get_data_range` | Available history and bar-count estimate for a symbol |
|
|
99
|
+
|
|
100
|
+
The cheap static catalogs are also published as MCP resources
|
|
101
|
+
(`backtest360://catalog/{name}`, `backtest360://schema/strategy`) for clients that
|
|
102
|
+
support resource attachment.
|
|
103
|
+
|
|
104
|
+
## Response shaping
|
|
105
|
+
|
|
106
|
+
A full backtest result is megabytes; an agent's context is not. `run_backtest` and
|
|
107
|
+
`compare_backtests` take `response_detail`:
|
|
108
|
+
|
|
109
|
+
- `summary` (default) — headline metrics, warnings, counts, equity endpoints
|
|
110
|
+
- `stats` — every metric the plan allows
|
|
111
|
+
- `full` — plus series (downsampled, endpoints preserved) and trades (paginated)
|
|
112
|
+
|
|
113
|
+
`include=["trades", "equity_curve", "monthly_returns", "yearly_returns"]` adds specific
|
|
114
|
+
blocks at the lighter levels. Results exceeding the output cap are reduced further and
|
|
115
|
+
explicitly marked `truncated_by_mcp` — never silently cut. Shaping only ever selects and
|
|
116
|
+
thins what the engine returned; no value is computed or altered.
|
|
117
|
+
|
|
118
|
+
## Error semantics
|
|
119
|
+
|
|
120
|
+
Designed for agents:
|
|
121
|
+
|
|
122
|
+
- **Fixable by changing the request** → returned as a normal result: failed validations
|
|
123
|
+
arrive as `{"valid": false, "errors": [...]}` with machine codes and document
|
|
124
|
+
locations; engine rejections arrive as `{"accepted": false, "error": ...}` with a hint.
|
|
125
|
+
- **Not fixable that way** → a tool error with explicit guidance: rate limits carry the
|
|
126
|
+
`Retry-After` value; engine-busy says retry with backoff; a compute timeout says
|
|
127
|
+
do **not** retry and reduce scope instead; permission problems name the missing
|
|
128
|
+
capability. Engine request ids are included for support.
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
pip install -e ".[dev]"
|
|
134
|
+
pytest # unit suite against a mock engine — no network
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# backtest360-mcp
|
|
2
|
+
|
|
3
|
+
MCP server exposing the [Backtest360](https://backtest360.com) engine API as tools for AI agents.
|
|
4
|
+
|
|
5
|
+
Connect any MCP-capable AI client and drive real backtests conversationally: discover
|
|
6
|
+
indicators, build and validate strategies, run backtests, and read the results — all
|
|
7
|
+
against the deterministic Backtest360 engine. The server contains no AI and computes no
|
|
8
|
+
numbers of its own; it is a thin, faithful adapter over the engine HTTP API. Your engine
|
|
9
|
+
API key and its plan govern everything (permissions, rate limits, data access).
|
|
10
|
+
|
|
11
|
+
**Status: pre-release (v0.1.x).** Local stdio transport. Remote (HTTP) deployment is planned.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install backtest360-mcp # or, from a clone: pip install -e .
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Requires Python 3.10+ and a Backtest360 API key — create one at
|
|
20
|
+
[backtest360.com](https://backtest360.com).
|
|
21
|
+
|
|
22
|
+
## Configuration
|
|
23
|
+
|
|
24
|
+
Everything is environment-driven:
|
|
25
|
+
|
|
26
|
+
| Variable | Required | Default | Purpose |
|
|
27
|
+
|---|---|---|---|
|
|
28
|
+
| `BACKTEST360_API_KEY` | yes | — | Engine API key, sent as `X-API-Key` |
|
|
29
|
+
| `BACKTEST360_ENGINE_URL` | no | `https://api.backtest360.com` | Engine base URL |
|
|
30
|
+
| `BACKTEST360_MCP_TIMEOUT` | no | `300` | Per-request timeout (seconds) |
|
|
31
|
+
| `BACKTEST360_MCP_MAX_OUTPUT_BYTES` | no | `100000` | Hard cap on a single tool result |
|
|
32
|
+
|
|
33
|
+
## Connect an MCP client
|
|
34
|
+
|
|
35
|
+
Add the server to your MCP client's configuration (the common `mcpServers` shape):
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"mcpServers": {
|
|
40
|
+
"backtest360": {
|
|
41
|
+
"command": "backtest360-mcp",
|
|
42
|
+
"env": {
|
|
43
|
+
"BACKTEST360_API_KEY": "b360_..."
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Prefer not to put the key in a config file? Point `command` at a small wrapper script
|
|
51
|
+
that exports the key from your secrets manager and then runs `backtest360-mcp`. A
|
|
52
|
+
minimal example config is in [`examples/mcp.json`](examples/mcp.json).
|
|
53
|
+
|
|
54
|
+
## Tools
|
|
55
|
+
|
|
56
|
+
| Tool | What it does |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `engine_info` | Engine version, API contract, health |
|
|
59
|
+
| `get_catalog` | Reference catalogs: operators, execution modes, stop types, sizing methods, bar frequencies, metric sections |
|
|
60
|
+
| `list_indicators` | Indicator discovery; per-indicator parameter schemas |
|
|
61
|
+
| `get_strategy_schema` | JSON Schema for strategy documents |
|
|
62
|
+
| `validate_strategy` | Validate a strategy without running it — returns structured, locatable errors |
|
|
63
|
+
| `run_backtest` | Run a historical backtest |
|
|
64
|
+
| `get_latest_signal` | Evaluate the most recent bar only (no P&L) |
|
|
65
|
+
| `compare_backtests` | Run several strategies on the same data, side by side |
|
|
66
|
+
| `compute_stats` | Compute the metric set from an externally produced returns series |
|
|
67
|
+
| `search_tickers` / `list_tickers` | Asset discovery for server-side data fetch |
|
|
68
|
+
| `get_data_range` | Available history and bar-count estimate for a symbol |
|
|
69
|
+
|
|
70
|
+
The cheap static catalogs are also published as MCP resources
|
|
71
|
+
(`backtest360://catalog/{name}`, `backtest360://schema/strategy`) for clients that
|
|
72
|
+
support resource attachment.
|
|
73
|
+
|
|
74
|
+
## Response shaping
|
|
75
|
+
|
|
76
|
+
A full backtest result is megabytes; an agent's context is not. `run_backtest` and
|
|
77
|
+
`compare_backtests` take `response_detail`:
|
|
78
|
+
|
|
79
|
+
- `summary` (default) — headline metrics, warnings, counts, equity endpoints
|
|
80
|
+
- `stats` — every metric the plan allows
|
|
81
|
+
- `full` — plus series (downsampled, endpoints preserved) and trades (paginated)
|
|
82
|
+
|
|
83
|
+
`include=["trades", "equity_curve", "monthly_returns", "yearly_returns"]` adds specific
|
|
84
|
+
blocks at the lighter levels. Results exceeding the output cap are reduced further and
|
|
85
|
+
explicitly marked `truncated_by_mcp` — never silently cut. Shaping only ever selects and
|
|
86
|
+
thins what the engine returned; no value is computed or altered.
|
|
87
|
+
|
|
88
|
+
## Error semantics
|
|
89
|
+
|
|
90
|
+
Designed for agents:
|
|
91
|
+
|
|
92
|
+
- **Fixable by changing the request** → returned as a normal result: failed validations
|
|
93
|
+
arrive as `{"valid": false, "errors": [...]}` with machine codes and document
|
|
94
|
+
locations; engine rejections arrive as `{"accepted": false, "error": ...}` with a hint.
|
|
95
|
+
- **Not fixable that way** → a tool error with explicit guidance: rate limits carry the
|
|
96
|
+
`Retry-After` value; engine-busy says retry with backoff; a compute timeout says
|
|
97
|
+
do **not** retry and reduce scope instead; permission problems name the missing
|
|
98
|
+
capability. Engine request ids are included for support.
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
pip install -e ".[dev]"
|
|
104
|
+
pytest # unit suite against a mock engine — no network
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "backtest360-mcp"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "MCP server exposing the Backtest360 engine API as tools for AI agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [
|
|
12
|
+
{name = "Backtest360", email = "hello@backtest360.com"},
|
|
13
|
+
]
|
|
14
|
+
requires-python = ">=3.10"
|
|
15
|
+
keywords = [
|
|
16
|
+
"backtesting",
|
|
17
|
+
"trading",
|
|
18
|
+
"quantitative-finance",
|
|
19
|
+
"mcp",
|
|
20
|
+
"model-context-protocol",
|
|
21
|
+
"ai-agents",
|
|
22
|
+
"api-client",
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 3 - Alpha",
|
|
26
|
+
"Intended Audience :: Financial and Insurance Industry",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"Programming Language :: Python :: 3.10",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
33
|
+
"Typing :: Typed",
|
|
34
|
+
]
|
|
35
|
+
dependencies = [
|
|
36
|
+
"mcp>=1.27",
|
|
37
|
+
"httpx>=0.27",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.optional-dependencies]
|
|
41
|
+
dev = [
|
|
42
|
+
"pytest>=8",
|
|
43
|
+
"pytest-asyncio>=0.23",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[project.scripts]
|
|
47
|
+
backtest360-mcp = "backtest360_mcp.server:main"
|
|
48
|
+
|
|
49
|
+
[project.urls]
|
|
50
|
+
Homepage = "https://backtest360.com"
|
|
51
|
+
Repository = "https://github.com/Backtest360/backtest360-mcp"
|
|
52
|
+
"API Reference" = "https://api.backtest360.com/docs"
|
|
53
|
+
Changelog = "https://github.com/Backtest360/backtest360-mcp/blob/main/CHANGELOG.md"
|
|
54
|
+
Issues = "https://github.com/Backtest360/backtest360-mcp/issues"
|
|
55
|
+
|
|
56
|
+
[tool.hatch.version]
|
|
57
|
+
source = "vcs"
|
|
58
|
+
|
|
59
|
+
[tool.pytest.ini_options]
|
|
60
|
+
testpaths = ["tests"]
|
|
61
|
+
asyncio_mode = "auto"
|
|
62
|
+
markers = [
|
|
63
|
+
"integration: tests that require network access or a live engine",
|
|
64
|
+
]
|
|
65
|
+
addopts = "-m 'not integration'"
|
|
66
|
+
|
|
67
|
+
[tool.hatch.build.targets.wheel]
|
|
68
|
+
packages = ["src/backtest360_mcp"]
|
|
69
|
+
artifacts = ["src/backtest360_mcp/py.typed"]
|
|
70
|
+
|
|
71
|
+
[tool.hatch.build.targets.sdist]
|
|
72
|
+
include = [
|
|
73
|
+
"src/",
|
|
74
|
+
"README.md",
|
|
75
|
+
"LICENSE",
|
|
76
|
+
"CHANGELOG.md",
|
|
77
|
+
"pyproject.toml",
|
|
78
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Backtest360 MCP server — engine API as tools for AI agents."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("backtest360-mcp")
|
|
7
|
+
except PackageNotFoundError:
|
|
8
|
+
__version__ = "0.0.0.dev"
|
|
9
|
+
|
|
10
|
+
from backtest360_mcp.engine_client import EngineClient, EngineError
|
|
11
|
+
from backtest360_mcp.settings import Settings
|
|
12
|
+
|
|
13
|
+
__all__ = ["EngineClient", "EngineError", "Settings", "__version__"]
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Thin HTTP client for the Backtest360 engine API.
|
|
2
|
+
|
|
3
|
+
The server is a protocol adapter: requests go to the engine as-is, responses
|
|
4
|
+
come back as-is (tool-level shaping happens in :mod:`backtest360_mcp.shaping`).
|
|
5
|
+
This client adds only transport concerns — auth header, contract header,
|
|
6
|
+
timeouts, and uniform error mapping.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
# API contract version this server targets, sent via X-Client-Contract.
|
|
17
|
+
# The engine rejects the call with HTTP 409 when it no longer supports this
|
|
18
|
+
# contract, signalling that backtest360-mcp needs updating.
|
|
19
|
+
_CLIENT_CONTRACT = "1"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _server_version() -> str:
|
|
23
|
+
from backtest360_mcp import __version__
|
|
24
|
+
|
|
25
|
+
return __version__
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EngineError(Exception):
|
|
29
|
+
"""Raised on any non-2xx response from the engine (or client-side failure).
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
status: HTTP status code (0 for client-side errors raised before any
|
|
33
|
+
request was sent).
|
|
34
|
+
code: Machine-readable engine error code when the response carried one
|
|
35
|
+
(e.g. ``QUOTA_EXCEEDED``, ``COMPUTE_TIMEOUT``).
|
|
36
|
+
body: Parsed response body (dict) or raw text when JSON parsing failed.
|
|
37
|
+
request_id: ``X-Request-ID`` response header, when present. Joins the
|
|
38
|
+
failure to the engine's logs.
|
|
39
|
+
retry_after: Seconds to wait before retrying, from the ``Retry-After``
|
|
40
|
+
header on capacity responses (429/503). ``None`` when absent.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
message: str,
|
|
46
|
+
*,
|
|
47
|
+
status: int,
|
|
48
|
+
code: str | None = None,
|
|
49
|
+
body: dict[str, Any] | str | None = None,
|
|
50
|
+
request_id: str | None = None,
|
|
51
|
+
retry_after: float | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
super().__init__(message)
|
|
54
|
+
self.status = status
|
|
55
|
+
self.code = code
|
|
56
|
+
self.body = body
|
|
57
|
+
self.request_id = request_id
|
|
58
|
+
self.retry_after = retry_after
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class EngineClient:
|
|
62
|
+
"""Synchronous client for the engine endpoints the MCP server exposes.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
engine_url: Engine base URL (no trailing slash).
|
|
66
|
+
api_key: Key sent as ``X-API-Key`` on every request.
|
|
67
|
+
timeout: Per-request timeout in seconds.
|
|
68
|
+
transport: Optional httpx transport override (tests inject a
|
|
69
|
+
``httpx.MockTransport`` here).
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
engine_url: str,
|
|
75
|
+
api_key: str,
|
|
76
|
+
timeout: float = 300.0,
|
|
77
|
+
transport: httpx.BaseTransport | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
self._base_url = engine_url.rstrip("/")
|
|
80
|
+
self._api_key = api_key
|
|
81
|
+
self._timeout = timeout
|
|
82
|
+
self._transport = transport
|
|
83
|
+
|
|
84
|
+
# -----------------------------------------------------------------------
|
|
85
|
+
# Transport
|
|
86
|
+
# -----------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def _headers(self) -> dict[str, str]:
|
|
89
|
+
return {
|
|
90
|
+
"X-API-Key": self._api_key,
|
|
91
|
+
"X-Client-Version": f"backtest360-mcp/{_server_version()}",
|
|
92
|
+
"X-Client-Contract": _CLIENT_CONTRACT,
|
|
93
|
+
"Content-Type": "application/json",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
def request(
|
|
97
|
+
self,
|
|
98
|
+
method: str,
|
|
99
|
+
path: str,
|
|
100
|
+
*,
|
|
101
|
+
body: dict[str, Any] | None = None,
|
|
102
|
+
params: dict[str, Any] | None = None,
|
|
103
|
+
) -> Any:
|
|
104
|
+
"""Send a request and return the parsed JSON response.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
EngineError: On any non-2xx response, a non-JSON success body,
|
|
108
|
+
or a payload that cannot be serialized.
|
|
109
|
+
"""
|
|
110
|
+
url = f"{self._base_url}{path}"
|
|
111
|
+
try:
|
|
112
|
+
content = json.dumps(body, allow_nan=False) if body is not None else None
|
|
113
|
+
except (ValueError, TypeError) as exc:
|
|
114
|
+
raise EngineError(
|
|
115
|
+
f"Payload is not JSON-serializable: {exc}. "
|
|
116
|
+
"Check for NaN, Inf, or non-serializable values.",
|
|
117
|
+
status=0,
|
|
118
|
+
code="MCP_INVALID_PAYLOAD",
|
|
119
|
+
) from exc
|
|
120
|
+
|
|
121
|
+
with httpx.Client(timeout=self._timeout, transport=self._transport) as http:
|
|
122
|
+
response = http.request(
|
|
123
|
+
method, url, headers=self._headers(), params=params, content=content
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if response.status_code >= 400:
|
|
127
|
+
raise self._to_error(response)
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
return response.json()
|
|
131
|
+
except Exception:
|
|
132
|
+
raise EngineError(
|
|
133
|
+
f"Engine returned a non-JSON response (HTTP {response.status_code}).",
|
|
134
|
+
status=response.status_code,
|
|
135
|
+
code="MCP_MALFORMED_RESPONSE",
|
|
136
|
+
body=response.text or None,
|
|
137
|
+
) from None
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def _to_error(response: httpx.Response) -> EngineError:
|
|
141
|
+
"""Map an error response to an EngineError, preserving the engine's
|
|
142
|
+
structured detail (code, message) and operational headers."""
|
|
143
|
+
try:
|
|
144
|
+
resp_body: dict[str, Any] | str | None = response.json()
|
|
145
|
+
except Exception:
|
|
146
|
+
resp_body = response.text or None
|
|
147
|
+
|
|
148
|
+
request_id = response.headers.get("x-request-id")
|
|
149
|
+
retry_after_header = response.headers.get("retry-after")
|
|
150
|
+
try:
|
|
151
|
+
retry_after = float(retry_after_header) if retry_after_header else None
|
|
152
|
+
except ValueError:
|
|
153
|
+
retry_after = None
|
|
154
|
+
|
|
155
|
+
code: str | None = None
|
|
156
|
+
message = ""
|
|
157
|
+
if isinstance(resp_body, dict):
|
|
158
|
+
detail = resp_body.get("detail")
|
|
159
|
+
if isinstance(detail, str):
|
|
160
|
+
message = detail
|
|
161
|
+
elif isinstance(detail, dict):
|
|
162
|
+
message = detail.get("message", "") or response.text
|
|
163
|
+
code = detail.get("code")
|
|
164
|
+
elif isinstance(detail, list):
|
|
165
|
+
# 422 request-validation errors arrive as a list of per-field dicts.
|
|
166
|
+
parts = []
|
|
167
|
+
for item in detail:
|
|
168
|
+
if isinstance(item, dict):
|
|
169
|
+
loc = " -> ".join(str(p) for p in item.get("loc", []))
|
|
170
|
+
msg = item.get("msg", "")
|
|
171
|
+
parts.append(f"{loc}: {msg}" if loc else msg)
|
|
172
|
+
else:
|
|
173
|
+
parts.append(str(item))
|
|
174
|
+
message = "; ".join(filter(None, parts)) or response.text
|
|
175
|
+
else:
|
|
176
|
+
message = (
|
|
177
|
+
resp_body.get("error") or resp_body.get("message") or response.text
|
|
178
|
+
)
|
|
179
|
+
else:
|
|
180
|
+
message = str(resp_body) if resp_body else ""
|
|
181
|
+
|
|
182
|
+
return EngineError(
|
|
183
|
+
message or f"HTTP {response.status_code}",
|
|
184
|
+
status=response.status_code,
|
|
185
|
+
code=code,
|
|
186
|
+
body=resp_body,
|
|
187
|
+
request_id=request_id,
|
|
188
|
+
retry_after=retry_after,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# -----------------------------------------------------------------------
|
|
192
|
+
# Endpoints
|
|
193
|
+
# -----------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def version(self) -> dict[str, Any]:
|
|
196
|
+
return self.request("GET", "/api/version")
|
|
197
|
+
|
|
198
|
+
def health(self) -> dict[str, Any]:
|
|
199
|
+
return self.request("GET", "/api/health")
|
|
200
|
+
|
|
201
|
+
def indicators(self) -> Any:
|
|
202
|
+
return self.request("GET", "/api/indicators")
|
|
203
|
+
|
|
204
|
+
def strategy_schema(self) -> dict[str, Any]:
|
|
205
|
+
return self.request("GET", "/api/schemas/strategy")
|
|
206
|
+
|
|
207
|
+
def catalog(self, path: str) -> Any:
|
|
208
|
+
"""Fetch one reference catalog by its API path (e.g. '/api/operators')."""
|
|
209
|
+
return self.request("GET", path)
|
|
210
|
+
|
|
211
|
+
def validate_strategy(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
212
|
+
"""POST /api/validate-strategy.
|
|
213
|
+
|
|
214
|
+
A failed validation arrives as HTTP 422 whose body *is* the validation
|
|
215
|
+
result (``{"valid": false, "errors": [...]}``). That outcome is
|
|
216
|
+
returned like a success — it is the agent's fix-and-retry input, not
|
|
217
|
+
a transport failure.
|
|
218
|
+
"""
|
|
219
|
+
try:
|
|
220
|
+
return self.request("POST", "/api/validate-strategy", body=body)
|
|
221
|
+
except EngineError as exc:
|
|
222
|
+
if (
|
|
223
|
+
exc.status == 422
|
|
224
|
+
and isinstance(exc.body, dict)
|
|
225
|
+
and "valid" in exc.body
|
|
226
|
+
):
|
|
227
|
+
return exc.body
|
|
228
|
+
raise
|
|
229
|
+
|
|
230
|
+
def backtest(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
231
|
+
return self.request("POST", "/api/backtest", body=body)
|
|
232
|
+
|
|
233
|
+
def latest_signal(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
234
|
+
return self.request("POST", "/api/latest-signal", body=body)
|
|
235
|
+
|
|
236
|
+
def compare(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
237
|
+
return self.request("POST", "/api/backtest/compare", body=body)
|
|
238
|
+
|
|
239
|
+
def stats(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
240
|
+
return self.request("POST", "/api/stats", body=body)
|
|
241
|
+
|
|
242
|
+
def ticker_search(self, query: str, asset_class: str | None, limit: int) -> Any:
|
|
243
|
+
params: dict[str, Any] = {"q": query, "limit": limit}
|
|
244
|
+
if asset_class:
|
|
245
|
+
params["asset_class"] = asset_class
|
|
246
|
+
return self.request("GET", "/api/data/search", params=params)
|
|
247
|
+
|
|
248
|
+
def tickers(self, asset_class: str | None) -> Any:
|
|
249
|
+
params = {"asset_class": asset_class} if asset_class else None
|
|
250
|
+
return self.request("GET", "/api/data/tickers", params=params)
|
|
251
|
+
|
|
252
|
+
def data_range(self, symbol: str, frequency: str) -> Any:
|
|
253
|
+
return self.request(
|
|
254
|
+
"GET", "/api/data/range", params={"symbol": symbol, "frequency": frequency}
|
|
255
|
+
)
|