alphaspec 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 (31) hide show
  1. alphaspec/__init__.py +8 -0
  2. alphaspec/_data/catalog/i18n/en.json +1016 -0
  3. alphaspec/_data/catalog/indicators.json +9790 -0
  4. alphaspec/_data/docs/MARKETS.md +29 -0
  5. alphaspec/_data/docs/RULES.md +133 -0
  6. alphaspec/_data/docs/RUNTIME_API.md +80 -0
  7. alphaspec/_data/docs/SPEC.md +99 -0
  8. alphaspec/_data/examples/artifacts/01-ema-cross-us-equity.json +20 -0
  9. alphaspec/_data/examples/artifacts/02-rsi-reversion-params.json +25 -0
  10. alphaspec/_data/examples/artifacts/03-mtf-trend-pullback.json +25 -0
  11. alphaspec/_data/examples/artifacts/04-breakout-retest-sequence.json +58 -0
  12. alphaspec/_data/examples/artifacts/05-cn-limit-up-pullback.json +33 -0
  13. alphaspec/_data/examples/scores/01-model-scores-cn-stock.json +17 -0
  14. alphaspec/_data/examples/scores/02-target-weights-us-equity.json +13 -0
  15. alphaspec/_data/schema/0.1/artifact.schema.json +259 -0
  16. alphaspec/_data/schema/0.1/common.schema.json +96 -0
  17. alphaspec/_data/schema/0.1/evidence.schema.json +152 -0
  18. alphaspec/_data/schema/0.1/gate-result.schema.json +55 -0
  19. alphaspec/_data/schema/0.1/score.schema.json +79 -0
  20. alphaspec/_data/skills/alphaspec/SKILL.md +93 -0
  21. alphaspec/_resources.py +77 -0
  22. alphaspec/cli.py +360 -0
  23. alphaspec/client.py +146 -0
  24. alphaspec/lints.py +266 -0
  25. alphaspec/mcp_server.py +231 -0
  26. alphaspec/validate.py +108 -0
  27. alphaspec-0.1.0.dist-info/METADATA +322 -0
  28. alphaspec-0.1.0.dist-info/RECORD +31 -0
  29. alphaspec-0.1.0.dist-info/WHEEL +4 -0
  30. alphaspec-0.1.0.dist-info/entry_points.txt +3 -0
  31. alphaspec-0.1.0.dist-info/licenses/LICENSE +202 -0
