pandaai-cli 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.
- cli.py +442 -0
- pandaai/__init__.py +12 -0
- pandaai/auth.py +103 -0
- pandaai/billing.py +20 -0
- pandaai/config.py +40 -0
- pandaai/constants.py +15 -0
- pandaai/download.py +41 -0
- pandaai/factor.py +96 -0
- pandaai/factor_core.py +283 -0
- pandaai/factor_list.py +183 -0
- pandaai/formatting.py +79 -0
- pandaai/output.py +99 -0
- pandaai_cli-0.1.0.dist-info/METADATA +451 -0
- pandaai_cli-0.1.0.dist-info/RECORD +19 -0
- pandaai_cli-0.1.0.dist-info/WHEEL +5 -0
- pandaai_cli-0.1.0.dist-info/entry_points.txt +2 -0
- pandaai_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- pandaai_cli-0.1.0.dist-info/top_level.txt +3 -0
- template.py +578 -0
cli.py
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
PandaAI 因子分析 CLI
|
|
4
|
+
|
|
5
|
+
用法:
|
|
6
|
+
python cli.py login [--phone PHONE] [--password PWD]
|
|
7
|
+
python cli.py factor_create --formula "..." [--name NAME]
|
|
8
|
+
python cli.py factor_info <factor_id>
|
|
9
|
+
python cli.py factor_update <factor_id> [--name NAME] [--adjustment-cycle N]
|
|
10
|
+
python cli.py factor_run <factor_id> [--download]
|
|
11
|
+
python cli.py factor_list [--limit N]
|
|
12
|
+
python cli.py balance
|
|
13
|
+
python cli.py factor_result <run_id>
|
|
14
|
+
python cli.py factor_delete <factor_id> [factor_id...] [--pattern PATTERN]
|
|
15
|
+
|
|
16
|
+
通用参数: --config PATH, --json
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import getpass
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
import os
|
|
24
|
+
import traceback
|
|
25
|
+
from typing import Optional, Dict, Any, Tuple
|
|
26
|
+
|
|
27
|
+
import requests
|
|
28
|
+
import yaml
|
|
29
|
+
|
|
30
|
+
from template import build_factor_json
|
|
31
|
+
from pandaai import (
|
|
32
|
+
STATUS_SUCCESS,
|
|
33
|
+
load_config, save_token, config_path,
|
|
34
|
+
do_login, login,
|
|
35
|
+
get_balance,
|
|
36
|
+
create_factor, run_factor, poll_factor_run, get_factor_output,
|
|
37
|
+
get_factor, update_factor, extract_factor_info, apply_factor_updates,
|
|
38
|
+
get_factor_run_results, format_factor_analysis_results,
|
|
39
|
+
list_factors, delete_factors, format_factor_list, enrich_factors_with_runs,
|
|
40
|
+
format_success, format_failure,
|
|
41
|
+
download_csv,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _fail(error_type: str, message: str) -> None:
|
|
46
|
+
"""输出错误 JSON 并退出"""
|
|
47
|
+
print(json.dumps(format_failure(status="ERROR", error_type=error_type, message=message), ensure_ascii=False))
|
|
48
|
+
sys.exit(1)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main():
|
|
52
|
+
parser = argparse.ArgumentParser(description="PandaAI 因子分析 CLI")
|
|
53
|
+
parser.add_argument("--config", type=str, default=None, help="配置文件路径")
|
|
54
|
+
parser.add_argument("--json", action="store_true", help="仅输出 JSON")
|
|
55
|
+
|
|
56
|
+
sub = parser.add_subparsers(dest="command", help="可用命令")
|
|
57
|
+
|
|
58
|
+
# ── login ──
|
|
59
|
+
p = sub.add_parser("login", help="登录并保存 token")
|
|
60
|
+
p.add_argument("--phone", type=str, default=None, help="手机号")
|
|
61
|
+
p.add_argument("--password", type=str, default=None, help="密码")
|
|
62
|
+
|
|
63
|
+
# ── factor_create ──
|
|
64
|
+
p = sub.add_parser("factor_create", help="创建因子分析(不执行),返回 factor_id")
|
|
65
|
+
cg = p.add_mutually_exclusive_group(required=True)
|
|
66
|
+
cg.add_argument("--code", type=str, help="Python 因子代码")
|
|
67
|
+
cg.add_argument("--formula", type=str, help="因子公式")
|
|
68
|
+
cg.add_argument("--file", type=str, help="从文件读取代码/公式")
|
|
69
|
+
p.add_argument("--name", type=str, default="新建因子分析", help="因子分析名称")
|
|
70
|
+
p.add_argument("--start-date", type=str, default=None, help="因子构建开始日期 YYYYMMDD")
|
|
71
|
+
p.add_argument("--end-date", type=str, default=None, help="因子构建结束日期 YYYYMMDD")
|
|
72
|
+
p.add_argument("--adjustment-cycle", type=str, default="1", help="调仓周期 1-10(默认 1)")
|
|
73
|
+
p.add_argument("--factor-direction", type=str, default="1", help="因子方向 0=负向 1=正向(默认 1)")
|
|
74
|
+
|
|
75
|
+
# ── factor_info ──
|
|
76
|
+
p = sub.add_parser("factor_info", help="查看因子详情(代码/公式 + 所有参数)")
|
|
77
|
+
p.add_argument("factor_id", type=str, help="因子分析 ID")
|
|
78
|
+
|
|
79
|
+
# ── factor_update ──
|
|
80
|
+
p = sub.add_parser("factor_update", help="修改因子参数")
|
|
81
|
+
p.add_argument("factor_id", type=str, help="因子分析 ID")
|
|
82
|
+
p.add_argument("--name", type=str, default=None, help="新名称")
|
|
83
|
+
ug = p.add_mutually_exclusive_group()
|
|
84
|
+
ug.add_argument("--code", type=str, default=None, help="新 Python 因子代码")
|
|
85
|
+
ug.add_argument("--formula", type=str, default=None, help="新因子公式")
|
|
86
|
+
ug.add_argument("--file", type=str, default=None, help="从文件读取新代码/公式")
|
|
87
|
+
p.add_argument("--start-date", type=str, default=None, help="因子构建开始日期 YYYYMMDD")
|
|
88
|
+
p.add_argument("--end-date", type=str, default=None, help="因子构建结束日期 YYYYMMDD")
|
|
89
|
+
p.add_argument("--adjustment-cycle", type=str, default=None, help="调仓周期 1-10")
|
|
90
|
+
p.add_argument("--factor-direction", type=str, default=None, help="因子方向 0=负向 1=正向")
|
|
91
|
+
|
|
92
|
+
# ── factor_run ──
|
|
93
|
+
p = sub.add_parser("factor_run", help="执行已有因子分析(启动 + 轮询 + 结果)")
|
|
94
|
+
p.add_argument("factor_id", type=str, help="因子分析 ID")
|
|
95
|
+
p.add_argument("--download", type=str, nargs="?", const="", default=None, help="下载 CSV(默认 ~/Downloads)")
|
|
96
|
+
p.add_argument("--poll-interval", type=int, default=2, help="轮询间隔(秒)")
|
|
97
|
+
p.add_argument("--timeout", type=int, default=600, help="超时时间(秒)")
|
|
98
|
+
|
|
99
|
+
# ── factor_list ──
|
|
100
|
+
p = sub.add_parser("factor_list", help="列出所有因子分析")
|
|
101
|
+
p.add_argument("--limit", type=int, default=100, help="每页条数(最大100)")
|
|
102
|
+
p.add_argument("--offset", type=int, default=0, help="偏移量")
|
|
103
|
+
p.add_argument("--no-detail", action="store_true", help="跳过获取分析摘要(更快)")
|
|
104
|
+
|
|
105
|
+
# ── balance ──
|
|
106
|
+
sub.add_parser("balance", help="查询算力余额")
|
|
107
|
+
|
|
108
|
+
# ── factor_result ──
|
|
109
|
+
p = sub.add_parser("factor_result", help="查询运行结果(14 个分析接口)")
|
|
110
|
+
p.add_argument("run_id", type=str, help="运行 ID")
|
|
111
|
+
p.add_argument("--download", type=str, nargs="?", const="", default=None, help="下载 CSV")
|
|
112
|
+
|
|
113
|
+
# ── factor_delete ──
|
|
114
|
+
p = sub.add_parser("factor_delete", help="删除因子分析")
|
|
115
|
+
p.add_argument("factor_id", type=str, nargs="*", help="因子分析 ID(可多个)")
|
|
116
|
+
p.add_argument("--pattern", type=str, default=None, help="按名称前缀批量删除")
|
|
117
|
+
p.add_argument("--yes", action="store_true", help="跳过确认")
|
|
118
|
+
|
|
119
|
+
args = parser.parse_args()
|
|
120
|
+
|
|
121
|
+
if not args.command:
|
|
122
|
+
parser.print_help()
|
|
123
|
+
sys.exit(1)
|
|
124
|
+
|
|
125
|
+
# ═══════════════════════════════════════════
|
|
126
|
+
# 加载配置
|
|
127
|
+
# ═══════════════════════════════════════════
|
|
128
|
+
config = load_config(args.config)
|
|
129
|
+
|
|
130
|
+
# ═══════════════════════════════════════════
|
|
131
|
+
# login
|
|
132
|
+
# ═══════════════════════════════════════════
|
|
133
|
+
if args.command == "login":
|
|
134
|
+
phone = args.phone or input("手机号: ").strip()
|
|
135
|
+
password = args.password
|
|
136
|
+
if not password:
|
|
137
|
+
password = getpass.getpass("密码: ").strip()
|
|
138
|
+
if not phone or not password:
|
|
139
|
+
_fail("LOGIN_FAILED", "手机号和密码不能为空")
|
|
140
|
+
token, uid, _ = do_login(config, phone, password, config.get("country_code", "86"))
|
|
141
|
+
save_token(token, uid, args.config)
|
|
142
|
+
print(f"✅ 登录成功! userId={uid}")
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
# ═══════════════════════════════════════════
|
|
146
|
+
# 认证
|
|
147
|
+
# ═══════════════════════════════════════════
|
|
148
|
+
token, uid, _ = login(config)
|
|
149
|
+
if not args.json:
|
|
150
|
+
pass # token 有效,静默继续
|
|
151
|
+
|
|
152
|
+
# ═══════════════════════════════════════════
|
|
153
|
+
# create
|
|
154
|
+
# ═══════════════════════════════════════════
|
|
155
|
+
if args.command == "factor_create":
|
|
156
|
+
if args.file:
|
|
157
|
+
try:
|
|
158
|
+
with open(args.file, "r") as f:
|
|
159
|
+
content = f.read()
|
|
160
|
+
except Exception as e:
|
|
161
|
+
_fail("INPUT_ERROR", f"读取文件失败: {e}")
|
|
162
|
+
elif args.code:
|
|
163
|
+
content = args.code
|
|
164
|
+
else:
|
|
165
|
+
content = args.formula
|
|
166
|
+
mode = "formula" if args.formula else "code"
|
|
167
|
+
if not content or not content.strip():
|
|
168
|
+
_fail("INPUT_ERROR", "代码/公式内容为空")
|
|
169
|
+
|
|
170
|
+
# 校验调仓周期
|
|
171
|
+
adj_cycle = args.adjustment_cycle
|
|
172
|
+
try:
|
|
173
|
+
adj_int = int(adj_cycle)
|
|
174
|
+
if adj_int < 1 or adj_int > 10:
|
|
175
|
+
_fail("INPUT_ERROR", "调仓周期必须在 1-10 之间")
|
|
176
|
+
except ValueError:
|
|
177
|
+
_fail("INPUT_ERROR", f"调仓周期必须为整数: {adj_cycle}")
|
|
178
|
+
|
|
179
|
+
factor_json = build_factor_json(content, mode, name=args.name,
|
|
180
|
+
start_date=args.start_date, end_date=args.end_date,
|
|
181
|
+
adjustment_cycle=args.adjustment_cycle,
|
|
182
|
+
factor_direction=args.factor_direction)
|
|
183
|
+
factor_id = create_factor(config, token, uid, factor_json)
|
|
184
|
+
if args.json:
|
|
185
|
+
print(json.dumps({"success": True, "factor_id": factor_id}, ensure_ascii=False))
|
|
186
|
+
else:
|
|
187
|
+
print(f"✅ 因子分析已创建: {factor_id}")
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
# ═══════════════════════════════════════════
|
|
191
|
+
# info
|
|
192
|
+
# ═══════════════════════════════════════════
|
|
193
|
+
if args.command == "factor_info":
|
|
194
|
+
factor_data = get_factor(config, token, uid, args.factor_id)
|
|
195
|
+
if factor_data is None:
|
|
196
|
+
_fail("INFO_FAILED", f"获取因子信息失败: {args.factor_id}")
|
|
197
|
+
|
|
198
|
+
info = extract_factor_info(factor_data)
|
|
199
|
+
|
|
200
|
+
if args.json:
|
|
201
|
+
print(json.dumps({"success": True, **info}, ensure_ascii=False, indent=2))
|
|
202
|
+
else:
|
|
203
|
+
from datetime import datetime as dt
|
|
204
|
+
mode_label = "Python 代码" if info["mode"] == "code" else "因子公式"
|
|
205
|
+
created = ""
|
|
206
|
+
if info.get("create_at"):
|
|
207
|
+
try:
|
|
208
|
+
created = dt.fromtimestamp(info["create_at"] / 1000).strftime("%Y-%m-%d %H:%M:%S")
|
|
209
|
+
except Exception:
|
|
210
|
+
created = str(info["create_at"])
|
|
211
|
+
|
|
212
|
+
print(f"名称: {info['name']}")
|
|
213
|
+
print(f"ID: {info['_id']}")
|
|
214
|
+
print(f"类型: {mode_label}")
|
|
215
|
+
print(f"创建时间: {created}")
|
|
216
|
+
print(f"调仓周期: {info['adjustment_cycle']}")
|
|
217
|
+
print(f"分组数量: {info['group_number']}")
|
|
218
|
+
print(f"因子方向: {info['factor_direction']} ({'正向' if info['factor_direction'] == '1' else '负向'})")
|
|
219
|
+
print(f"股票池: {info['stock_pool']}")
|
|
220
|
+
print(f"开始日期: {info['start_date']}")
|
|
221
|
+
print(f"结束日期: {info['end_date']}")
|
|
222
|
+
if info["last_run_id"]:
|
|
223
|
+
print(f"最后运行: {info['last_run_id']}")
|
|
224
|
+
print(f"\n{'─' * 50}")
|
|
225
|
+
print(f"【{mode_label}】")
|
|
226
|
+
print(info["content"])
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
# ═══════════════════════════════════════════
|
|
230
|
+
# update
|
|
231
|
+
# ═══════════════════════════════════════════
|
|
232
|
+
if args.command == "factor_update":
|
|
233
|
+
factor_data = get_factor(config, token, uid, args.factor_id)
|
|
234
|
+
if factor_data is None:
|
|
235
|
+
_fail("UPDATE_FAILED", f"获取因子信息失败: {args.factor_id}")
|
|
236
|
+
|
|
237
|
+
updates = {}
|
|
238
|
+
|
|
239
|
+
if args.name is not None:
|
|
240
|
+
updates["name"] = args.name
|
|
241
|
+
|
|
242
|
+
if args.file:
|
|
243
|
+
try:
|
|
244
|
+
with open(args.file, "r") as f:
|
|
245
|
+
updates["content"] = f.read()
|
|
246
|
+
except Exception as e:
|
|
247
|
+
_fail("INPUT_ERROR", f"读取文件失败: {e}")
|
|
248
|
+
updates["mode"] = "code"
|
|
249
|
+
elif args.code is not None:
|
|
250
|
+
updates["content"] = args.code
|
|
251
|
+
updates["mode"] = "code"
|
|
252
|
+
elif args.formula is not None:
|
|
253
|
+
updates["content"] = args.formula
|
|
254
|
+
updates["mode"] = "formula"
|
|
255
|
+
|
|
256
|
+
if args.start_date is not None:
|
|
257
|
+
updates["start_date"] = args.start_date
|
|
258
|
+
if args.end_date is not None:
|
|
259
|
+
updates["end_date"] = args.end_date
|
|
260
|
+
|
|
261
|
+
if args.adjustment_cycle is not None:
|
|
262
|
+
try:
|
|
263
|
+
adj_int = int(args.adjustment_cycle)
|
|
264
|
+
if adj_int < 1 or adj_int > 10:
|
|
265
|
+
_fail("INPUT_ERROR", "调仓周期必须在 1-10 之间")
|
|
266
|
+
except ValueError:
|
|
267
|
+
_fail("INPUT_ERROR", f"调仓周期必须为整数: {args.adjustment_cycle}")
|
|
268
|
+
updates["adjustment_cycle"] = args.adjustment_cycle
|
|
269
|
+
|
|
270
|
+
if args.factor_direction is not None:
|
|
271
|
+
if args.factor_direction not in ("0", "1"):
|
|
272
|
+
_fail("INPUT_ERROR", "因子方向必须为 0(负向) 或 1(正向)")
|
|
273
|
+
updates["factor_direction"] = args.factor_direction
|
|
274
|
+
|
|
275
|
+
if not updates:
|
|
276
|
+
print("没有需要更新的参数")
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
factor_json = apply_factor_updates(factor_data, updates)
|
|
280
|
+
factor_id = update_factor(config, token, uid, args.factor_id, factor_json)
|
|
281
|
+
if args.json:
|
|
282
|
+
print(json.dumps({"success": True, "factor_id": factor_id, "updated": list(updates.keys())}, ensure_ascii=False))
|
|
283
|
+
else:
|
|
284
|
+
print(f"✅ 因子分析已更新: {factor_id}")
|
|
285
|
+
print(f" 更新字段: {', '.join(updates.keys())}")
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
# ═══════════════════════════════════════════
|
|
289
|
+
# run
|
|
290
|
+
# ═══════════════════════════════════════════
|
|
291
|
+
if args.command == "factor_run":
|
|
292
|
+
factor_id = args.factor_id
|
|
293
|
+
|
|
294
|
+
balance_start = get_balance(config, token)
|
|
295
|
+
if not args.json:
|
|
296
|
+
power_start = balance_start.get("computingPower") if isinstance(balance_start, dict) else "?"
|
|
297
|
+
print(f"[INFO] 当前算力: {power_start}", file=sys.stderr)
|
|
298
|
+
|
|
299
|
+
run_id, run_error = run_factor(config, token, uid, factor_id)
|
|
300
|
+
if run_id is None:
|
|
301
|
+
balance_end = get_balance(config, token) or balance_start
|
|
302
|
+
power_end = balance_end.get("computingPower") if isinstance(balance_end, dict) else None
|
|
303
|
+
error_msg = run_error or "启动因子分析失败"
|
|
304
|
+
if not args.json:
|
|
305
|
+
print(f"[ERROR] {error_msg}", file=sys.stderr)
|
|
306
|
+
result = {
|
|
307
|
+
"success": False, "status": "RUN_FAILED",
|
|
308
|
+
"factor_id": factor_id, "factor_run_id": None,
|
|
309
|
+
"error": {"type": "RUN_FAILED", "message": error_msg},
|
|
310
|
+
"billing": {"balance": power_end, "status": "ok"}
|
|
311
|
+
}
|
|
312
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
313
|
+
sys.exit(1)
|
|
314
|
+
|
|
315
|
+
if not args.json:
|
|
316
|
+
print(f"[INFO] 因子分析已启动: {run_id}", file=sys.stderr)
|
|
317
|
+
print(f"[INFO] 等待执行完成...", file=sys.stderr)
|
|
318
|
+
|
|
319
|
+
run_result = poll_factor_run(config, token, uid, run_id, args.poll_interval, args.timeout)
|
|
320
|
+
|
|
321
|
+
output = None
|
|
322
|
+
full_results = None
|
|
323
|
+
if run_result["status"] == STATUS_SUCCESS:
|
|
324
|
+
output = get_factor_output(config, token, uid, factor_id)
|
|
325
|
+
full_results = get_factor_run_results(config, token, uid, run_id)
|
|
326
|
+
|
|
327
|
+
balance_end = get_balance(config, token) or balance_start
|
|
328
|
+
|
|
329
|
+
s = run_result["status"]
|
|
330
|
+
if s == STATUS_SUCCESS:
|
|
331
|
+
result = format_success(factor_id, run_id, run_result, balance_start, balance_end, output)
|
|
332
|
+
result["results"] = full_results
|
|
333
|
+
elif s == 3: # STATUS_FAILED
|
|
334
|
+
errors = run_result.get("errors", [])
|
|
335
|
+
msg = "; ".join(e.get("error_message", e.get("message", "")) for e in errors) if errors else "因子分析执行失败"
|
|
336
|
+
result = format_failure("FAILED", "WORKFLOW_FAILED", msg, factor_id, run_id, run_result, balance_start, balance_end)
|
|
337
|
+
elif s == 6: # STATUS_BILLING_STOP
|
|
338
|
+
result = format_failure("BILLING_STOP", "INSUFFICIENT_BALANCE", "余额不足", factor_id, run_id, run_result, balance_start, balance_end)
|
|
339
|
+
elif s == "TIMEOUT":
|
|
340
|
+
result = format_failure("TIMEOUT", "TIMEOUT", f"轮询超时 ({args.timeout}s)", factor_id, run_id, run_result, balance_start, balance_end)
|
|
341
|
+
else:
|
|
342
|
+
result = format_failure("UNKNOWN", "UNKNOWN", f"未知状态: {s}", factor_id, run_id, run_result, balance_start, balance_end)
|
|
343
|
+
|
|
344
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
345
|
+
if s == STATUS_SUCCESS and full_results and not args.json:
|
|
346
|
+
print("\n" + format_factor_analysis_results(full_results), file=sys.stderr)
|
|
347
|
+
download_csv(full_results, args.download)
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
# ═══════════════════════════════════════════
|
|
351
|
+
# list
|
|
352
|
+
# ═══════════════════════════════════════════
|
|
353
|
+
if args.command == "factor_list":
|
|
354
|
+
factors, total = list_factors(config, token, uid, limit=args.limit, offset=args.offset)
|
|
355
|
+
if factors is None:
|
|
356
|
+
_fail("LIST_FAILED", "获取因子列表失败")
|
|
357
|
+
|
|
358
|
+
if not args.no_detail and factors:
|
|
359
|
+
if not args.json:
|
|
360
|
+
print(f"[INFO] 正在获取分析摘要...", file=sys.stderr)
|
|
361
|
+
factors = enrich_factors_with_runs(config, token, uid, factors)
|
|
362
|
+
|
|
363
|
+
if args.json:
|
|
364
|
+
print(json.dumps({"success": True, "total": total, "count": len(factors),
|
|
365
|
+
"limit": args.limit, "offset": args.offset, "factors": factors},
|
|
366
|
+
ensure_ascii=False, indent=2))
|
|
367
|
+
else:
|
|
368
|
+
print(format_factor_list(factors, total_count=total, limit=args.limit, offset=args.offset))
|
|
369
|
+
return
|
|
370
|
+
|
|
371
|
+
# ═══════════════════════════════════════════
|
|
372
|
+
# balance
|
|
373
|
+
# ═══════════════════════════════════════════
|
|
374
|
+
if args.command == "balance":
|
|
375
|
+
balance = get_balance(config, token)
|
|
376
|
+
if balance is None:
|
|
377
|
+
_fail("BALANCE_FAILED", "查询算力余额失败")
|
|
378
|
+
if args.json:
|
|
379
|
+
print(json.dumps({"success": True, "balance": balance}, ensure_ascii=False))
|
|
380
|
+
else:
|
|
381
|
+
power = balance.get("computingPower", "?")
|
|
382
|
+
print(f"算力: {power}")
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
# ═══════════════════════════════════════════
|
|
386
|
+
# factor_result
|
|
387
|
+
# ═══════════════════════════════════════════
|
|
388
|
+
if args.command == "factor_result":
|
|
389
|
+
results = get_factor_run_results(config, token, uid, args.run_id)
|
|
390
|
+
if args.json:
|
|
391
|
+
print(json.dumps({"success": "error" not in results, **results}, ensure_ascii=False, indent=2))
|
|
392
|
+
else:
|
|
393
|
+
print(format_factor_analysis_results(results))
|
|
394
|
+
download_csv(results, args.download)
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
# ═══════════════════════════════════════════
|
|
398
|
+
# delete
|
|
399
|
+
# ═══════════════════════════════════════════
|
|
400
|
+
if args.command == "factor_delete":
|
|
401
|
+
if args.pattern:
|
|
402
|
+
ids = []
|
|
403
|
+
offset = 0
|
|
404
|
+
while True:
|
|
405
|
+
factors, _ = list_factors(config, token, uid, limit=100, offset=offset)
|
|
406
|
+
if not factors:
|
|
407
|
+
break
|
|
408
|
+
for f in factors:
|
|
409
|
+
if f.get("name", "").startswith(args.pattern):
|
|
410
|
+
ids.append(f["_id"])
|
|
411
|
+
offset += 100
|
|
412
|
+
if not ids:
|
|
413
|
+
print(f"没有匹配 '{args.pattern}' 的因子分析")
|
|
414
|
+
return
|
|
415
|
+
elif args.factor_id:
|
|
416
|
+
ids = args.factor_id
|
|
417
|
+
else:
|
|
418
|
+
print("用法: factor_delete <factor_id> [factor_id...] 或 factor_delete --pattern <前缀>")
|
|
419
|
+
sys.exit(1)
|
|
420
|
+
|
|
421
|
+
if not args.yes and not args.json:
|
|
422
|
+
print(f"将删除 {len(ids)} 个因子分析: {', '.join(ids[:5])}{'...' if len(ids) > 5 else ''}")
|
|
423
|
+
confirm = input("确认? (y/N): ").strip().lower()
|
|
424
|
+
if confirm not in ("y", "yes"):
|
|
425
|
+
print("已取消")
|
|
426
|
+
return
|
|
427
|
+
|
|
428
|
+
deleted, error = delete_factors(config, token, uid, ids)
|
|
429
|
+
if error:
|
|
430
|
+
_fail("DELETE_FAILED", f"删除失败 (已删{deleted}): {error}")
|
|
431
|
+
print(f"✅ 已删除 {deleted} 个因子分析")
|
|
432
|
+
return
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
if __name__ == "__main__":
|
|
436
|
+
try:
|
|
437
|
+
main()
|
|
438
|
+
except SystemExit:
|
|
439
|
+
pass
|
|
440
|
+
except Exception:
|
|
441
|
+
print(json.dumps(format_failure("ERROR", "UNKNOWN", f"CLI内部错误: {traceback.format_exc()}"), ensure_ascii=False))
|
|
442
|
+
sys.exit(1)
|
pandaai/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""PandaAI CLI 功能模块"""
|
|
2
|
+
|
|
3
|
+
from .constants import *
|
|
4
|
+
from .config import load_config, save_token, config_path
|
|
5
|
+
from .auth import do_login, login, api_url, make_headers
|
|
6
|
+
from .billing import get_balance
|
|
7
|
+
from .factor_core import create_factor, run_factor, poll_factor_run, get_factor_output, get_factor, update_factor, extract_factor_info, apply_factor_updates
|
|
8
|
+
from .output import get_factor_run_detail, get_node_output, extract_task_id, fetch_factor_analysis, get_factor_run_results
|
|
9
|
+
from .factor import FACTOR_ANALYSIS_ENDPOINTS, format_factor_analysis_results
|
|
10
|
+
from .factor_list import list_factors, delete_factors, format_factor_list, enrich_factors_with_runs
|
|
11
|
+
from .formatting import format_success, format_failure, _calc_duration
|
|
12
|
+
from .download import download_csv
|
pandaai/auth.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""认证:登录、token 验证"""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import sys
|
|
5
|
+
import json
|
|
6
|
+
from typing import Optional, Tuple
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from .config import save_token
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _api_url(config: dict, path: str) -> str:
|
|
14
|
+
"""拼接完整 API 地址"""
|
|
15
|
+
base = config["gateway_url"].rstrip("/")
|
|
16
|
+
return f"{base}{path}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _headers(token: str = None, uid: str = None, extra: dict = None) -> dict:
|
|
20
|
+
"""构造请求头"""
|
|
21
|
+
h = {"Content-Type": "application/json"}
|
|
22
|
+
if token:
|
|
23
|
+
h["Authorization"] = token
|
|
24
|
+
if uid:
|
|
25
|
+
h["uid"] = str(uid)
|
|
26
|
+
if extra:
|
|
27
|
+
h.update(extra)
|
|
28
|
+
return h
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _fail(error_type: str, message: str) -> None:
|
|
32
|
+
"""输出错误 JSON 并退出(导入自 formatting)"""
|
|
33
|
+
from .formatting import format_failure
|
|
34
|
+
print(json.dumps(format_failure(status="ERROR", error_type=error_type, message=message), ensure_ascii=False))
|
|
35
|
+
sys.exit(1)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def do_login(config: dict, phone: str, password: str, country_code: str = "86") -> Tuple[str, str, dict]:
|
|
39
|
+
"""
|
|
40
|
+
执行密码登录,返回 (token, uid, userinfo)
|
|
41
|
+
"""
|
|
42
|
+
url = _api_url(config, "/login/pw")
|
|
43
|
+
body = {
|
|
44
|
+
"phone": phone,
|
|
45
|
+
"password": hashlib.md5(password.encode()).hexdigest(),
|
|
46
|
+
"countryCode": country_code,
|
|
47
|
+
}
|
|
48
|
+
try:
|
|
49
|
+
resp = requests.post(url, json=body, timeout=10)
|
|
50
|
+
data = resp.json()
|
|
51
|
+
except Exception as e:
|
|
52
|
+
return _fail("LOGIN_FAILED", f"登录请求失败: {e}")
|
|
53
|
+
|
|
54
|
+
if data.get("code") != "200":
|
|
55
|
+
return _fail("LOGIN_FAILED", f"登录失败: {data.get('message', '未知错误')}")
|
|
56
|
+
|
|
57
|
+
token = data.get("data")
|
|
58
|
+
if not token:
|
|
59
|
+
return _fail("LOGIN_FAILED", "登录响应中没有 token")
|
|
60
|
+
|
|
61
|
+
user_id, userinfo = _get_user_info(config, token)
|
|
62
|
+
if user_id is None or userinfo is None:
|
|
63
|
+
return _fail("LOGIN_FAILED", "获取用户信息失败")
|
|
64
|
+
|
|
65
|
+
return token, user_id, userinfo
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def login(config: dict) -> Tuple[str, str, dict]:
|
|
69
|
+
"""
|
|
70
|
+
认证入口:优先用已保存的 token,失败则提示用户登录
|
|
71
|
+
返回 (token, uid, userinfo)
|
|
72
|
+
"""
|
|
73
|
+
saved_token = config.get("token", "")
|
|
74
|
+
saved_uid = config.get("uid", "")
|
|
75
|
+
|
|
76
|
+
if saved_token and saved_uid:
|
|
77
|
+
uid, userinfo = _get_user_info(config, saved_token)
|
|
78
|
+
if uid is not None and userinfo is not None:
|
|
79
|
+
return saved_token, uid, userinfo
|
|
80
|
+
|
|
81
|
+
return _fail("LOGIN_REQUIRED",
|
|
82
|
+
"未登录或 token 已过期,请先执行: python cli.py login --phone <手机号> --password <密码>")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _get_user_info(config: dict, token: str) -> Tuple[Optional[str], Optional[dict]]:
|
|
86
|
+
"""调 /user/info 获取 userId 和用户信息"""
|
|
87
|
+
url = _api_url(config, "/user/info")
|
|
88
|
+
try:
|
|
89
|
+
resp = requests.get(url, headers=_headers(token=token), timeout=10)
|
|
90
|
+
data = resp.json()
|
|
91
|
+
if data.get("code") == "200":
|
|
92
|
+
user_data = data.get("data", {})
|
|
93
|
+
if isinstance(user_data, dict):
|
|
94
|
+
uid = user_data.get("id")
|
|
95
|
+
return (str(uid) if uid else None), user_data
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
return None, None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# 导出工具函数供其他模块使用
|
|
102
|
+
api_url = _api_url
|
|
103
|
+
make_headers = _headers
|
pandaai/billing.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""算力余额查询"""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from .auth import api_url, make_headers
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_balance(config: dict, token: str) -> Optional[dict]:
|
|
11
|
+
"""查询用户算力余额"""
|
|
12
|
+
url = api_url(config, "/userWallet/myWallet")
|
|
13
|
+
try:
|
|
14
|
+
resp = requests.get(url, headers=make_headers(token=token), timeout=10)
|
|
15
|
+
data = resp.json()
|
|
16
|
+
if data.get("code") == "200":
|
|
17
|
+
return data.get("data", {})
|
|
18
|
+
except Exception:
|
|
19
|
+
pass
|
|
20
|
+
return None
|
pandaai/config.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""配置加载与 token 持久化"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import json
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def config_path(custom_path: str = None) -> str:
|
|
10
|
+
"""获取配置文件路径。默认 ~/.pandaai/config.yaml,可通过 --config 覆盖"""
|
|
11
|
+
if custom_path is None:
|
|
12
|
+
return os.path.join(os.path.expanduser("~"), ".pandaai", "config.yaml")
|
|
13
|
+
return custom_path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_config(config_path_arg: str = None) -> dict:
|
|
17
|
+
"""加载配置文件"""
|
|
18
|
+
path = config_path(config_path_arg)
|
|
19
|
+
if not os.path.exists(path):
|
|
20
|
+
print(json.dumps({"success": False, "error": {"type": "CONFIG_ERROR", "message": f"配置文件不存在: {path}"}}))
|
|
21
|
+
sys.exit(1)
|
|
22
|
+
with open(path, "r") as f:
|
|
23
|
+
return yaml.safe_load(f)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def save_token(token: str, uid: str, config_path_arg: str = None) -> None:
|
|
27
|
+
"""保存 token 和 uid 到配置文件(目录和文件不存在则自动创建)"""
|
|
28
|
+
path = config_path(config_path_arg)
|
|
29
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
30
|
+
if os.path.exists(path):
|
|
31
|
+
with open(path, "r") as f:
|
|
32
|
+
config = yaml.safe_load(f) or {}
|
|
33
|
+
else:
|
|
34
|
+
config = {}
|
|
35
|
+
config["token"] = token
|
|
36
|
+
config["uid"] = uid
|
|
37
|
+
# 确保至少有 gateway_url
|
|
38
|
+
config.setdefault("gateway_url", "https://www.pandaaiquant.com/pandaApi")
|
|
39
|
+
with open(path, "w") as f:
|
|
40
|
+
yaml.dump(config, f, allow_unicode=True, default_flow_style=False)
|
pandaai/constants.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""状态常量"""
|
|
2
|
+
|
|
3
|
+
STATUS_PENDING = 0
|
|
4
|
+
STATUS_RUNNING = 1
|
|
5
|
+
STATUS_SUCCESS = 2
|
|
6
|
+
STATUS_FAILED = 3
|
|
7
|
+
STATUS_BILLING_STOP = 6 # 余额不足终止
|
|
8
|
+
|
|
9
|
+
STATUS_MAP = {
|
|
10
|
+
STATUS_PENDING: "PENDING",
|
|
11
|
+
STATUS_RUNNING: "RUNNING",
|
|
12
|
+
STATUS_SUCCESS: "SUCCESS",
|
|
13
|
+
STATUS_FAILED: "FAILED",
|
|
14
|
+
STATUS_BILLING_STOP: "BILLING_STOP",
|
|
15
|
+
}
|
pandaai/download.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""CSV 下载"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def download_csv(results: dict, download_arg: str) -> None:
|
|
10
|
+
"""从结果中提取 CSV URL 并下载"""
|
|
11
|
+
csv_url = None
|
|
12
|
+
for _uuid, data in results.get("nodes", {}).items():
|
|
13
|
+
if isinstance(data, dict) and "url" in data:
|
|
14
|
+
csv_url = data["url"]
|
|
15
|
+
break
|
|
16
|
+
|
|
17
|
+
if not csv_url:
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
if download_arg == "":
|
|
21
|
+
save_dir = os.path.join(os.path.expanduser("~"), "Downloads")
|
|
22
|
+
filename = csv_url.split("/")[-1] if "/" in csv_url else "factor_data.csv"
|
|
23
|
+
save_path = os.path.join(save_dir, filename)
|
|
24
|
+
elif download_arg:
|
|
25
|
+
if os.path.isdir(download_arg) or download_arg.endswith("/"):
|
|
26
|
+
filename = csv_url.split("/")[-1] if "/" in csv_url else "factor_data.csv"
|
|
27
|
+
save_path = os.path.join(download_arg, filename)
|
|
28
|
+
else:
|
|
29
|
+
save_path = download_arg
|
|
30
|
+
else:
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
resp = requests.get(csv_url, timeout=60)
|
|
35
|
+
resp.raise_for_status()
|
|
36
|
+
os.makedirs(os.path.dirname(save_path) or ".", exist_ok=True)
|
|
37
|
+
with open(save_path, "wb") as f:
|
|
38
|
+
f.write(resp.content)
|
|
39
|
+
print(f"📥 CSV已保存: {save_path} ({len(resp.content)} bytes)", file=sys.stderr)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
print(f"⚠ CSV下载失败: {e}", file=sys.stderr)
|