hamuna-quant-cli 0.1.0.dev93__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.
- hamuna_quant_cli/README.md +117 -0
- hamuna_quant_cli/__init__.py +17 -0
- hamuna_quant_cli/__main__.py +978 -0
- hamuna_quant_cli/_market_fallback.py +82 -0
- hamuna_quant_cli/_metrics_15.py +342 -0
- hamuna_quant_cli/_test_akquant_parity.py +530 -0
- hamuna_quant_cli/akquant_data_adapter.py +295 -0
- hamuna_quant_cli/akquant_runner.py +620 -0
- hamuna_quant_cli/akquant_schema_adapter.py +443 -0
- hamuna_quant_cli/base_strategy.py +80 -0
- hamuna_quant_cli/cross_sectional_helpers.py +118 -0
- hamuna_quant_cli/live/__init__.py +25 -0
- hamuna_quant_cli/live/loader.py +121 -0
- hamuna_quant_cli/live/qmt_broker.py +683 -0
- hamuna_quant_cli/live/qmt_market.py +448 -0
- hamuna_quant_cli/live/runner.py +449 -0
- hamuna_quant_cli/prebuilt_downloader.py +263 -0
- hamuna_quant_cli/prebuilt_resolver.py +470 -0
- hamuna_quant_cli/qmt_translator.py +609 -0
- hamuna_quant_cli/runtime/__init__.py +2 -0
- hamuna_quant_cli/runtime/backtest.py +38 -0
- hamuna_quant_cli/runtime/cache.py +255 -0
- hamuna_quant_cli/runtime/discipline.py +359 -0
- hamuna_quant_cli/runtime/http_client.py +209 -0
- hamuna_quant_cli/runtime/s3client.py +109 -0
- hamuna_quant_cli/runtime/server_client.py +285 -0
- hamuna_quant_cli/scripts/server.json +4 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/METADATA +154 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/RECORD +32 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/WHEEL +5 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/entry_points.txt +2 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""hamuna_quant_cli.runtime.http_client: apikey HTTPS client → server proxy.
|
|
2
|
+
|
|
3
|
+
CLI 持 hamuna apikey (ADR-0001 §86), 调 server 端 /api/v1/proxy/data/{endpoint}.
|
|
4
|
+
Server 校验 apikey 后用内部 JWT 调容维接口 (符合 ADR-016 "数据接口全部在 server 端").
|
|
5
|
+
|
|
6
|
+
server.json 注入优先级 (Round 14 重构):
|
|
7
|
+
1. HAMUNA_SERVER env (字符串 URL, 跳过 server.json) — CI / 临时覆盖
|
|
8
|
+
2. HAMUNA_SERVER_JSON env (完整文件路径) — 调试 / 自定义
|
|
9
|
+
3. ./scripts/server.json (当前 cwd) — 项目根 dev 模式 (优先)
|
|
10
|
+
4. <hamuna_quant_cli 包根>/scripts/server.json — pip install 后用
|
|
11
|
+
5. ./server.json (简化, 当前 cwd) — 单文件部署
|
|
12
|
+
6. 默认 http://localhost:8080 — 兜底
|
|
13
|
+
|
|
14
|
+
credentials.json 不再含 api_base — 严格分离"部署"与"凭证".
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from urllib.parse import urlencode
|
|
22
|
+
from urllib.request import Request, urlopen
|
|
23
|
+
from urllib.error import HTTPError
|
|
24
|
+
import json
|
|
25
|
+
|
|
26
|
+
from . import cache
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# 默认 server 地址 (ADR-0001 §393)
|
|
30
|
+
_DEFAULT_SERVER = 'http://localhost:8080'
|
|
31
|
+
|
|
32
|
+
# 默认 apikey 路径 (~/.hamuna/credentials.json), 字段 api_key (含 key_id.key_secret 的整串)
|
|
33
|
+
_CREDS_PATH = Path.home() / '.hamuna' / 'credentials.json'
|
|
34
|
+
|
|
35
|
+
# server.json 候选路径 — 按优先级第一个存在的生效
|
|
36
|
+
_PACKAGE_ROOT = Path(__file__).resolve().parent.parent # hamuna_quant_cli/
|
|
37
|
+
_SERVER_CANDIDATES = [
|
|
38
|
+
Path.cwd() / 'scripts' / 'server.json', # dev: 项目根 scripts/
|
|
39
|
+
_PACKAGE_ROOT / 'scripts' / 'server.json', # pip install: 包内置 scripts/
|
|
40
|
+
Path.cwd() / 'server.json', # 单文件部署
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# 证券代码归一 (与后端 rongwei.NormalizeStockCode 同规则):
|
|
45
|
+
# 后缀 ".SH/.sz/.bj" → 剥; 前缀 "SH600000/sz000001" → 剥; 裸 6 位原样。
|
|
46
|
+
_CODE_SUFFIX_RE = re.compile(r'^(.*)\.(sh|sz|bj)$', re.IGNORECASE)
|
|
47
|
+
_CODE_PREFIX_RE = re.compile(r'^(sh|sz|bj)(\d{6})$', re.IGNORECASE)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _normalize_stock_code(code: str) -> str:
|
|
51
|
+
code = code.strip()
|
|
52
|
+
m = _CODE_SUFFIX_RE.match(code)
|
|
53
|
+
if m:
|
|
54
|
+
code = m.group(1)
|
|
55
|
+
m = _CODE_PREFIX_RE.match(code)
|
|
56
|
+
if m:
|
|
57
|
+
code = m.group(2)
|
|
58
|
+
return code
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _normalize_params(params: dict) -> dict:
|
|
62
|
+
"""StockCode 值归一成裸 6 位(容维上游不接受后缀/前缀)。其他字段不动。
|
|
63
|
+
|
|
64
|
+
归一发生在缓存 key 之前, 让 "600000.SH" 与 "600000" 命中同一本地缓存。
|
|
65
|
+
"""
|
|
66
|
+
out = dict(params)
|
|
67
|
+
for k, v in list(out.items()):
|
|
68
|
+
if k == 'StockCode' and isinstance(v, str):
|
|
69
|
+
out[k] = _normalize_stock_code(v)
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _resolve_server() -> str:
|
|
74
|
+
"""server 地址解析 (Round 14 多路径 fallback).
|
|
75
|
+
|
|
76
|
+
优先级: HAMUNA_SERVER env > HAMUNA_SERVER_JSON env > _SERVER_CANDIDATES > 默认.
|
|
77
|
+
credentials.json **不**再含 api_base (那是用户凭证, 不该混部署信息); 严格分离
|
|
78
|
+
"部署" 与 "凭证" 两个关注点. env 仍最高, 适合 CI / 本地临时覆盖.
|
|
79
|
+
"""
|
|
80
|
+
env = os.environ.get('HAMUNA_SERVER')
|
|
81
|
+
if env:
|
|
82
|
+
return env
|
|
83
|
+
json_path = os.environ.get('HAMUNA_SERVER_JSON')
|
|
84
|
+
if json_path:
|
|
85
|
+
try:
|
|
86
|
+
v = json.loads(Path(json_path).read_text(encoding='utf-8')).get('api_base')
|
|
87
|
+
if v:
|
|
88
|
+
return v
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
for cand in _SERVER_CANDIDATES:
|
|
92
|
+
if cand.is_file():
|
|
93
|
+
try:
|
|
94
|
+
v = json.loads(cand.read_text(encoding='utf-8')).get('api_base')
|
|
95
|
+
if v:
|
|
96
|
+
return v
|
|
97
|
+
except Exception:
|
|
98
|
+
continue
|
|
99
|
+
return _DEFAULT_SERVER
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
_TOKEN_CACHE: str | None = None # 进程内缓存 (commit 5 步只读一次 credentials.json)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _load_token() -> str:
|
|
106
|
+
"""返回完整 Bearer token 串 (key_id.key_secret).
|
|
107
|
+
|
|
108
|
+
ponytail: 进程内缓存; 部署/测试改 api_key 后需重启 CLI. add when: 用户报
|
|
109
|
+
"频繁读 credentials.json 慢/文件锁" → 改 mtime-based invalidation.
|
|
110
|
+
"""
|
|
111
|
+
global _TOKEN_CACHE
|
|
112
|
+
if _TOKEN_CACHE is not None:
|
|
113
|
+
return _TOKEN_CACHE
|
|
114
|
+
if not _CREDS_PATH.exists():
|
|
115
|
+
raise FileNotFoundError(
|
|
116
|
+
f'没有找到 Hamuna API key 凭证文件: {_CREDS_PATH}\n'
|
|
117
|
+
f'\n'
|
|
118
|
+
f'API key 在 Hamuna 平台控制台/账号后台签发(登录平台 → 账户设置/API Keys → 创建)。\n'
|
|
119
|
+
f'CLI 无 login 子命令; 手动创建凭证文件即可:\n'
|
|
120
|
+
f' {_CREDS_PATH}\n'
|
|
121
|
+
f' {{ "api_key": "hamuna_xxxxx" }}\n'
|
|
122
|
+
f'字段名是 api_key(不是 apikey). server 地址在部署方管 scripts/server.json,\n'
|
|
123
|
+
f'默认 http://localhost:8080(或设 HAMUNA_SERVER env 覆盖)。'
|
|
124
|
+
)
|
|
125
|
+
creds = json.loads(_CREDS_PATH.read_text(encoding='utf-8'))
|
|
126
|
+
if 'api_key' not in creds:
|
|
127
|
+
raise KeyError(
|
|
128
|
+
f'{_CREDS_PATH} 缺 api_key 字段。凭证格式: '
|
|
129
|
+
f'{{ "api_key": "hamuna_xxxxx" }}'
|
|
130
|
+
)
|
|
131
|
+
_TOKEN_CACHE = creds['api_key']
|
|
132
|
+
return _TOKEN_CACHE
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def verify_credentials() -> tuple[bool, str]:
|
|
136
|
+
"""验证 API key 是否合法: GET /api/v1/cli/whoami (API Key 鉴权)。
|
|
137
|
+
|
|
138
|
+
返回 (ok, msg):
|
|
139
|
+
ok=False + 消息 → 凭证缺失 / 401 非法 / server 不可达, 引导重签。
|
|
140
|
+
不抛异常 — 验证失败是"该去注册/申请 key"的信号, 不是让流程崩掉。
|
|
141
|
+
仅当 skii 加载时做一次, 不污染正常 run 的异常路径。
|
|
142
|
+
"""
|
|
143
|
+
try:
|
|
144
|
+
token = _load_token()
|
|
145
|
+
except Exception as e: # FileNotFoundError / KeyError
|
|
146
|
+
return False, f'凭证文件缺失: {e}'.split('\n')[0]
|
|
147
|
+
try:
|
|
148
|
+
req = Request(f'{_resolve_server()}/api/v1/cli/whoami', headers={
|
|
149
|
+
'Authorization': f'Bearer {token}',
|
|
150
|
+
'Accept': 'application/json',
|
|
151
|
+
})
|
|
152
|
+
with urlopen(req, timeout=10) as resp:
|
|
153
|
+
if resp.status != 200:
|
|
154
|
+
return False, f'whoami 返回 HTTP {resp.status}'
|
|
155
|
+
return True, 'API key 有效'
|
|
156
|
+
except HTTPError as e:
|
|
157
|
+
if e.code == 401:
|
|
158
|
+
return False, 'API key 无效或已失效 (HTTP 401)'
|
|
159
|
+
return False, f'whoami 失败: HTTP {e.code} {e.reason}'
|
|
160
|
+
except Exception as e:
|
|
161
|
+
return False, f'无法连接 server ({type(e).__name__})'
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def proxy_get(endpoint: str, params: dict | None = None, *, no_cache: bool = False) -> dict:
|
|
165
|
+
"""GET /api/v1/proxy/data/<endpoint>?<params> (API Key 鉴权).
|
|
166
|
+
|
|
167
|
+
`endpoint` 例: "snapshot", "history", "trade_days", "stock_info".
|
|
168
|
+
缓存语义: 默认走本地 ~/.hamuna/cache/<endpoint>/<key>.json; 写后下次命中。
|
|
169
|
+
no_cache=True: 跳过读 / 写 — 适合 verify_credentials 这种"必须是最新"的探测。
|
|
170
|
+
返回 {ok, value} envelope (server 端约定, 与 bridge_server 兼容)。
|
|
171
|
+
"""
|
|
172
|
+
token = _load_token()
|
|
173
|
+
params = _normalize_params(params or {})
|
|
174
|
+
cache_key = cache.make_key(endpoint, params)
|
|
175
|
+
if not no_cache:
|
|
176
|
+
hit = cache.read(cache_key)
|
|
177
|
+
if hit is not None:
|
|
178
|
+
return hit
|
|
179
|
+
qs = urlencode(params, doseq=True) if params else ''
|
|
180
|
+
url = f'{_resolve_server()}/api/v1/proxy/data/{endpoint}'
|
|
181
|
+
if qs:
|
|
182
|
+
url = f'{url}?{qs}'
|
|
183
|
+
req = Request(url, headers={
|
|
184
|
+
'Authorization': f'Bearer {token}',
|
|
185
|
+
'Accept': 'application/json',
|
|
186
|
+
})
|
|
187
|
+
with urlopen(req, timeout=30) as resp:
|
|
188
|
+
raw = resp.read().decode('utf-8', errors='replace')
|
|
189
|
+
payload = json.loads(raw)
|
|
190
|
+
if not no_cache:
|
|
191
|
+
cache.write(cache_key, payload)
|
|
192
|
+
return payload
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def proxy_post(endpoint: str, body: dict, *, no_cache: bool = False) -> dict:
|
|
196
|
+
"""POST /api/v1/proxy/data/<endpoint> (API Key 鉴权).
|
|
197
|
+
|
|
198
|
+
`body` 序列化成 JSON body. 不走本地缓存 (POST 一般是 mutation).
|
|
199
|
+
"""
|
|
200
|
+
token = _load_token()
|
|
201
|
+
url = f'{_resolve_server()}/api/v1/proxy/data/{endpoint}'
|
|
202
|
+
req = Request(url, data=json.dumps(body).encode('utf-8'), headers={
|
|
203
|
+
'Authorization': f'Bearer {token}',
|
|
204
|
+
'Content-Type': 'application/json',
|
|
205
|
+
'Accept': 'application/json',
|
|
206
|
+
}, method='POST')
|
|
207
|
+
with urlopen(req, timeout=30) as resp:
|
|
208
|
+
raw = resp.read().decode('utf-8', errors='replace')
|
|
209
|
+
return json.loads(raw)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""hamuna_quant_cli.runtime.s3client: 直连对象存储下载内置数据集整包.
|
|
2
|
+
|
|
3
|
+
背景: server 的 presign 下载地址在腾讯 COS 反代 (hz.yunvm.com:80) 上 403
|
|
4
|
+
SignatureDoesNotMatch; boto3 全头签名 get_object 正常。所以 CLI 从 server 拉
|
|
5
|
+
【加密的 S3 凭据】 (见 backend/internal/handler/datasets.go::DownloadArtifactCredentials),
|
|
6
|
+
用 raw API key 派生密钥解密后直连对象存储下载。
|
|
7
|
+
|
|
8
|
+
加密契约 (与 backend/internal/sourcecrypto/encrypt.go 逐参对齐, 改任意一边另一边
|
|
9
|
+
解密必挂):
|
|
10
|
+
IKM = raw API key (credentials.json 的 api_key 字段, 与 server 端加密用同一把 —
|
|
11
|
+
零密钥传输)
|
|
12
|
+
HKDF = HKDF-SHA256, salt="s3-creds|<datasetID>", info="hamuna-s3-creds-v1", 32 字节
|
|
13
|
+
AES = AES-256-GCM, nonce=envelope.iv, aad=envelope.aad (=datasetID)
|
|
14
|
+
envelope = {v:1, alg:"AES-256-GCM", sid, iv:b64, ct:b64, aad}
|
|
15
|
+
|
|
16
|
+
来源: skills/hamuna-strategy/strategy_cli/runtime/s3client.py wholesale 移植
|
|
17
|
+
(Round 14 独立分发, 不再依赖 v1 skill PYTHONPATH)。
|
|
18
|
+
依赖: cryptography (HKDF/AES-GCM) + boto3 (S3 签名下载)。
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import json
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import boto3
|
|
27
|
+
from botocore.config import Config
|
|
28
|
+
from cryptography.hazmat.primitives import hashes
|
|
29
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
30
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
31
|
+
|
|
32
|
+
_SALT_PREFIX = 's3-creds|'
|
|
33
|
+
_INFO = 'hamuna-s3-creds-v1'
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def derive_key(ikm: str, dataset_id: str) -> bytes:
|
|
37
|
+
"""HKDF-SHA256(ikm=raw api key, salt=s3-creds|<did>, info=hamuna-s3-creds-v1) → 32B。
|
|
38
|
+
|
|
39
|
+
Go 侧 sourcecrypto.DeriveKeyFrom 用同一固定参数 pin 了 base64 key
|
|
40
|
+
(TestDeriveKeyFromParity), 本模块 _selfcheck 也 pin 同一值 → 跨语言 parity 双端锁死。
|
|
41
|
+
"""
|
|
42
|
+
salt = (_SALT_PREFIX + dataset_id).encode('utf-8')
|
|
43
|
+
return HKDF(algorithm=hashes.SHA256(), length=32,
|
|
44
|
+
salt=salt, info=_INFO.encode('utf-8')).derive(ikm.encode('utf-8'))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def decrypt_s3_params(raw_api_key: str, dataset_id: str, env: dict) -> dict:
|
|
48
|
+
"""解密 server 下发的加密 S3 凭据 envelope → {endpoint, region, bucket, access_key, ...}。
|
|
49
|
+
|
|
50
|
+
env 必须是 {v:1, alg:'AES-256-GCM', sid, iv:b64, ct:b64, aad}, aad 字段 = dataset_id
|
|
51
|
+
(server 用 id.Hex() 作 GCM 关联数据)。解密失败 (API key 不匹配 / 参数漂移 / 篡改)
|
|
52
|
+
→ 抛 ValueError 带修复提示, 不静默用坏凭据 (下载必 403)。
|
|
53
|
+
"""
|
|
54
|
+
key = derive_key(raw_api_key, dataset_id)
|
|
55
|
+
try:
|
|
56
|
+
iv = base64.b64decode(env['iv'])
|
|
57
|
+
ct = base64.b64decode(env['ct'])
|
|
58
|
+
aad = env.get('aad', dataset_id).encode('utf-8')
|
|
59
|
+
plaintext = AESGCM(key).decrypt(iv, ct, aad)
|
|
60
|
+
except Exception as e:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f'解密 S3 凭据失败: {e}. 密钥派生参数 (salt/info) 或 API key 与 server 不匹配, '
|
|
63
|
+
f'或响应被篡改。'
|
|
64
|
+
) from e
|
|
65
|
+
return json.loads(plaintext.decode('utf-8'))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _client(params: dict):
|
|
69
|
+
"""boto3 S3 client: 全头签名 (s3v4) + path-style 寻址 (COS 反代要求)。"""
|
|
70
|
+
return boto3.client(
|
|
71
|
+
's3',
|
|
72
|
+
endpoint_url=params['endpoint'],
|
|
73
|
+
region_name=params.get('region', 'us-east-1'),
|
|
74
|
+
aws_access_key_id=params['access_key'],
|
|
75
|
+
aws_secret_access_key=params['secret_key'],
|
|
76
|
+
use_ssl=params.get('endpoint', '').startswith('https'),
|
|
77
|
+
config=Config(signature_version='s3v4',
|
|
78
|
+
s3={'addressing_style': 'path'}),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def download_to(params: dict, dest: Path) -> None:
|
|
83
|
+
"""boto3 全头签名 get_object 下载整包到 dest (分块流式, 不整包进内存)。
|
|
84
|
+
|
|
85
|
+
422MB 整包先落盘再经 pyarrow 读盘 (可 mmap), 避免 bytes + DataFrame 内存堆两份。
|
|
86
|
+
"""
|
|
87
|
+
client = _client(params)
|
|
88
|
+
resp = client.get_object(Bucket=params['bucket'], Key=params['key'])
|
|
89
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
with resp['Body'] as stream, dest.open('wb') as f:
|
|
91
|
+
while True:
|
|
92
|
+
chunk = stream.read(1 << 20) # 1MB
|
|
93
|
+
if not chunk:
|
|
94
|
+
break
|
|
95
|
+
f.write(chunk)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _selfcheck() -> None:
|
|
99
|
+
"""HKDF 派生跨语言 parity 自检 (不依赖网络 / server)。
|
|
100
|
+
|
|
101
|
+
base64 pin 值来自 Go 测试 TestDeriveKeyFromParity 的锚点 — 任一侧改
|
|
102
|
+
salt/info/长度, 这里即挂。运行:
|
|
103
|
+
python -c "from hamuna_quant_cli.runtime.s3client import _selfcheck; _selfcheck()"
|
|
104
|
+
"""
|
|
105
|
+
key = derive_key('hamuna_test_api_key_123456', 'abc123')
|
|
106
|
+
b64 = base64.b64encode(key).decode('ascii')
|
|
107
|
+
assert b64 == '5afG4AQFKc5zIxUKLkAh925TQAmROwX2/qXzsAfn2Oo=', (
|
|
108
|
+
f'HKDF key 与 Go 侧 pin 不符: {b64}')
|
|
109
|
+
print('[selfcheck] s3client HKDF parity OK')
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""strategy_cli.runtime.server_client (hamuna-strategy-v2): CLI → server 上传 (backtest result).
|
|
2
|
+
|
|
3
|
+
v2 精简底座: 仅保留 _api_call + upload_backtest_result + create_strategy;
|
|
4
|
+
扩展部分 (commiter 5 步打包上传需要):
|
|
5
|
+
- upload_strategy_code POST /strategies/{id}/code (multipart body + params)
|
|
6
|
+
- export_strategy POST /strategies/{id}/export (拉 server 端 .qmt.py shell)
|
|
7
|
+
|
|
8
|
+
不进 server_client 的 (不在 skill 端做): pyarmor / embedded API key / cert / QMT
|
|
9
|
+
shell 渲染 — 这些都跑在 server 端 (ADR-0023 + ADR-0025), cloud 是 single source
|
|
10
|
+
of truth. 本地仅做 multipart 拼 + 提交, 不碰加密 / 不读 cert.
|
|
11
|
+
|
|
12
|
+
底层鉴权 / envelope 解构 / NaN 清洗与 v1 完全一致 (wholesale copy → 极简裁剪, 见 v1
|
|
13
|
+
server_client.py git blame); 服务端契约 backend/internal/model/model.go::BacktestResult
|
|
14
|
+
仍 13 顶层 key + metrics 15 key 不变 (v1 / v2 同 schema)。
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import math
|
|
20
|
+
import mimetypes
|
|
21
|
+
import uuid
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from urllib.request import Request, urlopen
|
|
24
|
+
from urllib.error import HTTPError
|
|
25
|
+
|
|
26
|
+
from .http_client import _load_token, _resolve_server, _CREDS_PATH
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ServerError(RuntimeError):
|
|
30
|
+
"""server 端非 2xx 响应。"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _api_call(method: str, path: str, body: dict | None = None) -> dict:
|
|
34
|
+
"""通用 HTTPS 调用: 返 server 端 envelope.DataValue; 非 2xx / Result=false raise.
|
|
35
|
+
|
|
36
|
+
server 所有响应都是容维 envelope {Result, Error, Message, DataValue}
|
|
37
|
+
(backend/pkg/response/response.go), 此处解构出 DataValue 给 caller.
|
|
38
|
+
"""
|
|
39
|
+
token = _load_token()
|
|
40
|
+
url = f'{_resolve_server()}{path}'
|
|
41
|
+
data = json.dumps(body).encode('utf-8') if body is not None else None
|
|
42
|
+
headers = {
|
|
43
|
+
'Authorization': f'Bearer {token}',
|
|
44
|
+
'Accept': 'application/json',
|
|
45
|
+
}
|
|
46
|
+
if data is not None:
|
|
47
|
+
headers['Content-Type'] = 'application/json'
|
|
48
|
+
req = Request(url, data=data, method=method, headers=headers)
|
|
49
|
+
try:
|
|
50
|
+
with urlopen(req, timeout=60) as resp:
|
|
51
|
+
raw = resp.read().decode('utf-8')
|
|
52
|
+
except HTTPError as e:
|
|
53
|
+
resp_body = e.read().decode('utf-8', errors='replace')[:500]
|
|
54
|
+
if e.code == 401:
|
|
55
|
+
raise ServerError(
|
|
56
|
+
f'{method} {path} 失败: HTTP 401 — API key 无效或已失效.\n'
|
|
57
|
+
f'请在 Hamuna 平台控制台/账号后台重新签发 API key, 更新 {_CREDS_PATH} 的 api_key 字段。'
|
|
58
|
+
) from e
|
|
59
|
+
raise ServerError(
|
|
60
|
+
f'{method} {path} 失败: HTTP {e.code} {e.reason}; body={resp_body}'
|
|
61
|
+
) from e
|
|
62
|
+
envelope = json.loads(raw) if raw else {}
|
|
63
|
+
if isinstance(envelope, dict) and 'DataValue' in envelope:
|
|
64
|
+
if not envelope.get('Result', False):
|
|
65
|
+
raise ServerError(
|
|
66
|
+
f'{method} {path} 业务失败: Error={envelope.get("Error")} '
|
|
67
|
+
f'Message={envelope.get("Message")}'
|
|
68
|
+
)
|
|
69
|
+
return envelope['DataValue']
|
|
70
|
+
return envelope
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def upload_backtest_result(strategy_id: str, result: dict) -> dict:
|
|
74
|
+
"""PUT /api/v1/strategies/{id}/result → 上传 BacktestResult-shape dict.
|
|
75
|
+
|
|
76
|
+
兼容 akquant_runner.run_akquant_backtest() 输出 (13 顶层 key + metrics 子 dict 15 key + ...),
|
|
77
|
+
先做轻清洗: NaN/Inf → None (json 标准不允许), 后传给 server.
|
|
78
|
+
"""
|
|
79
|
+
payload = _sanitize_for_json(result)
|
|
80
|
+
return _api_call('PUT', f'/api/v1/strategies/{strategy_id}/result', payload)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def create_strategy(name: str, code: str = '', params: dict | None = None) -> dict:
|
|
84
|
+
"""POST /api/v1/strategies → 新建 strategy (供 _upload_after_run --name auto-create 用).
|
|
85
|
+
|
|
86
|
+
name 必填 (id 一致性锚点, 与 v1 server_client 同); code 可选 (后端落库 +
|
|
87
|
+
/source envelope 加密用, v2 --upload --code 路径才填); params 默认 {} (作
|
|
88
|
+
BacktestResult.params 落库). 返 server DataValue (含 _id / id / name).
|
|
89
|
+
"""
|
|
90
|
+
body: dict = {'name': name, 'code': code}
|
|
91
|
+
if params is not None:
|
|
92
|
+
body['params'] = params
|
|
93
|
+
return _api_call('POST', '/api/v1/strategies', body)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def upload_strategy_code(
|
|
97
|
+
strategy_id: str,
|
|
98
|
+
strategy_path: str,
|
|
99
|
+
params: dict | None = None,
|
|
100
|
+
) -> dict:
|
|
101
|
+
"""POST /api/v1/strategies/{id}/code (multipart) → 上传策略源码 + params.
|
|
102
|
+
|
|
103
|
+
ADR-0023: 后端存 code_obfuscated = body bytes (GBK 编码); cert + subscription gate
|
|
104
|
+
是真保护, 不在 skill 端做加密/pyminifier.
|
|
105
|
+
ADR-0025: multipart 含 `params` 字段 (JSON 字符串), server 落库覆盖 strategies.params
|
|
106
|
+
(CONFIG 块渲染源).
|
|
107
|
+
|
|
108
|
+
body bytes = UTF-8 源 → GBK (per `backend/internal/handler/qmt_export.go::storeBody`
|
|
109
|
+
注释 "Strategy files are GBK-encoded, server reads GBK first fall back UTF-8");
|
|
110
|
+
无法 GBK 编码的字符 → errors='replace' (不阻断上传).
|
|
111
|
+
|
|
112
|
+
返 {body_hash, ok} (per handler::UploadStrategyCode 响应 shape).
|
|
113
|
+
"""
|
|
114
|
+
code = Path(strategy_path).read_text(encoding='utf-8')
|
|
115
|
+
gbk = code.encode('gbk', errors='replace')
|
|
116
|
+
boundary = f'----hamuna-{uuid.uuid4().hex}'
|
|
117
|
+
filename = Path(strategy_path).name
|
|
118
|
+
ctype = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
|
|
119
|
+
|
|
120
|
+
parts: list[bytes] = [
|
|
121
|
+
(f'--{boundary}\r\n'
|
|
122
|
+
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
|
|
123
|
+
f'Content-Type: {ctype}\r\n\r\n').encode('utf-8'),
|
|
124
|
+
gbk,
|
|
125
|
+
]
|
|
126
|
+
if params is not None:
|
|
127
|
+
# multipart 表单字段: params = JSON 字符串 (per ADR-0025 + handler::storeBody)
|
|
128
|
+
parts.append(
|
|
129
|
+
(f'\r\n--{boundary}\r\n'
|
|
130
|
+
f'Content-Disposition: form-data; name="params"\r\n\r\n').encode('utf-8')
|
|
131
|
+
)
|
|
132
|
+
parts.append(json.dumps(params, ensure_ascii=False).encode('utf-8'))
|
|
133
|
+
parts.append(f'\r\n--{boundary}--\r\n'.encode('utf-8'))
|
|
134
|
+
data = b''.join(parts)
|
|
135
|
+
|
|
136
|
+
token = _load_token()
|
|
137
|
+
url = f'{_resolve_server()}/api/v1/strategies/{strategy_id}/code'
|
|
138
|
+
req = Request(
|
|
139
|
+
url, data=data, method='POST',
|
|
140
|
+
headers={
|
|
141
|
+
'Authorization': f'Bearer {token}',
|
|
142
|
+
'Content-Type': f'multipart/form-data; boundary={boundary}',
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
try:
|
|
146
|
+
with urlopen(req, timeout=60) as resp:
|
|
147
|
+
raw = resp.read().decode('utf-8')
|
|
148
|
+
except HTTPError as e:
|
|
149
|
+
resp_body = e.read().decode('utf-8', errors='replace')[:500]
|
|
150
|
+
if e.code == 401:
|
|
151
|
+
raise ServerError(
|
|
152
|
+
f'上传源码失败: HTTP 401 — API key 无效或已失效.\n'
|
|
153
|
+
f'请在 Hamuna 平台控制台/账号后台重新签发 API key, 更新 {_CREDS_PATH} 的 api_key 字段。'
|
|
154
|
+
) from e
|
|
155
|
+
raise ServerError(
|
|
156
|
+
f'上传源码失败: HTTP {e.code} {e.reason}; body={resp_body}'
|
|
157
|
+
) from e
|
|
158
|
+
envelope = json.loads(raw) if raw else {}
|
|
159
|
+
if isinstance(envelope, dict) and 'DataValue' in envelope:
|
|
160
|
+
if not envelope.get('Result', False):
|
|
161
|
+
raise ServerError(
|
|
162
|
+
f'上传源码业务失败: Error={envelope.get("Error")} '
|
|
163
|
+
f'Message={envelope.get("Message")}'
|
|
164
|
+
)
|
|
165
|
+
return envelope['DataValue']
|
|
166
|
+
return envelope
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def list_datasets() -> list:
|
|
170
|
+
"""GET /api/v1/datasets → server 内置数据集列表 (含 builtin/enabled 标记)。
|
|
171
|
+
|
|
172
|
+
CLI 取数 miss 时先查这里, 匹配到内置数据集就整包下载 (s3_credentials 路径)。
|
|
173
|
+
"""
|
|
174
|
+
out = _api_call('GET', '/api/v1/datasets')
|
|
175
|
+
if isinstance(out, list):
|
|
176
|
+
return out
|
|
177
|
+
if isinstance(out, dict) and 'DataValue' in out:
|
|
178
|
+
return out.get('DataValue', [])
|
|
179
|
+
return []
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def s3_credentials(dataset_id: str, key: str) -> dict:
|
|
183
|
+
"""GET /api/v1/datasets/{id}/artifacts/credentials?key= → 加密 S3 凭据 envelope。
|
|
184
|
+
|
|
185
|
+
server 用 raw API key 派生密钥加密 S3 连接参数 (handler::DownloadArtifactCredentials),
|
|
186
|
+
返回【bare AES-GCM envelope】(不带 DataValue 包装)。由 s3client.decrypt_s3_params
|
|
187
|
+
用同一 raw key 解密 → boto3 直连下载。presign 在 COS 反代上 403, 这是替代路径。
|
|
188
|
+
"""
|
|
189
|
+
import urllib.parse
|
|
190
|
+
qs = urllib.parse.urlencode({'key': key})
|
|
191
|
+
out = _api_call('GET', f'/api/v1/datasets/{dataset_id}/artifacts/credentials?{qs}')
|
|
192
|
+
if isinstance(out, dict) and out.get('ct'):
|
|
193
|
+
return out
|
|
194
|
+
raise ServerError(
|
|
195
|
+
f'artifacts/credentials 响应非加密 envelope: {out}'
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def export_strategy(strategy_id: str, output_path: str | None = None) -> dict:
|
|
200
|
+
"""POST /api/v1/strategies/{id}/export → 拉 cloud 渲染的 .qmt.py shell.
|
|
201
|
+
|
|
202
|
+
ADR-0023: cloud 是 single source of truth — shell 渲染 (CONFIG 块 + embedded
|
|
203
|
+
API key + cert 签) 全在 server 端. 本函数仅下载 + 落盘, 不读 shell 内容.
|
|
204
|
+
|
|
205
|
+
返 dict: {shell_path, shell_bytes} (shell_path = output_path 或 None).
|
|
206
|
+
"""
|
|
207
|
+
token = _load_token()
|
|
208
|
+
url = f'{_resolve_server()}/api/v1/strategies/{strategy_id}/export'
|
|
209
|
+
req = Request(
|
|
210
|
+
url, data=b'', method='POST',
|
|
211
|
+
headers={
|
|
212
|
+
'Authorization': f'Bearer {token}',
|
|
213
|
+
'Accept': 'application/octet-stream, application/json',
|
|
214
|
+
},
|
|
215
|
+
)
|
|
216
|
+
try:
|
|
217
|
+
with urlopen(req, timeout=60) as resp:
|
|
218
|
+
# server 既可返 raw .qmt.py 也可返 JSON envelope — 读 Content-Type 判
|
|
219
|
+
ctype = resp.headers.get('Content-Type', '')
|
|
220
|
+
raw = resp.read()
|
|
221
|
+
if 'json' in ctype.lower():
|
|
222
|
+
envelope = json.loads(raw.decode('utf-8'))
|
|
223
|
+
if isinstance(envelope, dict) and 'DataValue' in envelope:
|
|
224
|
+
if not envelope.get('Result', False):
|
|
225
|
+
raise ServerError(
|
|
226
|
+
f'QMT export 业务失败: Error={envelope.get("Error")} '
|
|
227
|
+
f'Message={envelope.get("Message")}'
|
|
228
|
+
)
|
|
229
|
+
# DataValue 可能是 base64 编码的 shell 字节
|
|
230
|
+
import base64
|
|
231
|
+
shell_bytes = base64.b64decode(envelope['DataValue'])
|
|
232
|
+
else:
|
|
233
|
+
shell_bytes = raw
|
|
234
|
+
else:
|
|
235
|
+
shell_bytes = raw
|
|
236
|
+
except HTTPError as e:
|
|
237
|
+
resp_body = e.read().decode('utf-8', errors='replace')[:500]
|
|
238
|
+
if e.code == 401:
|
|
239
|
+
raise ServerError(
|
|
240
|
+
f'QMT export 失败: HTTP 401 — API key 无效或已失效'
|
|
241
|
+
) from e
|
|
242
|
+
if e.code == 402:
|
|
243
|
+
raise ServerError(
|
|
244
|
+
f'QMT export 失败: HTTP 402 — 订阅未激活 (subscription gate)'
|
|
245
|
+
) from e
|
|
246
|
+
raise ServerError(
|
|
247
|
+
f'QMT export 失败: HTTP {e.code} {e.reason}; body={resp_body}'
|
|
248
|
+
) from e
|
|
249
|
+
|
|
250
|
+
if output_path:
|
|
251
|
+
Path(output_path).write_bytes(shell_bytes)
|
|
252
|
+
return {'shell_path': str(output_path), 'shell_bytes': len(shell_bytes)}
|
|
253
|
+
return {'shell_path': None, 'shell_bytes': len(shell_bytes)}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _sanitize_for_json(obj):
|
|
257
|
+
"""递归把 NaN/Inf → None + Timestamp/datetime → ISO 字符串 (json 标准不允许).
|
|
258
|
+
|
|
259
|
+
akquant runner 输出含 pd.Timestamp / datetime / numpy 标量 (e.g. equity_curve.date
|
|
260
|
+
列是 Timestamp; period.start/end 可能是 datetime); 不转换就 TypeError.
|
|
261
|
+
"""
|
|
262
|
+
# datetime / pd.Timestamp 优先 (避免落入 numpy 标量分支)
|
|
263
|
+
try:
|
|
264
|
+
import datetime as _dt
|
|
265
|
+
if isinstance(obj, _dt.datetime):
|
|
266
|
+
return obj.isoformat()
|
|
267
|
+
if isinstance(obj, _dt.date):
|
|
268
|
+
return obj.isoformat()
|
|
269
|
+
if isinstance(obj, _dt.timedelta):
|
|
270
|
+
return obj.total_seconds()
|
|
271
|
+
except ImportError:
|
|
272
|
+
pass
|
|
273
|
+
if isinstance(obj, dict):
|
|
274
|
+
return {k: _sanitize_for_json(v) for k, v in obj.items()}
|
|
275
|
+
if isinstance(obj, (list, tuple)):
|
|
276
|
+
return [_sanitize_for_json(x) for x in obj]
|
|
277
|
+
if isinstance(obj, float):
|
|
278
|
+
return None if (math.isnan(obj) or math.isinf(obj)) else obj
|
|
279
|
+
# numpy / pandas 标量 → 标 Python 类型
|
|
280
|
+
if hasattr(obj, 'item') and callable(obj.item):
|
|
281
|
+
try:
|
|
282
|
+
return obj.item()
|
|
283
|
+
except (ValueError, TypeError):
|
|
284
|
+
pass
|
|
285
|
+
return obj
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
{
|
|
2
|
+
"api_base": "http://localhost:8080",
|
|
3
|
+
"_comment": "部署配置层 — Round 14 注入方式: pip install hamuna-quant-cli 后, http_client.py 自动按优先级 1) HAMUNA_SERVER env 2) HAMUNA_SERVER_JSON env 3) ./scripts/server.json 4) <包根>/scripts/server.json (本文件) 5) ./server.json 6) 默认 localhost:8080 解析. 部署方只需改这一个文件 + 重装包即可切 server."
|
|
4
|
+
}
|