panda-trade 0.1.0__tar.gz

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,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: panda-trade
3
+ Version: 0.1.0
4
+ Summary: PandaAI 交易开放 API 的 Python SDK / CLI(OAuth 登录,无需 API Key)
5
+ Project-URL: Homepage, https://www.pandaaiquant.com
6
+ Keywords: pandaai,futures,trading,contest,oauth
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: httpx>=0.24
10
+
11
+ # panda-trade
12
+
13
+ PandaAI 期货仿真交易大赛 Python SDK。默认连接:
14
+ `https://www.pandaaiquant.com/openapi/v1`。
15
+
16
+ ## 安装
17
+
18
+ ```bash
19
+ pip install panda-trade
20
+ ```
21
+
22
+ ## 登录
23
+
24
+ ```python
25
+ from panda_trade import login
26
+
27
+ login()
28
+ ```
29
+
30
+ SDK 使用 OAuth + PKCE 打开官网授权页,不需要 API Key,也不会保存官网密码。凭证保存在
31
+ 用户目录的 `.panda-trade/credentials.json`。
32
+
33
+ 也可以使用命令行:
34
+
35
+ ```bash
36
+ panda-trade login
37
+ panda-trade whoami
38
+ panda-trade doctor
39
+ ```
40
+
41
+ ## 交易示例
42
+
43
+ ```python
44
+ from panda_trade import Client
45
+
46
+ client = Client()
47
+
48
+ print(client.snapshot())
49
+ print(client.quote("rb2610"))
50
+
51
+ # 发布策略前先使用 dry_run 验证
52
+ result = client.buy_open("rb2610", 1, price=3000, dry_run=True)
53
+ print(result)
54
+ ```
55
+
56
+ 通过环境变量覆盖服务地址或 OAuth client:
57
+
58
+ ```text
59
+ PANDA_TRADE_BASE_URL
60
+ PANDA_TRADE_CLIENT_ID
61
+ PANDA_TRADE_HOME
62
+ ```
@@ -0,0 +1,52 @@
1
+ # panda-trade
2
+
3
+ PandaAI 期货仿真交易大赛 Python SDK。默认连接:
4
+ `https://www.pandaaiquant.com/openapi/v1`。
5
+
6
+ ## 安装
7
+
8
+ ```bash
9
+ pip install panda-trade
10
+ ```
11
+
12
+ ## 登录
13
+
14
+ ```python
15
+ from panda_trade import login
16
+
17
+ login()
18
+ ```
19
+
20
+ SDK 使用 OAuth + PKCE 打开官网授权页,不需要 API Key,也不会保存官网密码。凭证保存在
21
+ 用户目录的 `.panda-trade/credentials.json`。
22
+
23
+ 也可以使用命令行:
24
+
25
+ ```bash
26
+ panda-trade login
27
+ panda-trade whoami
28
+ panda-trade doctor
29
+ ```
30
+
31
+ ## 交易示例
32
+
33
+ ```python
34
+ from panda_trade import Client
35
+
36
+ client = Client()
37
+
38
+ print(client.snapshot())
39
+ print(client.quote("rb2610"))
40
+
41
+ # 发布策略前先使用 dry_run 验证
42
+ result = client.buy_open("rb2610", 1, price=3000, dry_run=True)
43
+ print(result)
44
+ ```
45
+
46
+ 通过环境变量覆盖服务地址或 OAuth client:
47
+
48
+ ```text
49
+ PANDA_TRADE_BASE_URL
50
+ PANDA_TRADE_CLIENT_ID
51
+ PANDA_TRADE_HOME
52
+ ```
@@ -0,0 +1,34 @@
1
+ """PandaAI 期货仿真大赛 SDK(skills 赛道)。
2
+
3
+ 快速开始:
4
+
5
+ from panda_trade import login, Client
6
+
7
+ login() # 浏览器授权,一次即可
8
+ c = Client() # 自动续期,过期无感
9
+
10
+ c.snapshot() # 资金 + 持仓 + 挂单
11
+ c.buy_open("rb2510", 1, price=3200) # 限价开多
12
+ c.sell_close("rb2510", 1) # 市价平多
13
+ c.cancel_all() # 撤掉全部挂单
14
+
15
+ 先用 dry_run 验证逻辑,确认无误再真下单:
16
+
17
+ c.buy_open("rb2510", 3, dry_run=True)
18
+ """
19
+
20
+ from panda_trade.auth import Credentials, login, logout, refresh
21
+ from panda_trade.client import Client
22
+ from panda_trade.errors import (
23
+ AuthError,
24
+ ContestApiError,
25
+ OrderTimeout,
26
+ PandaContestError,
27
+ )
28
+
29
+ __version__ = "0.1.0"
30
+
31
+ __all__ = [
32
+ "login", "logout", "refresh", "Credentials", "Client",
33
+ "PandaContestError", "AuthError", "ContestApiError", "OrderTimeout",
34
+ ]
@@ -0,0 +1,269 @@
1
+ """登录与凭证管理。
2
+
3
+ 选手只需要 `login()` —— 其余(PKCE、本地回调 server、开浏览器、换 token、落盘)
4
+ 全部在这里完成。凭证存 ~/.panda-trade/credentials.json(权限 0600)。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import hashlib
11
+ import http.server
12
+ import json
13
+ import os
14
+ import secrets
15
+ import socket
16
+ import stat
17
+ import threading
18
+ import time
19
+ import urllib.parse
20
+ import webbrowser
21
+ from dataclasses import asdict, dataclass
22
+ from pathlib import Path
23
+
24
+ import httpx
25
+
26
+ from panda_trade.errors import AuthError, ContestApiError
27
+
28
+ DEFAULT_BASE_URL = os.getenv("PANDA_TRADE_BASE_URL", "https://www.pandaaiquant.com")
29
+ DEFAULT_CLIENT_ID = os.getenv("PANDA_TRADE_CLIENT_ID", "panda-contest-cli")
30
+ API_PREFIX = "/openapi/v1"
31
+
32
+ CRED_DIR = Path(os.getenv("PANDA_TRADE_HOME", Path.home() / ".panda-trade"))
33
+ CRED_FILE = CRED_DIR / "credentials.json"
34
+
35
+ # 提前多久刷新(避免请求发出时正好过期)
36
+ _REFRESH_SKEW = 120
37
+
38
+
39
+ @dataclass
40
+ class Credentials:
41
+ access_token: str
42
+ refresh_token: str
43
+ expires_at: float
44
+ account_id: str = ""
45
+ contest_id: str = ""
46
+ scope: str = ""
47
+ base_url: str = DEFAULT_BASE_URL
48
+ client_id: str = DEFAULT_CLIENT_ID
49
+
50
+ @property
51
+ def expired(self) -> bool:
52
+ return time.time() >= self.expires_at - _REFRESH_SKEW
53
+
54
+ def save(self) -> None:
55
+ CRED_DIR.mkdir(parents=True, exist_ok=True)
56
+ tmp = CRED_FILE.with_suffix(".tmp")
57
+ tmp.write_text(json.dumps(asdict(self), ensure_ascii=False, indent=2), encoding="utf-8")
58
+ tmp.replace(CRED_FILE)
59
+ try:
60
+ # 凭证不能让同机其它用户读到
61
+ os.chmod(CRED_FILE, stat.S_IRUSR | stat.S_IWUSR)
62
+ except OSError:
63
+ pass # Windows 上 chmod 语义有限,忽略
64
+
65
+ @classmethod
66
+ def load(cls) -> "Credentials | None":
67
+ if not CRED_FILE.exists():
68
+ return None
69
+ try:
70
+ return cls(**json.loads(CRED_FILE.read_text(encoding="utf-8")))
71
+ except (json.JSONDecodeError, TypeError):
72
+ return None
73
+
74
+ @classmethod
75
+ def clear(cls) -> None:
76
+ CRED_FILE.unlink(missing_ok=True)
77
+
78
+
79
+ # ══════════ PKCE ══════════
80
+
81
+ def _pkce_pair() -> tuple[str, str]:
82
+ verifier = base64.urlsafe_b64encode(secrets.token_bytes(48)).decode().rstrip("=")
83
+ challenge = base64.urlsafe_b64encode(
84
+ hashlib.sha256(verifier.encode("ascii")).digest()
85
+ ).decode().rstrip("=")
86
+ return verifier, challenge
87
+
88
+
89
+ # ══════════ 本地回调 ══════════
90
+
91
+ class _CallbackHandler(http.server.BaseHTTPRequestHandler):
92
+ result: dict = {}
93
+
94
+ def do_GET(self): # noqa: N802
95
+ parsed = urllib.parse.urlparse(self.path)
96
+ if parsed.path != "/callback":
97
+ self.send_response(404)
98
+ self.end_headers()
99
+ return
100
+ params = {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items()}
101
+ _CallbackHandler.result = params
102
+
103
+ ok = "code" in params
104
+ title = "授权成功" if ok else "授权未完成"
105
+ detail = "可以关闭本页面,回到命令行继续。" if ok else (
106
+ f"原因:{params.get('error', '未知')}")
107
+ body = f"""<!doctype html><html><head><meta charset="utf-8">
108
+ <title>{title}</title></head>
109
+ <body style="font-family:system-ui,-apple-system,'Microsoft YaHei';
110
+ display:flex;align-items:center;justify-content:center;height:100vh;margin:0">
111
+ <div style="text-align:center">
112
+ <div style="font-size:48px">{'✅' if ok else '⚠️'}</div>
113
+ <h2 style="margin:12px 0">{title}</h2>
114
+ <p style="color:#666">{detail}</p>
115
+ </div></body></html>"""
116
+ self.send_response(200)
117
+ self.send_header("Content-Type", "text/html; charset=utf-8")
118
+ self.end_headers()
119
+ self.wfile.write(body.encode("utf-8"))
120
+
121
+ def log_message(self, *args): # 静音:别把 HTTP 日志打给选手
122
+ pass
123
+
124
+
125
+ def _free_port() -> int:
126
+ with socket.socket() as s:
127
+ s.bind(("127.0.0.1", 0))
128
+ return s.getsockname()[1]
129
+
130
+
131
+ # ══════════ 登录 ══════════
132
+
133
+ def login(
134
+ *,
135
+ base_url: str = DEFAULT_BASE_URL,
136
+ client_id: str = DEFAULT_CLIENT_ID,
137
+ scope: str = "futures:read futures:trade",
138
+ timeout: int = 300,
139
+ force: bool = False,
140
+ open_browser: bool = True,
141
+ ) -> Credentials:
142
+ """浏览器授权登录。已登录且凭证有效时直接返回(除非 force=True)。
143
+
144
+ 整个过程选手只需要在浏览器里点一下「同意」。
145
+ """
146
+ if not force:
147
+ cred = Credentials.load()
148
+ if cred and not cred.expired:
149
+ return cred
150
+ if cred:
151
+ try:
152
+ return refresh(cred)
153
+ except (AuthError, ContestApiError):
154
+ pass # refresh 失效,走完整登录
155
+
156
+ verifier, challenge = _pkce_pair()
157
+ state = secrets.token_urlsafe(16)
158
+ port = _free_port()
159
+ redirect_uri = f"http://127.0.0.1:{port}/callback"
160
+
161
+ authorize_url = f"{base_url}{API_PREFIX}/oauth/authorize?" + urllib.parse.urlencode({
162
+ "response_type": "code",
163
+ "client_id": client_id,
164
+ "redirect_uri": redirect_uri,
165
+ "code_challenge": challenge,
166
+ "code_challenge_method": "S256",
167
+ "scope": scope,
168
+ "state": state,
169
+ })
170
+
171
+ _CallbackHandler.result = {}
172
+ server = http.server.HTTPServer(("127.0.0.1", port), _CallbackHandler)
173
+ server.timeout = 1
174
+ thread = threading.Thread(target=_serve_until_result, args=(server, timeout), daemon=True)
175
+ thread.start()
176
+
177
+ print("正在打开浏览器完成授权…")
178
+ print(f"如果没有自动打开,请手动访问:\n {authorize_url}\n")
179
+ if open_browser:
180
+ try:
181
+ webbrowser.open(authorize_url)
182
+ except Exception:
183
+ pass
184
+
185
+ thread.join(timeout + 2)
186
+ server.server_close()
187
+ params = _CallbackHandler.result
188
+
189
+ if not params:
190
+ raise AuthError(f"等待授权超时({timeout}s)。请重新执行登录。")
191
+ if params.get("error"):
192
+ raise AuthError(f"授权未通过:{params['error']}")
193
+ if params.get("state") != state:
194
+ raise AuthError("state 校验失败,可能遭遇 CSRF,请重新登录")
195
+ code = params.get("code")
196
+ if not code:
197
+ raise AuthError("回调未带授权码,请重新登录")
198
+
199
+ data = _post_token(base_url, {
200
+ "grant_type": "authorization_code",
201
+ "client_id": client_id,
202
+ "code": code,
203
+ "code_verifier": verifier,
204
+ "redirect_uri": redirect_uri,
205
+ })
206
+ cred = _to_credentials(data, base_url, client_id)
207
+ cred.save()
208
+ print(f"✅ 登录成功,已绑定参赛账户 {cred.account_id}")
209
+ return cred
210
+
211
+
212
+ def _serve_until_result(server: http.server.HTTPServer, timeout: int) -> None:
213
+ deadline = time.time() + timeout
214
+ while time.time() < deadline and not _CallbackHandler.result:
215
+ server.handle_request()
216
+
217
+
218
+ def refresh(cred: Credentials) -> Credentials:
219
+ """用 refresh_token 换新凭证。服务端会轮转,旧的立即作废。"""
220
+ data = _post_token(cred.base_url, {
221
+ "grant_type": "refresh_token",
222
+ "client_id": cred.client_id,
223
+ "refresh_token": cred.refresh_token,
224
+ })
225
+ new = _to_credentials(data, cred.base_url, cred.client_id)
226
+ new.save()
227
+ return new
228
+
229
+
230
+ def logout() -> None:
231
+ """撤销当前 access token 并删除本地凭证。"""
232
+ cred = Credentials.load()
233
+ if cred:
234
+ try:
235
+ httpx.post(
236
+ f"{cred.base_url}{API_PREFIX}/oauth/revoke",
237
+ headers={"Authorization": f"Bearer {cred.access_token}"},
238
+ timeout=10,
239
+ )
240
+ except httpx.HTTPError:
241
+ pass # 网络问题不该阻止本地清理
242
+ Credentials.clear()
243
+
244
+
245
+ def _post_token(base_url: str, form: dict) -> dict:
246
+ try:
247
+ resp = httpx.post(f"{base_url}{API_PREFIX}/oauth/token", data=form, timeout=20)
248
+ except httpx.HTTPError as e:
249
+ raise AuthError(f"连接授权服务失败:{e}") from e
250
+ try:
251
+ body = resp.json()
252
+ except ValueError:
253
+ raise AuthError(f"授权服务返回异常(HTTP {resp.status_code})") from None
254
+ if resp.status_code != 200 or body.get("code") != "0":
255
+ raise AuthError(body.get("message") or f"换取凭证失败(HTTP {resp.status_code})")
256
+ return body["data"]
257
+
258
+
259
+ def _to_credentials(data: dict, base_url: str, client_id: str) -> Credentials:
260
+ return Credentials(
261
+ access_token=data["access_token"],
262
+ refresh_token=data["refresh_token"],
263
+ expires_at=time.time() + int(data.get("expires_in", 7200)),
264
+ account_id=data.get("account_id", ""),
265
+ contest_id=data.get("contest_id", ""),
266
+ scope=data.get("scope", ""),
267
+ base_url=base_url,
268
+ client_id=client_id,
269
+ )
@@ -0,0 +1,144 @@
1
+ """命令行:panda-trade login / logout / whoami / doctor / positions / orders"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from panda_trade import auth
10
+ from panda_trade.client import Client
11
+ from panda_trade.errors import PandaContestError
12
+
13
+
14
+ def _fmt(v: float) -> str:
15
+ return f"{v:,.2f}"
16
+
17
+
18
+ def cmd_login(args) -> int:
19
+ cred = auth.login(force=args.force, open_browser=not args.no_browser)
20
+ print(f"账户 {cred.account_id} | 赛事 {cred.contest_id} | 权限 {cred.scope}")
21
+ return 0
22
+
23
+
24
+ def cmd_logout(_args) -> int:
25
+ auth.logout()
26
+ print("已登出,本地凭证已清除")
27
+ return 0
28
+
29
+
30
+ def cmd_whoami(_args) -> int:
31
+ cred = auth.Credentials.load()
32
+ if not cred:
33
+ print("尚未登录。执行:panda-trade login")
34
+ return 1
35
+ try:
36
+ me = Client(cred).user_info()
37
+ except PandaContestError as e:
38
+ print(f"✗ 无法读取用户信息:{e}")
39
+ return 1
40
+ user = me.get("user", {})
41
+ binding = me.get("binding", {})
42
+ import time
43
+ remain = int(cred.expires_at - time.time())
44
+ print(f"用户 ID : {user.get('id', '-')}")
45
+ print(f"用户名 : {user.get('username') or '-'}")
46
+ print(f"手机号 : {user.get('phone') or '-'}")
47
+ print(f"参赛账户 : {binding.get('accountId') or cred.account_id}")
48
+ print(f"赛事 : {binding.get('contestId') or cred.contest_id}")
49
+ print(f"权限 : {' '.join(me.get('scopes') or []) or cred.scope}")
50
+ print(f"凭证 : {'有效,剩余 %ds' % remain if remain > 0 else '已过期(下次调用会自动续期)'}")
51
+ print(f"服务地址 : {cred.base_url}")
52
+ return 0
53
+
54
+
55
+ def cmd_doctor(_args) -> int:
56
+ """自检。凭证过期 / 监控未就绪 / 非交易时段是最常见的三类问题。"""
57
+ try:
58
+ report = Client().doctor()
59
+ except PandaContestError as e:
60
+ print(f"✗ {e}")
61
+ return 1
62
+ for c in report["checks"]:
63
+ mark = "✓" if c["ok"] else "✗"
64
+ print(f" {mark} {c['name']:12} {c['detail']}")
65
+ print()
66
+ print("整体状态:" + ("正常" if report["ok"] else "有问题,见上方 ✗ 项"))
67
+ return 0 if report["ok"] else 1
68
+
69
+
70
+ def cmd_account(_args) -> int:
71
+ a = Client().account()
72
+ if not a.get("ready"):
73
+ print("账户资金快照尚未生成(监控可能刚拉起),请稍后重试")
74
+ return 1
75
+ print(f"动态权益 : {_fmt(a['totalProfit'])}")
76
+ print(f"可用资金 : {_fmt(a['availableFunds'])}")
77
+ print(f"占用保证金: {_fmt(a['margin'])} 风险度 {a['riskRate'] * 100:.2f}%")
78
+ print(f"持仓盈亏 : {_fmt(a['holdingPnl'])} 当日盈亏 {_fmt(a['dailyPnl'])}")
79
+ print(f"手续费 : {_fmt(a['cost'])}")
80
+ return 0
81
+
82
+
83
+ def cmd_positions(_args) -> int:
84
+ rows = Client().positions()
85
+ if not rows:
86
+ print("(无持仓)")
87
+ return 0
88
+ print(f"{'合约':<12}{'方向':<6}{'持仓':>5}{'今/昨':>9}{'可平':>5}"
89
+ f"{'持仓均价':>10}{'最新价':>10}{'浮盈':>12}")
90
+ for p in rows:
91
+ print(f"{p['contractCode']:<12}{p['directionText']:<6}{p['position']:>5}"
92
+ f"{str(p['tdPosition']) + '/' + str(p['ydPosition']):>9}{p['closable']:>5}"
93
+ f"{p['holdPrice']:>10.2f}{p['lastPrice']:>10.2f}{p['holdingPnl']:>12,.2f}")
94
+ return 0
95
+
96
+
97
+ def cmd_orders(args) -> int:
98
+ rows = Client().orders(status=args.status)
99
+ if not rows:
100
+ print("(无委托)")
101
+ return 0
102
+ print(f"{'委托号':<16}{'合约':<10}{'买卖':<6}{'开平':<6}{'类型':<6}"
103
+ f"{'价格':>9}{'数量':>5}{'已成':>5}{'状态':<10}")
104
+ for o in rows:
105
+ print(f"{o['orderId']:<16}{o['contractCode']:<10}{o['sideText']:<6}{o['offsetText']:<6}"
106
+ f"{o['orderTypeText']:<6}{o['price']:>9.2f}{o['quantity']:>5}"
107
+ f"{o['filledQuantity']:>5}{o['statusText']:<10}")
108
+ return 0
109
+
110
+
111
+ def main(argv=None) -> int:
112
+ ap = argparse.ArgumentParser(
113
+ prog="panda-trade", description="期货仿真大赛 CLI(skills 赛道)")
114
+ sub = ap.add_subparsers(dest="cmd", required=True)
115
+
116
+ p = sub.add_parser("login", help="浏览器授权登录")
117
+ p.add_argument("--force", action="store_true", help="忽略已有凭证,强制重新登录")
118
+ p.add_argument("--no-browser", action="store_true", help="不自动开浏览器,只打印链接")
119
+ p.set_defaults(func=cmd_login)
120
+
121
+ sub.add_parser("logout", help="登出并清除本地凭证").set_defaults(func=cmd_logout)
122
+ sub.add_parser("whoami", help="查看当前登录状态").set_defaults(func=cmd_whoami)
123
+ sub.add_parser("doctor", help="连通性与状态自检").set_defaults(func=cmd_doctor)
124
+ sub.add_parser("account", help="账户资金").set_defaults(func=cmd_account)
125
+ sub.add_parser("positions", help="持仓").set_defaults(func=cmd_positions)
126
+
127
+ p = sub.add_parser("orders", help="委托查询")
128
+ p.add_argument("--status", default=None,
129
+ help="open=挂单 / done=已完结 / filled / cancelled ...")
130
+ p.set_defaults(func=cmd_orders)
131
+
132
+ args = ap.parse_args(argv)
133
+ try:
134
+ return args.func(args)
135
+ except PandaContestError as e:
136
+ print(f"✗ {e}", file=sys.stderr)
137
+ return 1
138
+ except KeyboardInterrupt:
139
+ print("\n已取消", file=sys.stderr)
140
+ return 130
141
+
142
+
143
+ if __name__ == "__main__":
144
+ sys.exit(main())
@@ -0,0 +1,262 @@
1
+ """交易客户端。
2
+
3
+ 设计原则(渐进式披露):常见操作一行搞定,高级参数按需再加。
4
+
5
+ c = Client()
6
+ c.buy_open("rb2510", 1) # 市价开多 1 手
7
+ c.buy_open("rb2510", 1, price=3200) # 限价
8
+ c.buy_open("rb2510", 1, price=3200, tif="ioc")
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import uuid
14
+ from typing import Any
15
+
16
+ import httpx
17
+
18
+ from panda_trade.auth import API_PREFIX, Credentials, login, refresh
19
+ from panda_trade.errors import ContestApiError, OrderTimeout, PandaContestError
20
+
21
+
22
+ class Client:
23
+ """大赛交易客户端。凭证过期会自动续期,选手无感。"""
24
+
25
+ def __init__(self, credentials: Credentials | None = None, *, timeout: float = 30.0):
26
+ cred = credentials or Credentials.load()
27
+ if cred is None:
28
+ raise PandaContestError(
29
+ "尚未登录。请先执行:\n"
30
+ " from panda_trade import login\n"
31
+ " login()\n"
32
+ "或命令行:panda-trade login"
33
+ )
34
+ self._cred = cred
35
+ self._http = httpx.Client(timeout=timeout)
36
+
37
+ # ── 账户信息 ──
38
+ @property
39
+ def account_id(self) -> str:
40
+ return self._cred.account_id
41
+
42
+ @property
43
+ def contest_id(self) -> str:
44
+ return self._cred.contest_id
45
+
46
+ # ══════════ 查询 ══════════
47
+
48
+ def account(self) -> dict:
49
+ """账户资金:权益/可用/保证金/浮盈/风险度。"""
50
+ return self._get("/futures/account")
51
+
52
+ def user_info(self) -> dict:
53
+ """当前官网登录用户资料及本次 OAuth 账户绑定。"""
54
+ return self._get("/oauth/me")
55
+
56
+ def positions(self) -> list[dict]:
57
+ """持仓(多空分开,含今昨仓)。"""
58
+ return self._get("/futures/positions")
59
+
60
+ def orders(self, *, status: str | None = None, trade_date: str | None = None,
61
+ count: int = 50) -> list[dict]:
62
+ """委托查询。status: open=挂单 / done=已完结 / filled / cancelled ..."""
63
+ return self._get("/futures/orders", params={
64
+ "status": status, "tradeDate": trade_date, "count": count})
65
+
66
+ def open_orders(self) -> list[dict]:
67
+ """挂单(在途委托)—— 最常用的查询。"""
68
+ return self._get("/futures/orders/open")
69
+
70
+ def order(self, order_id: str) -> dict:
71
+ """单笔委托详情。"""
72
+ return self._get(f"/futures/orders/{order_id}")
73
+
74
+ def trades(self, *, trade_date: str | None = None, count: int = 50) -> list[dict]:
75
+ """成交记录。"""
76
+ return self._get("/futures/trades", params={"tradeDate": trade_date, "count": count})
77
+
78
+ def snapshot(self) -> dict:
79
+ """一次拿齐资金 + 持仓 + 挂单。
80
+
81
+ 大多数 skill 开头都要这三样,一次调用省三次往返,也避免三次之间状态不一致。
82
+ """
83
+ return self._get("/futures/snapshot")
84
+
85
+ def settlement(self) -> dict:
86
+ """结算单(CTP 原文)。"""
87
+ return self._get("/futures/settlement")
88
+
89
+ def quote(self, contract: str) -> dict:
90
+ """实时行情快照:最新价、买卖一档、涨跌停、持仓量。
91
+
92
+ 报限价单前先看这个 —— 否则价格只能靠猜。
93
+ 历史 K 线用 panda_data(get_future_daily / get_future_min),本方法只给实时。
94
+ """
95
+ return self._get("/futures/quote", params={"contractCode": contract})
96
+
97
+ def quotes(self, contracts: list[str]) -> list[dict]:
98
+ """批量实时行情(最多 50 个)。"""
99
+ return self._get("/futures/quotes", params={"contractCodes": ",".join(contracts)})
100
+
101
+ def contracts(self, keyword: str | None = None) -> list[dict]:
102
+ """可交易合约列表。"""
103
+ return self._get("/futures/contracts", params={"keyword": keyword})
104
+
105
+ # ══════════ 下单 ══════════
106
+
107
+ def place_order(
108
+ self, contract: str, side: str, offset: str, volume: int, *,
109
+ price: float | None = None, tif: str | None = None,
110
+ client_order_id: str | None = None, dry_run: bool = False,
111
+ ) -> dict:
112
+ """通用下单。price 不传 = 市价。
113
+
114
+ - 市价单会被服务端转成「涨跌停价 + IOC 限价单」(CTP 的真市价单只有中金所支持),
115
+ 响应里的 executedAs / protectionPrice 会告知实际报单方式
116
+ - client_order_id 不传则自动生成 —— **幂等默认开启**,网络重试不会重复下单
117
+ - 超时抛 OrderTimeout(状态未知,**不要重试**,用 client_order_id 查询确认)
118
+ """
119
+ cid = client_order_id or uuid.uuid4().hex
120
+ body = {
121
+ "contractCode": contract, "side": side, "offset": offset,
122
+ "orderType": "limit" if price is not None else "market",
123
+ "volume": volume, "clientOrderId": cid, "dryRun": dry_run,
124
+ }
125
+ if price is not None:
126
+ body["price"] = price
127
+ if tif:
128
+ body["timeInForce"] = tif
129
+
130
+ data = self._post("/futures/orders", body)
131
+ if data.get("status") == "unknown":
132
+ raise OrderTimeout(cid, data.get("message", "下单回执超时"))
133
+ return data
134
+
135
+ # 四个语义化快捷方法:期货的开平方向容易写反,用名字固定下来
136
+ def buy_open(self, contract: str, volume: int, **kw) -> dict:
137
+ """买入开仓(做多)。"""
138
+ return self.place_order(contract, "buy", "open", volume, **kw)
139
+
140
+ def sell_open(self, contract: str, volume: int, **kw) -> dict:
141
+ """卖出开仓(做空)。"""
142
+ return self.place_order(contract, "sell", "open", volume, **kw)
143
+
144
+ def buy_close(self, contract: str, volume: int, **kw) -> dict:
145
+ """买入平仓(平空头)。"""
146
+ return self.place_order(contract, "buy", "close", volume, **kw)
147
+
148
+ def sell_close(self, contract: str, volume: int, **kw) -> dict:
149
+ """卖出平仓(平多头)。"""
150
+ return self.place_order(contract, "sell", "close", volume, **kw)
151
+
152
+ def cancel(self, order_id: str) -> dict:
153
+ """撤单。"""
154
+ return self._delete(f"/futures/orders/{order_id}")
155
+
156
+ # ══════════ 组合操作(服务端逐笔执行,避免选手自己循环出半成品状态)══════════
157
+
158
+ def cancel_all(self, contract: str | None = None) -> list[dict]:
159
+ """撤掉全部挂单(可按合约过滤)。返回每笔的结果。"""
160
+ out = []
161
+ for o in self.open_orders():
162
+ if contract and o["contractCode"].split(".")[0].lower() != contract.split(".")[0].lower():
163
+ continue
164
+ try:
165
+ out.append({"orderId": o["orderId"], "result": self.cancel(o["orderId"])})
166
+ except PandaContestError as e:
167
+ out.append({"orderId": o["orderId"], "error": str(e)})
168
+ return out
169
+
170
+ def close_all(self, contract: str | None = None, **kw) -> list[dict]:
171
+ """平掉全部持仓(可按合约过滤)。市价平,逐笔返回结果。
172
+
173
+ ⚠️ 逐笔执行,中途失败会留下部分平仓状态 —— 返回值里每笔的成败都在,
174
+ 请检查后再决定是否重试剩余的。
175
+ """
176
+ out = []
177
+ for p in self.positions():
178
+ if contract and p["contractCode"].split(".")[0].lower() != contract.split(".")[0].lower():
179
+ continue
180
+ closable = int(p.get("closable") or 0)
181
+ if closable <= 0:
182
+ continue
183
+ # 平多用 sell_close,平空用 buy_close
184
+ fn = self.sell_close if p["direction"] == "long" else self.buy_close
185
+ try:
186
+ out.append({"contractCode": p["contractCode"], "direction": p["direction"],
187
+ "volume": closable, "result": fn(p["contractCode"], closable, **kw)})
188
+ except PandaContestError as e:
189
+ out.append({"contractCode": p["contractCode"], "error": str(e)})
190
+ return out
191
+
192
+ # ══════════ 自检 ══════════
193
+
194
+ def doctor(self) -> dict:
195
+ """连通性与状态自检。凭证过期 / 监控未就绪 / 非交易时段是最常见的三类问题。"""
196
+ report: dict[str, Any] = {"ok": True, "checks": []}
197
+
198
+ def add(name, ok, detail=""):
199
+ report["checks"].append({"name": name, "ok": ok, "detail": detail})
200
+ if not ok:
201
+ report["ok"] = False
202
+
203
+ import time as _t
204
+ remain = int(self._cred.expires_at - _t.time())
205
+ add("凭证有效", remain > 0,
206
+ f"access 剩余 {max(remain, 0)}s;scope={self._cred.scope}")
207
+ add("账户绑定", bool(self._cred.account_id),
208
+ f"账户 {self._cred.account_id}(赛事 {self._cred.contest_id})")
209
+ try:
210
+ acc = self.account()
211
+ add("账户数据可读", bool(acc.get("ready")),
212
+ "资金快照就绪" if acc.get("ready") else "快照未生成,账户监控可能刚拉起")
213
+ add("资金状况", True,
214
+ f"权益 {acc.get('totalProfit', 0):,.2f} / 可用 {acc.get('availableFunds', 0):,.2f}")
215
+ except ContestApiError as e:
216
+ add("账户数据可读", False, str(e))
217
+ try:
218
+ add("挂单查询", True, f"当前挂单 {len(self.open_orders())} 笔")
219
+ except ContestApiError as e:
220
+ add("挂单查询", False, str(e))
221
+ return report
222
+
223
+ # ══════════ HTTP ══════════
224
+
225
+ def _get(self, path: str, params: dict | None = None) -> Any:
226
+ return self._request("GET", path, params=_clean(params))
227
+
228
+ def _post(self, path: str, body: dict) -> Any:
229
+ return self._request("POST", path, json=body)
230
+
231
+ def _delete(self, path: str) -> Any:
232
+ return self._request("DELETE", path)
233
+
234
+ def _request(self, method: str, path: str, *, _retried: bool = False, **kw) -> Any:
235
+ url = f"{self._cred.base_url}{API_PREFIX}{path}"
236
+ headers = {"Authorization": f"Bearer {self._cred.access_token}"}
237
+ try:
238
+ resp = self._http.request(method, url, headers=headers, **kw)
239
+ except httpx.HTTPError as e:
240
+ raise PandaContestError(f"请求失败:{e}") from e
241
+
242
+ # access 过期 → 自动续期后重试一次(选手无感)
243
+ if resp.status_code == 401 and not _retried:
244
+ self._cred = refresh(self._cred)
245
+ return self._request(method, path, _retried=True, **kw)
246
+
247
+ try:
248
+ body = resp.json()
249
+ except ValueError:
250
+ raise PandaContestError(
251
+ f"服务返回非 JSON(HTTP {resp.status_code}):{resp.text[:200]}") from None
252
+
253
+ if body.get("code") != "0":
254
+ raise ContestApiError(
255
+ body.get("code", "unknown"), body.get("message", ""), body.get("data"),
256
+ http_status=resp.status_code, request_id=body.get("request_id", ""),
257
+ )
258
+ return body.get("data")
259
+
260
+
261
+ def _clean(params: dict | None) -> dict | None:
262
+ return {k: v for k, v in (params or {}).items() if v is not None} or None
@@ -0,0 +1,54 @@
1
+ """SDK 异常。
2
+
3
+ 服务端每个业务错误都带 suggestion / 可操作数据,这里原样透出——
4
+ 选手(和 AI)读 str(e) 就能知道下一步该做什么。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+
12
+ class PandaContestError(Exception):
13
+ """SDK 基类异常。"""
14
+
15
+
16
+ class AuthError(PandaContestError):
17
+ """登录/授权失败。"""
18
+
19
+
20
+ class ContestApiError(PandaContestError):
21
+ """服务端返回的业务错误。"""
22
+
23
+ def __init__(self, code: str, message: str, data: dict[str, Any] | None = None,
24
+ *, http_status: int = 0, request_id: str = "") -> None:
25
+ self.code = code
26
+ self.message = message
27
+ self.data = data or {}
28
+ self.http_status = http_status
29
+ self.request_id = request_id
30
+ # suggestion 是排查的关键,直接拼进异常信息里
31
+ suggestion = self.data.get("suggestion")
32
+ text = f"[{code}] {message}"
33
+ if suggestion:
34
+ text += f"\n建议:{suggestion}"
35
+ if request_id:
36
+ text += f"\n(request_id: {request_id})"
37
+ super().__init__(text)
38
+
39
+
40
+ class OrderTimeout(PandaContestError):
41
+ """下单等回执超时 —— 状态未知,**不是失败**。
42
+
43
+ 柜台可能已受理。切勿直接重试,否则可能重复下单;
44
+ 请用 client_order_id 查询委托确认结果。
45
+ """
46
+
47
+ def __init__(self, client_order_id: str, message: str) -> None:
48
+ self.client_order_id = client_order_id
49
+ super().__init__(
50
+ f"{message}\n"
51
+ f"⚠️ 委托状态未知,请勿直接重试。用以下方式确认后再决定:\n"
52
+ f" c.orders(status='open') # 看是否已挂上\n"
53
+ f" clientOrderId = {client_order_id!r}"
54
+ )
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: panda-trade
3
+ Version: 0.1.0
4
+ Summary: PandaAI 交易开放 API 的 Python SDK / CLI(OAuth 登录,无需 API Key)
5
+ Project-URL: Homepage, https://www.pandaaiquant.com
6
+ Keywords: pandaai,futures,trading,contest,oauth
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: httpx>=0.24
10
+
11
+ # panda-trade
12
+
13
+ PandaAI 期货仿真交易大赛 Python SDK。默认连接:
14
+ `https://www.pandaaiquant.com/openapi/v1`。
15
+
16
+ ## 安装
17
+
18
+ ```bash
19
+ pip install panda-trade
20
+ ```
21
+
22
+ ## 登录
23
+
24
+ ```python
25
+ from panda_trade import login
26
+
27
+ login()
28
+ ```
29
+
30
+ SDK 使用 OAuth + PKCE 打开官网授权页,不需要 API Key,也不会保存官网密码。凭证保存在
31
+ 用户目录的 `.panda-trade/credentials.json`。
32
+
33
+ 也可以使用命令行:
34
+
35
+ ```bash
36
+ panda-trade login
37
+ panda-trade whoami
38
+ panda-trade doctor
39
+ ```
40
+
41
+ ## 交易示例
42
+
43
+ ```python
44
+ from panda_trade import Client
45
+
46
+ client = Client()
47
+
48
+ print(client.snapshot())
49
+ print(client.quote("rb2610"))
50
+
51
+ # 发布策略前先使用 dry_run 验证
52
+ result = client.buy_open("rb2610", 1, price=3000, dry_run=True)
53
+ print(result)
54
+ ```
55
+
56
+ 通过环境变量覆盖服务地址或 OAuth client:
57
+
58
+ ```text
59
+ PANDA_TRADE_BASE_URL
60
+ PANDA_TRADE_CLIENT_ID
61
+ PANDA_TRADE_HOME
62
+ ```
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ panda_trade/__init__.py
4
+ panda_trade/auth.py
5
+ panda_trade/cli.py
6
+ panda_trade/client.py
7
+ panda_trade/errors.py
8
+ panda_trade.egg-info/PKG-INFO
9
+ panda_trade.egg-info/SOURCES.txt
10
+ panda_trade.egg-info/dependency_links.txt
11
+ panda_trade.egg-info/entry_points.txt
12
+ panda_trade.egg-info/requires.txt
13
+ panda_trade.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ panda-trade = panda_trade.cli:main
@@ -0,0 +1 @@
1
+ httpx>=0.24
@@ -0,0 +1 @@
1
+ panda_trade
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "panda-trade"
7
+ version = "0.1.0"
8
+ description = "PandaAI 交易开放 API 的 Python SDK / CLI(OAuth 登录,无需 API Key)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ dependencies = ["httpx>=0.24"]
12
+ keywords = ["pandaai", "futures", "trading", "contest", "oauth"]
13
+
14
+ [project.urls]
15
+ Homepage = "https://www.pandaaiquant.com"
16
+
17
+ [project.scripts]
18
+ panda-trade = "panda_trade.cli:main"
19
+
20
+ [tool.setuptools]
21
+ packages = ["panda_trade"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+