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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,4 @@
1
+ pandas>=1.5
2
+
3
+ [dev]
4
+ pytest>=7
@@ -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)