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
|
@@ -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).
|
alphaspec/_resources.py
ADDED
|
@@ -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")
|