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.
@@ -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
+ )