stratify-mcp 0.1.1__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.
- stratify_mcp-0.1.1/PKG-INFO +131 -0
- stratify_mcp-0.1.1/README.md +115 -0
- stratify_mcp-0.1.1/pyproject.toml +29 -0
- stratify_mcp-0.1.1/setup.cfg +4 -0
- stratify_mcp-0.1.1/stratify_mcp/__init__.py +12 -0
- stratify_mcp-0.1.1/stratify_mcp/client.py +182 -0
- stratify_mcp-0.1.1/stratify_mcp/exceptions.py +52 -0
- stratify_mcp-0.1.1/stratify_mcp/models.py +110 -0
- stratify_mcp-0.1.1/stratify_mcp.egg-info/PKG-INFO +131 -0
- stratify_mcp-0.1.1/stratify_mcp.egg-info/SOURCES.txt +12 -0
- stratify_mcp-0.1.1/stratify_mcp.egg-info/dependency_links.txt +1 -0
- stratify_mcp-0.1.1/stratify_mcp.egg-info/requires.txt +4 -0
- stratify_mcp-0.1.1/stratify_mcp.egg-info/top_level.txt +1 -0
- stratify_mcp-0.1.1/tests/test_client.py +201 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stratify-mcp
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Python client for the Stratify MCP options-backtesting API
|
|
5
|
+
Author: Stratify
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://stratify.aeon-labs.site
|
|
8
|
+
Project-URL: Documentation, https://stratify.aeon-labs.site/docs
|
|
9
|
+
Project-URL: Source, https://github.com/Srinath-exe/stratify-mcp
|
|
10
|
+
Project-URL: Issues, https://github.com/Srinath-exe/stratify-mcp/issues
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: pandas>=1.5
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
16
|
+
|
|
17
|
+
# stratify-mcp
|
|
18
|
+
|
|
19
|
+
Python client for [Stratify](https://stratify.aeon-labs.site) — real 1-minute NIFTY options data,
|
|
20
|
+
honest backtests (out-of-sample, walk-forward, deflated Sharpe, all reported alongside the
|
|
21
|
+
number, not instead of it).
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install stratify-mcp
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quickstart
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from stratify_mcp import StratifyClient
|
|
31
|
+
|
|
32
|
+
# One-time: create an account and get a key. The key is shown once -- save it.
|
|
33
|
+
signup = StratifyClient.signup("you@example.com")
|
|
34
|
+
client = StratifyClient(api_key=signup["api_key"])
|
|
35
|
+
|
|
36
|
+
result = client.run_backtest({
|
|
37
|
+
"legs": [
|
|
38
|
+
{"side": "sell", "type": "CE", "strike": {"delta_near": 0.2}},
|
|
39
|
+
{"side": "sell", "type": "PE", "strike": {"delta_near": 0.2}},
|
|
40
|
+
],
|
|
41
|
+
# The reason for the trade, not just the trade.
|
|
42
|
+
"entry": {"cadence": "weekly", "dte": 3, "time": "09:30",
|
|
43
|
+
"when": {"vix": {"gte": 15}}},
|
|
44
|
+
# Managed while it is open: take profit, and roll the tested side if it doubles.
|
|
45
|
+
"rules": [
|
|
46
|
+
{"when": {"pnl_pct_of_credit": {"gte": 0.6}}, "then": "close"},
|
|
47
|
+
{"when": {"leg_mark_mult": {"gte": 2.0, "leg": 0}},
|
|
48
|
+
"then": {"roll": {"legs": [0], "to": {"delta_near": 0.2}}}, "max_times": 2},
|
|
49
|
+
],
|
|
50
|
+
"portfolio": {"stop_after_losses": 3, "resume_after_days": 30},
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
print(result.summary["total_pnl_rupees"], result.summary["max_drawdown_rupees"])
|
|
54
|
+
print(result.summary["ratios"]) # sharpe, profit_factor, calmar (only above 30 trades)
|
|
55
|
+
print(result.honesty) # out-of-sample split, walk-forward folds, deflated Sharpe
|
|
56
|
+
result.trades # pandas.DataFrame, one row per trade
|
|
57
|
+
result.equity_curve # pandas.DataFrame
|
|
58
|
+
print(result.report_url) # shareable page with the full chart and every trade
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Already have a key? Skip `signup()`:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
client = StratifyClient(api_key="sk_live_...")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Why a Python client at all, when it's just JSON-RPC
|
|
68
|
+
|
|
69
|
+
There's no separate REST endpoint for `run_backtest` — every tool is reached through one
|
|
70
|
+
`POST /mcp` speaking MCP JSON-RPC 2.0. This package exists so you don't hand-roll that
|
|
71
|
+
envelope: `client.run_backtest(...)` is a real function call, errors come back as Python
|
|
72
|
+
exceptions you can `except`, and results come back as `pandas.DataFrame`s instead of raw
|
|
73
|
+
JSON, because that's what you're actually going to do with a table of trades.
|
|
74
|
+
|
|
75
|
+
## Errors
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from stratify_mcp import AuthenticationError, QuotaExceededError, ToolRefusalError
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
result = client.run_backtest(spec)
|
|
82
|
+
except AuthenticationError:
|
|
83
|
+
... # bad or revoked key
|
|
84
|
+
except QuotaExceededError as e:
|
|
85
|
+
... # e.limit, e.retry_after_seconds
|
|
86
|
+
except ToolRefusalError as e:
|
|
87
|
+
... # the server read your spec and refused it -- str(e) says why
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Methods
|
|
91
|
+
|
|
92
|
+
| Method | Returns |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `run_backtest(spec, lots=1, detail="standard")` | `BacktestResult` |
|
|
95
|
+
| `get_backtest(backtest_id, detail=None)` | `BacktestResult` |
|
|
96
|
+
| `describe_coverage()` | `dict` — symbols, date range, structures, gates, biases, cost model |
|
|
97
|
+
| `explain_methodology(topic=None)` | `dict` |
|
|
98
|
+
| `list_strategies(order="consistency", limit=None)` | `dict` — `{"strategies": [...], "bar": {...}, ...}`, this account's strategies that held up out-of-sample |
|
|
99
|
+
| `search(query)` / `fetch(id)` | `dict` |
|
|
100
|
+
| `StratifyClient.signup(email)` (staticmethod) | `dict` — includes `api_key`, shown once |
|
|
101
|
+
|
|
102
|
+
`detail` on `run_backtest`/`get_backtest`: `"summary"` (aggregates only, cheapest),
|
|
103
|
+
`"standard"` (default — equity curve, breakdowns, first 25 trades), `"full"` (every
|
|
104
|
+
stored trade).
|
|
105
|
+
|
|
106
|
+
## `BacktestResult`
|
|
107
|
+
|
|
108
|
+
| Property | Type |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `.summary` | `dict` |
|
|
111
|
+
| `.honesty` | `dict` |
|
|
112
|
+
| `.interpretation` | `str` |
|
|
113
|
+
| `.trades` | `pandas.DataFrame` |
|
|
114
|
+
| `.equity_curve` | `pandas.DataFrame` |
|
|
115
|
+
| `.qualified` / `.why_not_qualified` | `bool` / `str \| None` |
|
|
116
|
+
| `.backtest_id` / `.report_url` | `str` |
|
|
117
|
+
| `.warnings` | `list[str]` |
|
|
118
|
+
| `.to_dict()` | the complete raw payload |
|
|
119
|
+
|
|
120
|
+
## Development
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
pip install -e ".[dev]"
|
|
124
|
+
pytest
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Tests run against a mocked transport and need no live server or API key.
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# stratify-mcp
|
|
2
|
+
|
|
3
|
+
Python client for [Stratify](https://stratify.aeon-labs.site) — real 1-minute NIFTY options data,
|
|
4
|
+
honest backtests (out-of-sample, walk-forward, deflated Sharpe, all reported alongside the
|
|
5
|
+
number, not instead of it).
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install stratify-mcp
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from stratify_mcp import StratifyClient
|
|
15
|
+
|
|
16
|
+
# One-time: create an account and get a key. The key is shown once -- save it.
|
|
17
|
+
signup = StratifyClient.signup("you@example.com")
|
|
18
|
+
client = StratifyClient(api_key=signup["api_key"])
|
|
19
|
+
|
|
20
|
+
result = client.run_backtest({
|
|
21
|
+
"legs": [
|
|
22
|
+
{"side": "sell", "type": "CE", "strike": {"delta_near": 0.2}},
|
|
23
|
+
{"side": "sell", "type": "PE", "strike": {"delta_near": 0.2}},
|
|
24
|
+
],
|
|
25
|
+
# The reason for the trade, not just the trade.
|
|
26
|
+
"entry": {"cadence": "weekly", "dte": 3, "time": "09:30",
|
|
27
|
+
"when": {"vix": {"gte": 15}}},
|
|
28
|
+
# Managed while it is open: take profit, and roll the tested side if it doubles.
|
|
29
|
+
"rules": [
|
|
30
|
+
{"when": {"pnl_pct_of_credit": {"gte": 0.6}}, "then": "close"},
|
|
31
|
+
{"when": {"leg_mark_mult": {"gte": 2.0, "leg": 0}},
|
|
32
|
+
"then": {"roll": {"legs": [0], "to": {"delta_near": 0.2}}}, "max_times": 2},
|
|
33
|
+
],
|
|
34
|
+
"portfolio": {"stop_after_losses": 3, "resume_after_days": 30},
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
print(result.summary["total_pnl_rupees"], result.summary["max_drawdown_rupees"])
|
|
38
|
+
print(result.summary["ratios"]) # sharpe, profit_factor, calmar (only above 30 trades)
|
|
39
|
+
print(result.honesty) # out-of-sample split, walk-forward folds, deflated Sharpe
|
|
40
|
+
result.trades # pandas.DataFrame, one row per trade
|
|
41
|
+
result.equity_curve # pandas.DataFrame
|
|
42
|
+
print(result.report_url) # shareable page with the full chart and every trade
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Already have a key? Skip `signup()`:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
client = StratifyClient(api_key="sk_live_...")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Why a Python client at all, when it's just JSON-RPC
|
|
52
|
+
|
|
53
|
+
There's no separate REST endpoint for `run_backtest` — every tool is reached through one
|
|
54
|
+
`POST /mcp` speaking MCP JSON-RPC 2.0. This package exists so you don't hand-roll that
|
|
55
|
+
envelope: `client.run_backtest(...)` is a real function call, errors come back as Python
|
|
56
|
+
exceptions you can `except`, and results come back as `pandas.DataFrame`s instead of raw
|
|
57
|
+
JSON, because that's what you're actually going to do with a table of trades.
|
|
58
|
+
|
|
59
|
+
## Errors
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from stratify_mcp import AuthenticationError, QuotaExceededError, ToolRefusalError
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
result = client.run_backtest(spec)
|
|
66
|
+
except AuthenticationError:
|
|
67
|
+
... # bad or revoked key
|
|
68
|
+
except QuotaExceededError as e:
|
|
69
|
+
... # e.limit, e.retry_after_seconds
|
|
70
|
+
except ToolRefusalError as e:
|
|
71
|
+
... # the server read your spec and refused it -- str(e) says why
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Methods
|
|
75
|
+
|
|
76
|
+
| Method | Returns |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `run_backtest(spec, lots=1, detail="standard")` | `BacktestResult` |
|
|
79
|
+
| `get_backtest(backtest_id, detail=None)` | `BacktestResult` |
|
|
80
|
+
| `describe_coverage()` | `dict` — symbols, date range, structures, gates, biases, cost model |
|
|
81
|
+
| `explain_methodology(topic=None)` | `dict` |
|
|
82
|
+
| `list_strategies(order="consistency", limit=None)` | `dict` — `{"strategies": [...], "bar": {...}, ...}`, this account's strategies that held up out-of-sample |
|
|
83
|
+
| `search(query)` / `fetch(id)` | `dict` |
|
|
84
|
+
| `StratifyClient.signup(email)` (staticmethod) | `dict` — includes `api_key`, shown once |
|
|
85
|
+
|
|
86
|
+
`detail` on `run_backtest`/`get_backtest`: `"summary"` (aggregates only, cheapest),
|
|
87
|
+
`"standard"` (default — equity curve, breakdowns, first 25 trades), `"full"` (every
|
|
88
|
+
stored trade).
|
|
89
|
+
|
|
90
|
+
## `BacktestResult`
|
|
91
|
+
|
|
92
|
+
| Property | Type |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `.summary` | `dict` |
|
|
95
|
+
| `.honesty` | `dict` |
|
|
96
|
+
| `.interpretation` | `str` |
|
|
97
|
+
| `.trades` | `pandas.DataFrame` |
|
|
98
|
+
| `.equity_curve` | `pandas.DataFrame` |
|
|
99
|
+
| `.qualified` / `.why_not_qualified` | `bool` / `str \| None` |
|
|
100
|
+
| `.backtest_id` / `.report_url` | `str` |
|
|
101
|
+
| `.warnings` | `list[str]` |
|
|
102
|
+
| `.to_dict()` | the complete raw payload |
|
|
103
|
+
|
|
104
|
+
## Development
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
pip install -e ".[dev]"
|
|
108
|
+
pytest
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Tests run against a mocked transport and need no live server or API key.
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "stratify-mcp"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Python client for the Stratify MCP options-backtesting API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Stratify" }]
|
|
13
|
+
# pandas is a hard dependency, not optional: DataFrames are the whole point of this
|
|
14
|
+
# package existing alongside the MCP server itself (PRODUCT_REQUIREMENTS.md distribution
|
|
15
|
+
# surfaces table -- "Quants live here; returns pandas DataFrames"). Everything else is the
|
|
16
|
+
# standard library on purpose, so installing this pulls in exactly one real dependency.
|
|
17
|
+
dependencies = ["pandas>=1.5"]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = ["pytest>=7"]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://stratify.aeon-labs.site"
|
|
24
|
+
Documentation = "https://stratify.aeon-labs.site/docs"
|
|
25
|
+
Source = "https://github.com/Srinath-exe/stratify-mcp"
|
|
26
|
+
Issues = "https://github.com/Srinath-exe/stratify-mcp/issues"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
include = ["stratify_mcp*"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .client import StratifyClient
|
|
2
|
+
from .exceptions import (AuthenticationError, ProtocolError, QuotaExceededError,
|
|
3
|
+
StratifyError, ToolRefusalError, TransportError)
|
|
4
|
+
from .models import BacktestResult
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.1"
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"StratifyClient", "BacktestResult",
|
|
10
|
+
"StratifyError", "AuthenticationError", "QuotaExceededError",
|
|
11
|
+
"ToolRefusalError", "ProtocolError", "TransportError",
|
|
12
|
+
]
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""JSON-RPC client for the Stratify MCP server.
|
|
2
|
+
|
|
3
|
+
There is no separate REST endpoint for running a backtest (server/app.py:9-16) -- every
|
|
4
|
+
tool, including run_backtest, is only reachable through POST /mcp as MCP JSON-RPC 2.0
|
|
5
|
+
tools/call. This client speaks that protocol directly rather than waiting on a future
|
|
6
|
+
OpenAPI layer, using nothing beyond the standard library for the HTTP leg so installing
|
|
7
|
+
this package pulls in exactly one real dependency: pandas, for the DataFrames quants
|
|
8
|
+
actually asked for (PRODUCT_REQUIREMENTS.md, distribution surfaces table).
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
from urllib.parse import urljoin
|
|
14
|
+
|
|
15
|
+
from .exceptions import (AuthenticationError, ProtocolError, QuotaExceededError,
|
|
16
|
+
ToolRefusalError, TransportError)
|
|
17
|
+
from .models import BacktestResult
|
|
18
|
+
|
|
19
|
+
DEFAULT_BASE_URL = "https://stratify-mcp.aeon-labs.site"
|
|
20
|
+
DEFAULT_TIMEOUT = 120 # seconds -- server/app.py's own MCP endpoint allows a 7-year
|
|
21
|
+
# backtest up to 120s at p95 under load (sites-available/stratify-mcp)
|
|
22
|
+
|
|
23
|
+
_UNAUTHENTICATED = -32001
|
|
24
|
+
_QUOTA_EXCEEDED = -32002
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class StratifyClient:
|
|
28
|
+
"""One client per API key. Not thread-safe across concurrent calls sharing an
|
|
29
|
+
account's quota is fine (the server enforces that), but this object holds no
|
|
30
|
+
connection state, so making one per thread if you fan out is cheap and correct.
|
|
31
|
+
|
|
32
|
+
client = StratifyClient(api_key="sk_live_...")
|
|
33
|
+
result = client.run_backtest({
|
|
34
|
+
"structure": "short_strangle", "symbol": "NIFTY",
|
|
35
|
+
"params": {"pct_offset": 1.5, "sl_mult": 2.0},
|
|
36
|
+
"entry_time": "09:30",
|
|
37
|
+
})
|
|
38
|
+
result.trades # pandas.DataFrame
|
|
39
|
+
result.equity_curve # pandas.DataFrame
|
|
40
|
+
result.summary["cagr"]
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, api_key, base_url=DEFAULT_BASE_URL, timeout=DEFAULT_TIMEOUT):
|
|
44
|
+
if not api_key:
|
|
45
|
+
raise ValueError("api_key is required -- get one from StratifyClient.signup()")
|
|
46
|
+
self.api_key = api_key
|
|
47
|
+
self.base_url = base_url.rstrip("/")
|
|
48
|
+
self.timeout = timeout
|
|
49
|
+
self._request_id = 0
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------- public API
|
|
52
|
+
|
|
53
|
+
def run_backtest(self, spec, lots=1, detail="standard"):
|
|
54
|
+
"""spec: a dict matching the public StrategySpec shape (see describe_coverage() or
|
|
55
|
+
the server's knowledge base for the exact fields -- structure, params, entry_time,
|
|
56
|
+
etc.). detail: 'summary' (aggregates only), 'standard' (default -- equity curve,
|
|
57
|
+
breakdowns, first 25 trades), or 'full' (every trade up to the stored cap).
|
|
58
|
+
Returns a BacktestResult."""
|
|
59
|
+
payload = self._call("run_backtest",
|
|
60
|
+
{"spec": spec, "lots": lots, "detail": detail})
|
|
61
|
+
return BacktestResult(payload)
|
|
62
|
+
|
|
63
|
+
def get_backtest(self, backtest_id, detail=None):
|
|
64
|
+
"""Retrieve a previously run backtest by id, exactly as first computed."""
|
|
65
|
+
args = {"backtest_id": backtest_id}
|
|
66
|
+
if detail is not None:
|
|
67
|
+
args["detail"] = detail
|
|
68
|
+
return BacktestResult(self._call("get_backtest", args))
|
|
69
|
+
|
|
70
|
+
def describe_coverage(self):
|
|
71
|
+
"""Symbols, date range, resolution, structures, gates, biases, cost model, known
|
|
72
|
+
gaps. Call this before building a spec -- it is unauthenticated on the server side
|
|
73
|
+
but routed the same way here for one consistent client surface."""
|
|
74
|
+
return self._call("describe_coverage", {})
|
|
75
|
+
|
|
76
|
+
def explain_methodology(self, topic=None):
|
|
77
|
+
"""How a result is produced and how to judge it. `topic` narrows to one section;
|
|
78
|
+
omit it for the full document."""
|
|
79
|
+
args = {"topic": topic} if topic else {}
|
|
80
|
+
return self._call("explain_methodology", args)
|
|
81
|
+
|
|
82
|
+
def list_strategies(self, order="consistency", limit=None):
|
|
83
|
+
"""This account's strategies that held up out-of-sample, ranked by worst
|
|
84
|
+
walk-forward fold by default -- not by P&L. Returns the full response dict, not a
|
|
85
|
+
bare list: {'strategies': [...], 'bar': {what qualifies, why not just P&L, ...},
|
|
86
|
+
'n_entries': int, ...} -- the qualification bar travels with the list because a
|
|
87
|
+
caller reading the empty-list case needs to know what it would take, not just that
|
|
88
|
+
nothing qualified yet. Verified against a real (empty) account rather than
|
|
89
|
+
assumed. Use client.list_strategies()['strategies'] for just the entries, or
|
|
90
|
+
pandas.DataFrame(client.list_strategies()['strategies']) for a table."""
|
|
91
|
+
args = {"order": order}
|
|
92
|
+
if limit is not None:
|
|
93
|
+
args["limit"] = limit
|
|
94
|
+
return self._call("list_strategies", args)
|
|
95
|
+
|
|
96
|
+
def search(self, query):
|
|
97
|
+
"""Search what this service covers. Returns ids usable with fetch()."""
|
|
98
|
+
return self._call("search", {"query": query})
|
|
99
|
+
|
|
100
|
+
def fetch(self, id): # noqa: A002 -- matches the tool's own parameter name
|
|
101
|
+
"""Fetch a document or backtest result by id, as returned by search()."""
|
|
102
|
+
return self._call("fetch", {"id": id})
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def signup(email, base_url=DEFAULT_BASE_URL, timeout=DEFAULT_TIMEOUT):
|
|
106
|
+
"""Create an account and issue the first API key. No approval step (decision §0)
|
|
107
|
+
-- returns immediately with a usable key. THE KEY IS SHOWN ONCE: store
|
|
108
|
+
response['api_key'] yourself, the server does not let you recover it later.
|
|
109
|
+
Returns the raw signup response dict (account_id, key_id, api_key, tier, limits,
|
|
110
|
+
mcp_endpoint, ...) rather than a StratifyClient, since the two most common next
|
|
111
|
+
steps -- print the key for a human, or hand it straight to StratifyClient(api_key=)
|
|
112
|
+
-- both want the plain dict."""
|
|
113
|
+
url = urljoin(base_url.rstrip("/") + "/", "v1/signup")
|
|
114
|
+
body = json.dumps({"email": email}).encode()
|
|
115
|
+
req = urllib.request.Request(
|
|
116
|
+
url, data=body, method="POST",
|
|
117
|
+
headers={"Content-Type": "application/json"})
|
|
118
|
+
try:
|
|
119
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
120
|
+
return json.loads(resp.read())
|
|
121
|
+
except urllib.error.HTTPError as exc:
|
|
122
|
+
detail_body = exc.read()
|
|
123
|
+
try:
|
|
124
|
+
detail = json.loads(detail_body).get("error", detail_body.decode(errors="replace"))
|
|
125
|
+
except Exception:
|
|
126
|
+
detail = detail_body.decode(errors="replace")
|
|
127
|
+
raise TransportError(f"signup failed ({exc.code}): {detail}") from exc
|
|
128
|
+
except urllib.error.URLError as exc:
|
|
129
|
+
raise TransportError(f"could not reach {url}: {exc.reason}") from exc
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------- transport
|
|
132
|
+
|
|
133
|
+
def _call(self, tool_name, arguments):
|
|
134
|
+
self._request_id += 1
|
|
135
|
+
body = json.dumps({
|
|
136
|
+
"jsonrpc": "2.0", "id": self._request_id, "method": "tools/call",
|
|
137
|
+
"params": {"name": tool_name, "arguments": arguments},
|
|
138
|
+
}).encode()
|
|
139
|
+
url = urljoin(self.base_url + "/", "mcp")
|
|
140
|
+
req = urllib.request.Request(
|
|
141
|
+
url, data=body, method="POST",
|
|
142
|
+
headers={"Content-Type": "application/json",
|
|
143
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
144
|
+
"User-Agent": "stratify-py/1.0"})
|
|
145
|
+
try:
|
|
146
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
147
|
+
message = json.loads(resp.read())
|
|
148
|
+
except urllib.error.HTTPError as exc:
|
|
149
|
+
# The server returns non-2xx only for origin/size checks ahead of JSON-RPC
|
|
150
|
+
# (app.py:_serve_mcp) -- a real tool refusal or quota hit still comes back 200
|
|
151
|
+
# with a JSON-RPC body, handled below.
|
|
152
|
+
raise TransportError(f"HTTP {exc.code} from {url}") from exc
|
|
153
|
+
except urllib.error.URLError as exc:
|
|
154
|
+
raise TransportError(f"could not reach {url}: {exc.reason}") from exc
|
|
155
|
+
except json.JSONDecodeError as exc:
|
|
156
|
+
raise TransportError("server response was not valid JSON") from exc
|
|
157
|
+
|
|
158
|
+
if "error" in message:
|
|
159
|
+
err = message["error"]
|
|
160
|
+
code, msg, data = err.get("code"), err.get("message", ""), err.get("data") or {}
|
|
161
|
+
if code == _UNAUTHENTICATED:
|
|
162
|
+
raise AuthenticationError(msg, data)
|
|
163
|
+
if code == _QUOTA_EXCEEDED:
|
|
164
|
+
raise QuotaExceededError(msg, limit=data.get("limit"),
|
|
165
|
+
retry_after_seconds=data.get("retry_after_seconds"))
|
|
166
|
+
raise ProtocolError(msg, code=code)
|
|
167
|
+
|
|
168
|
+
result = message.get("result") or {}
|
|
169
|
+
if result.get("isError"):
|
|
170
|
+
text = "; ".join(b.get("text", "") for b in result.get("content", []))
|
|
171
|
+
raise ToolRefusalError(text or "the server refused this call")
|
|
172
|
+
|
|
173
|
+
structured = result.get("structuredContent")
|
|
174
|
+
if structured is not None:
|
|
175
|
+
return structured
|
|
176
|
+
# Fallback for a client/server pairing that only carries the text block -- parse
|
|
177
|
+
# the same JSON the structuredContent would have held (server/app.py:_tool_text
|
|
178
|
+
# always sends both today, but this keeps the client correct if that ever changes).
|
|
179
|
+
for block in result.get("content", []):
|
|
180
|
+
if block.get("type") == "text":
|
|
181
|
+
return json.loads(block["text"])
|
|
182
|
+
return {}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Exceptions the client raises.
|
|
2
|
+
|
|
3
|
+
Mirrors the three distinct failure shapes server/app.py actually produces (see its
|
|
4
|
+
_handle() dispatcher) rather than collapsing them into one generic error -- a caller needs
|
|
5
|
+
to tell "your key is wrong" from "you're over quota, retry in N seconds" from "the server
|
|
6
|
+
looked at your spec and said no" apart, and each needs different handling code.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StratifyError(Exception):
|
|
11
|
+
"""Base class for every error this client raises."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TransportError(StratifyError):
|
|
15
|
+
"""The request never got a JSON-RPC response at all -- a network failure, a timeout,
|
|
16
|
+
or a non-2xx HTTP status from something other than this API (a proxy, a CDN page)."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuthenticationError(StratifyError):
|
|
20
|
+
"""JSON-RPC error -32001. The key is missing, malformed, or revoked. `how_to_fix` and
|
|
21
|
+
`alternative_headers`, when the server sent them, are on `.data`."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, message, data=None):
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.data = data or {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class QuotaExceededError(StratifyError):
|
|
29
|
+
"""JSON-RPC error -32002. `limit` names which of the three dimensions (requests, CPU
|
|
30
|
+
seconds, price points) was hit; `retry_after_seconds` is how long to back off."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, message, limit=None, retry_after_seconds=None):
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.limit = limit
|
|
35
|
+
self.retry_after_seconds = retry_after_seconds
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ToolRefusalError(StratifyError):
|
|
39
|
+
"""Not a protocol error -- a successful JSON-RPC response with isError=true. This is
|
|
40
|
+
the server having read the spec and refused it on its own terms: too narrow a window,
|
|
41
|
+
an anti-oracle floor, a malformed structure parameter. The message is written for a
|
|
42
|
+
human (or a model) to read and correct, so it is passed through unmodified."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ProtocolError(StratifyError):
|
|
46
|
+
"""A JSON-RPC error this client did not anticipate (unknown method, malformed request,
|
|
47
|
+
internal server error). Carries the raw code and message rather than pretending to a
|
|
48
|
+
category the server didn't declare."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, message, code=None):
|
|
51
|
+
super().__init__(message)
|
|
52
|
+
self.code = code
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Result wrapper. run_backtest and get_backtest return the same payload shape
|
|
2
|
+
(server/tools.py's get_backtest builds it by re-loading exactly what run_backtest stored),
|
|
3
|
+
so one class serves both.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BacktestResult:
|
|
8
|
+
"""Wraps a run_backtest/get_backtest response. Every field the server sent is on
|
|
9
|
+
`.raw`; the properties below are conveniences over the common ones, built lazily so a
|
|
10
|
+
caller who only wants `.summary` never pays for a DataFrame construction they didn't
|
|
11
|
+
ask for.
|
|
12
|
+
|
|
13
|
+
`detail="summary"` responses have no `trades`/`equity_curve` at all (server/tools.py's
|
|
14
|
+
_trim() drops them to keep that request cheap and non-exposing) -- .trades and
|
|
15
|
+
.equity_curve return an empty DataFrame in that case rather than raising, since "there
|
|
16
|
+
is no per-trade data in a summary response" is an expected, not exceptional, state.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, payload):
|
|
20
|
+
self.raw = payload
|
|
21
|
+
self._trades_df = None
|
|
22
|
+
self._equity_df = None
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def summary(self):
|
|
26
|
+
"""Aggregate metrics: total_pnl_rupees, mean_return_on_margin, win_rate,
|
|
27
|
+
max_drawdown_rupees, avg_margin_points, charges_share_of_gross, and
|
|
28
|
+
ratios.{sharpe,profit_factor,calmar} -- see explain_methodology('honesty') for
|
|
29
|
+
what each does and does not prove. No ratios at all below 30 trades (the sample
|
|
30
|
+
floor); check .honesty for why a number is or isn't here."""
|
|
31
|
+
return self.raw.get("summary", {})
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def honesty(self):
|
|
35
|
+
"""The out-of-sample split, walk-forward folds, bootstrap interval and deflated
|
|
36
|
+
Sharpe -- read this before trusting `.summary` at all."""
|
|
37
|
+
return self.raw.get("honesty", {})
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def interpretation(self):
|
|
41
|
+
"""Plain-language reading of this specific result, and what it does not support."""
|
|
42
|
+
return self.raw.get("interpretation")
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def trades(self):
|
|
46
|
+
"""pandas.DataFrame, one row per trade. Requires pandas (a hard dependency of this
|
|
47
|
+
package, not optional -- see pyproject.toml)."""
|
|
48
|
+
if self._trades_df is None:
|
|
49
|
+
import pandas as pd
|
|
50
|
+
self._trades_df = pd.DataFrame(self.raw.get("trades") or [])
|
|
51
|
+
return self._trades_df
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def equity_curve(self):
|
|
55
|
+
"""pandas.DataFrame, one row per trade in exit order -- cumulative net P&L and the
|
|
56
|
+
gap to the running peak. See .raw['equity_curve']['note'] for the exact column
|
|
57
|
+
semantics the server documents."""
|
|
58
|
+
if self._equity_df is None:
|
|
59
|
+
import pandas as pd
|
|
60
|
+
curve = self.raw.get("equity_curve") or {}
|
|
61
|
+
rows = curve.get("rows") or []
|
|
62
|
+
columns = curve.get("columns")
|
|
63
|
+
self._equity_df = pd.DataFrame(rows, columns=columns) if columns else pd.DataFrame(rows)
|
|
64
|
+
return self._equity_df
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def qualified(self):
|
|
68
|
+
"""True if this result was strong enough (out-of-sample and walk-forward, not
|
|
69
|
+
just P&L) to be saved into this account's strategy book. See .why_not_qualified
|
|
70
|
+
when False."""
|
|
71
|
+
return (self.raw.get("strategy_book") or {}).get("qualified", False)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def why_not_qualified(self):
|
|
75
|
+
"""None when .qualified is True; otherwise the server's explanation of which
|
|
76
|
+
check(s) failed."""
|
|
77
|
+
book = self.raw.get("strategy_book") or {}
|
|
78
|
+
return None if book.get("qualified") else book.get("note")
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def backtest_id(self):
|
|
82
|
+
return self.raw.get("backtest_id")
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def report_url(self):
|
|
86
|
+
"""A shareable, human-readable report page -- the full equity curve as a chart,
|
|
87
|
+
monthly bars, and every trade regardless of what `detail` was requested here."""
|
|
88
|
+
return self.raw.get("report_url")
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def warnings(self):
|
|
92
|
+
"""Disclosed modeling gaps and approximations -- e.g. a naked-margin ratio
|
|
93
|
+
calibrated today and applied to the past. Always worth reading, never worth
|
|
94
|
+
hiding behind a flag."""
|
|
95
|
+
return self.raw.get("warnings", [])
|
|
96
|
+
|
|
97
|
+
def to_dict(self):
|
|
98
|
+
"""The complete, unwrapped server payload."""
|
|
99
|
+
return self.raw
|
|
100
|
+
|
|
101
|
+
def __repr__(self):
|
|
102
|
+
# Field names verified against a real run_backtest response, not assumed: there is
|
|
103
|
+
# no "cagr" -- the summary's actual return metrics are total_pnl_rupees,
|
|
104
|
+
# mean_return_on_margin, and ratios.sharpe/profit_factor/calmar.
|
|
105
|
+
s = self.summary
|
|
106
|
+
n = s.get("n_trades")
|
|
107
|
+
pnl = s.get("total_pnl_rupees")
|
|
108
|
+
pnl_txt = f"pnl={pnl:+,.0f}Rs" if isinstance(pnl, (int, float)) else "pnl=n/a"
|
|
109
|
+
n_txt = f"n_trades={n}" if n is not None else "insufficient_sample"
|
|
110
|
+
return f"<BacktestResult {self.backtest_id or '(unsaved)'} {pnl_txt} {n_txt}>"
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stratify-mcp
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Python client for the Stratify MCP options-backtesting API
|
|
5
|
+
Author: Stratify
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://stratify.aeon-labs.site
|
|
8
|
+
Project-URL: Documentation, https://stratify.aeon-labs.site/docs
|
|
9
|
+
Project-URL: Source, https://github.com/Srinath-exe/stratify-mcp
|
|
10
|
+
Project-URL: Issues, https://github.com/Srinath-exe/stratify-mcp/issues
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: pandas>=1.5
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
16
|
+
|
|
17
|
+
# stratify-mcp
|
|
18
|
+
|
|
19
|
+
Python client for [Stratify](https://stratify.aeon-labs.site) — real 1-minute NIFTY options data,
|
|
20
|
+
honest backtests (out-of-sample, walk-forward, deflated Sharpe, all reported alongside the
|
|
21
|
+
number, not instead of it).
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install stratify-mcp
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quickstart
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from stratify_mcp import StratifyClient
|
|
31
|
+
|
|
32
|
+
# One-time: create an account and get a key. The key is shown once -- save it.
|
|
33
|
+
signup = StratifyClient.signup("you@example.com")
|
|
34
|
+
client = StratifyClient(api_key=signup["api_key"])
|
|
35
|
+
|
|
36
|
+
result = client.run_backtest({
|
|
37
|
+
"legs": [
|
|
38
|
+
{"side": "sell", "type": "CE", "strike": {"delta_near": 0.2}},
|
|
39
|
+
{"side": "sell", "type": "PE", "strike": {"delta_near": 0.2}},
|
|
40
|
+
],
|
|
41
|
+
# The reason for the trade, not just the trade.
|
|
42
|
+
"entry": {"cadence": "weekly", "dte": 3, "time": "09:30",
|
|
43
|
+
"when": {"vix": {"gte": 15}}},
|
|
44
|
+
# Managed while it is open: take profit, and roll the tested side if it doubles.
|
|
45
|
+
"rules": [
|
|
46
|
+
{"when": {"pnl_pct_of_credit": {"gte": 0.6}}, "then": "close"},
|
|
47
|
+
{"when": {"leg_mark_mult": {"gte": 2.0, "leg": 0}},
|
|
48
|
+
"then": {"roll": {"legs": [0], "to": {"delta_near": 0.2}}}, "max_times": 2},
|
|
49
|
+
],
|
|
50
|
+
"portfolio": {"stop_after_losses": 3, "resume_after_days": 30},
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
print(result.summary["total_pnl_rupees"], result.summary["max_drawdown_rupees"])
|
|
54
|
+
print(result.summary["ratios"]) # sharpe, profit_factor, calmar (only above 30 trades)
|
|
55
|
+
print(result.honesty) # out-of-sample split, walk-forward folds, deflated Sharpe
|
|
56
|
+
result.trades # pandas.DataFrame, one row per trade
|
|
57
|
+
result.equity_curve # pandas.DataFrame
|
|
58
|
+
print(result.report_url) # shareable page with the full chart and every trade
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Already have a key? Skip `signup()`:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
client = StratifyClient(api_key="sk_live_...")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Why a Python client at all, when it's just JSON-RPC
|
|
68
|
+
|
|
69
|
+
There's no separate REST endpoint for `run_backtest` — every tool is reached through one
|
|
70
|
+
`POST /mcp` speaking MCP JSON-RPC 2.0. This package exists so you don't hand-roll that
|
|
71
|
+
envelope: `client.run_backtest(...)` is a real function call, errors come back as Python
|
|
72
|
+
exceptions you can `except`, and results come back as `pandas.DataFrame`s instead of raw
|
|
73
|
+
JSON, because that's what you're actually going to do with a table of trades.
|
|
74
|
+
|
|
75
|
+
## Errors
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from stratify_mcp import AuthenticationError, QuotaExceededError, ToolRefusalError
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
result = client.run_backtest(spec)
|
|
82
|
+
except AuthenticationError:
|
|
83
|
+
... # bad or revoked key
|
|
84
|
+
except QuotaExceededError as e:
|
|
85
|
+
... # e.limit, e.retry_after_seconds
|
|
86
|
+
except ToolRefusalError as e:
|
|
87
|
+
... # the server read your spec and refused it -- str(e) says why
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Methods
|
|
91
|
+
|
|
92
|
+
| Method | Returns |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `run_backtest(spec, lots=1, detail="standard")` | `BacktestResult` |
|
|
95
|
+
| `get_backtest(backtest_id, detail=None)` | `BacktestResult` |
|
|
96
|
+
| `describe_coverage()` | `dict` — symbols, date range, structures, gates, biases, cost model |
|
|
97
|
+
| `explain_methodology(topic=None)` | `dict` |
|
|
98
|
+
| `list_strategies(order="consistency", limit=None)` | `dict` — `{"strategies": [...], "bar": {...}, ...}`, this account's strategies that held up out-of-sample |
|
|
99
|
+
| `search(query)` / `fetch(id)` | `dict` |
|
|
100
|
+
| `StratifyClient.signup(email)` (staticmethod) | `dict` — includes `api_key`, shown once |
|
|
101
|
+
|
|
102
|
+
`detail` on `run_backtest`/`get_backtest`: `"summary"` (aggregates only, cheapest),
|
|
103
|
+
`"standard"` (default — equity curve, breakdowns, first 25 trades), `"full"` (every
|
|
104
|
+
stored trade).
|
|
105
|
+
|
|
106
|
+
## `BacktestResult`
|
|
107
|
+
|
|
108
|
+
| Property | Type |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `.summary` | `dict` |
|
|
111
|
+
| `.honesty` | `dict` |
|
|
112
|
+
| `.interpretation` | `str` |
|
|
113
|
+
| `.trades` | `pandas.DataFrame` |
|
|
114
|
+
| `.equity_curve` | `pandas.DataFrame` |
|
|
115
|
+
| `.qualified` / `.why_not_qualified` | `bool` / `str \| None` |
|
|
116
|
+
| `.backtest_id` / `.report_url` | `str` |
|
|
117
|
+
| `.warnings` | `list[str]` |
|
|
118
|
+
| `.to_dict()` | the complete raw payload |
|
|
119
|
+
|
|
120
|
+
## Development
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
pip install -e ".[dev]"
|
|
124
|
+
pytest
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Tests run against a mocked transport and need no live server or API key.
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
stratify_mcp/__init__.py
|
|
4
|
+
stratify_mcp/client.py
|
|
5
|
+
stratify_mcp/exceptions.py
|
|
6
|
+
stratify_mcp/models.py
|
|
7
|
+
stratify_mcp.egg-info/PKG-INFO
|
|
8
|
+
stratify_mcp.egg-info/SOURCES.txt
|
|
9
|
+
stratify_mcp.egg-info/dependency_links.txt
|
|
10
|
+
stratify_mcp.egg-info/requires.txt
|
|
11
|
+
stratify_mcp.egg-info/top_level.txt
|
|
12
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
stratify_mcp
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Unit tests against a mocked transport -- no live server required. Each test patches
|
|
2
|
+
urllib.request.urlopen to hand back exactly the JSON-RPC envelope server/app.py would
|
|
3
|
+
produce for that scenario (copied from reading app.py's _handle()/tools.py directly, not
|
|
4
|
+
guessed), so these pin the wire contract this client actually depends on.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import urllib.error
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from stratify_mcp import (AuthenticationError, BacktestResult, ProtocolError,
|
|
13
|
+
QuotaExceededError, StratifyClient, ToolRefusalError)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _FakeResponse:
|
|
17
|
+
def __init__(self, body):
|
|
18
|
+
self._body = json.dumps(body).encode()
|
|
19
|
+
|
|
20
|
+
def read(self):
|
|
21
|
+
return self._body
|
|
22
|
+
|
|
23
|
+
def __enter__(self):
|
|
24
|
+
return self
|
|
25
|
+
|
|
26
|
+
def __exit__(self, *a):
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _rpc_result(structured):
|
|
31
|
+
return {"jsonrpc": "2.0", "id": 1,
|
|
32
|
+
"result": {"content": [{"type": "text", "text": json.dumps(structured)}],
|
|
33
|
+
"structuredContent": structured, "isError": False}}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _rpc_error(code, message, data=None):
|
|
37
|
+
err = {"code": code, "message": message}
|
|
38
|
+
if data:
|
|
39
|
+
err["data"] = data
|
|
40
|
+
return {"jsonrpc": "2.0", "id": 1, "error": err}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _rpc_refusal(message):
|
|
44
|
+
return {"jsonrpc": "2.0", "id": 1,
|
|
45
|
+
"result": {"content": [{"type": "text", "text": message}], "isError": True}}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _client():
|
|
49
|
+
return StratifyClient(api_key="sk_live_test", base_url="https://example.invalid")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_run_backtest_returns_backtest_result_with_dataframes():
|
|
53
|
+
payload = {
|
|
54
|
+
"backtest_id": "bt_123", "report_url": "https://example.invalid/r/tok",
|
|
55
|
+
"summary": {"cagr": 0.42, "n_trades": 40},
|
|
56
|
+
"honesty": {"oos_split": "70/30"},
|
|
57
|
+
"trades": [{"n": 1, "entry": "2025-07-01 09:30", "pnl_pts": 12.5},
|
|
58
|
+
{"n": 2, "entry": "2025-07-08 09:30", "pnl_pts": -4.0}],
|
|
59
|
+
"equity_curve": {"columns": ["date", "equity_rupees"],
|
|
60
|
+
"rows": [["2025-07-01", 100000], ["2025-07-08", 101250]]},
|
|
61
|
+
"strategy_book": {"qualified": True},
|
|
62
|
+
}
|
|
63
|
+
with patch("urllib.request.urlopen", return_value=_FakeResponse(_rpc_result(payload))) as m:
|
|
64
|
+
result = _client().run_backtest(
|
|
65
|
+
{"structure": "short_strangle", "params": {"pct_offset": 1.5}})
|
|
66
|
+
|
|
67
|
+
assert isinstance(result, BacktestResult)
|
|
68
|
+
assert result.backtest_id == "bt_123"
|
|
69
|
+
assert result.qualified is True
|
|
70
|
+
assert result.summary["cagr"] == 0.42
|
|
71
|
+
assert len(result.trades) == 2
|
|
72
|
+
assert list(result.trades["pnl_pts"]) == [12.5, -4.0]
|
|
73
|
+
assert list(result.equity_curve.columns) == ["date", "equity_rupees"]
|
|
74
|
+
assert len(result.equity_curve) == 2
|
|
75
|
+
|
|
76
|
+
# Confirms the actual wire request shape, not just the response handling.
|
|
77
|
+
sent_body = json.loads(m.call_args[0][0].data)
|
|
78
|
+
assert sent_body["method"] == "tools/call"
|
|
79
|
+
assert sent_body["params"]["name"] == "run_backtest"
|
|
80
|
+
assert sent_body["params"]["arguments"]["spec"]["structure"] == "short_strangle"
|
|
81
|
+
assert sent_body["params"]["arguments"]["detail"] == "standard"
|
|
82
|
+
assert m.call_args[0][0].get_header("Authorization") == "Bearer sk_live_test"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_summary_detail_has_no_trades_but_does_not_raise():
|
|
86
|
+
payload = {"backtest_id": "bt_999", "summary": {"cagr": 0.1},
|
|
87
|
+
"strategy_book": {"qualified": False, "note": "n_trades below 30"}}
|
|
88
|
+
with patch("urllib.request.urlopen", return_value=_FakeResponse(_rpc_result(payload))):
|
|
89
|
+
result = _client().run_backtest({"structure": "long_option", "params": {}},
|
|
90
|
+
detail="summary")
|
|
91
|
+
assert result.qualified is False
|
|
92
|
+
assert result.why_not_qualified == "n_trades below 30"
|
|
93
|
+
assert result.trades.empty
|
|
94
|
+
assert result.equity_curve.empty
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_authentication_error():
|
|
98
|
+
body = _rpc_error(-32001, "missing or invalid API key",
|
|
99
|
+
{"how_to_fix": "POST /v1/signup ..."})
|
|
100
|
+
with patch("urllib.request.urlopen", return_value=_FakeResponse(body)):
|
|
101
|
+
with pytest.raises(AuthenticationError) as exc_info:
|
|
102
|
+
_client().describe_coverage()
|
|
103
|
+
assert "how_to_fix" in exc_info.value.data
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_quota_exceeded_error_carries_limit_and_retry_after():
|
|
107
|
+
body = _rpc_error(-32002, "cpu_seconds_per_hour exceeded",
|
|
108
|
+
{"limit": "cpu_seconds_per_hour", "retry_after_seconds": 120})
|
|
109
|
+
with patch("urllib.request.urlopen", return_value=_FakeResponse(body)):
|
|
110
|
+
with pytest.raises(QuotaExceededError) as exc_info:
|
|
111
|
+
_client().run_backtest({"structure": "credit_spread", "params": {}})
|
|
112
|
+
assert exc_info.value.limit == "cpu_seconds_per_hour"
|
|
113
|
+
assert exc_info.value.retry_after_seconds == 120
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_tool_refusal_is_not_a_protocol_error():
|
|
117
|
+
body = _rpc_refusal("this strategy's queries touched only 4 distinct contracts; at "
|
|
118
|
+
"least 20 are required. Widen the period or the strike range")
|
|
119
|
+
with patch("urllib.request.urlopen", return_value=_FakeResponse(body)):
|
|
120
|
+
with pytest.raises(ToolRefusalError, match="distinct contracts"):
|
|
121
|
+
_client().run_backtest({"structure": "iron_condor", "params": {}})
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_unrecognised_error_code_becomes_protocol_error():
|
|
125
|
+
body = _rpc_error(-32603, "internal error")
|
|
126
|
+
with patch("urllib.request.urlopen", return_value=_FakeResponse(body)):
|
|
127
|
+
with pytest.raises(ProtocolError) as exc_info:
|
|
128
|
+
_client().describe_coverage()
|
|
129
|
+
assert exc_info.value.code == -32603
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_describe_coverage_search_fetch_list_strategies_send_correct_tool_names():
|
|
133
|
+
for method_call, tool_name, expected_args in [
|
|
134
|
+
(lambda c: c.describe_coverage(), "describe_coverage", {}),
|
|
135
|
+
(lambda c: c.explain_methodology("margin"), "explain_methodology", {"topic": "margin"}),
|
|
136
|
+
(lambda c: c.search("SENSEX weekly"), "search", {"query": "SENSEX weekly"}),
|
|
137
|
+
(lambda c: c.fetch("bt_1"), "fetch", {"id": "bt_1"}),
|
|
138
|
+
(lambda c: c.list_strategies(order="pnl", limit=5), "list_strategies",
|
|
139
|
+
{"order": "pnl", "limit": 5}),
|
|
140
|
+
]:
|
|
141
|
+
with patch("urllib.request.urlopen", return_value=_FakeResponse(_rpc_result({"ok": True}))) as m:
|
|
142
|
+
method_call(_client())
|
|
143
|
+
sent = json.loads(m.call_args[0][0].data)
|
|
144
|
+
assert sent["params"]["name"] == tool_name
|
|
145
|
+
assert sent["params"]["arguments"] == expected_args
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_get_backtest_returns_backtest_result():
|
|
149
|
+
payload = {"backtest_id": "bt_777", "summary": {"cagr": 0.2},
|
|
150
|
+
"trades": [], "equity_curve": {"columns": [], "rows": []},
|
|
151
|
+
"strategy_book": {"qualified": False, "note": "x"}}
|
|
152
|
+
with patch("urllib.request.urlopen", return_value=_FakeResponse(_rpc_result(payload))) as m:
|
|
153
|
+
result = _client().get_backtest("bt_777", detail="full")
|
|
154
|
+
assert isinstance(result, BacktestResult)
|
|
155
|
+
assert result.backtest_id == "bt_777"
|
|
156
|
+
sent = json.loads(m.call_args[0][0].data)
|
|
157
|
+
assert sent["params"]["arguments"] == {"backtest_id": "bt_777", "detail": "full"}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_signup_returns_raw_dict():
|
|
161
|
+
body = {"account_id": "acc_1", "key_id": "key_1", "api_key": "sk_live_new",
|
|
162
|
+
"tier": "free", "mcp_endpoint": "https://example.invalid/mcp"}
|
|
163
|
+
with patch("urllib.request.urlopen", return_value=_FakeResponse(body)) as m:
|
|
164
|
+
result = StratifyClient.signup("me@example.com", base_url="https://example.invalid")
|
|
165
|
+
assert result["api_key"] == "sk_live_new"
|
|
166
|
+
sent = json.loads(m.call_args[0][0].data)
|
|
167
|
+
assert sent == {"email": "me@example.com"}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_signup_failure_raises_transport_error():
|
|
171
|
+
from stratify_mcp import TransportError
|
|
172
|
+
|
|
173
|
+
def raise_http_error(*a, **kw):
|
|
174
|
+
raise urllib.error.HTTPError(
|
|
175
|
+
"https://example.invalid/v1/signup", 400, "bad request", {},
|
|
176
|
+
fp=__import__("io").BytesIO(json.dumps({"error": "a valid email is required"}).encode()))
|
|
177
|
+
|
|
178
|
+
with patch("urllib.request.urlopen", side_effect=raise_http_error):
|
|
179
|
+
with pytest.raises(TransportError, match="a valid email is required"):
|
|
180
|
+
StratifyClient.signup("not-an-email", base_url="https://example.invalid")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_missing_api_key_raises_immediately():
|
|
184
|
+
with pytest.raises(ValueError):
|
|
185
|
+
StratifyClient(api_key="")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_repr_uses_real_field_names_not_a_nonexistent_cagr():
|
|
189
|
+
"""Field names verified against a real local server run, not assumed: there is no
|
|
190
|
+
'cagr' in the actual summary payload -- the real return metric is total_pnl_rupees."""
|
|
191
|
+
result = BacktestResult({"backtest_id": "bt_1",
|
|
192
|
+
"summary": {"total_pnl_rupees": 6584.19, "n_trades": 50}})
|
|
193
|
+
text = repr(result)
|
|
194
|
+
assert "bt_1" in text
|
|
195
|
+
assert "6,584" in text or "6584" in text
|
|
196
|
+
assert "n_trades=50" in text
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_repr_handles_insufficient_sample_without_crashing():
|
|
200
|
+
result = BacktestResult({"backtest_id": "bt_2", "summary": {}})
|
|
201
|
+
assert "insufficient_sample" in repr(result)
|