alphaspec/lints.py ADDED
@@ -0,0 +1,266 @@
1
+ """Semantic lint rules. Each rule exists because the mistake it catches was made in
2
+ production at least once and the runtime accepted it without complaint.
3
+
4
+ Every rule code here must be documented in docs/RULES.md (enforced by tests).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import datetime as _dt
9
+ from collections.abc import Iterator
10
+ from typing import Any
11
+
12
+ from ._resources import load_catalog
13
+ from .validate import Issue
14
+
15
+ RULES: dict[str, str] = {
16
+ "AS001": "Indicator reference does not resolve",
17
+ "AS002": "Parameter reference does not resolve",
18
+ "AS003": "Higher-timeframe indicator uses LIVE bars (look-ahead)",
19
+ "AS004": "Indicator references form a cycle",
20
+ "AS005": "Unknown indicator type",
21
+ "AS006": "Unknown indicator parameter",
22
+ "AS007": "Indicator not available on this market",
23
+ "AS008": "timeframe/barPolicy ignored for primary-series-only indicator",
24
+ "AS009": "Indicator timeframe finer than primary timeframe",
25
+ "AS010": "tick timeframe cannot be bar-backtested",
26
+ "AS011": "Symbol missing venue suffix",
27
+ "AS012": "searchTrials required for generated output",
28
+ "AS013": "No exit rule",
29
+ "AS014": "Intraday indicator on a too-coarse primary timeframe",
30
+ "AS015": "Indicator defined but never used",
31
+ "AS101": "Duplicate symbol in submission",
32
+ "AS102": "Every score is null",
33
+ "AS103": "asOf is in the future",
34
+ "AS104": "Zeros look like missing values",
35
+ "AS105": "Weights sum above 1",
36
+ }
37
+
38
+ _TF_SECONDS = {"tick": 1, "1m": 60, "5m": 300, "15m": 900, "30m": 1800,
39
+ "1h": 3600, "4h": 14400, "1d": 86400, "1w": 604800}
40
+
41
+ # Markets whose codes are only unique together with a venue suffix (600000.SH vs 600000.SZ...).
42
+ _SUFFIX_MARKET_PREFIXES = ("cn_", "hk_")
43
+
44
+ # Built-in aliases the runtime resolves without an indicators entry.
45
+ _BUILTIN_ALIASES = {"close"}
46
+
47
+
48
+ def _issue(code: str, sev: str, path: str, msg: str, hint: str = "") -> Issue:
49
+ return Issue(code, sev, path, msg, hint)
50
+
51
+
52
+ def run(doc: dict[str, Any], kind: str) -> list[Issue]:
53
+ if kind == "artifact":
54
+ return list(_artifact(doc))
55
+ if kind == "score":
56
+ return list(_score(doc))
57
+ return []
58
+
59
+
60
+ # ---------------------------------------------------------------- shared
61
+
62
+ def _walk(node: Any, path: str) -> Iterator[tuple[str, Any]]:
63
+ yield path, node
64
+ if isinstance(node, dict):
65
+ for k, v in node.items():
66
+ yield from _walk(v, f"{path}/{k}")
67
+ elif isinstance(node, list):
68
+ for i, v in enumerate(node):
69
+ yield from _walk(v, f"{path}/{i}")
70
+
71
+
72
+ def _ind_refs(node: Any, path: str) -> Iterator[tuple[str, str]]:
73
+ for p, n in _walk(node, path):
74
+ if isinstance(n, dict) and isinstance(n.get("ind"), str):
75
+ yield p, n["ind"]
76
+
77
+
78
+ def _param_refs(node: Any, path: str) -> Iterator[tuple[str, str]]:
79
+ for p, n in _walk(node, path):
80
+ if isinstance(n, dict) and set(n) == {"ref"} and isinstance(n["ref"], str):
81
+ yield p, n["ref"]
82
+
83
+
84
+ def _symbols_need_suffix(market: str) -> bool:
85
+ return market.startswith(_SUFFIX_MARKET_PREFIXES)
86
+
87
+
88
+ def _check_symbol(market: str, symbol: str, path: str) -> Iterator[Issue]:
89
+ if _symbols_need_suffix(market) and "." not in symbol:
90
+ yield _issue("AS011", "error", path,
91
+ f"symbol {symbol!r} has no venue suffix; on {market} the suffix is part of the identity",
92
+ "write e.g. 600000.SH / 000001.SZ / 00700.HK")
93
+
94
+
95
+ def _check_producer(producer: dict[str, Any], path: str) -> Iterator[Issue]:
96
+ if producer.get("kind") in ("AI_AGENT", "FACTOR_MINER") and "searchTrials" not in producer:
97
+ yield _issue("AS012", "error", f"{path}/searchTrials",
98
+ f"producer.kind={producer['kind']} must report searchTrials",
99
+ "count every candidate you evaluated before submitting this one; "
100
+ "it feeds the multiple-testing correction")
101
+
102
+
103
+ # ---------------------------------------------------------------- artifact
104
+
105
+ def _artifact(doc: dict[str, Any]) -> Iterator[Issue]:
106
+ market: str = doc["market"]
107
+ primary: str = doc["timeframe"]
108
+ dsl: dict[str, Any] = doc["dsl"]
109
+ indicators: dict[str, Any] = dsl["indicators"]
110
+ params: dict[str, Any] = dsl.get("params", {})
111
+ catalog = load_catalog()
112
+ by_type = {t["type"]: t for t in catalog["types"]}
113
+ primary_only = set(catalog.get("primarySeriesOnly", []))
114
+ common_keys = set(catalog.get("commonKeys", []))
115
+
116
+ known_aliases = set(indicators) | _BUILTIN_ALIASES
117
+
118
+ # AS001 / AS002 - every reference resolves.
119
+ for p, alias in _ind_refs(dsl, "/dsl"):
120
+ if alias not in known_aliases:
121
+ yield _issue("AS001", "error", p, f"indicator alias {alias!r} is not defined in dsl.indicators",
122
+ "an unresolved reference compiles to nothing and the rule never fires")
123
+ for p, name in _param_refs(dsl, "/dsl"):
124
+ if name not in params:
125
+ yield _issue("AS002", "error", p, f"parameter {name!r} is not defined in dsl.params")
126
+
127
+ # AS004 - cycles between indicators.
128
+ graph = {a: {r for _, r in _ind_refs(spec, "")} & set(indicators) for a, spec in indicators.items()}
129
+ yield from _cycles(graph)
130
+
131
+ # AS015 - unused indicators.
132
+ used = {r for _, r in _ind_refs({"entry": dsl["entry"], "exit": dsl.get("exit")}, "")}
133
+ frontier = list(used)
134
+ while frontier:
135
+ for r in graph.get(frontier.pop(), ()):
136
+ if r not in used:
137
+ used.add(r)
138
+ frontier.append(r)
139
+ for alias in indicators:
140
+ if alias not in used:
141
+ yield _issue("AS015", "warning", f"/dsl/indicators/{alias}",
142
+ f"indicator {alias!r} is never referenced by entry/exit")
143
+
144
+ for alias, spec in indicators.items():
145
+ path = f"/dsl/indicators/{alias}"
146
+ itype = spec["type"]
147
+ meta = by_type.get(itype)
148
+ if meta is None:
149
+ yield _issue("AS005", "error", f"{path}/type", f"unknown indicator type {itype!r}",
150
+ "see catalog/indicators.json for the supported types")
151
+ continue
152
+
153
+ allowed = common_keys | {p["key"] for p in meta.get("params", [])} \
154
+ | set(meta.get("operandKeys", [])) | {"in", "left", "right"}
155
+ for key in spec:
156
+ if key not in allowed:
157
+ yield _issue("AS006", "warning", f"{path}/{key}",
158
+ f"{itype} has no parameter {key!r}; it will be ignored")
159
+
160
+ profile = meta.get("profile", "core")
161
+ if profile == "ext.cn" and not market.startswith("cn_"):
162
+ yield _issue("AS007", "error", f"{path}/type",
163
+ f"{itype} encodes China-market rules (profile ext.cn) and has no meaning on {market}")
164
+
165
+ tf = spec.get("timeframe")
166
+ if itype in primary_only and (tf or "barPolicy" in spec):
167
+ yield _issue("AS008", "warning", path,
168
+ f"{itype} is always computed on the primary series; timeframe/barPolicy are ignored")
169
+ continue
170
+
171
+ if tf:
172
+ if _TF_SECONDS[tf] < _TF_SECONDS[primary]:
173
+ yield _issue("AS009", "error", f"{path}/timeframe",
174
+ f"indicator timeframe {tf} is finer than the primary timeframe {primary}",
175
+ "a finer series cannot be derived from coarser bars; lower the primary timeframe")
176
+ elif _TF_SECONDS[tf] > _TF_SECONDS[primary] and spec.get("barPolicy") == "LIVE":
177
+ yield _issue("AS003", "error", f"{path}/barPolicy",
178
+ f"{tf} indicator on a {primary} strategy with barPolicy=LIVE reads the "
179
+ f"still-forming {tf} bar; in a backtest that bar already contains the future",
180
+ "use barPolicy CLOSED (the default)")
181
+
182
+ max_tf = meta.get("maxPrimaryTimeframe")
183
+ if max_tf and _TF_SECONDS[primary] > _TF_SECONDS.get(max_tf, 10**9):
184
+ yield _issue("AS014", "error", f"{path}/type",
185
+ f"{itype} is an intraday indicator and needs a primary timeframe of {max_tf} or finer, "
186
+ f"strategy runs on {primary}")
187
+
188
+ if primary == "tick":
189
+ yield _issue("AS010", "warning", "/timeframe",
190
+ "tick strategies are evaluated per market-data frame; that dimension does not exist "
191
+ "in bar history, so bar backtests will refuse them")
192
+
193
+ if "exit" not in dsl:
194
+ yield _issue("AS013", "warning", "/dsl",
195
+ "no exit rule: positions close only through stop rules or rebalancing")
196
+
197
+ universe = doc.get("universe") or {}
198
+ for i, sym in enumerate(universe.get("symbols", [])):
199
+ yield from _check_symbol(market, sym, f"/universe/symbols/{i}")
200
+
201
+ yield from _check_producer(doc["producer"], "/producer")
202
+
203
+
204
+ def _cycles(graph: dict[str, set[str]]) -> Iterator[Issue]:
205
+ WHITE, GREY, BLACK = 0, 1, 2
206
+ color = {n: WHITE for n in graph}
207
+ reported: set[frozenset[str]] = set()
208
+
209
+ def visit(n: str, stack: list[str]) -> Iterator[Issue]:
210
+ color[n] = GREY
211
+ stack.append(n)
212
+ for m in sorted(graph[n]):
213
+ if color[m] == GREY:
214
+ cyc = stack[stack.index(m):]
215
+ key = frozenset(cyc)
216
+ if key not in reported:
217
+ reported.add(key)
218
+ yield _issue("AS004", "error", f"/dsl/indicators/{m}",
219
+ "indicator references form a cycle: " + " -> ".join(cyc + [m]))
220
+ elif color[m] == WHITE:
221
+ yield from visit(m, stack)
222
+ stack.pop()
223
+ color[n] = BLACK
224
+
225
+ for n in sorted(graph):
226
+ if color[n] == WHITE:
227
+ yield from visit(n, [])
228
+
229
+
230
+ # ---------------------------------------------------------------- score
231
+
232
+ def _score(doc: dict[str, Any]) -> Iterator[Issue]:
233
+ market = doc["market"]
234
+ rows_key = "scores" if "scores" in doc else "weights"
235
+ rows = doc[rows_key]
236
+
237
+ seen: dict[str, int] = {}
238
+ for i, row in enumerate(rows):
239
+ sym = row["symbol"]
240
+ if sym in seen:
241
+ yield _issue("AS101", "error", f"/{rows_key}/{i}/symbol",
242
+ f"{sym} also appears at index {seen[sym]}")
243
+ else:
244
+ seen[sym] = i
245
+ yield from _check_symbol(market, sym, f"/{rows_key}/{i}/symbol")
246
+
247
+ if rows_key == "scores":
248
+ values = [r["score"] for r in rows]
249
+ if all(v is None for v in values):
250
+ yield _issue("AS102", "error", "/scores", "every score is null; nothing can be ranked")
251
+ zeros = sum(1 for v in values if v == 0)
252
+ if zeros and None not in values and zeros / len(values) >= 0.2:
253
+ yield _issue("AS104", "warning", "/scores",
254
+ f"{zeros} of {len(values)} scores are exactly 0 and none are null",
255
+ "if 0 means 'no data', send null: 0 is a real rank position")
256
+ else:
257
+ total = sum(r["weight"] for r in rows)
258
+ if total > 1 + 1e-9:
259
+ yield _issue("AS105", "error", "/weights", f"weights sum to {total:.6f}; must be <= 1 (remainder is cash)")
260
+
261
+ as_of = _dt.date.fromisoformat(doc["asOf"])
262
+ # One day of slack: asOf is in the market's calendar, which can be ahead of UTC.
263
+ if as_of > _dt.datetime.now(_dt.timezone.utc).date() + _dt.timedelta(days=1):
264
+ yield _issue("AS103", "error", "/asOf", f"asOf {as_of} is in the future")
265
+
266
+ yield from _check_producer(doc["producer"], "/producer")
@@ -0,0 +1,231 @@
1
+ """MCP server (stdio) for agents that cannot run a shell.
2
+
3
+ pip install "alphaspec[mcp]"
4
+ alphaspec-mcp # started by the MCP client, not by hand
5
+
6
+ The harness talks MCP (JSON-RPC over stdio) to this process; this process talks HTTPS to the
7
+ runtime's /api/agent/v1 with the same :class:`Client` the CLI uses. Nothing here adds a
8
+ capability the public API does not already have, and no tool can place orders.
9
+
10
+ Offline tools (validate, catalog, schema, examples, docs) work without any configuration.
11
+ Runtime tools read ALPHASPEC_BASE_URL and ALPHASPEC_API_KEY from the environment the client
12
+ passes to this process.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from typing import Any
18
+
19
+ try: # mcp >= 2
20
+ from mcp.server.mcpserver import MCPServer
21
+ from mcp.server.mcpserver.exceptions import ToolError
22
+ except ImportError: # mcp 1.x
23
+ from mcp.server.fastmcp import FastMCP as MCPServer # type: ignore[no-redef]
24
+ from mcp.server.fastmcp.exceptions import ToolError # type: ignore[no-redef]
25
+
26
+ from . import __version__
27
+ from ._resources import data_root, default_lang, load_catalog, load_schema, localized
28
+ from .cli import _DOCS, _market_allows
29
+ from .client import AlphaSpecError, Client, Rejected
30
+ from .lints import RULES
31
+ from .validate import validate as _validate
32
+
33
+ INSTRUCTIONS = """AlphaSpec documents are JSON trading-strategy contracts with no code in them.
34
+ Never invent indicator names or parameter keys: find them with search_indicators / get_indicator
35
+ and read each entry's pitfalls. Start from get_example, then call validate until there are no
36
+ errors. Constants are {"const": n}, never bare numbers. Set producer.searchTrials to every
37
+ candidate you evaluated, discarded ones included. Submit only when the user wants the strategy
38
+ run; evaluations have a daily quota, so do not loop submissions hunting for a passing variant.
39
+ Never state performance numbers the runtime did not return. promote creates a DISABLED paper
40
+ strategy; tell the user to start it themselves. No tool here can place orders."""
41
+
42
+ server = MCPServer("alphaspec", instructions=INSTRUCTIONS, version=__version__)
43
+
44
+
45
+ def _client() -> Client:
46
+ try:
47
+ return Client()
48
+ except ValueError as exc:
49
+ raise ToolError(f"{exc}. Set it in this MCP server's env block in the client config.") from None
50
+
51
+
52
+ def _call(fn, *args):
53
+ try:
54
+ return fn(*args)
55
+ except Rejected:
56
+ raise
57
+ except AlphaSpecError as exc:
58
+ raise ToolError(str(exc)) from None
59
+ except OSError as exc:
60
+ raise ToolError(f"cannot reach runtime: {exc}") from None
61
+
62
+
63
+ def _report(doc: Any) -> dict[str, Any]:
64
+ r = _validate(doc)
65
+ return {"ok": r.ok, "kind": r.kind, "errors": len(r.errors), "warnings": len(r.warnings),
66
+ "issues": [i.__dict__ for i in r.issues]}
67
+
68
+
69
+ def _lang(lang: str) -> str:
70
+ if lang and lang not in ("en", "zh"):
71
+ raise ToolError("lang must be en or zh")
72
+ return lang or default_lang()
73
+
74
+
75
+ def _as_doc(document: dict[str, Any] | str) -> Any:
76
+ # Some clients pass nested objects as a JSON string.
77
+ if isinstance(document, str):
78
+ try:
79
+ return json.loads(document)
80
+ except json.JSONDecodeError as exc:
81
+ raise ToolError(f"document is not valid JSON: {exc}") from None
82
+ return document
83
+
84
+
85
+ # ---------------------------------------------------------------- offline
86
+
87
+ @server.tool()
88
+ def validate(document: dict[str, Any] | str) -> dict[str, Any]:
89
+ """Validate an AlphaSpec document (artifact or score) locally, with the same rules the runtime
90
+ applies. Fix every error; treat warnings as probable mistakes. Use explain_rule for any code."""
91
+ return _report(_as_doc(document))
92
+
93
+
94
+ @server.tool()
95
+ def explain_rule(code: str) -> str:
96
+ """Why a lint rule (e.g. AS003) exists and how to fix a document that breaks it."""
97
+ code = code.upper()
98
+ text = (data_root() / "docs" / "RULES.md").read_text(encoding="utf-8")
99
+ start = text.find(f"### {code}")
100
+ if start < 0:
101
+ raise ToolError(f"unknown rule {code}; known: {', '.join(RULES)}")
102
+ end = text.find("\n### ", start + 1)
103
+ return text[start:end if end > 0 else None].strip()
104
+
105
+
106
+ @server.tool()
107
+ def search_indicators(query: str = "", market: str = "", profile: str = "", lang: str = "") -> list[dict[str, Any]]:
108
+ """Find indicator types you may use. query matches name, summary and parameters in English or
109
+ Chinese (e.g. "RSI", "moving average", "breakout", "均线"); market hides types unavailable there
110
+ (e.g. us_equity hides China-only ones); profile is core | ext.cn | ext.l2 | ext.data; lang is en | zh.
111
+ Returns a compact list; call get_indicator for parameters, units and pitfalls before using a type."""
112
+ lang = _lang(lang)
113
+ q = query.lower()
114
+ rows = []
115
+ for t in load_catalog()["types"]:
116
+ if profile and t["profile"] != profile:
117
+ continue
118
+ if not _market_allows(t["profile"], market or None):
119
+ continue
120
+ if q and q not in json.dumps(t, ensure_ascii=False).lower():
121
+ continue
122
+ loc = localized(t, lang)
123
+ rows.append({"type": t["type"], "profile": t["profile"], "label": loc.get("label", ""),
124
+ "summary": loc.get("summary", ""),
125
+ "params": [p["key"] for p in t.get("params", [])],
126
+ "operands": t.get("operandKeys", [])})
127
+ return rows
128
+
129
+
130
+ @server.tool()
131
+ def get_indicator(type: str, lang: str = "") -> dict[str, Any]:
132
+ """Full catalog entry for one indicator type: parameter keys, defaults, ranges, unit, pitfalls.
133
+ lang is en | zh."""
134
+ t = next((x for x in load_catalog()["types"] if x["type"] == type.upper()), None)
135
+ if t is None:
136
+ raise ToolError(f"unknown indicator type {type}; use search_indicators")
137
+ return localized(t, _lang(lang))
138
+
139
+
140
+ @server.tool()
141
+ def get_schema(kind: str) -> dict[str, Any]:
142
+ """JSON Schema for a document kind: artifact, score, gate-result, evidence or common."""
143
+ if kind not in ("artifact", "score", "gate-result", "evidence", "common"):
144
+ raise ToolError("kind must be one of artifact, score, gate-result, evidence, common")
145
+ return load_schema(kind)
146
+
147
+
148
+ @server.tool()
149
+ def list_examples() -> list[dict[str, Any]]:
150
+ """Example documents to start from."""
151
+ out = []
152
+ for f in sorted((data_root() / "examples").rglob("*.json")):
153
+ doc = json.loads(f.read_text(encoding="utf-8"))
154
+ out.append({"name": f.stem, "kind": doc.get("kind"), "market": doc.get("market"),
155
+ "title": doc.get("title") or doc.get("scoreName")})
156
+ return out
157
+
158
+
159
+ @server.tool()
160
+ def get_example(name: str) -> dict[str, Any]:
161
+ """One example document by name (a prefix such as "01-ema-cross" is enough)."""
162
+ match = [f for f in sorted((data_root() / "examples").rglob("*.json"))
163
+ if f.stem == name or f.stem.startswith(name)]
164
+ if not match:
165
+ raise ToolError(f"no example named {name}; use list_examples")
166
+ return json.loads(match[0].read_text(encoding="utf-8"))
167
+
168
+
169
+ @server.tool()
170
+ def get_doc(name: str) -> str:
171
+ """Reference document: spec (field semantics), rules (every lint rule), markets, runtime (API and gate)."""
172
+ if name not in _DOCS:
173
+ raise ToolError(f"name must be one of {', '.join(sorted(_DOCS))}")
174
+ return (data_root() / "docs" / _DOCS[name]).read_text(encoding="utf-8")
175
+
176
+
177
+ # ---------------------------------------------------------------- runtime
178
+
179
+ @server.tool()
180
+ def whoami() -> dict[str, Any]:
181
+ """Check the configured API key: user, contract version, what the key can and cannot do."""
182
+ return _call(_client().whoami)
183
+
184
+
185
+ @server.tool()
186
+ def submit(document: dict[str, Any] | str) -> dict[str, Any]:
187
+ """Validate locally, then submit an artifact to the runtime. Returns {accepted, artifactId,
188
+ warnings} or {accepted: false, issues}. Submitting does not run anything; call evaluate."""
189
+ doc = _as_doc(document)
190
+ try:
191
+ res = _call(_client().submit, doc)
192
+ except Rejected as exc: # subclass of AlphaSpecError, but a normal outcome for an agent
193
+ return {"accepted": False, "issues": exc.issues}
194
+ return {"accepted": True, **res}
195
+
196
+
197
+ @server.tool()
198
+ def evaluate(artifact_id: int) -> dict[str, Any]:
199
+ """Start a platform evaluation (one backtest, in-sample and out-of-sample segments chosen by the
200
+ runtime). Returns immediately; poll gate_result every few seconds. Uses the daily quota."""
201
+ return _call(_client().evaluate, artifact_id)
202
+
203
+
204
+ @server.tool()
205
+ def gate_result(artifact_id: int) -> dict[str, Any]:
206
+ """Latest evaluation of an artifact. state is RUNNING (poll again later), FAILED (error says why)
207
+ or DONE (result is the gate-result document). Each check names its number in `metric`; read it
208
+ instead of guessing from the code."""
209
+ state, body = _call(_client().gate_result, artifact_id)
210
+ return {"state": state, "result" if state == "DONE" else "detail": body}
211
+
212
+
213
+ @server.tool()
214
+ def list_submissions() -> list[dict[str, Any]]:
215
+ """My submitted artifacts, newest first, with the latest verdict."""
216
+ return _call(_client().list)
217
+
218
+
219
+ @server.tool()
220
+ def promote(artifact_id: int, name: str = "") -> dict[str, Any]:
221
+ """Turn a gate-passed artifact into a paper-trading strategy. It is created DISABLED; the user
222
+ starts it in the runtime's UI. Only call this when the user asked for it."""
223
+ return _call(_client().promote, artifact_id, name or None)
224
+
225
+
226
+ def main() -> None:
227
+ server.run()
228
+
229
+
230
+ if __name__ == "__main__":
231
+ main()
alphaspec/validate.py ADDED
@@ -0,0 +1,108 @@
1
+ """Validate AlphaSpec documents: JSON Schema first, then semantic lint rules.
2
+
3
+ Schema validation answers "is this well-formed". Lint rules answer "will this do what
4
+ its author thinks": unresolved references, look-ahead, indicators that cannot exist on
5
+ this market, and similar mistakes that a runtime would otherwise accept and run wrongly.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ from jsonschema import Draft202012Validator, FormatChecker
13
+ from referencing import Registry, Resource
14
+
15
+ from ._resources import CONTRACT_VERSION, load_schema, schema_dir
16
+
17
+ KINDS = ("artifact", "score", "gate-result", "evidence")
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Issue:
22
+ code: str
23
+ severity: str # "error" | "warning"
24
+ path: str
25
+ message: str
26
+ hint: str = ""
27
+
28
+ def __str__(self) -> str:
29
+ loc = self.path or "/"
30
+ tail = f" hint: {self.hint}" if self.hint else ""
31
+ return f"{self.severity.upper():7} {self.code} at {loc}: {self.message}{tail}"
32
+
33
+
34
+ @dataclass
35
+ class Report:
36
+ kind: str | None
37
+ issues: list[Issue] = field(default_factory=list)
38
+
39
+ @property
40
+ def errors(self) -> list[Issue]:
41
+ return [i for i in self.issues if i.severity == "error"]
42
+
43
+ @property
44
+ def warnings(self) -> list[Issue]:
45
+ return [i for i in self.issues if i.severity == "warning"]
46
+
47
+ @property
48
+ def ok(self) -> bool:
49
+ return not self.errors
50
+
51
+ def codes(self) -> list[str]:
52
+ return sorted({i.code for i in self.issues})
53
+
54
+
55
+ def _registry(version: str) -> Registry:
56
+ resources = []
57
+ for path in sorted(schema_dir(version).glob("*.schema.json")):
58
+ name = path.name.removesuffix(".schema.json")
59
+ doc = load_schema(name, version)
60
+ resources.append((doc["$id"], Resource.from_contents(doc)))
61
+ return Registry().with_resources(resources)
62
+
63
+
64
+ def _validator(kind: str, version: str = CONTRACT_VERSION) -> Draft202012Validator:
65
+ return Draft202012Validator(
66
+ load_schema(kind, version),
67
+ registry=_registry(version),
68
+ format_checker=FormatChecker(),
69
+ )
70
+
71
+
72
+ def _pointer(parts) -> str:
73
+ return "".join(f"/{p}" for p in parts)
74
+
75
+
76
+ def _schema_issues(doc: Any, kind: str) -> list[Issue]:
77
+ validator = _validator(kind)
78
+ out = []
79
+ for err in sorted(validator.iter_errors(doc), key=lambda e: list(e.absolute_path)):
80
+ # oneOf / anyOf failures are reported as a single opaque message; surface the
81
+ # most specific sub-error instead so the author sees which field is wrong.
82
+ best = err
83
+ if err.context:
84
+ best = min(err.context, key=lambda e: (-len(e.absolute_path), len(e.message)))
85
+ out.append(Issue("SCHEMA", "error", _pointer(best.absolute_path), best.message))
86
+ return out
87
+
88
+
89
+ def validate(doc: Any, kind: str | None = None) -> Report:
90
+ """Validate one document. kind is read from doc['kind'] when not given."""
91
+ if kind is None and isinstance(doc, dict):
92
+ kind = doc.get("kind")
93
+ if kind not in KINDS:
94
+ return Report(kind, [Issue("UNKNOWN_KIND", "error", "/kind",
95
+ f"kind must be one of {', '.join(KINDS)}, got {kind!r}")])
96
+ if isinstance(doc, dict) and doc.get("contractVersion") not in (None, CONTRACT_VERSION):
97
+ return Report(kind, [Issue("UNSUPPORTED_VERSION", "error", "/contractVersion",
98
+ f"this validator understands contractVersion {CONTRACT_VERSION}, "
99
+ f"got {doc.get('contractVersion')!r}")])
100
+
101
+ report = Report(kind, _schema_issues(doc, kind))
102
+ if report.errors:
103
+ # Lint rules assume a well-formed document.
104
+ return report
105
+
106
+ from . import lints
107
+ report.issues.extend(lints.run(doc, kind))
108
+ return report