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.
- alphaspec/__init__.py +8 -0
- alphaspec/_data/catalog/i18n/en.json +1016 -0
- alphaspec/_data/catalog/indicators.json +9790 -0
- alphaspec/_data/docs/MARKETS.md +29 -0
- alphaspec/_data/docs/RULES.md +133 -0
- alphaspec/_data/docs/RUNTIME_API.md +80 -0
- alphaspec/_data/docs/SPEC.md +99 -0
- alphaspec/_data/examples/artifacts/01-ema-cross-us-equity.json +20 -0
- alphaspec/_data/examples/artifacts/02-rsi-reversion-params.json +25 -0
- alphaspec/_data/examples/artifacts/03-mtf-trend-pullback.json +25 -0
- alphaspec/_data/examples/artifacts/04-breakout-retest-sequence.json +58 -0
- alphaspec/_data/examples/artifacts/05-cn-limit-up-pullback.json +33 -0
- alphaspec/_data/examples/scores/01-model-scores-cn-stock.json +17 -0
- alphaspec/_data/examples/scores/02-target-weights-us-equity.json +13 -0
- alphaspec/_data/schema/0.1/artifact.schema.json +259 -0
- alphaspec/_data/schema/0.1/common.schema.json +96 -0
- alphaspec/_data/schema/0.1/evidence.schema.json +152 -0
- alphaspec/_data/schema/0.1/gate-result.schema.json +55 -0
- alphaspec/_data/schema/0.1/score.schema.json +79 -0
- alphaspec/_data/skills/alphaspec/SKILL.md +93 -0
- alphaspec/_resources.py +77 -0
- alphaspec/cli.py +360 -0
- alphaspec/client.py +146 -0
- alphaspec/lints.py +266 -0
- alphaspec/mcp_server.py +231 -0
- alphaspec/validate.py +108 -0
- alphaspec-0.1.0.dist-info/METADATA +322 -0
- alphaspec-0.1.0.dist-info/RECORD +31 -0
- alphaspec-0.1.0.dist-info/WHEEL +4 -0
- alphaspec-0.1.0.dist-info/entry_points.txt +3 -0
- 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")
|
alphaspec/mcp_server.py
ADDED
|
@@ -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
|