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.
@@ -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
+ }