mcp-server-worldquantbrain 0.1.0__py3-none-any.whl

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.
Files changed (52) hide show
  1. mcp_server_worldquantbrain/__init__.py +11 -0
  2. mcp_server_worldquantbrain/__main__.py +3 -0
  3. mcp_server_worldquantbrain/cli.py +127 -0
  4. mcp_server_worldquantbrain/client/__init__.py +3 -0
  5. mcp_server_worldquantbrain/client/alphas.py +259 -0
  6. mcp_server_worldquantbrain/client/base.py +607 -0
  7. mcp_server_worldquantbrain/client/cache.py +48 -0
  8. mcp_server_worldquantbrain/client/data.py +103 -0
  9. mcp_server_worldquantbrain/client/filters.py +60 -0
  10. mcp_server_worldquantbrain/client/ledger.py +132 -0
  11. mcp_server_worldquantbrain/client/paging.py +22 -0
  12. mcp_server_worldquantbrain/client/retry.py +37 -0
  13. mcp_server_worldquantbrain/client/session.py +107 -0
  14. mcp_server_worldquantbrain/client/simulations.py +103 -0
  15. mcp_server_worldquantbrain/client/users.py +77 -0
  16. mcp_server_worldquantbrain/config.py +185 -0
  17. mcp_server_worldquantbrain/errors.py +79 -0
  18. mcp_server_worldquantbrain/http_app.py +50 -0
  19. mcp_server_worldquantbrain/integrations/__init__.py +0 -0
  20. mcp_server_worldquantbrain/integrations/langgraph.py +137 -0
  21. mcp_server_worldquantbrain/knowledge/__init__.py +20 -0
  22. mcp_server_worldquantbrain/knowledge/fastexpr-cheatsheet.md +36 -0
  23. mcp_server_worldquantbrain/knowledge/settings-guide.md +20 -0
  24. mcp_server_worldquantbrain/knowledge/submission-criteria.md +24 -0
  25. mcp_server_worldquantbrain/logging_setup.py +18 -0
  26. mcp_server_worldquantbrain/models/__init__.py +0 -0
  27. mcp_server_worldquantbrain/models/alpha.py +190 -0
  28. mcp_server_worldquantbrain/models/auth.py +12 -0
  29. mcp_server_worldquantbrain/models/base.py +23 -0
  30. mcp_server_worldquantbrain/models/data.py +132 -0
  31. mcp_server_worldquantbrain/models/simulation.py +117 -0
  32. mcp_server_worldquantbrain/models/user.py +38 -0
  33. mcp_server_worldquantbrain/ops/__init__.py +9 -0
  34. mcp_server_worldquantbrain/ops/alphas.py +200 -0
  35. mcp_server_worldquantbrain/ops/auth.py +34 -0
  36. mcp_server_worldquantbrain/ops/data.py +132 -0
  37. mcp_server_worldquantbrain/ops/registry.py +151 -0
  38. mcp_server_worldquantbrain/ops/simulations.py +147 -0
  39. mcp_server_worldquantbrain/ops/submit.py +156 -0
  40. mcp_server_worldquantbrain/ops/users.py +38 -0
  41. mcp_server_worldquantbrain/prompts.py +38 -0
  42. mcp_server_worldquantbrain/py.typed +0 -0
  43. mcp_server_worldquantbrain/redaction.py +70 -0
  44. mcp_server_worldquantbrain/resources.py +51 -0
  45. mcp_server_worldquantbrain/server.py +52 -0
  46. mcp_server_worldquantbrain/sim_manager.py +467 -0
  47. mcp_server_worldquantbrain/tools.py +121 -0
  48. mcp_server_worldquantbrain-0.1.0.dist-info/METADATA +285 -0
  49. mcp_server_worldquantbrain-0.1.0.dist-info/RECORD +52 -0
  50. mcp_server_worldquantbrain-0.1.0.dist-info/WHEEL +4 -0
  51. mcp_server_worldquantbrain-0.1.0.dist-info/entry_points.txt +3 -0
  52. mcp_server_worldquantbrain-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,11 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ from mcp_server_worldquantbrain.client import BrainClient
