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
@@ -0,0 +1,93 @@
1
+ ---
2
+ name: alphaspec
3
+ description: Write, fix, validate and submit AlphaSpec documents — declarative trading-strategy artifacts (a rule tree of indicators with entry/exit conditions, universe and risk settings) and model score submissions — and run them on an AlphaSpec runtime for backtest, gate and paper trading. Use when asked to turn a trading idea into a strategy, write or edit a strategy as JSON/DSL, explain an AlphaSpec validation error (SCHEMA, AS001–AS105), or submit / evaluate / promote a strategy.
4
+ ---
5
+
6
+ # AlphaSpec
7
+
8
+ AlphaSpec documents are JSON and contain no code. Everything you need comes from the `alphaspec` command; do not guess indicator names or parameter keys.
9
+
10
+ ## Setup (once)
11
+
12
+ ```bash
13
+ pip install alphaspec # validator, indicator catalog, schemas, examples, runtime client
14
+ alphaspec --version
15
+ ```
16
+
17
+ If you have no shell but the `alphaspec` MCP server is connected, use its tools instead: the same steps map to `list_examples`/`get_example`, `search_indicators`/`get_indicator`, `validate`/`explain_rule`, `submit`, `evaluate` then poll `gate_result`, and `promote`.
18
+
19
+ ## Workflow
20
+
21
+ 1. **Pick the document type.**
22
+ - The user has a model or factor producing a number per symbol per day → `kind: "score"` (see `alphaspec examples 01-model-scores` and `alphaspec schema score`).
23
+ - The user describes conditions on indicators → `kind: "artifact"`. The rest of this guide is about artifacts.
24
+ 2. **Start from an example**: `alphaspec examples` lists them; `alphaspec examples 01-ema-cross` prints one.
25
+ 3. **Choose market and timeframe.** Markets: `alphaspec docs markets`. Timeframes are exactly `tick 1m 5m 15m 30m 1h 4h 1d 1w` (`1h`, never `60m`).
26
+ 4. **Find indicators** — never invent names:
27
+ - `alphaspec catalog --market <market> --search <word>` to find candidates (search in English or Chinese: `RSI`, `moving average`, `breakout`, `均线`). Add `--lang zh` or `--lang en` to pick the description language; the default follows the locale.
28
+ - `alphaspec catalog --type <TYPE>` for the full entry: parameter keys, defaults, ranges, meaning, unit and **pitfalls**. Read the pitfalls; several conditions that look natural can never be true (e.g. `close > DAY_HIGH`).
29
+ 5. **Write the document**, then **validate until clean**:
30
+ ```bash
31
+ alphaspec validate doc.json # fix every ERROR; treat WARNINGs as probable mistakes
32
+ alphaspec rules --explain AS003 # why a rule exists and how to fix it
33
+ ```
34
+ 6. **Submit and evaluate** (only if the user wants it run; see below).
35
+
36
+ ## Rule tree essentials
37
+
38
+ ```json
39
+ "dsl": {
40
+ "version": 1,
41
+ "params": { "fastPeriod": 20 },
42
+ "indicators": {
43
+ "fast": { "type": "EMA", "period": { "ref": "fastPeriod" } },
44
+ "slow": { "type": "EMA", "period": 60 },
45
+ "trend": { "type": "EMA", "period": 50, "timeframe": "1d", "barPolicy": "CLOSED" }
46
+ },
47
+ "entry": { "type": "AND",
48
+ "left": { "type": "CROSS_UP", "left": { "ind": "fast" }, "right": { "ind": "slow" } },
49
+ "right": { "type": "GT", "left": { "ind": "close" }, "right": { "ind": "trend" } } },
50
+ "exit": { "type": "CROSS_DOWN", "left": { "ind": "fast" }, "right": { "ind": "slow" } }
51
+ }
52
+ ```
53
+
54
+ - `AND` / `OR`: `left` and `right` are **rules**. `NOT` / `DAY_LATCH`: the child goes in `rule`, not `left`.
55
+ - Comparisons `GT GTE LT LTE EQ NEQ CROSS_UP CROSS_DOWN`: `left` / `right` are **operands**.
56
+ - An operand is `{"ind": alias}`, `{"const": n}`, `{"ref": param}`, inline arithmetic `{"op": "SUB", "left": …, "right": …}` or `{"op": "COUNT" | "BARS_SINCE", "rule": …, "bars": n}`. **Never a bare number**: `"right": 30` is invalid, write `{"const": 30}`.
57
+ - `close` is a built-in alias. Every other alias must be declared in `indicators`.
58
+ - Temporal rules: `HOLD_FOR` (held each of the last N bars), `WITHIN` (held at least once in the last N), `SEQUENCE` (`first` then `then` within N bars).
59
+ - Level events `TOUCH BREAK RECLAIM RETEST HOLD` take any operand as `level` (a band, a moving average, a pivot).
60
+ - An indicator on a coarser `timeframe` than the strategy's must use `barPolicy: "CLOSED"` (the default). `LIVE` there is look-ahead (AS003).
61
+ - Put anything tunable in `dsl.params` and reference it with `{"ref": …}`.
62
+ - Symbols on `cn_*` / `hk_*` carry the venue suffix: `600000.SH`, `000001.SZ`, `00700.HK` (AS011).
63
+ - Indicators of profile `ext.cn` (price-limit streaks, sealed boards, call auction) exist only on `cn_*` markets.
64
+
65
+ ## Producer
66
+
67
+ If you (an AI) wrote or tuned the document: `"producer": {"kind": "AI_AGENT", "name": "<your tool>@<version>", "modelId": "<model>", "searchTrials": N}`, where `N` counts **every** candidate evaluated in this search, including discarded ones. Under-reporting it hides search luck from the gate.
68
+
69
+ ## Submitting to the runtime
70
+
71
+ Needs `ALPHASPEC_BASE_URL` and `ALPHASPEC_API_KEY` (the user creates the key in the runtime's account page; never ask them to paste it into a file you commit).
72
+
73
+ ```bash
74
+ alphaspec whoami # check the key first
75
+ alphaspec submit doc.json --evaluate # validate, submit, evaluate on platform-chosen windows, print the gate result
76
+ alphaspec result <artifactId> # re-read the latest gate result
77
+ alphaspec promote <artifactId> # only after the gate passed; creates a DISABLED paper strategy
78
+ ```
79
+
80
+ - Exit code 1 = rejected, gate not passed or evaluation failed. Read the printed checks and hints, change the document, submit it again as a new artifact and count it in `searchTrials`.
81
+ - Evaluations have a daily quota. Do not loop submissions hunting for a passing variant; that is exactly the search luck the gate corrects for.
82
+ - Never state backtest numbers the runtime did not return. After `promote`, tell the user the strategy is created disabled and they start it themselves.
83
+ - Full API and status codes: `alphaspec docs runtime`.
84
+
85
+ ## Never
86
+
87
+ - Put code, scripts or expression strings into a document.
88
+ - Include orders, account ids or broker routes.
89
+ - Report performance figures as fields of a submission. The runtime computes them.
90
+
91
+ ## Reference
92
+
93
+ `alphaspec docs spec` (field semantics: time, bars, units) · `alphaspec docs rules` (every lint rule) · `alphaspec schema artifact` (the JSON Schema).
@@ -0,0 +1,77 @@
1
+ """Locate the bundled schema and catalog files.
2
+
3
+ Installed wheels carry them under alphaspec/_data; a source checkout reads them
4
+ from the repository root, so there is exactly one copy to edit.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from functools import lru_cache
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ CONTRACT_VERSION = "0.1"
14
+
15
+ _PKG_DATA = Path(__file__).resolve().parent / "_data"
16
+ _REPO_ROOT = Path(__file__).resolve().parents[2]
17
+
18
+
19
+ def _root() -> Path:
20
+ if (_PKG_DATA / "schema").is_dir():
21
+ return _PKG_DATA
22
+ if (_REPO_ROOT / "schema").is_dir():
23
+ return _REPO_ROOT
24
+ raise FileNotFoundError("alphaspec schema files not found; reinstall the package")
25
+
26
+
27
+ def schema_dir(version: str = CONTRACT_VERSION) -> Path:
28
+ return _root() / "schema" / version
29
+
30
+
31
+ @lru_cache(maxsize=None)
32
+ def load_schema(name: str, version: str = CONTRACT_VERSION) -> dict[str, Any]:
33
+ path = schema_dir(version) / f"{name}.schema.json"
34
+ return json.loads(path.read_text(encoding="utf-8"))
35
+
36
+
37
+ @lru_cache(maxsize=None)
38
+ def load_catalog() -> dict[str, Any]:
39
+ path = _root() / "catalog" / "indicators.json"
40
+ return json.loads(path.read_text(encoding="utf-8"))
41
+
42
+
43
+ def default_lang() -> str:
44
+ """Language for catalog text: $ALPHASPEC_LANG, else zh on a Chinese locale, else en."""
45
+ import os
46
+ lang = os.environ.get("ALPHASPEC_LANG") or ""
47
+ if lang in ("zh", "en"):
48
+ return lang
49
+ loc = os.environ.get("LC_ALL") or os.environ.get("LANG") or ""
50
+ return "zh" if loc.lower().startswith("zh") else "en"
51
+
52
+
53
+ def localized(entry: dict[str, Any], lang: str) -> dict[str, Any]:
54
+ """A catalog entry with summary, pitfalls, label and parameter descriptions in one language.
55
+
56
+ The catalog keeps Chinese in the top-level fields and English under ``en``; agents asked
57
+ for one language should not have to read both."""
58
+ out = {k: v for k, v in entry.items() if k != "en"}
59
+ labels = entry.get("labels") or {}
60
+ if labels:
61
+ out["label"] = labels.get(lang) or labels.get("zh")
62
+ out.pop("labels")
63
+ if lang == "en" and "en" in entry:
64
+ en = entry["en"]
65
+ out["summary"] = en.get("summary", out.get("summary"))
66
+ if "pitfalls" in en:
67
+ out["pitfalls"] = en["pitfalls"]
68
+ descs = en.get("params", {})
69
+ out["params"] = [{**p, "description": descs[p["key"]]} if p["key"] in descs else p
70
+ for p in entry.get("params", [])]
71
+ return out
72
+
73
+
74
+ def data_root() -> Path:
75
+ """Directory holding schema/, catalog/, examples/ and docs/ (installed copy or checkout)."""
76
+ return _root()
77
+
alphaspec/cli.py ADDED
@@ -0,0 +1,360 @@
1
+ """Command line.
2
+
3
+ alphaspec validate FILE... check documents locally
4
+ alphaspec rules [--explain CODE] list lint rules / why one exists
5
+ alphaspec catalog [--market M] [--profile P] [--search TEXT] [--type T] [--lang en|zh]
6
+ indicators you may use, with meaning and parameters
7
+ alphaspec schema KIND print a JSON Schema (artifact, score, gate-result, evidence, common)
8
+ alphaspec examples [NAME] list or print example documents
9
+ alphaspec docs NAME print a reference document (spec, rules, markets, runtime)
10
+ alphaspec skill [--install DIR] print the Agent Skill, or write it to DIR/alphaspec/SKILL.md
11
+
12
+ alphaspec whoami check the API key against the runtime
13
+ alphaspec submit FILE [--evaluate] [--wait] [--promote [--name N]]
14
+ alphaspec evaluate ARTIFACT_ID [--wait]
15
+ alphaspec result ARTIFACT_ID
16
+ alphaspec list
17
+ alphaspec promote ARTIFACT_ID [--name N]
18
+
19
+ Runtime commands read ALPHASPEC_BASE_URL and ALPHASPEC_API_KEY (or --base-url / --api-key).
20
+ Exit codes: 0 ok · 1 rejected, gate not passed or evaluation failed · 2 usage · 3 runtime / network error.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import sys
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from . import __version__
31
+ from .client import AlphaSpecError, Client, EvaluationFailed, Rejected
32
+ from ._resources import data_root, default_lang, load_catalog, load_schema, localized
33
+ from .lints import RULES
34
+ from .validate import validate
35
+
36
+
37
+ def _cmd_validate(args: argparse.Namespace) -> int:
38
+ failed = 0
39
+ for name in args.files:
40
+ path = Path(name)
41
+ try:
42
+ doc = json.loads(path.read_text(encoding="utf-8"))
43
+ except (OSError, json.JSONDecodeError) as exc:
44
+ print(f"{path}: cannot read: {exc}")
45
+ failed += 1
46
+ continue
47
+ report = validate(doc)
48
+ bad = not report.ok or (args.strict and report.warnings)
49
+ failed += bool(bad)
50
+ if args.json:
51
+ print(json.dumps({
52
+ "file": str(path), "kind": report.kind, "ok": not bad,
53
+ "issues": [i.__dict__ for i in report.issues],
54
+ }, ensure_ascii=False))
55
+ continue
56
+ status = "FAIL" if bad else "ok"
57
+ print(f"{path}: {status} ({len(report.errors)} errors, {len(report.warnings)} warnings)")
58
+ for issue in report.issues:
59
+ print(f" {issue}")
60
+ return 1 if failed else 0
61
+
62
+
63
+ def _cmd_rules(args: argparse.Namespace) -> int:
64
+ if args.explain:
65
+ code = args.explain.upper()
66
+ text = (data_root() / "docs" / "RULES.md").read_text(encoding="utf-8")
67
+ start = text.find(f"### {code}")
68
+ if start < 0:
69
+ print(f"unknown rule {code}", file=sys.stderr)
70
+ return 2
71
+ end = text.find("\n### ", start + 1)
72
+ print(text[start:end if end > 0 else None].strip())
73
+ return 0
74
+ for code, title in RULES.items():
75
+ print(f"{code} {title}")
76
+ return 0
77
+
78
+
79
+ def _market_allows(profile: str, market: str | None) -> bool:
80
+ if market is None:
81
+ return True
82
+ if profile == "ext.cn":
83
+ return market.startswith("cn_")
84
+ return True
85
+
86
+
87
+ def _cmd_catalog(args: argparse.Namespace) -> int:
88
+ cat = load_catalog()
89
+ if args.type:
90
+ t = next((x for x in cat["types"] if x["type"] == args.type.upper()), None)
91
+ if t is None:
92
+ print(f"unknown indicator type {args.type}", file=sys.stderr)
93
+ return 2
94
+ print(json.dumps(localized(t, args.lang), ensure_ascii=False, indent=2))
95
+ return 0
96
+ rows = [t for t in cat["types"]
97
+ if (args.profile is None or t["profile"] == args.profile)
98
+ and _market_allows(t["profile"], args.market)
99
+ and (args.search is None or args.search.lower() in json.dumps(t, ensure_ascii=False).lower())]
100
+ rows = [localized(t, args.lang) for t in rows]
101
+ if args.json:
102
+ print(json.dumps(rows, ensure_ascii=False, indent=2))
103
+ return 0
104
+ for t in rows:
105
+ params = ", ".join(f"{p['key']}={p.get('default', '?')}" for p in t.get("params", []))
106
+ operands = ", ".join(t.get("operandKeys", []))
107
+ sig = params or (f"operands: {operands}" if operands else "")
108
+ print(f"{t['type']:28} {t['profile']:8} {sig}")
109
+ if t.get("summary"):
110
+ print(f" {t['summary']}")
111
+ print(f"\n{len(rows)} indicator types. Details: alphaspec catalog --type NAME", file=sys.stderr)
112
+ return 0
113
+
114
+
115
+ def _cmd_schema(args: argparse.Namespace) -> int:
116
+ print(json.dumps(load_schema(args.kind), ensure_ascii=False, indent=2))
117
+ return 0
118
+
119
+
120
+ def _cmd_examples(args: argparse.Namespace) -> int:
121
+ files = sorted((data_root() / "examples").rglob("*.json"))
122
+ if not args.name:
123
+ for f in files:
124
+ doc = json.loads(f.read_text(encoding="utf-8"))
125
+ print(f"{f.stem:36} {doc.get('kind'):9} {doc.get('market'):12} {doc.get('title') or doc.get('scoreName')}")
126
+ return 0
127
+ match = [f for f in files if f.stem == args.name or f.stem.startswith(args.name)]
128
+ if not match:
129
+ print(f"no example named {args.name}", file=sys.stderr)
130
+ return 2
131
+ print(match[0].read_text(encoding="utf-8"), end="")
132
+ return 0
133
+
134
+
135
+ _DOCS = {"spec": "SPEC.md", "rules": "RULES.md", "markets": "MARKETS.md", "runtime": "RUNTIME_API.md"}
136
+
137
+
138
+ def _cmd_docs(args: argparse.Namespace) -> int:
139
+ print((data_root() / "docs" / _DOCS[args.name]).read_text(encoding="utf-8"), end="")
140
+ return 0
141
+
142
+
143
+ def _cmd_skill(args: argparse.Namespace) -> int:
144
+ text = (data_root() / "skills" / "alphaspec" / "SKILL.md").read_text(encoding="utf-8")
145
+ if not args.install:
146
+ print(text, end="")
147
+ return 0
148
+ target = Path(args.install).expanduser() / "alphaspec" / "SKILL.md"
149
+ target.parent.mkdir(parents=True, exist_ok=True)
150
+ target.write_text(text, encoding="utf-8")
151
+ print(f"installed {target}")
152
+ return 0
153
+
154
+
155
+ def _client(args: argparse.Namespace) -> Client:
156
+ return Client(base_url=args.base_url, api_key=args.api_key)
157
+
158
+
159
+ def _out(args: argparse.Namespace, obj: Any, human: str) -> None:
160
+ print(json.dumps(obj, ensure_ascii=False, indent=2) if args.json else human)
161
+
162
+
163
+ def _print_gate(args: argparse.Namespace, gate: dict[str, Any]) -> int:
164
+ if args.json:
165
+ print(json.dumps(gate, ensure_ascii=False, indent=2))
166
+ else:
167
+ verdict = "PASSED" if gate.get("passed") else "NOT PASSED"
168
+ print(f"gate {verdict} (computedBy {gate.get('computedBy')})")
169
+ sample = gate.get("sample") or {}
170
+ if sample:
171
+ line = f" in-sample {sample.get('inSampleFrom')} ~ {sample.get('inSampleTo')}"
172
+ if sample.get("outOfSampleFrom"):
173
+ line += f" · out-of-sample {sample.get('outOfSampleFrom')} ~ {sample.get('outOfSampleTo')}"
174
+ print(line)
175
+ for c in gate.get("checks", []):
176
+ mark = "✓" if c.get("passed") else "✗"
177
+ metric = c.get("metric") or "actual"
178
+ print(f" {mark} {c.get('code'):24} {metric}={c.get('actual')} threshold={c.get('threshold')}")
179
+ # Printed for passing checks too: without it a reader guesses what the number is.
180
+ if c.get("hint"):
181
+ print(f" {c['hint']}")
182
+ report = validate(gate)
183
+ if not report.ok:
184
+ print("warning: runtime returned a gate-result that does not match the contract:", file=sys.stderr)
185
+ for i in report.issues:
186
+ print(f" {i}", file=sys.stderr)
187
+ return 0 if gate.get("passed") else 1
188
+
189
+
190
+ def _cmd_whoami(args: argparse.Namespace) -> int:
191
+ me = _client(args).whoami()
192
+ _out(args, me, f"{me.get('username')} · key {me.get('keyId')} · contract {me.get('contractVersion')}\n"
193
+ f" can: {', '.join(me.get('canDo', []))}\n"
194
+ f" cannot: {', '.join(me.get('cannotDo', []))}")
195
+ return 0
196
+
197
+
198
+ def _evaluate_and_wait(args: argparse.Namespace, c: Client, artifact_id: int) -> int:
199
+ started = c.evaluate(artifact_id)
200
+ if not args.wait:
201
+ _out(args, started, f"evaluation {started.get('evaluationId')} {started.get('status')} "
202
+ f"— poll with: alphaspec result {artifact_id}")
203
+ return 0
204
+ if not args.json:
205
+ print(f"evaluating artifact {artifact_id} (in-sample + out-of-sample backtests) ...", file=sys.stderr)
206
+ return _print_gate(args, c.wait(artifact_id, timeout=args.timeout))
207
+
208
+
209
+ def _cmd_submit(args: argparse.Namespace) -> int:
210
+ doc = json.loads(Path(args.file).read_text(encoding="utf-8"))
211
+ c = _client(args)
212
+ res = c.submit(doc)
213
+ aid = res["artifactId"]
214
+ warnings = res.get("warnings") or []
215
+ if not args.json:
216
+ print(f"submitted: artifact {aid}" + (f" ({len(warnings)} warnings)" if warnings else ""))
217
+ for w in warnings:
218
+ print(f" WARNING {w.get('code')} at {w.get('path')}: {w.get('message')}")
219
+ elif not (args.evaluate or args.promote):
220
+ print(json.dumps(res, ensure_ascii=False, indent=2))
221
+ if not (args.evaluate or args.promote):
222
+ return 0
223
+ rc = _evaluate_and_wait(argparse.Namespace(**{**vars(args), "wait": True}), c, aid)
224
+ if rc != 0 or not args.promote:
225
+ return rc
226
+ return _promote(args, c, aid)
227
+
228
+
229
+ def _cmd_evaluate(args: argparse.Namespace) -> int:
230
+ return _evaluate_and_wait(args, _client(args), args.artifact_id)
231
+
232
+
233
+ def _cmd_result(args: argparse.Namespace) -> int:
234
+ state, body = _client(args).gate_result(args.artifact_id)
235
+ if state == "DONE":
236
+ return _print_gate(args, body)
237
+ _out(args, body, f"{state}" + (f": {body.get('error')}" if body.get("error") else ""))
238
+ return 1 if state == "FAILED" else 0
239
+
240
+
241
+ def _cmd_list(args: argparse.Namespace) -> int:
242
+ rows = _client(args).list()
243
+ if args.json:
244
+ print(json.dumps(rows, ensure_ascii=False, indent=2))
245
+ return 0
246
+ for r in rows:
247
+ verdict = "—" if not r.get("evaluated") else ("passed" if r.get("passed") else "not passed")
248
+ print(f"{r.get('artifactId'):>8} {verdict:11} {r.get('title')}")
249
+ return 0
250
+
251
+
252
+ def _promote(args: argparse.Namespace, c: Client, artifact_id: int) -> int:
253
+ res = c.promote(artifact_id, getattr(args, "name", None))
254
+ what = "created" if res.get("created") else "already exists"
255
+ _out(args, res, f"paper strategy {res.get('strategyId')} {what} (disabled) — {res.get('next')}")
256
+ return 0
257
+
258
+
259
+ def _cmd_promote(args: argparse.Namespace) -> int:
260
+ return _promote(args, _client(args), args.artifact_id)
261
+
262
+
263
+ def main(argv: list[str] | None = None) -> int:
264
+ parser = argparse.ArgumentParser(prog="alphaspec", description="AlphaSpec: validate documents and talk to a runtime.")
265
+ parser.add_argument("--version", action="version", version=f"alphaspec {__version__}")
266
+ sub = parser.add_subparsers(dest="command", required=True)
267
+
268
+ v = sub.add_parser("validate", help="validate documents locally")
269
+ v.add_argument("files", nargs="+")
270
+ v.add_argument("--strict", action="store_true", help="treat warnings as failures")
271
+ v.add_argument("--json", action="store_true", help="one JSON line per file")
272
+ v.set_defaults(func=_cmd_validate)
273
+
274
+ r = sub.add_parser("rules", help="list lint rules")
275
+ r.add_argument("--explain", metavar="CODE", help="print why a rule exists and how to fix it")
276
+ r.set_defaults(func=_cmd_rules)
277
+
278
+ c = sub.add_parser("catalog", help="indicator types you may use")
279
+ c.add_argument("--market", help="only types usable on this market (e.g. us_equity hides China-only types)")
280
+ c.add_argument("--profile", choices=["core", "ext.cn", "ext.l2", "ext.data"])
281
+ c.add_argument("--search", help="substring match on name, summary and parameters")
282
+ c.add_argument("--type", help="full entry for one type")
283
+ c.add_argument("--lang", choices=["en", "zh"], default=default_lang(),
284
+ help="language of descriptions (default: $ALPHASPEC_LANG, else from the locale)")
285
+ c.add_argument("--json", action="store_true")
286
+ c.set_defaults(func=_cmd_catalog)
287
+
288
+ sc = sub.add_parser("schema", help="print a JSON Schema")
289
+ sc.add_argument("kind", choices=["artifact", "score", "gate-result", "evidence", "common"])
290
+ sc.set_defaults(func=_cmd_schema)
291
+
292
+ ex = sub.add_parser("examples", help="list or print example documents")
293
+ ex.add_argument("name", nargs="?")
294
+ ex.set_defaults(func=_cmd_examples)
295
+
296
+ dc = sub.add_parser("docs", help="print a reference document")
297
+ dc.add_argument("name", choices=sorted(_DOCS))
298
+ dc.set_defaults(func=_cmd_docs)
299
+
300
+ sk = sub.add_parser("skill", help="print or install the Agent Skill")
301
+ sk.add_argument("--install", metavar="DIR",
302
+ help="skills directory, e.g. ~/.claude/skills (writes DIR/alphaspec/SKILL.md)")
303
+ sk.set_defaults(func=_cmd_skill)
304
+
305
+ remote = argparse.ArgumentParser(add_help=False)
306
+ remote.add_argument("--base-url", help="runtime base URL (default: $ALPHASPEC_BASE_URL)")
307
+ remote.add_argument("--api-key", help="API key (default: $ALPHASPEC_API_KEY)")
308
+ remote.add_argument("--json", action="store_true", help="machine-readable output")
309
+
310
+ w = sub.add_parser("whoami", parents=[remote], help="check the API key")
311
+ w.set_defaults(func=_cmd_whoami)
312
+
313
+ s = sub.add_parser("submit", parents=[remote], help="submit an artifact")
314
+ s.add_argument("file")
315
+ s.add_argument("--evaluate", action="store_true", help="start an evaluation after submitting and wait for it")
316
+ s.add_argument("--promote", action="store_true", help="also turn it into a paper strategy if the gate passes")
317
+ s.add_argument("--name", help="strategy name for --promote (default: document title)")
318
+ s.add_argument("--timeout", type=float, default=900, help="seconds to wait for the evaluation")
319
+ s.set_defaults(func=_cmd_submit)
320
+
321
+ e = sub.add_parser("evaluate", parents=[remote], help="start a platform evaluation")
322
+ e.add_argument("artifact_id", type=int)
323
+ e.add_argument("--wait", action="store_true", help="wait for the gate result")
324
+ e.add_argument("--timeout", type=float, default=900)
325
+ e.set_defaults(func=_cmd_evaluate)
326
+
327
+ g = sub.add_parser("result", parents=[remote], help="show the latest gate result")
328
+ g.add_argument("artifact_id", type=int)
329
+ g.set_defaults(func=_cmd_result)
330
+
331
+ lst = sub.add_parser("list", parents=[remote], help="list my submissions")
332
+ lst.set_defaults(func=_cmd_list)
333
+
334
+ p = sub.add_parser("promote", parents=[remote], help="turn a gate-passed artifact into a paper strategy")
335
+ p.add_argument("artifact_id", type=int)
336
+ p.add_argument("--name")
337
+ p.set_defaults(func=_cmd_promote)
338
+
339
+ args = parser.parse_args(argv)
340
+ try:
341
+ return args.func(args)
342
+ except Rejected as exc:
343
+ print(f"rejected: {len(exc.issues)} issue(s)", file=sys.stderr)
344
+ for i in exc.issues:
345
+ print(f" {i.get('severity', 'error').upper():7} {i.get('code')} at {i.get('path') or '/'}: {i.get('message')}",
346
+ file=sys.stderr)
347
+ return 1
348
+ except EvaluationFailed as exc:
349
+ print(str(exc), file=sys.stderr)
350
+ return 1
351
+ except ValueError as exc:
352
+ print(str(exc), file=sys.stderr)
353
+ return 2
354
+ except (AlphaSpecError, OSError, TimeoutError) as exc:
355
+ print(f"runtime error: {exc}", file=sys.stderr)
356
+ return 3
357
+
358
+
359
+ if __name__ == "__main__":
360
+ sys.exit(main())
alphaspec/client.py ADDED
@@ -0,0 +1,146 @@
1
+ """Client for an AlphaSpec runtime's agent API (/api/agent/v1).
2
+
3
+ Standard library only. Configure with arguments or environment variables:
4
+
5
+ ALPHASPEC_BASE_URL e.g. https://your-runtime.example.com
6
+ ALPHASPEC_API_KEY eqk_... (created in the runtime's account page)
7
+
8
+ The API key can submit documents, run evaluations and read results. It cannot place
9
+ orders or touch accounts; that boundary is enforced by the runtime, not by this client.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import time
16
+ import urllib.error
17
+ import urllib.request
18
+ from typing import Any
19
+
20
+ from .validate import Report, validate
21
+
22
+ API_PREFIX = "/api/agent/v1"
23
+
24
+
25
+ class AlphaSpecError(Exception):
26
+ """Any non-success response from the runtime."""
27
+
28
+ def __init__(self, status: int, body: Any, message: str | None = None):
29
+ self.status = status
30
+ self.body = body
31
+ detail = body.get("message") if isinstance(body, dict) else None
32
+ super().__init__(message or f"HTTP {status}: {detail or body}")
33
+
34
+
35
+ class Rejected(AlphaSpecError):
36
+ """The document failed validation (locally or on the runtime). ``issues`` lists every problem."""
37
+
38
+ def __init__(self, status: int, body: Any, issues: list[dict[str, Any]]):
39
+ self.issues = issues
40
+ super().__init__(status, body, f"rejected with {len(issues)} issue(s)")
41
+
42
+
43
+ class EvaluationFailed(AlphaSpecError):
44
+ """The runtime could not finish an evaluation (no data for the symbols, unsupported universe ...)."""
45
+
46
+
47
+ class Client:
48
+ def __init__(self, base_url: str | None = None, api_key: str | None = None, timeout: float = 30.0):
49
+ base_url = base_url or os.environ.get("ALPHASPEC_BASE_URL")
50
+ api_key = api_key or os.environ.get("ALPHASPEC_API_KEY")
51
+ if not base_url:
52
+ raise ValueError("base_url is required (or set ALPHASPEC_BASE_URL)")
53
+ if not api_key:
54
+ raise ValueError("api_key is required (or set ALPHASPEC_API_KEY)")
55
+ self.base_url = base_url.rstrip("/")
56
+ self.api_key = api_key
57
+ self.timeout = timeout
58
+
59
+ # ------------------------------------------------------------------ calls
60
+
61
+ def whoami(self) -> dict[str, Any]:
62
+ return self._request("GET", "/whoami")[1]
63
+
64
+ def submit(self, doc: dict[str, Any], validate_locally: bool = True) -> dict[str, Any]:
65
+ """Submit an artifact. Returns ``{"artifactId": ..., "warnings": [...]}``.
66
+
67
+ Validates locally first (same rules as the runtime) so obvious mistakes never
68
+ cost a round trip. Raises :class:`Rejected` with every issue on failure.
69
+ """
70
+ if validate_locally:
71
+ report: Report = validate(doc)
72
+ if not report.ok:
73
+ raise Rejected(0, None, [i.__dict__ for i in report.issues])
74
+ return self._request("POST", "/artifacts", doc)[1]
75
+
76
+ def list(self) -> list[dict[str, Any]]:
77
+ return self._request("GET", "/artifacts")[1]
78
+
79
+ def evaluate(self, artifact_id: int) -> dict[str, Any]:
80
+ """Start a platform evaluation. Returns ``{"evaluationId", "status"}`` immediately."""
81
+ return self._request("POST", f"/artifacts/{artifact_id}/evaluate")[1]
82
+
83
+ def gate_result(self, artifact_id: int) -> tuple[str, dict[str, Any]]:
84
+ """Return ``(state, body)`` where state is DONE, RUNNING or FAILED.
85
+
86
+ DONE carries the gate-result document; RUNNING and FAILED carry ``{"status", "error"?}``.
87
+ """
88
+ status, body = self._request("GET", f"/artifacts/{artifact_id}/gate-result", allow=(202, 409))
89
+ if status == 202:
90
+ return "RUNNING", body
91
+ if status == 409:
92
+ return "FAILED", body
93
+ return "DONE", body
94
+
95
+ def wait(self, artifact_id: int, timeout: float = 900, interval: float = 3) -> dict[str, Any]:
96
+ """Poll until the evaluation finishes. Returns the gate-result document."""
97
+ deadline = time.monotonic() + timeout
98
+ while True:
99
+ state, body = self.gate_result(artifact_id)
100
+ if state == "DONE":
101
+ return body
102
+ if state == "FAILED":
103
+ raise EvaluationFailed(409, body, f"evaluation failed: {body.get('error')}")
104
+ if time.monotonic() > deadline:
105
+ raise TimeoutError(f"evaluation of artifact {artifact_id} still running after {timeout}s")
106
+ time.sleep(interval)
107
+
108
+ def promote(self, artifact_id: int, name: str | None = None) -> dict[str, Any]:
109
+ """Turn a gate-passed artifact into a paper-trading strategy (created disabled;
110
+ the user starts it in the runtime's UI)."""
111
+ return self._request("POST", f"/artifacts/{artifact_id}/promote", {"name": name} if name else None)[1]
112
+
113
+ # ------------------------------------------------------------------ http
114
+
115
+ def _request(self, method: str, path: str, body: Any = None, allow: tuple[int, ...] = ()) -> tuple[int, Any]:
116
+ data = None if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
117
+ req = urllib.request.Request(
118
+ self.base_url + API_PREFIX + path,
119
+ data=data,
120
+ method=method,
121
+ headers={
122
+ "Authorization": f"Bearer {self.api_key}",
123
+ "Content-Type": "application/json",
124
+ "Accept": "application/json",
125
+ "User-Agent": "alphaspec-python",
126
+ },
127
+ )
128
+ try:
129
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
130
+ return resp.status, _decode(resp.read())
131
+ except urllib.error.HTTPError as e:
132
+ payload = _decode(e.read())
133
+ if e.code in allow:
134
+ return e.code, payload
135
+ if e.code == 422 and isinstance(payload, dict):
136
+ raise Rejected(422, payload, payload.get("issues", [])) from None
137
+ raise AlphaSpecError(e.code, payload) from None
138
+
139
+
140
+ def _decode(raw: bytes) -> Any:
141
+ if not raw:
142
+ return None
143
+ try:
144
+ return json.loads(raw.decode("utf-8"))
145
+ except (UnicodeDecodeError, json.JSONDecodeError):
146
+ return raw.decode("utf-8", errors="replace")