4
+ from mcp_server_worldquantbrain.config import Settings
5
+
6
+ try:
7
+ __version__ = version("mcp-server-worldquantbrain")
8
+ except PackageNotFoundError:
9
+ __version__ = "0.0.0"
10
+
11
+ __all__ = ["BrainClient", "Settings", "__version__"]
@@ -0,0 +1,3 @@
1
+ from mcp_server_worldquantbrain.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,127 @@
1
+ import argparse
2
+ import asyncio
3
+ import getpass
4
+ import sys
5
+
6
+ from pydantic import SecretStr, ValidationError
7
+
8
+ from mcp_server_worldquantbrain import __version__
9
+ from mcp_server_worldquantbrain.client.base import BrainClient
10
+ from mcp_server_worldquantbrain.client.session import write_private
11
+ from mcp_server_worldquantbrain.config import Settings
12
+ from mcp_server_worldquantbrain.errors import BrainError
13
+ from mcp_server_worldquantbrain.logging_setup import configure_logging
14
+
15
+
16
+ def _parser() -> argparse.ArgumentParser:
17
+ parser = argparse.ArgumentParser(
18
+ prog="mcp-server-worldquantbrain", description="MCP server for WorldQuant BRAIN."
19
+ )
20
+ parser.add_argument("--version", action="version", version=__version__)
21
+ commands = parser.add_subparsers(dest="command")
22
+
23
+ serve = commands.add_parser("serve", help="run the MCP server (default)")
24
+ serve.add_argument("--transport", choices=["stdio", "streamable-http"], default="stdio")
25
+ serve.add_argument("--host", default="127.0.0.1")
26
+ serve.add_argument("--port", type=int, default=8000)
27
+
28
+ auth = commands.add_parser("auth", help="log in and store the session")
29
+ auth.add_argument("--force", action="store_true", help="ignore the saved session")
30
+
31
+ commands.add_parser("status", help="show the session state")
32
+ commands.add_parser("tools", help="list the tools this configuration exposes")
33
+ return parser
34
+
35
+
36
+ def _serve(args: argparse.Namespace, settings: Settings) -> int:
37
+ from mcp_server_worldquantbrain.server import create_server
38
+
39
+ server = create_server(settings)
40
+ if args.transport == "stdio":
41
+ server.run()
42
+ return 0
43
+ import uvicorn
44
+
45
+ from mcp_server_worldquantbrain.http_app import build_http_app
46
+
47
+ app = build_http_app(server, settings, host=args.host)
48
+ uvicorn.run(app, host=args.host, port=args.port, log_config=None)
49
+ return 0
50
+
51
+
52
+ def _prompt_credentials(settings: Settings) -> None:
53
+ print("No BRAIN credentials found.", file=sys.stderr)
54
+ settings.email = input("Email: ").strip()
55
+ settings.password = SecretStr(getpass.getpass("Password: "))
56
+
57
+
58
+ def _store_credentials(settings: Settings) -> None:
59
+ assert settings.email and settings.password
60
+ path = settings.resolved_credentials_file
61
+ write_private(path, [settings.email, settings.password.get_secret_value()])
62
+ print(f"Saved credentials to {path}", file=sys.stderr)
63
+
64
+
65
+ async def _auth(args: argparse.Namespace, settings: Settings) -> int:
66
+ prompted = settings.credentials() is None
67
+ if prompted:
68
+ _prompt_credentials(settings)
69
+ async with BrainClient(settings) as client:
70
+ status = await client.authenticate(force=args.force or prompted)
71
+ if prompted and status.state in ("authenticated", "biometric_required"):
72
+ _store_credentials(settings)
73
+ for _ in range(5):
74
+ if status.state != "biometric_required":
75
+ break
76
+ print(f"\nFinish the biometric check in a browser:\n\n {status.biometric_url}\n")
77
+ input("Press Enter when done...")
78
+ status = await client.complete_authentication()
79
+ print(status.model_dump_json(indent=2))
80
+ return 0 if status.state == "authenticated" else 1
81
+
82
+
83
+ async def _status(settings: Settings) -> int:
84
+ async with BrainClient(settings) as client:
85
+ status = await client.auth_status()
86
+ print(status.model_dump_json(indent=2))
87
+ return 0 if status.state == "authenticated" else 1
88
+
89
+
90
+ def _tools(settings: Settings) -> int:
91
+ from mcp_server_worldquantbrain.ops import enabled_operations
92
+
93
+ operations = enabled_operations(settings)
94
+ width = max(len(op.name) for op in operations)
95
+ for op in operations:
96
+ print(f"{op.name:<{width}} {op.title}")
97
+ if not settings.allow_submit:
98
+ print("\nsubmit_alpha is disabled. Set WQB_ALLOW_SUBMIT=true to enable it.")
99
+ return 0
100
+
101
+
102
+ def main(argv: list[str] | None = None) -> int:
103
+ args = _parser().parse_args(argv)
104
+ try:
105
+ settings = Settings()
106
+ except ValidationError as exc:
107
+ # Only names and reasons: the input values could be secrets.
108
+ for error in exc.errors():
109
+ name = "_".join(str(part) for part in error["loc"]).upper()
110
+ print(f"Invalid WQB_{name}: {error['msg']}", file=sys.stderr)
111
+ return 2
112
+ configure_logging(settings.log_level)
113
+ if args.command is None:
114
+ args = _parser().parse_args(["serve"])
115
+ try:
116
+ if args.command == "serve":
117
+ return _serve(args, settings)
118
+ if args.command == "auth":
119
+ return asyncio.run(_auth(args, settings))
120
+ if args.command == "status":
121
+ return asyncio.run(_status(settings))
122
+ return _tools(settings)
123
+ except BrainError as exc:
124
+ print(exc.for_agent(), file=sys.stderr)
125
+ return 1
126
+ except (KeyboardInterrupt, EOFError):
127
+ return 130
@@ -0,0 +1,3 @@
1
+ from mcp_server_worldquantbrain.client.base import BrainClient
2
+
3
+ __all__ = ["BrainClient"]
@@ -0,0 +1,259 @@
1
+ from typing import Any, Literal
2
+
3
+ from mcp_server_worldquantbrain.client.base import BrainClient
4
+ from mcp_server_worldquantbrain.client.paging import page_info, segment
5
+ from mcp_server_worldquantbrain.errors import NotFound, PollTimeout, ServerError
6
+ from mcp_server_worldquantbrain.models.alpha import (
7
+ AlphaPage,
8
+ AlphaSummary,
9
+ Check,
10
+ CheckResult,
11
+ CorrelationResult,
12
+ DatePnl,
13
+ PnlSummary,
14
+ RecordSet,
15
+ RecordSetNames,
16
+ YearPnl,
17
+ )
18
+
19
+ TOP_CORRELATIONS = 10
20
+ _TOP_COLUMNS = ("id", "name", "correlation", "sharpe", "fitness", "region", "universe")
21
+ MAX_DAILY_ROWS = 500
22
+ _SETTLED_RESULTS = {"PASS", "WARNING", "FAIL", "ERROR"}
23
+
24
+
25
+ def decode_records(payload: Any) -> tuple[list[str], list[dict[str, Any]]]:
26
+ """Turn BRAIN's positional {schema, records} block into named rows."""
27
+ if not isinstance(payload, dict):
28
+ return [], []
29
+ schema = payload.get("schema")
30
+ properties = schema.get("properties") if isinstance(schema, dict) else None
31
+ columns = [str(column.get("name")) for column in properties or []]
32
+ rows = [
33
+ dict(zip(columns, record, strict=False))
34
+ for record in payload.get("records") or []
35
+ if isinstance(record, list | tuple)
36
+ ]
37
+ return columns, rows
38
+
39
+
40
+ def find_checks(payload: Any) -> list[dict[str, Any]]:
41
+ """Collect every check in the payload, since a failure may sit in any block."""
42
+ if not isinstance(payload, dict):
43
+ return []
44
+ found: list[dict[str, Any]] = []
45
+ for key, value in payload.items():
46
+ if key == "checks" and isinstance(value, list):
47
+ found.extend(check for check in value if isinstance(check, dict))
48
+ else:
49
+ found.extend(find_checks(value))
50
+ return found
51
+
52
+
53
+ async def get_alpha(client: BrainClient, alpha_id: str, *, max_seconds: float = 60) -> AlphaSummary:
54
+ response = await client.poll(f"/alphas/{segment(alpha_id)}", max_seconds=max_seconds)
55
+ body = response.json()
56
+ if not isinstance(body, dict):
57
+ raise ServerError(f"GET /alphas/{alpha_id} returned an unexpected body.")
58
+ return AlphaSummary.from_api(body)
59
+
60
+
61
+ async def list_alphas(client: BrainClient, query: list[tuple[str, Any]]) -> AlphaPage:
62
+ body = await client.request_json("GET", "/users/self/alphas", params=query) or {}
63
+ results = body.get("results") or []
64
+ query_map = dict(query)
65
+ next_offset, truncated = page_info(
66
+ body.get("count"), int(query_map["offset"]), len(results), int(query_map["limit"])
67
+ )
68
+ return AlphaPage(
69
+ count=body.get("count"),
70
+ next_offset=next_offset,
71
+ truncated_by_api=truncated,
72
+ items=[AlphaSummary.from_api(item) for item in results],
73
+ )
74
+
75
+
76
+ async def list_recordsets(client: BrainClient, alpha_id: str) -> RecordSetNames:
77
+ body = await client.request_json("GET", f"/alphas/{segment(alpha_id)}/recordsets")
78
+ names: list[str] = []
79
+ entries = body.get("results", body) if isinstance(body, dict) else body
80
+ for entry in entries or []:
81
+ name = entry.get("name") or entry.get("id") if isinstance(entry, dict) else entry
82
+ if name:
83
+ names.append(str(name))
84
+ return RecordSetNames(alpha_id=alpha_id, names=names)
85
+
86
+
87
+ async def _recordset_payload(
88
+ client: BrainClient, alpha_id: str, name: str, max_seconds: float
89
+ ) -> Any:
90
+ url = f"/alphas/{segment(alpha_id)}/recordsets/{segment(name)}"
91
+ try:
92
+ response = await client.poll(url, max_seconds=max_seconds)
93
+ except NotFound as exc:
94
+ available = await list_recordsets(client, alpha_id)
95
+ exc.hint = f"Available recordsets: {', '.join(available.names) or 'none'}."
96
+ raise
97
+ return response.json()
98
+
99
+
100
+ async def get_recordset(
101
+ client: BrainClient, alpha_id: str, name: str, *, max_rows: int, max_seconds: float = 60
102
+ ) -> RecordSet:
103
+ columns, rows = decode_records(await _recordset_payload(client, alpha_id, name, max_seconds))
104
+ return RecordSet(
105
+ alpha_id=alpha_id,
106
+ name=name,
107
+ columns=columns,
108
+ rows=rows[-max_rows:],
109
+ total_rows=len(rows),
110
+ truncated=len(rows) > max_rows,
111
+ )
112
+
113
+
114
+ async def get_pnl(
115
+ client: BrainClient,
116
+ alpha_id: str,
117
+ *,
118
+ include_daily: bool,
119
+ last_n: int | None,
120
+ max_seconds: float = 60,
121
+ ) -> PnlSummary:
122
+ columns, rows = decode_records(await _recordset_payload(client, alpha_id, "pnl", max_seconds))
123
+ date_column = next((c for c in columns if c.lower() == "date"), None)
124
+ pnl_column = next((c for c in columns if "pnl" in c.lower()), None)
125
+ if date_column is None or pnl_column is None:
126
+ return PnlSummary(alpha_id=alpha_id, total_rows=len(rows))
127
+ points = [(str(r[date_column]), r[pnl_column]) for r in rows if r.get(pnl_column) is not None]
128
+ # The pnl recordset is cumulative, so a year's PnL is the change between year-end values.
129
+ year_end: dict[int, float] = {}
130
+ for date, value in points:
131
+ year_end[int(date[:4])] = float(value)
132
+ yearly: list[YearPnl] = []
133
+ previous = 0.0
134
+ for year in sorted(year_end):
135
+ yearly.append(
136
+ YearPnl(year=year, pnl=year_end[year] - previous, cumulative_pnl=year_end[year])
137
+ )
138
+ previous = year_end[year]
139
+ daily = None
140
+ truncated = False
141
+ if include_daily or last_n:
142
+ keep = last_n or MAX_DAILY_ROWS
143
+ daily = [DatePnl(date=d, cumulative_pnl=v) for d, v in points[-keep:]]
144
+ truncated = len(points) > keep
145
+ return PnlSummary(
146
+ alpha_id=alpha_id,
147
+ start_date=points[0][0] if points else None,
148
+ end_date=points[-1][0] if points else None,
149
+ total_pnl=float(points[-1][1]) if points else None,
150
+ total_rows=len(rows),
151
+ yearly=yearly,
152
+ daily=daily,
153
+ truncated=truncated,
154
+ )
155
+
156
+
157
+ async def update_alpha(client: BrainClient, alpha_id: str, body: dict[str, Any]) -> AlphaSummary:
158
+ data = await client.request_json("PATCH", f"/alphas/{segment(alpha_id)}", json_body=body)
159
+ if isinstance(data, dict) and data.get("id"):
160
+ return AlphaSummary.from_api(data)
161
+ return await get_alpha(client, alpha_id)
162
+
163
+
164
+ def summarize_checks(alpha_id: str, payload: Any) -> CheckResult:
165
+ checks = [Check.model_validate(check) for check in find_checks(payload)]
166
+ failed = [c.name or "" for c in checks if c.result in ("FAIL", "ERROR")]
167
+ warnings = [c.name or "" for c in checks if c.result == "WARNING"]
168
+ # An unknown or missing result is not a pass: treating it as one could let a bad alpha submit.
169
+ unsettled = [c for c in checks if c.result not in _SETTLED_RESULTS]
170
+ # BRAIN leaves correlation checks PENDING here even once it has answered, and the correlation
171
+ # endpoints report them separately, so they do not hold the verdict open.
172
+ correlation_pending = [
173
+ c.name or ""
174
+ for c in unsettled
175
+ if c.result == "PENDING" and (c.name or "").endswith("_CORRELATION")
176
+ ]
177
+ pending = not checks or len(unsettled) > len(correlation_pending)
178
+ verdict = "FAIL" if failed else "PENDING" if pending else "WARNING" if warnings else "PASS"
179
+ next_step = None
180
+ if verdict == "PENDING":
181
+ next_step = "Call check_alpha_submission again in a minute."
182
+ elif correlation_pending:
183
+ next_step = "Call get_alpha_self_correlation for the correlation figure."
184
+ return CheckResult(
185
+ alpha_id=alpha_id,
186
+ verdict=verdict,
187
+ checks=checks,
188
+ failed=failed,
189
+ warnings=warnings,
190
+ pending=pending and not failed,
191
+ correlation_pending=correlation_pending,
192
+ next_step=next_step,
193
+ )
194
+
195
+
196
+ async def check_submission(
197
+ client: BrainClient, alpha_id: str, *, wait_seconds: float
198
+ ) -> CheckResult:
199
+ try:
200
+ response = await client.poll(f"/alphas/{segment(alpha_id)}/check", max_seconds=wait_seconds)
201
+ except PollTimeout:
202
+ return CheckResult(
203
+ alpha_id=alpha_id,
204
+ verdict="PENDING",
205
+ pending=True,
206
+ next_step="BRAIN is still computing the checks. Call check_alpha_submission again.",
207
+ )
208
+ return summarize_checks(alpha_id, response.json())
209
+
210
+
211
+ async def get_correlation(
212
+ client: BrainClient,
213
+ alpha_id: str,
214
+ kind: Literal["self", "prod"],
215
+ *,
216
+ wait_seconds: float,
217
+ threshold: float,
218
+ ) -> CorrelationResult:
219
+ try:
220
+ response = await client.poll(
221
+ f"/alphas/{segment(alpha_id)}/correlations/{kind}",
222
+ max_seconds=wait_seconds,
223
+ )
224
+ except PollTimeout:
225
+ return CorrelationResult(
226
+ alpha_id=alpha_id,
227
+ kind=kind,
228
+ threshold=threshold,
229
+ pending=True,
230
+ next_step=f"BRAIN is still computing it. Call get_alpha_{kind}_correlation again.",
231
+ )
232
+ payload = response.json()
233
+ _, rows = decode_records(payload)
234
+ highest = payload.get("max") if isinstance(payload, dict) else None
235
+ lowest = payload.get("min") if isinstance(payload, dict) else None
236
+ if kind == "self":
237
+ ranked = sorted(rows, key=lambda row: row.get("correlation") or 0, reverse=True)
238
+ top = [{k: r[k] for k in _TOP_COLUMNS if r.get(k) is not None} for r in ranked]
239
+ if highest is None and ranked:
240
+ highest = ranked[0].get("correlation")
241
+ else:
242
+ top = [row for row in rows if row.get("alphas")][-TOP_CORRELATIONS:]
243
+ if highest is None and top:
244
+ highest = top[-1].get("max")
245
+ passes: bool | None = None
246
+ if highest is not None:
247
+ passes = highest < threshold
248
+ elif isinstance(payload, dict) and payload.get("records") == []:
249
+ passes = True # BRAIN answered with an empty list: nothing to correlate against.
250
+ return CorrelationResult(
251
+ alpha_id=alpha_id,
252
+ kind=kind,
253
+ max_correlation=highest,
254
+ min_correlation=lowest,
255
+ threshold=threshold,
256
+ passes=passes,
257
+ count=len(rows),
258
+ top=top[:TOP_CORRELATIONS],
259
+ )