feat: add initial frontend setup with styles, types, and testing framework
- Created a global CSS file for styling the frontend with responsive design. - Introduced TypeScript types for various entities including Research, Alpha, and Account. - Implemented Playwright tests for account management and data synchronization workflows. - Configured TypeScript with strict settings and included necessary libraries. - Set up Vite as the build tool with React plugin and API proxy configuration. - Added a Python script to initialize environment variables securely.
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.local
|
||||||
|
**/.venv
|
||||||
|
**/node_modules
|
||||||
|
**/__pycache__
|
||||||
|
**/.pytest_cache
|
||||||
|
**/.ruff_cache
|
||||||
|
frontend/dist
|
||||||
|
frontend/test-results
|
||||||
|
frontend/playwright-report
|
||||||
|
*.dump
|
||||||
|
output
|
||||||
|
.playwright-cli
|
||||||
|
backend/tests
|
||||||
|
frontend/tests
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
backups
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Generate real values with: python3 scripts/init_env.py
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=replace-with-a-strong-password-at-least-12-characters
|
||||||
|
# Keep URL-safe characters (hex recommended) for the PostgreSQL connection URL.
|
||||||
|
POSTGRES_PASSWORD=replace-with-random-hex-password
|
||||||
|
# Fernet key: URL-safe base64 encoding of 32 random bytes; independent of passwords.
|
||||||
|
ENCRYPTION_KEY=replace-with-generated-fernet-key
|
||||||
|
LOCAL_PORT=8080
|
||||||
|
# Public deployment only; bare DNS hostname, without scheme or path.
|
||||||
|
DOMAIN=alpha.example.com
|
||||||
|
# WorldQuant email/password are configured after system login, not in this file.
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
**/.venv/
|
||||||
|
**/__pycache__/
|
||||||
|
**/.pytest_cache/
|
||||||
|
**/.ruff_cache/
|
||||||
|
**/*.pyc
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/test-results/
|
||||||
|
frontend/playwright-report/
|
||||||
|
*.db
|
||||||
|
*.dump
|
||||||
|
.DS_Store
|
||||||
|
.local/
|
||||||
|
output/
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
.playwright-cli/
|
||||||
|
backups/
|
||||||
|
|
||||||
|
account.json
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{$SITE_ADDRESS:http://:80} {
|
||||||
|
encode zstd gzip
|
||||||
|
header {
|
||||||
|
X-Content-Type-Options nosniff
|
||||||
|
Referrer-Policy no-referrer
|
||||||
|
X-Frame-Options DENY
|
||||||
|
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||||
|
-Server
|
||||||
|
}
|
||||||
|
handle /api/* {
|
||||||
|
reverse_proxy backend:8000
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
root * /srv
|
||||||
|
try_files {path} /index.html
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
FROM ghcr.io/astral-sh/uv:0.7.20 AS uv
|
||||||
|
FROM python:3.12-slim-bookworm
|
||||||
|
COPY --from=uv /uv /usr/local/bin/uv
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 UV_COMPILE_BYTECODE=1 PATH="/app/.venv/bin:$PATH"
|
||||||
|
WORKDIR /app
|
||||||
|
COPY backend/pyproject.toml backend/uv.lock ./
|
||||||
|
RUN uv sync --frozen --no-dev --no-install-project
|
||||||
|
COPY backend/app ./app
|
||||||
|
COPY backend/alembic.ini ./
|
||||||
|
COPY backend/migrations ./migrations
|
||||||
|
RUN useradd --create-home --uid 10001 wq && chown -R wq:wq /app
|
||||||
|
USER wq
|
||||||
|
EXPOSE 8000
|
||||||
|
# The durable runner has one owner. Multiple workers/replicas are not supported.
|
||||||
|
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:create_app --factory --host 0.0.0.0 --port 8000 --workers 1 --proxy-headers"]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:22-bookworm-slim AS build
|
||||||
|
WORKDIR /app
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||||
|
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM caddy:2-alpine
|
||||||
|
COPY Caddyfile /etc/caddy/Caddyfile
|
||||||
|
COPY --from=build /app/dist /srv
|
||||||
|
EXPOSE 80 443
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# WorldQuant Alpha 研究工作空间
|
||||||
|
|
||||||
|
个人单账户系统。首期实现平台资料、Alpha 同步与查询、PnL 缓存、本地备注/标签/收藏/研究状态。平台接口只读,认证除外;不会回测、触发检查、回写属性或提交 Alpha。
|
||||||
|
|
||||||
|
需求与后续路线图见 [项目方案](docs/project-plan.md)。前端 React 19 + TypeScript + Semi Design,后端 Python 3.12 + FastAPI + HTTPX + SQLAlchemy,PostgreSQL 保存数据,Caddy 提供 Web 入口。前后端独立依赖、独立构建,所有部署文件位于根目录。
|
||||||
|
|
||||||
|
## 本机启动
|
||||||
|
|
||||||
|
需要 Docker Engine/Desktop 和 Docker Compose v2;初始化脚本需要 Python 3。项目根目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/init_env.py
|
||||||
|
docker compose up -d --build --wait
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
打开 **http://localhost:8080**。用户名默认 `admin`,初始密码读取本机 `.env` 中的 `ADMIN_PASSWORD`。脚本以仅当前用户可读写的权限创建 `.env`,不会覆盖已有文件或打印密钥。已有 `.env` 时直接启动即可。
|
||||||
|
|
||||||
|
`web` 仅绑定 `127.0.0.1`;`backend` 和 `db` 没有宿主机端口。使用 `localhost` 访问以匹配请求来源校验。修改端口时更改 `.env` 中的 `LOCAL_PORT` 并重建容器。
|
||||||
|
|
||||||
|
首次使用:
|
||||||
|
|
||||||
|
1. 登录系统,在“个人信息”保存 WorldQuant 邮箱和密码,点击“连接 WorldQuant”。
|
||||||
|
2. 如平台要求人工验证,在显示的入口完成操作,再点击“继续验证”;后台保留同一挑战会话。
|
||||||
|
3. 在“Alpha 管理”手动同步平台数据,或导入指定 Alpha ID。任务面板显示进度、错误、取消和重试。
|
||||||
|
4. 点击 Alpha 打开详情。研究记录保存在本地;PnL 点击获取后缓存。下次同步会更新平台数据并保留本地研究记录。
|
||||||
|
|
||||||
|
平台未返回的资料与指标保留为空。数值筛选采用平台原始单位,例如 Turnover `0.15` 表示 15%。日期筛选边界为 UTC;时间显示采用个人页的时区偏好。
|
||||||
|
|
||||||
|
## 公网 HTTPS 部署
|
||||||
|
|
||||||
|
`compose.public.yaml` 是独立配置,不与本机配置叠加。先在服务器完成上面的密钥初始化,将 `.env` 中 `DOMAIN` 改为自己的域名(无协议、路径、端口)。DNS 指向服务器,允许入站 TCP 80/443,UDP 443 可选。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f compose.public.yaml up -d --build --wait
|
||||||
|
docker compose -f compose.public.yaml logs --tail=100 web
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 `https://你的域名`。Caddy 自动签发/续期证书并重定向 HTTP;后端固定启用 `Secure`、`HttpOnly`、`SameSite=Strict` 的服务端会话 Cookie,并校验写请求来源。无开放注册。数据库仅在 Compose 网络中可见。证书持久化到 `caddy_data` 卷。
|
||||||
|
|
||||||
|
不要使用示例域名申请证书。公网证书签发取决于实际 DNS、服务器网络与 ACME 服务;本地测试不能代替真实域名的 HTTPS 验收。生产环境请保持单个 backend 容器、单个 Uvicorn worker;首期任务执行不支持多副本。
|
||||||
|
|
||||||
|
## 配置与账户安全
|
||||||
|
|
||||||
|
| 配置 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | 仅首次空库初始化管理员,重启不会重置现有密码 |
|
||||||
|
| `POSTGRES_PASSWORD` | 数据库密码;初始化脚本使用随机十六进制,避免连接 URL 转义问题 |
|
||||||
|
| `ENCRYPTION_KEY` | 独立 Fernet 密钥,加密数据库中的 WorldQuant 密码 |
|
||||||
|
| `LOCAL_PORT` | 本机入口端口,默认 8080 |
|
||||||
|
| `DOMAIN` | 公网域名 |
|
||||||
|
|
||||||
|
WorldQuant 密码仅在后端解密。平台 Cookie 仅保存在后端内存,进程重启后重新认证。前端不保存密码或 Cookie 副本;日志与响应不输出平台认证正文。`.env` 不进入 Docker 构建上下文,应与数据库备份分别安全保管。丢失 `ENCRYPTION_KEY` 后须重新输入平台密码;切勿在正常升级时重新生成它。
|
||||||
|
|
||||||
|
修改系统密码(同时撤销所有系统会话):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec backend python -m app.cli reset-password
|
||||||
|
```
|
||||||
|
|
||||||
|
公网时为命令补上 `-f compose.public.yaml`。单账户一旦同步确认身份,不允许更换邮箱混入其他账户数据。断开平台连接会暂停未完成任务;重新连接并确认身份后恢复。
|
||||||
|
|
||||||
|
## 升级与数据库迁移
|
||||||
|
|
||||||
|
依赖锁文件分别是 `backend/uv.lock` 和 `frontend/pnpm-lock.yaml`。每次后端启动都会执行 `alembic upgrade head`,失败时不会启动 API。升级前先备份数据库与 `.env`,然后:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build --wait
|
||||||
|
docker compose exec backend alembic current
|
||||||
|
docker compose logs --tail=100 backend
|
||||||
|
```
|
||||||
|
|
||||||
|
普通 `docker compose down` 不删除数据卷,重建后数据保留。**`down -v` 会删除数据库与证书卷,正常停机/升级不要使用。**
|
||||||
|
|
||||||
|
新建迁移的开发命令为 `uv run alembic revision --autogenerate -m "说明"`;须人工审阅生成文件,再运行 `uv run alembic upgrade head` 和测试。已发布的迁移文件保持不变。
|
||||||
|
|
||||||
|
## 备份与恢复
|
||||||
|
|
||||||
|
以下为本机配置命令;公网统一补上 `-f compose.public.yaml`,自定义项目名时保持相同 `-p`。数据库备份包括平台快照、研究记录、账户密文和任务。备份文件仍属于私有数据。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p backups
|
||||||
|
umask 077
|
||||||
|
docker compose exec -T db pg_dump -U wq -d wq -Fc --no-owner > backups/wq.dump
|
||||||
|
```
|
||||||
|
|
||||||
|
先恢复到独立数据库校验,不覆盖正在使用的数据:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T db createdb -U wq wq_restore_check
|
||||||
|
docker compose exec -T db pg_restore -U wq -d wq_restore_check --no-owner --exit-on-error < backups/wq.dump
|
||||||
|
docker compose exec -T db psql -U wq -d wq_restore_check -c 'SELECT count(*) FROM alphas; SELECT count(*) FROM research;'
|
||||||
|
```
|
||||||
|
|
||||||
|
确认备份正确后,若需要恢复正式库,下面操作会覆盖现有库内容,应先另做当前备份并停止写入:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose stop web backend
|
||||||
|
docker compose exec -T db pg_restore -U wq -d wq --clean --if-exists --no-owner --exit-on-error < backups/wq.dump
|
||||||
|
docker compose up -d --wait
|
||||||
|
```
|
||||||
|
|
||||||
|
跨机器恢复时同时使用原来的 `ENCRYPTION_KEY`。恢复后运行中的任务自动回到队列,按已提交检查点继续;平台会话可能要求重新连接或人工验证。
|
||||||
|
|
||||||
|
## 本地开发与验证
|
||||||
|
|
||||||
|
后端使用 `uv` 和 Python 3.12,前端使用 Node 22.12+ 与 pnpm 9.15.0。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
uv sync --python 3.12
|
||||||
|
uv run ruff check app tests
|
||||||
|
uv run pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
自动化测试使用隔离的 SQLite 与 HTTPX 模拟上游,不读取真实凭据或请求 WorldQuant。涵盖 API 鉴权、加密、查询/导出一致性、同步重试与恢复、人工验证、类型差异和 PnL schema。部署验收另用真实 PostgreSQL 和 Docker。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
corepack enable
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build
|
||||||
|
pnpm exec playwright install chromium
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器测试自动启动临时数据库、模拟平台 API 和 Vite,使用 620 条明确标记 `TEST` 的合成 Alpha。不会向正式数据库写入样例。测试验证系统登录、账户连接、多页同步、SUPER 详情、备注与收藏在刷新后保留、PnL、超过 500 条 CSV 及退出。截图写入忽略目录 `output/playwright/`。
|
||||||
|
|
||||||
|
需要重跑 Docker 持久化和备份验收时,先停止占用 8080 的本机实例(不删除卷),创建独立测试环境。以下脚本只接受 `wq-alpha-acceptance*` 项目名:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .local
|
||||||
|
python3 scripts/init_env.py --output .local/docker-test.env
|
||||||
|
docker compose --env-file .local/docker-test.env -p wq-alpha-acceptance up -d --build --wait
|
||||||
|
python3 backend/tests/docker_acceptance.py --env-file .local/docker-test.env
|
||||||
|
# 仅删除上面新建的测试容器和测试卷
|
||||||
|
docker compose --env-file .local/docker-test.env -p wq-alpha-acceptance down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
独立启动模拟环境以开发 UI(两个终端):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 终端 1,backend/ 下
|
||||||
|
uv run uvicorn tests.browser_server:create_test_app --factory --host 127.0.0.1 --port 18000
|
||||||
|
# 终端 2,frontend/ 下
|
||||||
|
WQ_DEV_API=http://127.0.0.1:18000 pnpm dev --port 5179
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 `http://127.0.0.1:5179`,系统测试密码 `browser-test-password`,平台邮箱 `test@example.com`、密码任意。每次停止服务即丢弃临时测试数据。
|
||||||
|
|
||||||
|
开发真实后端时显式配置 `DATABASE_URL` 指向自己的开发 PostgreSQL,设置 `ADMIN_PASSWORD`、`ENCRYPTION_KEY`、`PUBLIC_ORIGIN=http://localhost:5173`,执行迁移后用 `uv run uvicorn app.main:create_app --factory --host 127.0.0.1 --port 8000` 启动。`pnpm dev` 默认代理到此地址。生产 Compose 不开放开发数据库端口。
|
||||||
|
|
||||||
|
## 接口与数据约定
|
||||||
|
|
||||||
|
FastAPI 的 `/openapi.json` 与 `/docs` 可在后端开发端口访问;生产 Web 入口只代理 `/api/*`,不对外公开文档页面。
|
||||||
|
|
||||||
|
- `/api/v1/auth`:登录、退出、会话;除登录与健康检查外,业务接口都需要 Cookie。
|
||||||
|
- `/api/v1/account`:偏好、加密凭据、连接/验证/断开/资料刷新。
|
||||||
|
- `/api/v1/alphas`:服务端筛选与排序、详情、本地研究记录、批量编辑、流式 CSV。
|
||||||
|
- `/api/v1/alphas/{id}/pnl`:只读缓存;刷新通过 `pnl_refresh` 任务。
|
||||||
|
- `/api/v1/sync-jobs`:创建任务立即返回 202 和 ID,查询、取消与重试。
|
||||||
|
|
||||||
|
写请求需 `X-WQ-Request: 1`;浏览器跨站写入被拒绝。Alpha 平台快照、`research` 本地研究、`pnl_cache` 分开存储。研究状态固定为 `inbox/candidate/optimizing/archived`;平台类型、语言、状态按原值显示。
|
||||||
|
|
||||||
|
同步按“未提交/已提交 × 可见/隐藏”分页,每页数据与检查点同事务提交,Alpha ID 幂等更新。失败任务保留进度,重试只处理剩余页或失败 ID。上游 `Retry-After` 等待可被取消。分页过程中平台记录移动可能造成重复或遗漏,通过 ID 去重和再次全量同步校正;单次没有查到不自动删除本地记录。
|
||||||
|
|
||||||
|
## 日志排查与验证边界
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs --tail=100 backend
|
||||||
|
docker compose logs --tail=100 web
|
||||||
|
docker compose logs --tail=100 db
|
||||||
|
curl -f http://localhost:8080/api/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
|
平台任务失败时先查看任务面板的错误及失败 ID。401 登录失效需重新登录系统;平台人工验证需回个人页;429 会按平台等待时间自动重试。密钥损坏/丢失时重新配置平台凭据。不要为排错把密码、认证响应或 Cookie 加入日志。
|
||||||
|
|
||||||
|
实现使用旧项目已知请求形态并对模拟上游做自动化验证。WorldQuant 当前真实账号权限、人工验证页面行为、实际数据 schema、真实账户全量同步及公网证书签发,均需要在自己的账户/域名完成只读联调;未取得该证据前不宣称已验证。验收实测结果见 [验收记录](docs/verification.md)。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = migrations
|
||||||
|
prepend_sys_path = .
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""Normalize upstream business data, without making platform or research decisions."""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
|
||||||
|
from .models import Alpha, Research, ResearchTag, now
|
||||||
|
|
||||||
|
SENSITIVE_KEYS = {
|
||||||
|
"password",
|
||||||
|
"token",
|
||||||
|
"cookies",
|
||||||
|
"cookie",
|
||||||
|
"authorization",
|
||||||
|
"credentials",
|
||||||
|
"secret",
|
||||||
|
"accesstoken",
|
||||||
|
"refreshtoken",
|
||||||
|
"sessiontoken",
|
||||||
|
"clientsecret",
|
||||||
|
"authorizationheader",
|
||||||
|
"setcookie",
|
||||||
|
"apikey",
|
||||||
|
"csrftoken",
|
||||||
|
"xsrftoken",
|
||||||
|
"authentication",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize(value):
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
k: sanitize(v) for k, v in value.items() if re.sub(r"[^a-z]", "", k.lower()) not in SENSITIVE_KEYS
|
||||||
|
}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [sanitize(v) for v in value]
|
||||||
|
if isinstance(value, float) and not math.isfinite(value):
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def number(value):
|
||||||
|
if value is None or isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
result = float(value)
|
||||||
|
return result if math.isfinite(result) else None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def date(value):
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value.replace("Z", "+00:00")) if value else None
|
||||||
|
except (ValueError, TypeError, AttributeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def code(value):
|
||||||
|
return value.get("code") if isinstance(value, dict) else value if isinstance(value, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_alpha(db, raw: dict):
|
||||||
|
alpha_id = raw.get("id")
|
||||||
|
if not isinstance(alpha_id, str) or not alpha_id:
|
||||||
|
raise ValueError("Alpha 数据缺少 ID")
|
||||||
|
item = await db.get(Alpha, alpha_id)
|
||||||
|
if item is None:
|
||||||
|
item = Alpha(id=alpha_id)
|
||||||
|
db.add(item)
|
||||||
|
settings = raw.get("settings") or {}
|
||||||
|
metrics = raw.get("is") if isinstance(raw.get("is"), dict) else {}
|
||||||
|
item.name = raw.get("name")
|
||||||
|
item.expression = code(raw.get("regular"))
|
||||||
|
item.selection, item.combo = code(raw.get("selection")), code(raw.get("combo"))
|
||||||
|
item.alpha_type, item.language = raw.get("type"), settings.get("language")
|
||||||
|
item.stage, item.status, item.hidden = raw.get("stage"), raw.get("status"), raw.get("hidden") is True
|
||||||
|
item.region, item.universe = settings.get("region"), settings.get("universe")
|
||||||
|
item.settings, item.is_metrics = sanitize(settings), sanitize(metrics)
|
||||||
|
item.os_metrics = sanitize(raw.get("os")) if isinstance(raw.get("os"), dict) else {}
|
||||||
|
item.checks = sanitize(metrics.get("checks") or raw.get("checks") or [])
|
||||||
|
for key in ("sharpe", "fitness", "returns", "turnover", "margin", "drawdown"):
|
||||||
|
setattr(item, key, number(metrics.get(key)))
|
||||||
|
item.date_created, item.date_submitted = date(raw.get("dateCreated")), date(raw.get("dateSubmitted"))
|
||||||
|
item.synced_at, item.raw = now(), sanitize(raw)
|
||||||
|
await db.flush()
|
||||||
|
if await db.get(Research, alpha_id) is None:
|
||||||
|
db.add(Research(alpha_id=alpha_id))
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def list_statement(filters):
|
||||||
|
query = select(Alpha, Research).join(Research, Research.alpha_id == Alpha.id)
|
||||||
|
q = filters.q
|
||||||
|
if q:
|
||||||
|
pattern = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
||||||
|
query = query.where(
|
||||||
|
or_(
|
||||||
|
*(
|
||||||
|
getattr(Alpha, f).ilike(pattern, escape="\\")
|
||||||
|
for f in ("id", "name", "expression", "selection", "combo")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for name in ("region", "universe", "alpha_type", "language", "status", "stage", "hidden"):
|
||||||
|
value = getattr(filters, name)
|
||||||
|
if value is not None:
|
||||||
|
query = query.where(getattr(Alpha, name) == value)
|
||||||
|
if filters.research_state:
|
||||||
|
query = query.where(Research.state == filters.research_state)
|
||||||
|
if filters.favorite is not None:
|
||||||
|
query = query.where(Research.favorite == filters.favorite)
|
||||||
|
if filters.tag:
|
||||||
|
query = query.where(Alpha.id.in_(select(ResearchTag.alpha_id).where(ResearchTag.tag == filters.tag)))
|
||||||
|
if filters.created_from:
|
||||||
|
query = query.where(Alpha.date_created >= filters.created_from)
|
||||||
|
if filters.created_to:
|
||||||
|
query = query.where(Alpha.date_created <= filters.created_to)
|
||||||
|
for name in ("sharpe", "fitness", "returns", "turnover", "margin", "drawdown"):
|
||||||
|
for suffix, compare in (("min", "ge"), ("max", "le")):
|
||||||
|
value = getattr(filters, f"{name}_{suffix}")
|
||||||
|
if value is not None:
|
||||||
|
column = getattr(Alpha, name)
|
||||||
|
query = query.where(column >= value if compare == "ge" else column <= value)
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
|
def sorted_statement(query, sort, direction):
|
||||||
|
column = getattr(Alpha, sort)
|
||||||
|
order = column.desc() if direction == "desc" else column.asc()
|
||||||
|
return query.order_by(order.nullslast(), Alpha.id.asc())
|
||||||
|
|
||||||
|
|
||||||
|
def summary(item: Alpha, research: Research):
|
||||||
|
keys = (
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"alpha_type",
|
||||||
|
"language",
|
||||||
|
"stage",
|
||||||
|
"status",
|
||||||
|
"hidden",
|
||||||
|
"region",
|
||||||
|
"universe",
|
||||||
|
"sharpe",
|
||||||
|
"fitness",
|
||||||
|
"returns",
|
||||||
|
"turnover",
|
||||||
|
"margin",
|
||||||
|
"drawdown",
|
||||||
|
"date_created",
|
||||||
|
"date_submitted",
|
||||||
|
"synced_at",
|
||||||
|
)
|
||||||
|
result = {k: getattr(item, k) for k in keys}
|
||||||
|
result["expression_preview"] = (item.expression or item.selection or "")[:240]
|
||||||
|
result["research"] = {
|
||||||
|
k: getattr(research, k) for k in ("note", "tags", "favorite", "state", "updated_at")
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def pnl_points(raw):
|
||||||
|
"""Use schema column names, preserving missing values rather than creating zero PnL."""
|
||||||
|
records = raw.get("records")
|
||||||
|
schema = raw.get("schema") or {}
|
||||||
|
properties = schema.get("properties", []) if isinstance(schema, dict) else schema
|
||||||
|
if isinstance(properties, dict):
|
||||||
|
names = list(properties)
|
||||||
|
else:
|
||||||
|
names = [p.get("name", "") if isinstance(p, dict) else str(p) for p in properties]
|
||||||
|
normalized = [name.lower() for name in names]
|
||||||
|
if not isinstance(records, list):
|
||||||
|
raise ValueError("PnL 缺少 records")
|
||||||
|
points = []
|
||||||
|
for row in records:
|
||||||
|
if isinstance(row, dict):
|
||||||
|
row = {str(k).lower(): v for k, v in row.items()}
|
||||||
|
timestamp = next((row[k] for k in ("date", "datetime", "timestamp") if k in row), None)
|
||||||
|
value = next((row[k] for k in ("pnl", "value") if k in row), None)
|
||||||
|
else:
|
||||||
|
date_i = next(
|
||||||
|
(i for i, n in enumerate(normalized) if n in ("date", "datetime", "timestamp")), None
|
||||||
|
)
|
||||||
|
pnl_i = next((i for i, n in enumerate(normalized) if n in ("pnl", "value")), None)
|
||||||
|
if date_i is None or pnl_i is None or not isinstance(row, list) or len(row) <= max(date_i, pnl_i):
|
||||||
|
raise ValueError("PnL schema 无法识别日期或数值列")
|
||||||
|
timestamp, value = row[date_i], row[pnl_i]
|
||||||
|
if timestamp is not None:
|
||||||
|
if isinstance(timestamp, (int, float)):
|
||||||
|
from datetime import timezone
|
||||||
|
|
||||||
|
timestamp = datetime.fromtimestamp(
|
||||||
|
timestamp / 1000 if timestamp > 1e11 else timestamp, tz=timezone.utc
|
||||||
|
).isoformat()
|
||||||
|
points.append({"date": str(timestamp), "value": number(value)})
|
||||||
|
return sorted(points, key=lambda p: p["date"])
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Administrative commands run inside the backend container."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import getpass
|
||||||
|
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from .config import Settings
|
||||||
|
from .db import create_database
|
||||||
|
from .models import Admin, LoginSession
|
||||||
|
from .security import password_hasher
|
||||||
|
|
||||||
|
|
||||||
|
async def reset_password():
|
||||||
|
password = getpass.getpass("New admin password (12+ characters): ")
|
||||||
|
if len(password) < 12 or password != getpass.getpass("Confirm password: "):
|
||||||
|
raise SystemExit("Password too short or confirmation does not match")
|
||||||
|
engine, sessions = create_database(Settings().database_url)
|
||||||
|
async with sessions() as db:
|
||||||
|
admin = await db.get(Admin, 1)
|
||||||
|
admin.password_hash = password_hasher.hash(password)
|
||||||
|
await db.execute(delete(LoginSession))
|
||||||
|
await db.commit()
|
||||||
|
await engine.dispose()
|
||||||
|
print("Admin password updated; all system sessions revoked.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("command", choices=["reset-password"])
|
||||||
|
parser.parse_args()
|
||||||
|
asyncio.run(reset_password())
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Deployment configuration; secrets are required and never included in API responses."""
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from pydantic import Field, SecretStr, model_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file="../.env", extra="ignore")
|
||||||
|
|
||||||
|
database_url: str = "postgresql+asyncpg://wq:wq@localhost:5432/wq"
|
||||||
|
admin_username: str = "admin"
|
||||||
|
admin_password: SecretStr = Field(min_length=12)
|
||||||
|
encryption_key: SecretStr
|
||||||
|
public_origin: str = "http://localhost:8080"
|
||||||
|
cookie_secure: bool = False
|
||||||
|
session_hours: int = Field(default=24, ge=1, le=168)
|
||||||
|
wq_base_url: str = "https://api.worldquantbrain.com"
|
||||||
|
request_timeout: float = 30
|
||||||
|
retry_attempts: int = Field(default=4, ge=1, le=8)
|
||||||
|
enable_runner: bool = True
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_secrets(self):
|
||||||
|
Fernet(self.encryption_key.get_secret_value().encode())
|
||||||
|
if self.cookie_secure and not self.public_origin.startswith("https://"):
|
||||||
|
raise ValueError("COOKIE_SECURE requires an HTTPS PUBLIC_ORIGIN")
|
||||||
|
return self
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Async database sessions; each request and background operation owns its transaction."""
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
|
||||||
|
def create_database(url: str):
|
||||||
|
engine = create_async_engine(url, pool_pre_ping=True, hide_parameters=True)
|
||||||
|
return engine, async_sessionmaker(engine, expire_on_commit=False)
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
"""Durable, single-process task runner.
|
||||||
|
|
||||||
|
Each page and its checkpoint commit in one transaction. A single Uvicorn process
|
||||||
|
owns the runner and shared upstream session; do not scale replicas before adding
|
||||||
|
a distributed lease and session coordinator.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from contextlib import suppress
|
||||||
|
from datetime import timedelta
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
from .alphas import pnl_points, sanitize, upsert_alpha
|
||||||
|
from .models import Account, Alpha, Job, JobItem, Pnl, now
|
||||||
|
from .security import cipher
|
||||||
|
from .worldquant import VerificationRequired, WqClient, WqError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
ACTIVE = ("queued", "running", "waiting_auth", "waiting_connection")
|
||||||
|
AUTH_KINDS = ("connect", "verify", "profile")
|
||||||
|
|
||||||
|
|
||||||
|
async def create_job(db, kind, payload=None):
|
||||||
|
job = Job(id=str(uuid4()), kind=kind, payload=payload or {}, checkpoint={})
|
||||||
|
db.add(job)
|
||||||
|
await db.commit()
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
class Runner:
|
||||||
|
def __init__(self, sessions, settings, client=None):
|
||||||
|
self.sessions, self.settings = sessions, settings
|
||||||
|
self.client = client or WqClient(settings)
|
||||||
|
self.loop_task = None
|
||||||
|
self.active_task = None
|
||||||
|
self.active_id = None
|
||||||
|
self.stopping = False
|
||||||
|
self.disconnecting = False
|
||||||
|
self.control_lock = asyncio.Lock()
|
||||||
|
self.recover_database = False
|
||||||
|
self.wake = asyncio.Event()
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
async with self.sessions() as db:
|
||||||
|
await db.execute(update(Job).where(Job.status == "running").values(status="queued"))
|
||||||
|
account = await db.get(Account, 1)
|
||||||
|
if account.connection_status in ("connected", "connecting", "verification_required"):
|
||||||
|
account.connection_status = "expired"
|
||||||
|
account.verification_url = None
|
||||||
|
await db.commit()
|
||||||
|
self.loop_task = asyncio.create_task(self.run_loop())
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
self.stopping = True
|
||||||
|
self.wake.set()
|
||||||
|
if self.active_task:
|
||||||
|
self.active_task.cancel()
|
||||||
|
if self.loop_task:
|
||||||
|
await self.loop_task
|
||||||
|
await self.client.close()
|
||||||
|
|
||||||
|
async def cancel(self, job_id):
|
||||||
|
if self.active_id == job_id and self.active_task:
|
||||||
|
self.active_task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await self.active_task
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
# Prevent the scheduler from starting another request while credentials are cleared.
|
||||||
|
async with self.control_lock:
|
||||||
|
self.disconnecting = True
|
||||||
|
try:
|
||||||
|
if self.active_task:
|
||||||
|
await self.cancel(self.active_id)
|
||||||
|
self.client.disconnect()
|
||||||
|
async with self.sessions() as db:
|
||||||
|
account = await db.get(Account, 1)
|
||||||
|
account.connection_status = "disconnected"
|
||||||
|
account.verification_url = None
|
||||||
|
account.connection_error = None
|
||||||
|
await db.execute(
|
||||||
|
update(Job).where(Job.status.in_(ACTIVE)).values(status="waiting_connection")
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
finally:
|
||||||
|
self.disconnecting = False
|
||||||
|
|
||||||
|
async def run_loop(self):
|
||||||
|
while not self.stopping:
|
||||||
|
try:
|
||||||
|
await self.run_next()
|
||||||
|
except (SQLAlchemyError, OSError):
|
||||||
|
# A temporary DB outage must not silently kill the scheduler.
|
||||||
|
self.recover_database = True
|
||||||
|
logger.warning("Task runner is waiting for database recovery")
|
||||||
|
await self.wait_for_work()
|
||||||
|
|
||||||
|
async def wait_for_work(self):
|
||||||
|
self.wake.clear()
|
||||||
|
if not self.stopping:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self.wake.wait(), timeout=1)
|
||||||
|
except TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def run_next(self):
|
||||||
|
async with self.control_lock:
|
||||||
|
async with self.sessions() as db:
|
||||||
|
if self.recover_database:
|
||||||
|
await db.execute(update(Job).where(Job.status == "running").values(status="queued"))
|
||||||
|
await db.commit()
|
||||||
|
self.recover_database = False
|
||||||
|
job = (
|
||||||
|
await db.scalars(
|
||||||
|
select(Job)
|
||||||
|
.where(Job.status == "queued")
|
||||||
|
.order_by(Job.kind.in_(AUTH_KINDS).desc(), Job.created_at)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
job_id = job.id if job else None
|
||||||
|
if job_id and not self.stopping:
|
||||||
|
self.active_id = job_id
|
||||||
|
self.active_task = asyncio.create_task(self.execute(job_id))
|
||||||
|
if job_id and self.active_task:
|
||||||
|
try:
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await self.active_task
|
||||||
|
finally:
|
||||||
|
self.active_id, self.active_task = None, None
|
||||||
|
else:
|
||||||
|
await self.wait_for_work()
|
||||||
|
|
||||||
|
async def set_account(self, status, error=None, url=None):
|
||||||
|
async with self.sessions() as db:
|
||||||
|
account = await db.get(Account, 1)
|
||||||
|
account.connection_status, account.connection_error, account.verification_url = status, error, url
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def ensure_connected(self, force=False):
|
||||||
|
async with self.sessions() as db:
|
||||||
|
account = await db.get(Account, 1)
|
||||||
|
if (
|
||||||
|
not account.email
|
||||||
|
or not account.password_encrypted
|
||||||
|
or (account.connection_status == "disconnected" and not force)
|
||||||
|
):
|
||||||
|
raise WqError("请先配置并连接 WorldQuant", "disconnected")
|
||||||
|
password = cipher(self.settings).decrypt(account.password_encrypted.encode()).decode()
|
||||||
|
email = account.email
|
||||||
|
if self.client.verification_url and not force:
|
||||||
|
raise VerificationRequired(self.client.verification_url)
|
||||||
|
await self.client.authenticate(email, password, force=force)
|
||||||
|
await self.set_account("connected")
|
||||||
|
|
||||||
|
async def refresh_profile(self):
|
||||||
|
raw = await self.client.profile()
|
||||||
|
async with self.sessions() as db:
|
||||||
|
account = await db.get(Account, 1)
|
||||||
|
user_id = raw.get("id")
|
||||||
|
if not user_id or (account.wq_user_id and account.wq_user_id != str(user_id)):
|
||||||
|
self.client.disconnect()
|
||||||
|
raise WqError("平台账户身份不匹配,请核对凭据", "identity_mismatch")
|
||||||
|
account.wq_user_id = str(user_id)
|
||||||
|
# An allowlist avoids persisting unknown personal/security fields.
|
||||||
|
account.profile = sanitize(
|
||||||
|
{
|
||||||
|
k: raw[k]
|
||||||
|
for k in (
|
||||||
|
"id",
|
||||||
|
"username",
|
||||||
|
"name",
|
||||||
|
"email",
|
||||||
|
"firstName",
|
||||||
|
"lastName",
|
||||||
|
"role",
|
||||||
|
"roles",
|
||||||
|
"permissions",
|
||||||
|
"type",
|
||||||
|
)
|
||||||
|
if k in raw
|
||||||
|
}
|
||||||
|
)
|
||||||
|
account.connection_status, account.connection_error = "connected", None
|
||||||
|
account.verification_url, account.last_synced_at = None, now()
|
||||||
|
await db.execute(
|
||||||
|
update(Job)
|
||||||
|
.where(Job.status.in_(("waiting_auth", "waiting_connection")), ~Job.kind.in_(AUTH_KINDS))
|
||||||
|
.values(status="queued", error=None)
|
||||||
|
)
|
||||||
|
# Superseded connect/verify attempts are complete after identity confirmation.
|
||||||
|
await db.execute(
|
||||||
|
update(Job)
|
||||||
|
.where(Job.status.in_(("waiting_auth", "waiting_connection")), Job.kind.in_(AUTH_KINDS))
|
||||||
|
.values(status="completed", error=None)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def checkpoint(self, job_id, values):
|
||||||
|
async with self.sessions() as db:
|
||||||
|
job = await db.get(Job, job_id)
|
||||||
|
if job.cancel_requested:
|
||||||
|
raise asyncio.CancelledError()
|
||||||
|
for key, value in values.items():
|
||||||
|
setattr(job, key, value)
|
||||||
|
job.updated_at = now()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def execute(self, job_id):
|
||||||
|
async def retrying(delay):
|
||||||
|
await self.checkpoint(job_id, {"next_retry_at": now() + timedelta(seconds=delay)})
|
||||||
|
|
||||||
|
self.client.on_retry = retrying
|
||||||
|
try:
|
||||||
|
await self.checkpoint(job_id, {"status": "running", "error": None, "next_retry_at": None})
|
||||||
|
async with self.sessions() as db:
|
||||||
|
job = await db.get(Job, job_id)
|
||||||
|
kind, payload = job.kind, job.payload
|
||||||
|
if kind == "verify":
|
||||||
|
if not self.client.verification_url:
|
||||||
|
await self.ensure_connected(force=True)
|
||||||
|
else:
|
||||||
|
await self.client.verify()
|
||||||
|
await self.refresh_profile()
|
||||||
|
else:
|
||||||
|
await self.ensure_connected(force=kind == "connect")
|
||||||
|
if kind in ("connect", "profile"):
|
||||||
|
await self.refresh_profile()
|
||||||
|
elif kind == "full_sync":
|
||||||
|
await self.sync_all(job_id)
|
||||||
|
else:
|
||||||
|
await self.sync_ids(job_id, kind, payload["alpha_ids"])
|
||||||
|
async with self.sessions() as db:
|
||||||
|
job = await db.get(Job, job_id)
|
||||||
|
job.status = "completed_with_errors" if job.failed else "completed"
|
||||||
|
job.next_retry_at, job.updated_at = None, now()
|
||||||
|
await db.commit()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
async with self.sessions() as db:
|
||||||
|
job = await db.get(Job, job_id)
|
||||||
|
job.status = (
|
||||||
|
"cancelled"
|
||||||
|
if job.cancel_requested
|
||||||
|
else "waiting_connection"
|
||||||
|
if self.disconnecting
|
||||||
|
else "queued"
|
||||||
|
if self.stopping
|
||||||
|
else "cancelled"
|
||||||
|
)
|
||||||
|
job.next_retry_at, job.updated_at = None, now()
|
||||||
|
await db.commit()
|
||||||
|
except VerificationRequired as exc:
|
||||||
|
await self.set_account("verification_required", str(exc), exc.url)
|
||||||
|
await self.checkpoint(
|
||||||
|
job_id, {"status": "waiting_auth", "error": str(exc), "next_retry_at": None}
|
||||||
|
)
|
||||||
|
except WqError as exc:
|
||||||
|
waiting = exc.code in ("disconnected", "authentication_failed", "identity_mismatch")
|
||||||
|
if waiting:
|
||||||
|
await self.set_account("disconnected" if exc.code == "disconnected" else "error", str(exc))
|
||||||
|
await self.checkpoint(
|
||||||
|
job_id,
|
||||||
|
{
|
||||||
|
"status": "waiting_connection" if waiting else "failed",
|
||||||
|
"error": str(exc),
|
||||||
|
"next_retry_at": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Do not expose upstream bodies, decrypted credentials, or SQL bind parameters.
|
||||||
|
logger.error("Job %s failed with an internal error", job_id)
|
||||||
|
await self.checkpoint(
|
||||||
|
job_id,
|
||||||
|
{"status": "failed", "error": "任务内部错误,请检查服务日志后重试", "next_retry_at": None},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self.client.on_retry = None
|
||||||
|
|
||||||
|
async def sync_all(self, job_id):
|
||||||
|
partitions = [
|
||||||
|
("UNSUBMITTED", False),
|
||||||
|
("UNSUBMITTED", True),
|
||||||
|
("SUBMITTED", False),
|
||||||
|
("SUBMITTED", True),
|
||||||
|
]
|
||||||
|
async with self.sessions() as db:
|
||||||
|
job = await db.get(Job, job_id)
|
||||||
|
checkpoint = job.checkpoint
|
||||||
|
before = job.created_at.isoformat()
|
||||||
|
start_partition, offset = checkpoint.get("partition", 0), checkpoint.get("offset", 0)
|
||||||
|
for partition in range(start_partition, len(partitions)):
|
||||||
|
submission, hidden = partitions[partition]
|
||||||
|
while True:
|
||||||
|
await self.checkpoint(job_id, {"next_retry_at": None})
|
||||||
|
raw = await self.client.alphas(submission, hidden, offset, before)
|
||||||
|
rows = raw.get("results")
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
raise WqError("Alpha 列表缺少 results,已保留当前进度", "invalid_response")
|
||||||
|
async with self.sessions() as db:
|
||||||
|
job = await db.get(Job, job_id)
|
||||||
|
if job.cancel_requested:
|
||||||
|
raise asyncio.CancelledError()
|
||||||
|
for raw_alpha in rows:
|
||||||
|
await upsert_alpha(db, raw_alpha)
|
||||||
|
if not await db.get(JobItem, (job_id, raw_alpha["id"])):
|
||||||
|
db.add(JobItem(job_id=job_id, alpha_id=raw_alpha["id"]))
|
||||||
|
job.processed += 1
|
||||||
|
# Trust explicit next/count when available; otherwise an empty page ends iteration.
|
||||||
|
count = raw.get("count")
|
||||||
|
more = bool(rows)
|
||||||
|
if "next" in raw:
|
||||||
|
more = bool(raw["next"])
|
||||||
|
elif isinstance(count, int):
|
||||||
|
more = offset + len(rows) < count and bool(rows)
|
||||||
|
if more and not rows:
|
||||||
|
raise WqError("平台分页未前进", "invalid_response")
|
||||||
|
offset += len(rows)
|
||||||
|
job.checkpoint = {
|
||||||
|
"partition": partition if more else partition + 1,
|
||||||
|
"offset": offset if more else 0,
|
||||||
|
}
|
||||||
|
job.updated_at = now()
|
||||||
|
await db.commit()
|
||||||
|
if not more:
|
||||||
|
break
|
||||||
|
offset = 0
|
||||||
|
async with self.sessions() as db:
|
||||||
|
job = await db.get(Job, job_id)
|
||||||
|
job.total = job.processed
|
||||||
|
account = await db.get(Account, 1)
|
||||||
|
account.last_synced_at = now()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def sync_ids(self, job_id, kind, alpha_ids):
|
||||||
|
await self.checkpoint(job_id, {"total": len(alpha_ids)})
|
||||||
|
for alpha_id in alpha_ids:
|
||||||
|
async with self.sessions() as db:
|
||||||
|
previous = await db.get(JobItem, (job_id, alpha_id))
|
||||||
|
if previous and not previous.error:
|
||||||
|
continue
|
||||||
|
await self.checkpoint(job_id, {"next_retry_at": None})
|
||||||
|
error = None
|
||||||
|
try:
|
||||||
|
raw = await (
|
||||||
|
self.client.pnl(alpha_id) if kind == "pnl_refresh" else self.client.alpha(alpha_id)
|
||||||
|
)
|
||||||
|
if kind != "pnl_refresh" and raw.get("id") != alpha_id:
|
||||||
|
raise ValueError("平台返回的 Alpha ID 与请求不一致")
|
||||||
|
points = pnl_points(raw) if kind == "pnl_refresh" else None
|
||||||
|
except VerificationRequired:
|
||||||
|
raise
|
||||||
|
except WqError as exc:
|
||||||
|
if exc.code not in ("not_found", "access_denied"):
|
||||||
|
raise
|
||||||
|
error = str(exc)
|
||||||
|
except ValueError as exc:
|
||||||
|
error = str(exc)
|
||||||
|
async with self.sessions() as db:
|
||||||
|
job = await db.get(Job, job_id)
|
||||||
|
if job.cancel_requested:
|
||||||
|
raise asyncio.CancelledError()
|
||||||
|
previous = await db.get(JobItem, (job_id, alpha_id))
|
||||||
|
if previous and previous.error:
|
||||||
|
job.failed -= 1
|
||||||
|
if not previous:
|
||||||
|
previous = JobItem(job_id=job_id, alpha_id=alpha_id)
|
||||||
|
db.add(previous)
|
||||||
|
if not error:
|
||||||
|
if kind == "pnl_refresh":
|
||||||
|
if not await db.get(Alpha, alpha_id):
|
||||||
|
error = "请先导入此 Alpha"
|
||||||
|
else:
|
||||||
|
pnl = await db.get(Pnl, alpha_id)
|
||||||
|
if pnl is None:
|
||||||
|
pnl = Pnl(alpha_id=alpha_id)
|
||||||
|
db.add(pnl)
|
||||||
|
pnl.raw, pnl.points, pnl.fetched_at = sanitize(raw), points, now()
|
||||||
|
else:
|
||||||
|
await upsert_alpha(db, raw)
|
||||||
|
previous.error = error
|
||||||
|
if error:
|
||||||
|
job.failed += 1
|
||||||
|
else:
|
||||||
|
job.processed += 1
|
||||||
|
job.updated_at = now()
|
||||||
|
await db.commit()
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
"""FastAPI application and authenticated public API."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request, Response
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
from sqlalchemy import delete, func, select, text
|
||||||
|
|
||||||
|
from .alphas import list_statement, sorted_statement, summary
|
||||||
|
from .config import Settings
|
||||||
|
from .db import create_database
|
||||||
|
from .jobs import ACTIVE, AUTH_KINDS, Runner, create_job
|
||||||
|
from .models import Account, Admin, Alpha, Job, JobItem, LoginSession, Pnl, Research, ResearchTag, now
|
||||||
|
from .schemas import (
|
||||||
|
AccountOutput,
|
||||||
|
AlphaDetail,
|
||||||
|
AlphaFilters,
|
||||||
|
AlphaPage,
|
||||||
|
BulkInput,
|
||||||
|
BulkOutput,
|
||||||
|
CredentialsInput,
|
||||||
|
ErrorOutput,
|
||||||
|
FacetsOutput,
|
||||||
|
HealthOutput,
|
||||||
|
JobErrorOutput,
|
||||||
|
JobInput,
|
||||||
|
JobOutput,
|
||||||
|
LoginInput,
|
||||||
|
OkOutput,
|
||||||
|
PnlOutput,
|
||||||
|
PreferencesInput,
|
||||||
|
ResearchInput,
|
||||||
|
SessionOutput,
|
||||||
|
normalize_tags,
|
||||||
|
)
|
||||||
|
from .security import bootstrap, cipher, issue_session, require_auth, token_hash, valid_password
|
||||||
|
|
||||||
|
|
||||||
|
def account_output(account):
|
||||||
|
keys = (
|
||||||
|
"email",
|
||||||
|
"wq_user_id",
|
||||||
|
"profile",
|
||||||
|
"connection_status",
|
||||||
|
"connection_error",
|
||||||
|
"verification_url",
|
||||||
|
"last_synced_at",
|
||||||
|
"display_name",
|
||||||
|
"theme",
|
||||||
|
"timezone",
|
||||||
|
"page_size",
|
||||||
|
)
|
||||||
|
return {**{k: getattr(account, k) for k in keys}, "configured": bool(account.password_encrypted)}
|
||||||
|
|
||||||
|
|
||||||
|
async def set_tags(db, record, tags):
|
||||||
|
record.tags = normalize_tags(tags)
|
||||||
|
await db.execute(delete(ResearchTag).where(ResearchTag.alpha_id == record.alpha_id))
|
||||||
|
db.add_all(ResearchTag(alpha_id=record.alpha_id, tag=t) for t in record.tags)
|
||||||
|
|
||||||
|
|
||||||
|
def csv_cell(value):
|
||||||
|
"""Neutralize spreadsheet formulas in untrusted names, expressions, notes and tags."""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, str) and (
|
||||||
|
value.lstrip().startswith(("=", "+", "-", "@")) or value.startswith(("\t", "\r", "\n"))
|
||||||
|
):
|
||||||
|
return "'" + value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(settings=None, wq_client=None):
|
||||||
|
settings = settings or Settings()
|
||||||
|
engine, sessions = create_database(settings.database_url)
|
||||||
|
runner = Runner(sessions, settings, client=wq_client)
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app):
|
||||||
|
async with sessions() as db:
|
||||||
|
await bootstrap(db, settings)
|
||||||
|
if settings.enable_runner:
|
||||||
|
await runner.start()
|
||||||
|
yield
|
||||||
|
if settings.enable_runner:
|
||||||
|
await runner.stop()
|
||||||
|
else:
|
||||||
|
await runner.client.close()
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="WorldQuant Alpha Research API",
|
||||||
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
responses={code: {"model": ErrorOutput} for code in (401, 403, 404, 409, 422, 429)},
|
||||||
|
)
|
||||||
|
app.state.engine, app.state.sessions, app.state.runner = engine, sessions, runner
|
||||||
|
app.state.settings = settings
|
||||||
|
login_failures = defaultdict(list)
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def validation_error(request, exc):
|
||||||
|
# Pydantic's default error includes the submitted value, possibly a password.
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=422,
|
||||||
|
content={
|
||||||
|
"detail": "; ".join(f"{'.'.join(str(v) for v in e['loc'])}: {e['msg']}" for e in exc.errors())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def browser_security(request, call_next):
|
||||||
|
if request.method not in ("GET", "HEAD", "OPTIONS"):
|
||||||
|
if request.headers.get("X-WQ-Request") != "1":
|
||||||
|
return JSONResponse({"detail": "缺少请求校验头"}, status_code=403)
|
||||||
|
origin = request.headers.get("Origin")
|
||||||
|
if origin and origin.rstrip("/") != settings.public_origin.rstrip("/"):
|
||||||
|
return JSONResponse({"detail": "请求来源不被允许"}, status_code=403)
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
response.headers["Referrer-Policy"] = "no-referrer"
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return response
|
||||||
|
|
||||||
|
@app.get("/api/v1/health", response_model=HealthOutput, tags=["health"])
|
||||||
|
async def health():
|
||||||
|
async with sessions() as db:
|
||||||
|
await db.execute(text("SELECT 1"))
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
@app.post("/api/v1/auth/login", response_model=SessionOutput, tags=["auth"])
|
||||||
|
async def login(body: LoginInput, request: Request, response: Response):
|
||||||
|
key = request.client.host if request.client else "unknown"
|
||||||
|
current = time.monotonic()
|
||||||
|
# Bound the rate-limit map and remove expired entries.
|
||||||
|
for peer in list(login_failures):
|
||||||
|
login_failures[peer] = [t for t in login_failures[peer] if current - t < 60]
|
||||||
|
if not login_failures[peer]:
|
||||||
|
del login_failures[peer]
|
||||||
|
if len(login_failures[key]) >= 5:
|
||||||
|
raise HTTPException(429, "登录尝试过多,请一分钟后重试")
|
||||||
|
async with sessions() as db:
|
||||||
|
admin = await db.get(Admin, 1)
|
||||||
|
matches = await asyncio.to_thread(valid_password, admin.password_hash, body.password)
|
||||||
|
if body.username != admin.username or not matches:
|
||||||
|
login_failures[key].append(current)
|
||||||
|
raise HTTPException(401, "用户名或密码不正确")
|
||||||
|
login_failures.pop(key, None)
|
||||||
|
token = await issue_session(db, settings.session_hours)
|
||||||
|
response.set_cookie(
|
||||||
|
"wq_session",
|
||||||
|
token,
|
||||||
|
httponly=True,
|
||||||
|
secure=settings.cookie_secure,
|
||||||
|
samesite="strict",
|
||||||
|
max_age=settings.session_hours * 3600,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
return {"username": admin.username}
|
||||||
|
|
||||||
|
api = APIRouter(prefix="/api/v1", dependencies=[Depends(require_auth)])
|
||||||
|
|
||||||
|
@api.get("/auth/me", response_model=SessionOutput, tags=["auth"])
|
||||||
|
async def me():
|
||||||
|
async with sessions() as db:
|
||||||
|
return {"username": (await db.get(Admin, 1)).username}
|
||||||
|
|
||||||
|
@api.post("/auth/logout", response_model=OkOutput, tags=["auth"])
|
||||||
|
async def logout(request: Request, response: Response):
|
||||||
|
async with sessions() as db:
|
||||||
|
await db.execute(
|
||||||
|
delete(LoginSession).where(
|
||||||
|
LoginSession.token_hash == token_hash(request.cookies["wq_session"])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
response.delete_cookie(
|
||||||
|
"wq_session", path="/", httponly=True, secure=settings.cookie_secure, samesite="strict"
|
||||||
|
)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@api.get("/account", response_model=AccountOutput, tags=["account"])
|
||||||
|
async def get_account():
|
||||||
|
async with sessions() as db:
|
||||||
|
return account_output(await db.get(Account, 1))
|
||||||
|
|
||||||
|
@api.put("/account/credentials", response_model=AccountOutput, tags=["account"])
|
||||||
|
async def credentials(body: CredentialsInput):
|
||||||
|
async with sessions() as db:
|
||||||
|
account = await db.get(Account, 1)
|
||||||
|
if account.wq_user_id and account.email.casefold() != body.email.casefold():
|
||||||
|
raise HTTPException(409, "本系统已绑定一个账户;不允许混入其他账户的 Alpha")
|
||||||
|
await runner.disconnect()
|
||||||
|
async with sessions() as db:
|
||||||
|
account = await db.get(Account, 1)
|
||||||
|
account.email = body.email
|
||||||
|
account.password_encrypted = cipher(settings).encrypt(body.password.encode()).decode()
|
||||||
|
await db.commit()
|
||||||
|
return account_output(account)
|
||||||
|
|
||||||
|
@api.patch("/account/preferences", response_model=AccountOutput, tags=["account"])
|
||||||
|
async def preferences(body: PreferencesInput):
|
||||||
|
async with sessions() as db:
|
||||||
|
account = await db.get(Account, 1)
|
||||||
|
for key, value in body.model_dump().items():
|
||||||
|
setattr(account, key, value)
|
||||||
|
await db.commit()
|
||||||
|
return account_output(account)
|
||||||
|
|
||||||
|
async def account_job(kind):
|
||||||
|
async with sessions() as db:
|
||||||
|
account = await db.scalar(select(Account).where(Account.id == 1).with_for_update())
|
||||||
|
if not account.password_encrypted:
|
||||||
|
raise HTTPException(409, "请先保存 WorldQuant 账户配置")
|
||||||
|
existing = (
|
||||||
|
await db.scalars(
|
||||||
|
select(Job).where(Job.kind.in_(AUTH_KINDS), Job.status.in_(("running", "queued")))
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
if kind == "connect":
|
||||||
|
account.connection_status = "connecting"
|
||||||
|
account.connection_error = None
|
||||||
|
job = await create_job(db, kind)
|
||||||
|
runner.wake.set()
|
||||||
|
return job
|
||||||
|
|
||||||
|
@api.post("/account/connect", status_code=202, response_model=JobOutput, tags=["account"])
|
||||||
|
async def connect():
|
||||||
|
return await account_job("connect")
|
||||||
|
|
||||||
|
@api.post("/account/verify", status_code=202, response_model=JobOutput, tags=["account"])
|
||||||
|
async def verify():
|
||||||
|
return await account_job("verify")
|
||||||
|
|
||||||
|
@api.post("/account/refresh", status_code=202, response_model=JobOutput, tags=["account"])
|
||||||
|
async def refresh():
|
||||||
|
return await account_job("profile")
|
||||||
|
|
||||||
|
@api.post("/account/disconnect", response_model=OkOutput, tags=["account"])
|
||||||
|
async def disconnect():
|
||||||
|
await runner.disconnect()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@api.get("/alphas", response_model=AlphaPage, tags=["alphas"])
|
||||||
|
async def get_alphas(filters: Annotated[AlphaFilters, Query()]):
|
||||||
|
query = list_statement(filters)
|
||||||
|
async with sessions() as db:
|
||||||
|
total = await db.scalar(select(func.count()).select_from(query.subquery()))
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
sorted_statement(query, filters.sort, filters.direction)
|
||||||
|
.limit(filters.limit)
|
||||||
|
.offset(filters.offset)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return {
|
||||||
|
"items": [summary(a, r) for a, r in rows],
|
||||||
|
"total": total,
|
||||||
|
"limit": filters.limit,
|
||||||
|
"offset": filters.offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.get("/alphas/facets", response_model=FacetsOutput, tags=["alphas"])
|
||||||
|
async def facets():
|
||||||
|
async with sessions() as db:
|
||||||
|
result = {}
|
||||||
|
for key in ("region", "universe", "alpha_type", "language", "status", "stage"):
|
||||||
|
column = getattr(Alpha, key)
|
||||||
|
result[key] = list(
|
||||||
|
(
|
||||||
|
await db.scalars(
|
||||||
|
select(column).where(column.is_not(None)).distinct().order_by(column)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
result["tags"] = list(
|
||||||
|
(await db.scalars(select(ResearchTag.tag).distinct().order_by(ResearchTag.tag))).all()
|
||||||
|
)
|
||||||
|
result["total"] = await db.scalar(select(func.count()).select_from(Alpha))
|
||||||
|
result["favorites"] = await db.scalar(
|
||||||
|
select(func.count()).select_from(Research).where(Research.favorite.is_(True))
|
||||||
|
)
|
||||||
|
result["last_sync"] = await db.scalar(select(func.max(Alpha.synced_at)))
|
||||||
|
return result
|
||||||
|
|
||||||
|
@api.get(
|
||||||
|
"/alphas/export",
|
||||||
|
response_class=StreamingResponse,
|
||||||
|
responses={200: {"content": {"text/csv": {"schema": {"type": "string"}}}}},
|
||||||
|
tags=["alphas"],
|
||||||
|
)
|
||||||
|
async def export(filters: Annotated[AlphaFilters, Query()]):
|
||||||
|
query = sorted_statement(list_statement(filters), filters.sort, filters.direction)
|
||||||
|
|
||||||
|
async def content():
|
||||||
|
yield "\ufeff"
|
||||||
|
buffer = io.StringIO()
|
||||||
|
writer = csv.writer(buffer)
|
||||||
|
columns = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"expression",
|
||||||
|
"selection",
|
||||||
|
"combo",
|
||||||
|
"alpha_type",
|
||||||
|
"language",
|
||||||
|
"stage",
|
||||||
|
"status",
|
||||||
|
"hidden",
|
||||||
|
"region",
|
||||||
|
"universe",
|
||||||
|
"sharpe",
|
||||||
|
"fitness",
|
||||||
|
"returns",
|
||||||
|
"turnover",
|
||||||
|
"margin",
|
||||||
|
"drawdown",
|
||||||
|
"date_created",
|
||||||
|
"date_submitted",
|
||||||
|
"synced_at",
|
||||||
|
]
|
||||||
|
writer.writerow(columns + ["research_state", "favorite", "tags", "note"])
|
||||||
|
yield buffer.getvalue()
|
||||||
|
buffer.seek(0)
|
||||||
|
buffer.truncate(0)
|
||||||
|
async with sessions() as db:
|
||||||
|
rows = await db.stream(query.execution_options(yield_per=200))
|
||||||
|
async for a, r in rows:
|
||||||
|
writer.writerow(
|
||||||
|
[csv_cell(getattr(a, key)) for key in columns]
|
||||||
|
+ [r.state, r.favorite, csv_cell(";".join(r.tags)), csv_cell(r.note)]
|
||||||
|
)
|
||||||
|
yield buffer.getvalue()
|
||||||
|
buffer.seek(0)
|
||||||
|
buffer.truncate(0)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
content(),
|
||||||
|
media_type="text/csv; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="alphas.csv"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.patch("/alphas/research/bulk", response_model=BulkOutput, tags=["alphas"])
|
||||||
|
async def bulk(body: BulkInput):
|
||||||
|
async with sessions() as db:
|
||||||
|
rows = (
|
||||||
|
await db.scalars(
|
||||||
|
select(Research).where(Research.alpha_id.in_(body.alpha_ids)).with_for_update()
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
if len(rows) != len(body.alpha_ids):
|
||||||
|
raise HTTPException(404, "部分 Alpha 尚未同步,本次未修改任何记录")
|
||||||
|
for row in rows:
|
||||||
|
tags = (set(row.tags) | set(body.add_tags)) - set(body.remove_tags)
|
||||||
|
try:
|
||||||
|
await set_tags(db, row, list(tags))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(422, str(exc)) from None
|
||||||
|
if body.state:
|
||||||
|
row.state = body.state
|
||||||
|
row.updated_at = now()
|
||||||
|
await db.commit()
|
||||||
|
return {"updated": len(rows)}
|
||||||
|
|
||||||
|
@api.get("/alphas/{alpha_id}", response_model=AlphaDetail, tags=["alphas"])
|
||||||
|
async def detail(alpha_id: str):
|
||||||
|
async with sessions() as db:
|
||||||
|
a, r = await db.get(Alpha, alpha_id), await db.get(Research, alpha_id)
|
||||||
|
if a is None:
|
||||||
|
raise HTTPException(404, "Alpha 尚未同步")
|
||||||
|
return {
|
||||||
|
**summary(a, r),
|
||||||
|
**{
|
||||||
|
key: getattr(a, key)
|
||||||
|
for key in (
|
||||||
|
"expression",
|
||||||
|
"selection",
|
||||||
|
"combo",
|
||||||
|
"settings",
|
||||||
|
"is_metrics",
|
||||||
|
"os_metrics",
|
||||||
|
"checks",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.patch("/alphas/{alpha_id}/research", response_model=OkOutput, tags=["alphas"])
|
||||||
|
async def research(alpha_id: str, body: ResearchInput):
|
||||||
|
async with sessions() as db:
|
||||||
|
row = await db.scalar(select(Research).where(Research.alpha_id == alpha_id).with_for_update())
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(404, "Alpha 尚未同步")
|
||||||
|
for key, value in body.model_dump(exclude_unset=True).items():
|
||||||
|
if key == "tags":
|
||||||
|
await set_tags(db, row, value)
|
||||||
|
else:
|
||||||
|
setattr(row, key, value)
|
||||||
|
row.updated_at = now()
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@api.get("/alphas/{alpha_id}/pnl", response_model=PnlOutput, tags=["alphas"])
|
||||||
|
async def pnl(alpha_id: str):
|
||||||
|
async with sessions() as db:
|
||||||
|
if not await db.get(Alpha, alpha_id):
|
||||||
|
raise HTTPException(404, "Alpha 尚未同步")
|
||||||
|
record = await db.get(Pnl, alpha_id)
|
||||||
|
return {
|
||||||
|
"cached": record is not None,
|
||||||
|
"points": record.points if record else [],
|
||||||
|
"fetched_at": record.fetched_at if record else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.post("/sync-jobs", status_code=202, response_model=JobOutput, tags=["sync-jobs"])
|
||||||
|
async def new_job(body: JobInput):
|
||||||
|
async with sessions() as db:
|
||||||
|
# Serialize creation against the singleton account, avoiding duplicate full scans.
|
||||||
|
account = await db.scalar(select(Account).where(Account.id == 1).with_for_update())
|
||||||
|
if not account.password_encrypted or account.connection_status in ("disconnected", "error"):
|
||||||
|
raise HTTPException(409, "请先连接 WorldQuant")
|
||||||
|
payload = {"alpha_ids": body.alpha_ids}
|
||||||
|
existing = (
|
||||||
|
await db.scalars(select(Job).where(Job.kind == body.kind, Job.status.in_(ACTIVE)))
|
||||||
|
).all()
|
||||||
|
for job in existing:
|
||||||
|
if job.payload == payload:
|
||||||
|
return job
|
||||||
|
job = await create_job(db, body.kind, payload)
|
||||||
|
runner.wake.set()
|
||||||
|
return job
|
||||||
|
|
||||||
|
@api.get("/sync-jobs", response_model=list[JobOutput], tags=["sync-jobs"])
|
||||||
|
async def get_jobs():
|
||||||
|
async with sessions() as db:
|
||||||
|
return (await db.scalars(select(Job).order_by(Job.created_at.desc()).limit(100))).all()
|
||||||
|
|
||||||
|
@api.get("/sync-jobs/{job_id}", response_model=JobOutput, tags=["sync-jobs"])
|
||||||
|
async def get_job(job_id: str):
|
||||||
|
async with sessions() as db:
|
||||||
|
job = await db.get(Job, job_id)
|
||||||
|
if job is None:
|
||||||
|
raise HTTPException(404, "任务不存在")
|
||||||
|
return job
|
||||||
|
|
||||||
|
@api.get("/sync-jobs/{job_id}/errors", response_model=list[JobErrorOutput], tags=["sync-jobs"])
|
||||||
|
async def job_errors(job_id: str):
|
||||||
|
async with sessions() as db:
|
||||||
|
rows = (
|
||||||
|
await db.scalars(select(JobItem).where(JobItem.job_id == job_id, JobItem.error.is_not(None)))
|
||||||
|
).all()
|
||||||
|
return [{"alpha_id": row.alpha_id, "error": row.error} for row in rows]
|
||||||
|
|
||||||
|
@api.post("/sync-jobs/{job_id}/cancel", response_model=OkOutput, tags=["sync-jobs"])
|
||||||
|
async def cancel_job(job_id: str):
|
||||||
|
async with sessions() as db:
|
||||||
|
job = await db.get(Job, job_id)
|
||||||
|
if job is None:
|
||||||
|
raise HTTPException(404, "任务不存在")
|
||||||
|
if job.status not in ACTIVE:
|
||||||
|
return {"ok": True}
|
||||||
|
job.cancel_requested = True
|
||||||
|
if job.status != "running":
|
||||||
|
job.status = "cancelled"
|
||||||
|
await db.commit()
|
||||||
|
await runner.cancel(job_id)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@api.post("/sync-jobs/{job_id}/retry", response_model=JobOutput, tags=["sync-jobs"])
|
||||||
|
async def retry_job(job_id: str):
|
||||||
|
async with sessions() as db:
|
||||||
|
job = await db.get(Job, job_id)
|
||||||
|
if job is None:
|
||||||
|
raise HTTPException(404, "任务不存在")
|
||||||
|
if job.status not in (
|
||||||
|
"failed",
|
||||||
|
"cancelled",
|
||||||
|
"completed_with_errors",
|
||||||
|
"waiting_connection",
|
||||||
|
"waiting_auth",
|
||||||
|
):
|
||||||
|
raise HTTPException(409, "该任务当前不需要重试")
|
||||||
|
job.status, job.error, job.cancel_requested, job.next_retry_at = "queued", None, False, None
|
||||||
|
job.updated_at = now()
|
||||||
|
await db.commit()
|
||||||
|
runner.wake.set()
|
||||||
|
return job
|
||||||
|
|
||||||
|
app.include_router(api)
|
||||||
|
return app
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Platform snapshots and local research are intentionally stored separately."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Index, Integer, String, Text
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Admin(Base):
|
||||||
|
__tablename__ = "admins"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, default=1)
|
||||||
|
username: Mapped[str] = mapped_column(String(100), unique=True)
|
||||||
|
password_hash: Mapped[str] = mapped_column(Text)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginSession(Base):
|
||||||
|
__tablename__ = "login_sessions"
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Account(Base):
|
||||||
|
__tablename__ = "accounts"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, default=1)
|
||||||
|
email: Mapped[str | None] = mapped_column(String(254))
|
||||||
|
password_encrypted: Mapped[str | None] = mapped_column(Text)
|
||||||
|
wq_user_id: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
profile: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
connection_status: Mapped[str] = mapped_column(String(30), default="disconnected")
|
||||||
|
connection_error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
verification_url: Mapped[str | None] = mapped_column(Text)
|
||||||
|
last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
display_name: Mapped[str] = mapped_column(String(100), default="研究员")
|
||||||
|
theme: Mapped[str] = mapped_column(String(10), default="light")
|
||||||
|
timezone: Mapped[str] = mapped_column(String(64), default="Asia/Shanghai")
|
||||||
|
page_size: Mapped[int] = mapped_column(Integer, default=25)
|
||||||
|
|
||||||
|
|
||||||
|
class Alpha(Base):
|
||||||
|
__tablename__ = "alphas"
|
||||||
|
id: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||||
|
name: Mapped[str | None] = mapped_column(Text)
|
||||||
|
expression: Mapped[str | None] = mapped_column(Text)
|
||||||
|
selection: Mapped[str | None] = mapped_column(Text)
|
||||||
|
combo: Mapped[str | None] = mapped_column(Text)
|
||||||
|
alpha_type: Mapped[str | None] = mapped_column(String(50), index=True)
|
||||||
|
language: Mapped[str | None] = mapped_column(String(50))
|
||||||
|
stage: Mapped[str | None] = mapped_column(String(50), index=True)
|
||||||
|
status: Mapped[str | None] = mapped_column(String(80), index=True)
|
||||||
|
hidden: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
region: Mapped[str | None] = mapped_column(String(50), index=True)
|
||||||
|
universe: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
settings: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
is_metrics: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
os_metrics: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
checks: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
|
sharpe: Mapped[float | None] = mapped_column(Float, index=True)
|
||||||
|
fitness: Mapped[float | None] = mapped_column(Float, index=True)
|
||||||
|
returns: Mapped[float | None] = mapped_column(Float)
|
||||||
|
turnover: Mapped[float | None] = mapped_column(Float)
|
||||||
|
margin: Mapped[float | None] = mapped_column(Float)
|
||||||
|
drawdown: Mapped[float | None] = mapped_column(Float)
|
||||||
|
date_created: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
date_submitted: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
synced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
raw: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
__table_args__ = (Index("ix_alphas_region_status", "region", "status"),)
|
||||||
|
|
||||||
|
|
||||||
|
class Research(Base):
|
||||||
|
__tablename__ = "research"
|
||||||
|
alpha_id: Mapped[str] = mapped_column(ForeignKey("alphas.id"), primary_key=True)
|
||||||
|
note: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
tags: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
|
favorite: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
state: Mapped[str] = mapped_column(String(30), default="inbox", index=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
|
||||||
|
|
||||||
|
class Pnl(Base):
|
||||||
|
__tablename__ = "pnl_cache"
|
||||||
|
alpha_id: Mapped[str] = mapped_column(ForeignKey("alphas.id"), primary_key=True)
|
||||||
|
raw: Mapped[dict] = mapped_column(JSON)
|
||||||
|
points: Mapped[list] = mapped_column(JSON)
|
||||||
|
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
|
||||||
|
|
||||||
|
class ResearchTag(Base):
|
||||||
|
__tablename__ = "research_tags"
|
||||||
|
alpha_id: Mapped[str] = mapped_column(ForeignKey("alphas.id"), primary_key=True)
|
||||||
|
tag: Mapped[str] = mapped_column(String(60), primary_key=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Job(Base):
|
||||||
|
__tablename__ = "sync_jobs"
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||||
|
kind: Mapped[str] = mapped_column(String(30))
|
||||||
|
status: Mapped[str] = mapped_column(String(30), default="queued", index=True)
|
||||||
|
payload: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
checkpoint: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
processed: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
failed: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
total: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
cancel_requested: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
|
||||||
|
|
||||||
|
class JobItem(Base):
|
||||||
|
__tablename__ = "job_items"
|
||||||
|
job_id: Mapped[str] = mapped_column(ForeignKey("sync_jobs.id"), primary_key=True)
|
||||||
|
alpha_id: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||||
|
error: Mapped[str | None] = mapped_column(Text)
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""Validated public API contracts. Platform state is intentionally not a closed enum."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Literal
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
ResearchState = Literal["inbox", "candidate", "optimizing", "archived"]
|
||||||
|
SortField = Literal[
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"sharpe",
|
||||||
|
"fitness",
|
||||||
|
"returns",
|
||||||
|
"turnover",
|
||||||
|
"margin",
|
||||||
|
"drawdown",
|
||||||
|
"date_created",
|
||||||
|
"date_submitted",
|
||||||
|
"synced_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Contract(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginInput(Contract):
|
||||||
|
username: str = Field(min_length=1, max_length=100)
|
||||||
|
password: str = Field(min_length=1, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialsInput(Contract):
|
||||||
|
email: str = Field(max_length=254)
|
||||||
|
password: str = Field(min_length=1, max_length=1024)
|
||||||
|
|
||||||
|
@field_validator("email")
|
||||||
|
@classmethod
|
||||||
|
def valid_email(cls, value):
|
||||||
|
value = value.strip()
|
||||||
|
if not re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", value):
|
||||||
|
raise ValueError("邮箱格式不正确")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class PreferencesInput(Contract):
|
||||||
|
display_name: str = Field(min_length=1, max_length=100)
|
||||||
|
theme: Literal["light", "dark"]
|
||||||
|
timezone: str
|
||||||
|
page_size: Literal[25, 50, 100]
|
||||||
|
|
||||||
|
@field_validator("timezone")
|
||||||
|
@classmethod
|
||||||
|
def valid_timezone(cls, value):
|
||||||
|
try:
|
||||||
|
ZoneInfo(value)
|
||||||
|
except (ZoneInfoNotFoundError, ValueError):
|
||||||
|
raise ValueError("未知时区") from None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class AlphaFilters(Contract):
|
||||||
|
q: str | None = Field(default=None, max_length=300)
|
||||||
|
region: str | None = None
|
||||||
|
universe: str | None = None
|
||||||
|
alpha_type: str | None = None
|
||||||
|
language: str | None = None
|
||||||
|
status: str | None = None
|
||||||
|
stage: str | None = None
|
||||||
|
hidden: bool | None = None
|
||||||
|
research_state: ResearchState | None = None
|
||||||
|
favorite: bool | None = None
|
||||||
|
tag: str | None = Field(default=None, max_length=60)
|
||||||
|
created_from: datetime | None = None
|
||||||
|
created_to: datetime | None = None
|
||||||
|
sharpe_min: float | None = None
|
||||||
|
sharpe_max: float | None = None
|
||||||
|
fitness_min: float | None = None
|
||||||
|
fitness_max: float | None = None
|
||||||
|
returns_min: float | None = None
|
||||||
|
returns_max: float | None = None
|
||||||
|
turnover_min: float | None = None
|
||||||
|
turnover_max: float | None = None
|
||||||
|
margin_min: float | None = None
|
||||||
|
margin_max: float | None = None
|
||||||
|
drawdown_min: float | None = None
|
||||||
|
drawdown_max: float | None = None
|
||||||
|
sort: SortField = "date_created"
|
||||||
|
direction: Literal["asc", "desc"] = "desc"
|
||||||
|
limit: int = Field(default=25, ge=1, le=100)
|
||||||
|
offset: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
@field_validator("created_from", "created_to")
|
||||||
|
@classmethod
|
||||||
|
def utc_dates(cls, value):
|
||||||
|
return value.replace(tzinfo=timezone.utc) if value and value.tzinfo is None else value
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def range_order(self):
|
||||||
|
for key in ("sharpe", "fitness", "returns", "turnover", "margin", "drawdown"):
|
||||||
|
lo, hi = getattr(self, f"{key}_min"), getattr(self, f"{key}_max")
|
||||||
|
if lo is not None and hi is not None and lo > hi:
|
||||||
|
raise ValueError(f"{key} 最小值不能大于最大值")
|
||||||
|
if self.created_from and self.created_to and self.created_from > self.created_to:
|
||||||
|
raise ValueError("开始时间不能晚于结束时间")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_tags(values):
|
||||||
|
result = sorted(set(v.strip() for v in values if v.strip()))
|
||||||
|
if len(result) > 30 or any(len(v) > 60 for v in result):
|
||||||
|
raise ValueError("最多 30 个标签,每个标签最多 60 字符")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class ResearchInput(Contract):
|
||||||
|
note: str = Field(default="", max_length=20000)
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
favorite: bool = False
|
||||||
|
state: ResearchState = "inbox"
|
||||||
|
_tags = field_validator("tags")(normalize_tags)
|
||||||
|
|
||||||
|
|
||||||
|
def valid_ids(values):
|
||||||
|
result = list(dict.fromkeys(values))
|
||||||
|
if not result or len(result) > 100 or any(not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", v) for v in result):
|
||||||
|
raise ValueError("请输入 1–100 个合法 Alpha ID(字母、数字、下划线或短横线)")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class BulkInput(Contract):
|
||||||
|
alpha_ids: list[str]
|
||||||
|
add_tags: list[str] = Field(default_factory=list)
|
||||||
|
remove_tags: list[str] = Field(default_factory=list)
|
||||||
|
state: ResearchState | None = None
|
||||||
|
_ids = field_validator("alpha_ids")(valid_ids)
|
||||||
|
_tags = field_validator("add_tags", "remove_tags")(normalize_tags)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def disjoint(self):
|
||||||
|
if set(self.add_tags) & set(self.remove_tags):
|
||||||
|
raise ValueError("同一标签不能同时添加和移除")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class JobInput(Contract):
|
||||||
|
kind: Literal["full_sync", "alpha_refresh", "pnl_refresh"]
|
||||||
|
alpha_ids: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_ids(self):
|
||||||
|
if self.kind == "full_sync":
|
||||||
|
if self.alpha_ids:
|
||||||
|
raise ValueError("全量同步不接受 Alpha ID")
|
||||||
|
else:
|
||||||
|
self.alpha_ids = valid_ids(self.alpha_ids)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ResearchOutput(ResearchInput):
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AlphaSummary(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str | None
|
||||||
|
expression_preview: str
|
||||||
|
alpha_type: str | None
|
||||||
|
language: str | None
|
||||||
|
stage: str | None
|
||||||
|
status: str | None
|
||||||
|
hidden: bool
|
||||||
|
region: str | None
|
||||||
|
universe: str | None
|
||||||
|
sharpe: float | None
|
||||||
|
fitness: float | None
|
||||||
|
returns: float | None
|
||||||
|
turnover: float | None
|
||||||
|
margin: float | None
|
||||||
|
drawdown: float | None
|
||||||
|
date_created: datetime | None
|
||||||
|
date_submitted: datetime | None
|
||||||
|
synced_at: datetime
|
||||||
|
research: ResearchOutput
|
||||||
|
|
||||||
|
|
||||||
|
class AlphaDetail(AlphaSummary):
|
||||||
|
expression: str | None
|
||||||
|
selection: str | None
|
||||||
|
combo: str | None
|
||||||
|
settings: dict
|
||||||
|
is_metrics: dict
|
||||||
|
os_metrics: dict
|
||||||
|
checks: list
|
||||||
|
|
||||||
|
|
||||||
|
class AlphaPage(BaseModel):
|
||||||
|
items: list[AlphaSummary]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class JobOutput(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: str
|
||||||
|
kind: str
|
||||||
|
status: str
|
||||||
|
processed: int
|
||||||
|
failed: int
|
||||||
|
total: int | None
|
||||||
|
error: str | None
|
||||||
|
next_retry_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AccountOutput(BaseModel):
|
||||||
|
email: str | None
|
||||||
|
configured: bool
|
||||||
|
wq_user_id: str | None
|
||||||
|
profile: dict
|
||||||
|
connection_status: str
|
||||||
|
connection_error: str | None
|
||||||
|
verification_url: str | None
|
||||||
|
last_synced_at: datetime | None
|
||||||
|
display_name: str
|
||||||
|
theme: Literal["light", "dark"]
|
||||||
|
timezone: str
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class SessionOutput(BaseModel):
|
||||||
|
username: str
|
||||||
|
|
||||||
|
|
||||||
|
class OkOutput(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorOutput(BaseModel):
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
|
class HealthOutput(BaseModel):
|
||||||
|
status: Literal["ok"]
|
||||||
|
|
||||||
|
|
||||||
|
class BulkOutput(BaseModel):
|
||||||
|
updated: int
|
||||||
|
|
||||||
|
|
||||||
|
class PnlPoint(BaseModel):
|
||||||
|
date: str
|
||||||
|
value: float | None
|
||||||
|
|
||||||
|
|
||||||
|
class PnlOutput(BaseModel):
|
||||||
|
cached: bool
|
||||||
|
points: list[PnlPoint]
|
||||||
|
fetched_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class FacetsOutput(BaseModel):
|
||||||
|
region: list[str]
|
||||||
|
universe: list[str]
|
||||||
|
alpha_type: list[str]
|
||||||
|
language: list[str]
|
||||||
|
status: list[str]
|
||||||
|
stage: list[str]
|
||||||
|
tags: list[str]
|
||||||
|
total: int
|
||||||
|
favorites: int
|
||||||
|
last_sync: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class JobErrorOutput(BaseModel):
|
||||||
|
alpha_id: str
|
||||||
|
error: str
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Server-side authentication and encryption utilities."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import VerificationError
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from fastapi import Depends, HTTPException, Request
|
||||||
|
from fastapi.security import APIKeyCookie
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from .models import Account, Admin, LoginSession, now
|
||||||
|
|
||||||
|
password_hasher = PasswordHasher()
|
||||||
|
session_cookie = APIKeyCookie(name="wq_session", auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
def token_hash(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def cipher(settings) -> Fernet:
|
||||||
|
return Fernet(settings.encryption_key.get_secret_value().encode())
|
||||||
|
|
||||||
|
|
||||||
|
async def bootstrap(db, settings):
|
||||||
|
"""Only initialize missing singleton records; deployments never reset existing passwords."""
|
||||||
|
if not await db.get(Admin, 1):
|
||||||
|
db.add(
|
||||||
|
Admin(
|
||||||
|
id=1,
|
||||||
|
username=settings.admin_username,
|
||||||
|
password_hash=password_hasher.hash(settings.admin_password.get_secret_value()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not await db.get(Account, 1):
|
||||||
|
db.add(Account(id=1))
|
||||||
|
await db.execute(delete(LoginSession).where(LoginSession.expires_at < now()))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def valid_password(encoded: str, value: str) -> bool:
|
||||||
|
try:
|
||||||
|
return password_hasher.verify(encoded, value)
|
||||||
|
except VerificationError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def issue_session(db, hours: int) -> str:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
db.add(LoginSession(token_hash=token_hash(token), expires_at=now() + timedelta(hours=hours)))
|
||||||
|
await db.commit()
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
async def require_auth(request: Request, token: str | None = Depends(session_cookie)):
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(401, "请先登录系统")
|
||||||
|
async with request.app.state.sessions() as db:
|
||||||
|
row = await db.get(LoginSession, token_hash(token))
|
||||||
|
# SQLite fixtures lose tzinfo; production PostgreSQL preserves it.
|
||||||
|
if row is None or row.expires_at.replace(tzinfo=now().tzinfo) <= now():
|
||||||
|
raise HTTPException(401, "系统登录已过期,请重新登录")
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Read-only WorldQuant adapter. Authentication is the only allowed upstream POST.
|
||||||
|
|
||||||
|
No upstream response body or request headers are included in exceptions: they may
|
||||||
|
contain credentials, cookies, or temporary authentication links.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
|
from typing import Awaitable, Callable
|
||||||
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class WqError(Exception):
|
||||||
|
def __init__(self, message: str, code: str = "upstream_error"):
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
class VerificationRequired(WqError):
|
||||||
|
def __init__(self, url: str):
|
||||||
|
super().__init__("请在 WorldQuant 完成人工验证,再点击继续验证", "verification_required")
|
||||||
|
self.url = url
|
||||||
|
|
||||||
|
|
||||||
|
class WqClient:
|
||||||
|
def __init__(self, settings, transport=None, sleep=asyncio.sleep):
|
||||||
|
self.settings = settings
|
||||||
|
self.client = httpx.AsyncClient(
|
||||||
|
base_url=settings.wq_base_url,
|
||||||
|
timeout=settings.request_timeout,
|
||||||
|
transport=transport,
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
self.lock = asyncio.Lock()
|
||||||
|
self.authenticated = False
|
||||||
|
self.auth_generation = 0
|
||||||
|
self.credentials: tuple[str, str] | None = None
|
||||||
|
self.verification_url: str | None = None
|
||||||
|
self.sleep = sleep
|
||||||
|
self.on_retry: Callable[[float], Awaitable[None]] | None = None
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
await self.client.aclose()
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
self.authenticated = False
|
||||||
|
self.credentials = None
|
||||||
|
self.verification_url = None
|
||||||
|
self.client.cookies.clear()
|
||||||
|
|
||||||
|
def safe_verification_url(self, response: httpx.Response) -> str:
|
||||||
|
url = urljoin(str(response.url), response.headers.get("Location", ""))
|
||||||
|
base = urlparse(self.settings.wq_base_url)
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if (
|
||||||
|
not response.headers.get("Location")
|
||||||
|
or parsed.scheme != base.scheme
|
||||||
|
or parsed.netloc != base.netloc
|
||||||
|
):
|
||||||
|
raise WqError("平台返回了无法确认的验证地址,请到 BRAIN 官网检查账户", "invalid_verification")
|
||||||
|
return url
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def retry_delay(value: str | None, attempt: int) -> float:
|
||||||
|
if value:
|
||||||
|
try:
|
||||||
|
seconds = float(value)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
seconds = (parsedate_to_datetime(value) - datetime.now(timezone.utc)).total_seconds()
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
seconds = -1
|
||||||
|
if math.isfinite(seconds) and seconds >= 0:
|
||||||
|
return seconds
|
||||||
|
return min(2**attempt + random.uniform(0, 0.5), 30)
|
||||||
|
|
||||||
|
async def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
|
||||||
|
for attempt in range(self.settings.retry_attempts):
|
||||||
|
try:
|
||||||
|
response = await self.client.request(method, path, **kwargs)
|
||||||
|
except (httpx.TimeoutException, httpx.NetworkError):
|
||||||
|
if attempt + 1 == self.settings.retry_attempts:
|
||||||
|
raise WqError("WorldQuant 网络请求失败,请稍后重试", "network_error") from None
|
||||||
|
response = None
|
||||||
|
if response is not None and response.status_code not in (429, 500, 502, 503, 504):
|
||||||
|
return response
|
||||||
|
if attempt + 1 == self.settings.retry_attempts:
|
||||||
|
raise WqError("WorldQuant 暂时限流或不可用,重试预算已用完", "retry_exhausted")
|
||||||
|
delay = self.retry_delay(
|
||||||
|
response.headers.get("Retry-After") if response is not None else None, attempt
|
||||||
|
)
|
||||||
|
if self.on_retry:
|
||||||
|
await self.on_retry(delay)
|
||||||
|
await self.sleep(delay)
|
||||||
|
raise WqError("重试失败")
|
||||||
|
|
||||||
|
async def authenticate(self, email: str, password: str, force=False, stale_generation=None):
|
||||||
|
async with self.lock:
|
||||||
|
if (
|
||||||
|
self.authenticated
|
||||||
|
and not force
|
||||||
|
and (stale_generation is None or stale_generation != self.auth_generation)
|
||||||
|
):
|
||||||
|
return
|
||||||
|
if self.verification_url and not force:
|
||||||
|
raise VerificationRequired(self.verification_url)
|
||||||
|
self.credentials = (email, password)
|
||||||
|
self.authenticated = False
|
||||||
|
if force:
|
||||||
|
self.client.cookies.clear()
|
||||||
|
self.verification_url = None
|
||||||
|
response = await self._request("POST", "/authentication", auth=httpx.BasicAuth(email, password))
|
||||||
|
if (
|
||||||
|
response.status_code == 401
|
||||||
|
and response.headers.get("WWW-Authenticate", "").lower() == "persona"
|
||||||
|
):
|
||||||
|
self.verification_url = self.safe_verification_url(response)
|
||||||
|
raise VerificationRequired(self.verification_url)
|
||||||
|
if response.status_code in (401, 403):
|
||||||
|
raise WqError("平台认证未通过,请检查凭据或账户访问权限", "authentication_failed")
|
||||||
|
if response.status_code not in (200, 201):
|
||||||
|
raise WqError(f"平台认证返回异常状态 {response.status_code}", "authentication_failed")
|
||||||
|
self.authenticated = True
|
||||||
|
self.auth_generation += 1
|
||||||
|
self.verification_url = None
|
||||||
|
|
||||||
|
async def verify(self):
|
||||||
|
"""Continue the same server-side challenge session after the human visits the URL."""
|
||||||
|
if not self.verification_url:
|
||||||
|
if not self.credentials:
|
||||||
|
raise WqError("验证会话已结束,请重新连接", "authentication_failed")
|
||||||
|
await self.authenticate(*self.credentials, force=True)
|
||||||
|
return
|
||||||
|
response = await self._request("POST", self.verification_url)
|
||||||
|
if response.status_code in (200, 201):
|
||||||
|
self.authenticated = True
|
||||||
|
self.auth_generation += 1
|
||||||
|
self.verification_url = None
|
||||||
|
elif response.status_code in (401, 403, 202):
|
||||||
|
raise VerificationRequired(self.verification_url)
|
||||||
|
else:
|
||||||
|
raise WqError("验证会话已失效,请重新连接", "authentication_failed")
|
||||||
|
|
||||||
|
async def get(self, path: str, params=None):
|
||||||
|
if not self.credentials:
|
||||||
|
raise WqError("请先连接 WorldQuant", "disconnected")
|
||||||
|
if not self.authenticated:
|
||||||
|
await self.authenticate(*self.credentials)
|
||||||
|
refreshed = False
|
||||||
|
for attempt in range(self.settings.retry_attempts):
|
||||||
|
generation = self.auth_generation
|
||||||
|
response = await self._request("GET", path, params=params)
|
||||||
|
if response.status_code == 401 and not refreshed:
|
||||||
|
await self.authenticate(*self.credentials, stale_generation=generation)
|
||||||
|
refreshed = True
|
||||||
|
continue
|
||||||
|
if response.status_code in (401, 403):
|
||||||
|
raise WqError("无权访问该平台资源", "access_denied")
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise WqError("平台资源不存在或不可访问", "not_found")
|
||||||
|
if response.status_code not in (200, 201, 202):
|
||||||
|
raise WqError(f"WorldQuant 请求返回状态 {response.status_code}")
|
||||||
|
# Recordsets may return 200/202 with Retry-After before results exist.
|
||||||
|
if (
|
||||||
|
response.headers.get("Retry-After")
|
||||||
|
and self.retry_delay(response.headers["Retry-After"], 0) > 0
|
||||||
|
):
|
||||||
|
if attempt + 1 == self.settings.retry_attempts:
|
||||||
|
raise WqError("平台数据仍在准备,请稍后重试", "pending")
|
||||||
|
delay = self.retry_delay(response.headers["Retry-After"], attempt)
|
||||||
|
if self.on_retry:
|
||||||
|
await self.on_retry(delay)
|
||||||
|
await self.sleep(delay)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
result = response.json()
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
raise ValueError()
|
||||||
|
return result
|
||||||
|
except ValueError:
|
||||||
|
raise WqError("平台返回的数据格式无法识别", "invalid_response") from None
|
||||||
|
raise WqError("平台数据尚未就绪", "pending")
|
||||||
|
|
||||||
|
async def profile(self):
|
||||||
|
return await self.get("/users/self")
|
||||||
|
|
||||||
|
async def alphas(self, submission, hidden, offset, before):
|
||||||
|
# Cover every platform stage; submitted records are not assumed to be OS only.
|
||||||
|
status_key = "status" if submission == "UNSUBMITTED" else "status!"
|
||||||
|
return await self.get(
|
||||||
|
"/users/self/alphas",
|
||||||
|
{
|
||||||
|
status_key: "UNSUBMITTED",
|
||||||
|
"hidden": str(hidden).lower(),
|
||||||
|
"limit": 100,
|
||||||
|
"offset": offset,
|
||||||
|
"order": "dateCreated",
|
||||||
|
"dateCreated<": before,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def alpha(self, alpha_id):
|
||||||
|
return await self.get(f"/alphas/{alpha_id}")
|
||||||
|
|
||||||
|
async def pnl(self, alpha_id):
|
||||||
|
return await self.get(f"/alphas/{alpha_id}/recordsets/pnl")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.models import Base
|
||||||
|
|
||||||
|
|
||||||
|
def migrations(connection):
|
||||||
|
context.configure(connection=connection, target_metadata=Base.metadata, compare_type=True)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
engine = create_async_engine(Settings().database_url)
|
||||||
|
async with engine.connect() as connection:
|
||||||
|
await connection.run_sync(migrations)
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
context.configure(url=Settings().database_url, target_metadata=Base.metadata, literal_binds=True)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
else:
|
||||||
|
asyncio.run(run())
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""${message}"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Initial account alpha and durable job schema"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = '0001'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('accounts',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=254), nullable=True),
|
||||||
|
sa.Column('password_encrypted', sa.Text(), nullable=True),
|
||||||
|
sa.Column('wq_user_id', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('profile', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('connection_status', sa.String(length=30), nullable=False),
|
||||||
|
sa.Column('connection_error', sa.Text(), nullable=True),
|
||||||
|
sa.Column('verification_url', sa.Text(), nullable=True),
|
||||||
|
sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('display_name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('theme', sa.String(length=10), nullable=False),
|
||||||
|
sa.Column('timezone', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('page_size', sa.Integer(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('admins',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('username', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('password_hash', sa.Text(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('username')
|
||||||
|
)
|
||||||
|
op.create_table('alphas',
|
||||||
|
sa.Column('id', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('name', sa.Text(), nullable=True),
|
||||||
|
sa.Column('expression', sa.Text(), nullable=True),
|
||||||
|
sa.Column('selection', sa.Text(), nullable=True),
|
||||||
|
sa.Column('combo', sa.Text(), nullable=True),
|
||||||
|
sa.Column('alpha_type', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('language', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('stage', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=80), nullable=True),
|
||||||
|
sa.Column('hidden', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('region', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('universe', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('settings', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('is_metrics', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('os_metrics', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('checks', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('sharpe', sa.Float(), nullable=True),
|
||||||
|
sa.Column('fitness', sa.Float(), nullable=True),
|
||||||
|
sa.Column('returns', sa.Float(), nullable=True),
|
||||||
|
sa.Column('turnover', sa.Float(), nullable=True),
|
||||||
|
sa.Column('margin', sa.Float(), nullable=True),
|
||||||
|
sa.Column('drawdown', sa.Float(), nullable=True),
|
||||||
|
sa.Column('date_created', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('date_submitted', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('synced_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('raw', sa.JSON(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_alphas_alpha_type'), 'alphas', ['alpha_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_alphas_date_created'), 'alphas', ['date_created'], unique=False)
|
||||||
|
op.create_index(op.f('ix_alphas_fitness'), 'alphas', ['fitness'], unique=False)
|
||||||
|
op.create_index(op.f('ix_alphas_region'), 'alphas', ['region'], unique=False)
|
||||||
|
op.create_index('ix_alphas_region_status', 'alphas', ['region', 'status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_alphas_sharpe'), 'alphas', ['sharpe'], unique=False)
|
||||||
|
op.create_index(op.f('ix_alphas_stage'), 'alphas', ['stage'], unique=False)
|
||||||
|
op.create_index(op.f('ix_alphas_status'), 'alphas', ['status'], unique=False)
|
||||||
|
op.create_table('login_sessions',
|
||||||
|
sa.Column('token_hash', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('token_hash')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_login_sessions_expires_at'), 'login_sessions', ['expires_at'], unique=False)
|
||||||
|
op.create_table('sync_jobs',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('kind', sa.String(length=30), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=30), nullable=False),
|
||||||
|
sa.Column('payload', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('checkpoint', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('processed', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('failed', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('total', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('error', sa.Text(), nullable=True),
|
||||||
|
sa.Column('next_retry_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('cancel_requested', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_sync_jobs_status'), 'sync_jobs', ['status'], unique=False)
|
||||||
|
op.create_table('job_items',
|
||||||
|
sa.Column('job_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('alpha_id', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('error', sa.Text(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['job_id'], ['sync_jobs.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('job_id', 'alpha_id')
|
||||||
|
)
|
||||||
|
op.create_table('pnl_cache',
|
||||||
|
sa.Column('alpha_id', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('raw', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('points', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('fetched_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['alpha_id'], ['alphas.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('alpha_id')
|
||||||
|
)
|
||||||
|
op.create_table('research',
|
||||||
|
sa.Column('alpha_id', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('note', sa.Text(), nullable=False),
|
||||||
|
sa.Column('tags', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('favorite', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('state', sa.String(length=30), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['alpha_id'], ['alphas.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('alpha_id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_research_state'), 'research', ['state'], unique=False)
|
||||||
|
op.create_table('research_tags',
|
||||||
|
sa.Column('alpha_id', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('tag', sa.String(length=60), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['alpha_id'], ['alphas.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('alpha_id', 'tag')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_research_tags_tag'), 'research_tags', ['tag'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_research_tags_tag'), table_name='research_tags')
|
||||||
|
op.drop_table('research_tags')
|
||||||
|
op.drop_index(op.f('ix_research_state'), table_name='research')
|
||||||
|
op.drop_table('research')
|
||||||
|
op.drop_table('pnl_cache')
|
||||||
|
op.drop_table('job_items')
|
||||||
|
op.drop_index(op.f('ix_sync_jobs_status'), table_name='sync_jobs')
|
||||||
|
op.drop_table('sync_jobs')
|
||||||
|
op.drop_index(op.f('ix_login_sessions_expires_at'), table_name='login_sessions')
|
||||||
|
op.drop_table('login_sessions')
|
||||||
|
op.drop_index(op.f('ix_alphas_status'), table_name='alphas')
|
||||||
|
op.drop_index(op.f('ix_alphas_stage'), table_name='alphas')
|
||||||
|
op.drop_index(op.f('ix_alphas_sharpe'), table_name='alphas')
|
||||||
|
op.drop_index('ix_alphas_region_status', table_name='alphas')
|
||||||
|
op.drop_index(op.f('ix_alphas_region'), table_name='alphas')
|
||||||
|
op.drop_index(op.f('ix_alphas_fitness'), table_name='alphas')
|
||||||
|
op.drop_index(op.f('ix_alphas_date_created'), table_name='alphas')
|
||||||
|
op.drop_index(op.f('ix_alphas_alpha_type'), table_name='alphas')
|
||||||
|
op.drop_table('alphas')
|
||||||
|
op.drop_table('admins')
|
||||||
|
op.drop_table('accounts')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[project]
|
||||||
|
name = "wq-alpha-system"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Personal WorldQuant Alpha research workspace"
|
||||||
|
requires-python = ">=3.12,<3.13"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115,<1", "uvicorn[standard]>=0.34,<1",
|
||||||
|
"httpx>=0.28,<1", "sqlalchemy[asyncio]>=2.0.38,<2.1",
|
||||||
|
"asyncpg>=0.30,<1", "alembic>=1.15,<2",
|
||||||
|
"pydantic-settings>=2.8,<3", "cryptography>=44,<50", "argon2-cffi>=23.1,<26"
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = ["pytest>=8,<10", "pytest-asyncio>=0.25,<2", "aiosqlite>=0.21,<1", "ruff>=0.11,<1"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["."]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
|
line-length = 110
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I"]
|
||||||
|
ignore = ["E501"]
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Isolated browser acceptance server: temporary database, synthetic upstream, no real network."""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.main import create_app
|
||||||
|
from app.models import Base
|
||||||
|
from app.worldquant import WqClient
|
||||||
|
|
||||||
|
TEST_PASSWORD = "browser-test-password"
|
||||||
|
|
||||||
|
|
||||||
|
def sample(index):
|
||||||
|
super_alpha = index % 7 == 0
|
||||||
|
language = "PYTHON" if index % 11 == 0 and not super_alpha else "FASTEXPR"
|
||||||
|
names = [
|
||||||
|
"短期价格反转",
|
||||||
|
"成交量与价格背离",
|
||||||
|
"分析师预期修正",
|
||||||
|
"行业中性动量",
|
||||||
|
"低波动质量信号",
|
||||||
|
"资金流向偏离",
|
||||||
|
"组合选择实验",
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"id": f"TEST{index:04}",
|
||||||
|
"name": names[index % len(names)] + f" · {index:03}",
|
||||||
|
"type": "SUPER" if super_alpha else "REGULAR",
|
||||||
|
"stage": "OS" if index % 3 == 0 else "IS",
|
||||||
|
"status": "ACTIVE" if index % 3 == 0 else "UNSUBMITTED",
|
||||||
|
"hidden": index % 5 == 0,
|
||||||
|
"regular": None
|
||||||
|
if super_alpha
|
||||||
|
else {
|
||||||
|
"code": "def alpha(data):\n return -data.close.rank()"
|
||||||
|
if language == "PYTHON"
|
||||||
|
else "group_neutralize(-rank(ts_delta(close, 5)), industry)"
|
||||||
|
},
|
||||||
|
"selection": {"code": "self_correlation < 0.5"} if super_alpha else None,
|
||||||
|
"combo": {"code": "alpha"} if super_alpha else None,
|
||||||
|
"settings": {
|
||||||
|
"region": ["USA", "CHN", "EUR"][index % 3],
|
||||||
|
"universe": "TOP3000",
|
||||||
|
"language": language,
|
||||||
|
"delay": 1,
|
||||||
|
"decay": 4,
|
||||||
|
"neutralization": "INDUSTRY",
|
||||||
|
"truncation": 0.08,
|
||||||
|
"pasteurization": "ON",
|
||||||
|
},
|
||||||
|
"is": {
|
||||||
|
"sharpe": None if index % 13 == 0 else round(0.8 + (index % 25) * 0.1, 2),
|
||||||
|
"fitness": round(0.3 + (index % 20) * 0.06, 2),
|
||||||
|
"turnover": round(0.08 + (index % 12) * 0.015, 3),
|
||||||
|
"returns": 0.09,
|
||||||
|
"margin": 0.0002,
|
||||||
|
"drawdown": 0.11,
|
||||||
|
"checks": [{"name": "LOW_SHARPE", "result": "PASS", "value": 2.1, "limit": 1.58}],
|
||||||
|
},
|
||||||
|
"os": {"sharpe": 1.1} if index % 3 == 0 else None,
|
||||||
|
"dateCreated": (datetime(2025, 1, 1, tzinfo=timezone.utc) + timedelta(days=index)).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_app():
|
||||||
|
temporary = tempfile.TemporaryDirectory(prefix="wq-browser-test-")
|
||||||
|
settings = Settings(
|
||||||
|
_env_file=None,
|
||||||
|
database_url=f"sqlite+aiosqlite:///{temporary.name}/test.db",
|
||||||
|
admin_password=TEST_PASSWORD,
|
||||||
|
encryption_key=Fernet.generate_key().decode(),
|
||||||
|
public_origin="http://127.0.0.1:5179",
|
||||||
|
)
|
||||||
|
records = [sample(i) for i in range(620)]
|
||||||
|
|
||||||
|
def upstream(request):
|
||||||
|
path = request.url.path
|
||||||
|
if path == "/authentication" and request.method == "POST":
|
||||||
|
return httpx.Response(
|
||||||
|
201, json={"user": {"id": "TEST_USER"}}, headers={"Set-Cookie": "mock=only; Path=/"}
|
||||||
|
)
|
||||||
|
if request.method != "GET":
|
||||||
|
raise AssertionError("Browser acceptance attempted an upstream mutation")
|
||||||
|
if path == "/users/self":
|
||||||
|
return httpx.Response(
|
||||||
|
200, json={"id": "TEST_USER", "name": "模拟研究员", "email": "test@example.com"}
|
||||||
|
)
|
||||||
|
if path == "/users/self/alphas":
|
||||||
|
unsubmitted = "status" in request.url.params
|
||||||
|
hidden = request.url.params.get("hidden") == "true"
|
||||||
|
matched = [
|
||||||
|
r for r in records if (r["status"] == "UNSUBMITTED") == unsubmitted and r["hidden"] == hidden
|
||||||
|
]
|
||||||
|
offset, limit = (
|
||||||
|
int(request.url.params.get("offset", 0)),
|
||||||
|
int(request.url.params.get("limit", 100)),
|
||||||
|
)
|
||||||
|
return httpx.Response(
|
||||||
|
200, json={"count": len(matched), "results": matched[offset : offset + limit]}
|
||||||
|
)
|
||||||
|
if path.endswith("/recordsets/pnl"):
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"schema": {"properties": [{"name": "date"}, {"name": "pnl"}]},
|
||||||
|
"records": [
|
||||||
|
[
|
||||||
|
(datetime(2025, 1, 1) + timedelta(days=i)).date().isoformat(),
|
||||||
|
i * 250 + (i % 13) * 85,
|
||||||
|
]
|
||||||
|
for i in range(180)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
record = next((r for r in records if path == f"/alphas/{r['id']}"), None)
|
||||||
|
return httpx.Response(200, json=record) if record else httpx.Response(404)
|
||||||
|
|
||||||
|
application = create_app(settings, WqClient(settings, transport=httpx.MockTransport(upstream)))
|
||||||
|
original_lifespan = application.router.lifespan_context
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app):
|
||||||
|
async with app.state.engine.begin() as connection:
|
||||||
|
await connection.run_sync(Base.metadata.create_all)
|
||||||
|
async with original_lifespan(app):
|
||||||
|
yield
|
||||||
|
temporary.cleanup()
|
||||||
|
|
||||||
|
application.router.lifespan_context = lifespan
|
||||||
|
return application
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.main import create_app
|
||||||
|
from app.models import Base
|
||||||
|
from app.worldquant import WqClient
|
||||||
|
|
||||||
|
ADMIN_PASSWORD = "test-admin-password-only"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def settings(tmp_path):
|
||||||
|
return Settings(
|
||||||
|
_env_file=None,
|
||||||
|
database_url=f"sqlite+aiosqlite:///{tmp_path}/test.db",
|
||||||
|
admin_password=ADMIN_PASSWORD,
|
||||||
|
encryption_key=Fernet.generate_key().decode(),
|
||||||
|
enable_runner=False,
|
||||||
|
public_origin="http://testserver",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def app(settings):
|
||||||
|
def unexpected_request(request):
|
||||||
|
raise AssertionError("Unit tests must never access the real platform")
|
||||||
|
|
||||||
|
application = create_app(settings, WqClient(settings, transport=httpx.MockTransport(unexpected_request)))
|
||||||
|
async with application.state.engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
async with application.router.lifespan_context(application):
|
||||||
|
yield application
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client(app):
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.ASGITransport(app=app), base_url="http://testserver", headers={"X-WQ-Request": "1"}
|
||||||
|
) as client:
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def logged_in(client):
|
||||||
|
response = await client.post("/api/v1/auth/login", json={"username": "admin", "password": ADMIN_PASSWORD})
|
||||||
|
assert response.status_code == 200
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def alpha(alpha_id="alpha1", **changes):
|
||||||
|
raw = {
|
||||||
|
"id": alpha_id,
|
||||||
|
"name": "Reversal",
|
||||||
|
"type": "REGULAR",
|
||||||
|
"stage": "IS",
|
||||||
|
"status": "UNSUBMITTED",
|
||||||
|
"hidden": False,
|
||||||
|
"regular": {"code": "-rank(ts_delta(close, 5))"},
|
||||||
|
"settings": {"region": "USA", "universe": "TOP3000", "language": "FASTEXPR", "delay": 1},
|
||||||
|
"is": {
|
||||||
|
"sharpe": 1.5,
|
||||||
|
"fitness": 0.8,
|
||||||
|
"turnover": 0.2,
|
||||||
|
"returns": 0.04,
|
||||||
|
"checks": [{"name": "LOW_SHARPE", "result": "FAIL", "value": 1.5, "limit": 1.58}],
|
||||||
|
},
|
||||||
|
"dateCreated": "2025-01-01T00:00:00Z",
|
||||||
|
}
|
||||||
|
return {**raw, **changes}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Run against an isolated acceptance Compose project; never use a personal data stack.
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
python3 backend/tests/docker_acceptance.py --env-file .local/docker-test.env
|
||||||
|
Requires images already built and services started with -p wq-alpha-acceptance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import http.cookiejar
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--env-file", required=True, type=Path)
|
||||||
|
parser.add_argument("--project", default="wq-alpha-acceptance")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not args.project.startswith("wq-alpha-acceptance"):
|
||||||
|
raise SystemExit("Only an isolated wq-alpha-acceptance* project is allowed")
|
||||||
|
values = dict(
|
||||||
|
line.split("=", 1)
|
||||||
|
for line in args.env_file.read_text().splitlines()
|
||||||
|
if "=" in line and not line.startswith("#")
|
||||||
|
)
|
||||||
|
compose = ["docker", "compose", "--env-file", str(args.env_file), "-p", args.project]
|
||||||
|
|
||||||
|
def run(arguments, data=None):
|
||||||
|
result = subprocess.run(compose + arguments, input=data, capture_output=True, check=True)
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
# Assert that we are addressing the isolated project rather than an unrelated localhost app.
|
||||||
|
published = run(["port", "web", "80"]).decode().strip()
|
||||||
|
assert published == f"127.0.0.1:{values.get('LOCAL_PORT', '8080')}"
|
||||||
|
base = f"http://localhost:{values.get('LOCAL_PORT', '8080')}"
|
||||||
|
opener = urllib.request.build_opener(
|
||||||
|
urllib.request.ProxyHandler({}), urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())
|
||||||
|
)
|
||||||
|
|
||||||
|
def request(path, method="GET", payload=None):
|
||||||
|
body = json.dumps(payload).encode() if payload is not None else None
|
||||||
|
return opener.open(
|
||||||
|
urllib.request.Request(
|
||||||
|
base + path,
|
||||||
|
data=body,
|
||||||
|
method=method,
|
||||||
|
headers={"Content-Type": "application/json", "X-WQ-Request": "1", "Origin": base},
|
||||||
|
),
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert json.load(request("/api/v1/health")) == {"status": "ok"}
|
||||||
|
html = request("/")
|
||||||
|
assert "frame-ancestors 'none'" in html.headers["Content-Security-Policy"]
|
||||||
|
assert b'<div id="root">' in html.read()
|
||||||
|
try:
|
||||||
|
request("/api/v1/alphas")
|
||||||
|
raise AssertionError("Unauthenticated data was exposed")
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
assert error.code == 401
|
||||||
|
json.load(
|
||||||
|
request(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
"POST",
|
||||||
|
{"username": values.get("ADMIN_USERNAME", "admin"), "password": values["ADMIN_PASSWORD"]},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
code = b"""import asyncio
|
||||||
|
from app.config import Settings
|
||||||
|
from app.db import create_database
|
||||||
|
from app.alphas import upsert_alpha
|
||||||
|
async def seed():
|
||||||
|
engine, sessions = create_database(Settings().database_url)
|
||||||
|
async with sessions() as db:
|
||||||
|
await upsert_alpha(db, {"id":"DOCKER_ACCEPTANCE", "name":"Synthetic acceptance record", "type":"REGULAR", "stage":"IS", "status":"UNSUBMITTED", "regular":{"code":"rank(close)"}, "settings":{"region":"USA", "language":"FASTEXPR"}, "is":{"sharpe":2.0}})
|
||||||
|
await db.commit()
|
||||||
|
await engine.dispose()
|
||||||
|
asyncio.run(seed())
|
||||||
|
"""
|
||||||
|
run(["exec", "-T", "backend", "python", "-"], code)
|
||||||
|
json.load(
|
||||||
|
request(
|
||||||
|
"/api/v1/alphas/DOCKER_ACCEPTANCE/research",
|
||||||
|
"PATCH",
|
||||||
|
{
|
||||||
|
"note": "persistent local note",
|
||||||
|
"tags": ["docker-verified"],
|
||||||
|
"state": "candidate",
|
||||||
|
"favorite": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print("PASS: PostgreSQL migration, login, API authorization, local research write")
|
||||||
|
run(["up", "-d", "--force-recreate", "--wait"])
|
||||||
|
# A persisted server session and all database rows survive container replacement.
|
||||||
|
detail = json.load(request("/api/v1/alphas/DOCKER_ACCEPTANCE"))
|
||||||
|
assert detail["research"]["note"] == "persistent local note"
|
||||||
|
run(["exec", "-T", "backend", "python", "-"], code.replace(b'"sharpe":2.0', b'"sharpe":3.0'))
|
||||||
|
detail = json.load(request("/api/v1/alphas/DOCKER_ACCEPTANCE"))
|
||||||
|
assert detail["sharpe"] == 3 and detail["research"]["state"] == "candidate"
|
||||||
|
assert detail["research"]["tags"] == ["docker-verified"]
|
||||||
|
print("PASS: container replacement preserves session and data; snapshot update preserves research")
|
||||||
|
dump = run(["exec", "-T", "db", "pg_dump", "-U", "wq", "-d", "wq", "-Fc", "--no-owner"])
|
||||||
|
backup = Path(".local/docker-acceptance.dump")
|
||||||
|
fd = os.open(backup, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
with os.fdopen(fd, "wb") as output:
|
||||||
|
output.write(dump)
|
||||||
|
# Only this script's scratch restore database is replaced on repeated acceptance runs.
|
||||||
|
run(["exec", "-T", "db", "dropdb", "-U", "wq", "--if-exists", "wq_acceptance_restore"])
|
||||||
|
run(["exec", "-T", "db", "createdb", "-U", "wq", "wq_acceptance_restore"])
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
"exec",
|
||||||
|
"-T",
|
||||||
|
"db",
|
||||||
|
"pg_restore",
|
||||||
|
"-U",
|
||||||
|
"wq",
|
||||||
|
"-d",
|
||||||
|
"wq_acceptance_restore",
|
||||||
|
"--no-owner",
|
||||||
|
"--exit-on-error",
|
||||||
|
],
|
||||||
|
dump,
|
||||||
|
)
|
||||||
|
rows = (
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
"exec",
|
||||||
|
"-T",
|
||||||
|
"db",
|
||||||
|
"psql",
|
||||||
|
"-U",
|
||||||
|
"wq",
|
||||||
|
"-d",
|
||||||
|
"wq_acceptance_restore",
|
||||||
|
"-At",
|
||||||
|
"-c",
|
||||||
|
"SELECT note || '|' || state FROM research WHERE alpha_id = 'DOCKER_ACCEPTANCE'",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.decode()
|
||||||
|
.strip()
|
||||||
|
)
|
||||||
|
assert rows == "persistent local note|candidate"
|
||||||
|
print("PASS: PostgreSQL custom-format backup restores records into independent database")
|
||||||
|
config = json.loads(run(["-f", "compose.public.yaml", "config", "--format", "json"]))
|
||||||
|
assert config["services"]["backend"]["environment"]["COOKIE_SECURE"] == "true"
|
||||||
|
assert config["services"]["backend"]["environment"]["PUBLIC_ORIGIN"].startswith("https://")
|
||||||
|
assert "ports" not in config["services"]["db"] and "ports" not in config["services"]["backend"]
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
"exec",
|
||||||
|
"-T",
|
||||||
|
"web",
|
||||||
|
"caddy",
|
||||||
|
"validate",
|
||||||
|
"--config",
|
||||||
|
"/etc/caddy/Caddyfile",
|
||||||
|
"--adapter",
|
||||||
|
"caddyfile",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
print("PASS: Caddy configuration and public HTTPS/Secure-cookie configuration checks")
|
||||||
|
# Keep the backend process alive while its database disappears and returns.
|
||||||
|
run(["stop", "db"])
|
||||||
|
run(["up", "-d", "--wait", "db"])
|
||||||
|
probe = b"""import asyncio
|
||||||
|
from app.config import Settings
|
||||||
|
from app.db import create_database
|
||||||
|
from app.jobs import create_job
|
||||||
|
async def enqueue():
|
||||||
|
engine, sessions = create_database(Settings().database_url)
|
||||||
|
async with sessions() as db:
|
||||||
|
job = await create_job(db, "profile")
|
||||||
|
print(job.id)
|
||||||
|
await engine.dispose()
|
||||||
|
asyncio.run(enqueue())
|
||||||
|
"""
|
||||||
|
job_id = run(["exec", "-T", "backend", "python", "-"], probe).decode().strip()
|
||||||
|
deadline = time.monotonic() + 15
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
status = json.load(request(f"/api/v1/sync-jobs/{job_id}"))["status"]
|
||||||
|
if status == "waiting_connection":
|
||||||
|
break
|
||||||
|
time.sleep(0.25)
|
||||||
|
else:
|
||||||
|
raise AssertionError("Scheduler did not resume after database restart")
|
||||||
|
print("PASS: live backend resumes task processing after database stop/start")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.alphas import upsert_alpha
|
||||||
|
from app.models import Account, Admin, LoginSession, Research, now
|
||||||
|
from app.security import cipher
|
||||||
|
from tests.conftest import ADMIN_PASSWORD, alpha
|
||||||
|
|
||||||
|
PREFIX = "/api/v1"
|
||||||
|
|
||||||
|
|
||||||
|
async def seed(app, count=1):
|
||||||
|
async with app.state.sessions() as db:
|
||||||
|
for i in range(count):
|
||||||
|
await upsert_alpha(db, alpha(f"a{i:04}", name=f"Signal {i:04}", **{"is": {"sharpe": i / 100}}))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_logout_expiry_and_password_hash(app, client):
|
||||||
|
assert (await client.get(f"{PREFIX}/alphas")).status_code == 401
|
||||||
|
wrong = await client.post(f"{PREFIX}/auth/login", json={"username": "admin", "password": "wrong"})
|
||||||
|
assert wrong.status_code == 401
|
||||||
|
login = await client.post(f"{PREFIX}/auth/login", json={"username": "admin", "password": ADMIN_PASSWORD})
|
||||||
|
assert login.status_code == 200
|
||||||
|
assert "HttpOnly" in login.headers["set-cookie"] and "SameSite=strict" in login.headers["set-cookie"]
|
||||||
|
token = client.cookies.get("wq_session")
|
||||||
|
async with app.state.sessions() as db:
|
||||||
|
admin = await db.get(Admin, 1)
|
||||||
|
session = (await db.scalars(select(LoginSession))).one()
|
||||||
|
assert admin.password_hash.startswith("$argon2id$") and ADMIN_PASSWORD not in admin.password_hash
|
||||||
|
assert session.token_hash != token
|
||||||
|
assert (await client.post(f"{PREFIX}/auth/logout")).status_code == 200
|
||||||
|
client.cookies.set("wq_session", token)
|
||||||
|
assert (await client.get(f"{PREFIX}/auth/me")).status_code == 401
|
||||||
|
await client.post(f"{PREFIX}/auth/login", json={"username": "admin", "password": ADMIN_PASSWORD})
|
||||||
|
async with app.state.sessions() as db:
|
||||||
|
session = (await db.scalars(select(LoginSession))).one()
|
||||||
|
session.expires_at = now() - timedelta(seconds=1)
|
||||||
|
await db.commit()
|
||||||
|
assert (await client.get(f"{PREFIX}/auth/me")).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_rate_limit_and_csrf(client):
|
||||||
|
for _ in range(5):
|
||||||
|
assert (
|
||||||
|
await client.post(f"{PREFIX}/auth/login", json={"username": "admin", "password": "wrong"})
|
||||||
|
).status_code == 401
|
||||||
|
assert (
|
||||||
|
await client.post(f"{PREFIX}/auth/login", json={"username": "admin", "password": ADMIN_PASSWORD})
|
||||||
|
).status_code == 429
|
||||||
|
assert (
|
||||||
|
await client.post(f"{PREFIX}/auth/login", headers={"X-WQ-Request": ""}, json={})
|
||||||
|
).status_code == 403
|
||||||
|
assert (
|
||||||
|
await client.post(f"{PREFIX}/auth/login", headers={"Origin": "https://evil.example"}, json={})
|
||||||
|
).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_public_session_cookie_is_secure(app, client):
|
||||||
|
app.state.settings.cookie_secure = True
|
||||||
|
response = await client.post(
|
||||||
|
f"{PREFIX}/auth/login", json={"username": "admin", "password": ADMIN_PASSWORD}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "; Secure" in response.headers["set-cookie"] and "; HttpOnly" in response.headers["set-cookie"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_credentials_encrypted_and_never_returned(app, logged_in):
|
||||||
|
secret = "private-worldquant-password"
|
||||||
|
r = await logged_in.put(
|
||||||
|
f"{PREFIX}/account/credentials", json={"email": "person@example.com", "password": secret}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200 and r.json()["configured"] is True
|
||||||
|
assert "password" not in r.text and secret not in r.text
|
||||||
|
async with app.state.sessions() as db:
|
||||||
|
account = await db.get(Account, 1)
|
||||||
|
assert secret not in account.password_encrypted
|
||||||
|
assert cipher(app.state.settings).decrypt(account.password_encrypted.encode()).decode() == secret
|
||||||
|
account.wq_user_id = "person"
|
||||||
|
await db.commit()
|
||||||
|
assert (
|
||||||
|
await logged_in.put(
|
||||||
|
f"{PREFIX}/account/credentials", json={"email": "other@example.com", "password": secret}
|
||||||
|
)
|
||||||
|
).status_code == 409
|
||||||
|
invalid = await logged_in.put(
|
||||||
|
f"{PREFIX}/account/credentials", json={"email": "invalid", "password": secret}
|
||||||
|
)
|
||||||
|
assert invalid.status_code == 422 and secret not in invalid.text
|
||||||
|
response = await logged_in.get(f"{PREFIX}/account")
|
||||||
|
assert response.json()["profile"] == {} and "password" not in response.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_preferences_and_openapi(app, logged_in):
|
||||||
|
values = {"display_name": "My Lab", "theme": "dark", "timezone": "UTC", "page_size": 100}
|
||||||
|
r = await logged_in.patch(f"{PREFIX}/account/preferences", json=values)
|
||||||
|
assert r.status_code == 200 and all(r.json()[k] == v for k, v in values.items())
|
||||||
|
values["timezone"] = "invalid/timezone"
|
||||||
|
assert (await logged_in.patch(f"{PREFIX}/account/preferences", json=values)).status_code == 422
|
||||||
|
contract = app.openapi()
|
||||||
|
assert contract["paths"]["/api/v1/alphas"]["get"]["security"] == [{"APIKeyCookie": []}]
|
||||||
|
assert "text/csv" in contract["paths"]["/api/v1/alphas/export"]["get"]["responses"]["200"]["content"]
|
||||||
|
assert contract["paths"]["/api/v1/alphas"]["get"]["responses"]["422"]["content"]["application/json"][
|
||||||
|
"schema"
|
||||||
|
]["$ref"].endswith("/ErrorOutput")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_local_research_survives_snapshot_and_bulk_is_atomic(app, logged_in):
|
||||||
|
await seed(app, 3)
|
||||||
|
r = await logged_in.patch(
|
||||||
|
f"{PREFIX}/alphas/a0000/research",
|
||||||
|
json={"note": "keep hypothesis", "tags": ["a", "a"], "state": "candidate", "favorite": True},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
async with app.state.sessions() as db:
|
||||||
|
await upsert_alpha(db, alpha("a0000", **{"is": {"sharpe": 9.25}}))
|
||||||
|
await db.commit()
|
||||||
|
detail = (await logged_in.get(f"{PREFIX}/alphas/a0000")).json()
|
||||||
|
assert detail["sharpe"] == 9.25 and detail["fitness"] is None
|
||||||
|
assert detail["research"]["note"] == "keep hypothesis" and detail["research"]["favorite"]
|
||||||
|
assert detail["research"]["tags"] == ["a"] and detail["research"]["state"] == "candidate"
|
||||||
|
invalid = await logged_in.patch(
|
||||||
|
f"{PREFIX}/alphas/research/bulk", json={"alpha_ids": ["a0000", "missing"], "add_tags": ["bad"]}
|
||||||
|
)
|
||||||
|
assert invalid.status_code == 404
|
||||||
|
assert (await logged_in.get(f"{PREFIX}/alphas?tag=bad")).json()["total"] == 0
|
||||||
|
r = await logged_in.patch(
|
||||||
|
f"{PREFIX}/alphas/research/bulk",
|
||||||
|
json={
|
||||||
|
"alpha_ids": ["a0000", "a0001"],
|
||||||
|
"add_tags": ["new"],
|
||||||
|
"remove_tags": ["a"],
|
||||||
|
"state": "optimizing",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.json()["updated"] == 2
|
||||||
|
result = (await logged_in.get(f"{PREFIX}/alphas?tag=new&research_state=optimizing")).json()
|
||||||
|
assert result["total"] == 2
|
||||||
|
assert (await logged_in.get(f"{PREFIX}/alphas?tag=ne")).json()["total"] == 0
|
||||||
|
await logged_in.patch(f"{PREFIX}/alphas/a0000/research", json={"note": "updated"})
|
||||||
|
detail = (await logged_in.get(f"{PREFIX}/alphas/a0000")).json()
|
||||||
|
assert detail["research"]["favorite"] and detail["research"]["tags"] == ["new"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_filter_pagination_sort_and_export_over_500(app, logged_in):
|
||||||
|
await seed(app, 603)
|
||||||
|
query = "region=USA&sharpe_min=0.02&sharpe_max=6&sort=sharpe&direction=desc"
|
||||||
|
page = (await logged_in.get(f"{PREFIX}/alphas?{query}&offset=10&limit=25")).json()
|
||||||
|
assert page["total"] == 599 and len(page["items"]) == 25
|
||||||
|
assert page["items"][0]["id"] == "a0590"
|
||||||
|
exported = await logged_in.get(f"{PREFIX}/alphas/export?{query}&offset=10&limit=25")
|
||||||
|
rows = list(csv.DictReader(io.StringIO(exported.text.lstrip("\ufeff"))))
|
||||||
|
assert len(rows) == 599 and rows[0]["id"] == "a0600" and rows[-1]["id"] == "a0002"
|
||||||
|
assert (await logged_in.get(f"{PREFIX}/alphas?q=Signal%200001")).json()["total"] == 1
|
||||||
|
assert (await logged_in.get(f"{PREFIX}/alphas?q=%25")).json()["total"] == 0
|
||||||
|
for query in (
|
||||||
|
"sort=raw",
|
||||||
|
"sharpe_min=nan",
|
||||||
|
"sharpe_min=inf",
|
||||||
|
"sharpe_min=5&sharpe_max=2",
|
||||||
|
"limit=1000",
|
||||||
|
"created_from=2026-01-01&created_to=2025-01-01T00:00:00Z",
|
||||||
|
):
|
||||||
|
assert (await logged_in.get(f"{PREFIX}/alphas?{query}")).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_export_formula_injection_and_detail_variants(app, logged_in):
|
||||||
|
async with app.state.sessions() as db:
|
||||||
|
await upsert_alpha(
|
||||||
|
db,
|
||||||
|
alpha(
|
||||||
|
"super1",
|
||||||
|
name="=DANGEROUS()",
|
||||||
|
type="SUPER",
|
||||||
|
regular=None,
|
||||||
|
selection={"code": "self_correlation < 0.5"},
|
||||||
|
combo={"code": "alpha"},
|
||||||
|
settings={"language": "FASTEXPR"},
|
||||||
|
**{"is": {"sharpe": None, "fitness": float("nan")}},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await upsert_alpha(
|
||||||
|
db,
|
||||||
|
alpha(
|
||||||
|
"python1", regular={"code": "def alpha():\n return close"}, settings={"language": "PYTHON"}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
row = await db.get(Research, "super1")
|
||||||
|
row.note = " @formula()"
|
||||||
|
await db.commit()
|
||||||
|
detail = (await logged_in.get(f"{PREFIX}/alphas/super1")).json()
|
||||||
|
assert detail["selection"] == "self_correlation < 0.5" and detail["combo"] == "alpha"
|
||||||
|
assert detail["expression"] is None and detail["sharpe"] is None and detail["fitness"] is None
|
||||||
|
assert (await logged_in.get(f"{PREFIX}/alphas/python1")).json()["language"] == "PYTHON"
|
||||||
|
csv_data = (await logged_in.get(f"{PREFIX}/alphas/export?q=super1")).text
|
||||||
|
row = list(csv.DictReader(io.StringIO(csv_data.lstrip("\ufeff"))))[0]
|
||||||
|
assert row["name"] == "'=DANGEROUS()" and row["note"] == "' @formula()"
|
||||||
|
assert (await logged_in.get(f"{PREFIX}/alphas/super1/pnl")).json() == {
|
||||||
|
"cached": False,
|
||||||
|
"points": [],
|
||||||
|
"fetched_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_job_api_deduplicates_and_retries(app, logged_in):
|
||||||
|
assert (await logged_in.post(f"{PREFIX}/sync-jobs", json={"kind": "full_sync"})).status_code == 409
|
||||||
|
await logged_in.put(
|
||||||
|
f"{PREFIX}/account/credentials", json={"email": "person@example.com", "password": "only-testing"}
|
||||||
|
)
|
||||||
|
connect = await logged_in.post(f"{PREFIX}/account/connect")
|
||||||
|
assert connect.status_code == 202
|
||||||
|
first = (await logged_in.post(f"{PREFIX}/sync-jobs", json={"kind": "full_sync"})).json()
|
||||||
|
again = (await logged_in.post(f"{PREFIX}/sync-jobs", json={"kind": "full_sync"})).json()
|
||||||
|
assert first["id"] == again["id"]
|
||||||
|
await logged_in.post(f"{PREFIX}/sync-jobs/{first['id']}/cancel")
|
||||||
|
assert (await logged_in.get(f"{PREFIX}/sync-jobs/{first['id']}")).json()["status"] == "cancelled"
|
||||||
|
assert (await logged_in.post(f"{PREFIX}/sync-jobs/{first['id']}/retry")).json()["status"] == "queued"
|
||||||
|
assert (
|
||||||
|
await logged_in.post(
|
||||||
|
f"{PREFIX}/sync-jobs", json={"kind": "alpha_refresh", "alpha_ids": ["../authentication"]}
|
||||||
|
)
|
||||||
|
).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("endpoint", ["account", "alphas", "alphas/export", "alphas/a1/pnl", "sync-jobs"])
|
||||||
|
async def test_every_data_endpoint_requires_session(client, endpoint):
|
||||||
|
assert (await client.get(f"{PREFIX}/{endpoint}")).status_code == 401
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.exc import OperationalError
|
||||||
|
|
||||||
|
from app.jobs import Runner, create_job
|
||||||
|
from app.models import Account, Alpha, Job, JobItem, Pnl, Research
|
||||||
|
from app.security import cipher
|
||||||
|
from app.worldquant import VerificationRequired, WqError
|
||||||
|
from tests.conftest import alpha
|
||||||
|
|
||||||
|
|
||||||
|
class FakePlatform:
|
||||||
|
def __init__(self):
|
||||||
|
self.verification_url = None
|
||||||
|
self.on_retry = None
|
||||||
|
self.page_calls = []
|
||||||
|
self.fail_page = True
|
||||||
|
self.fail_id = True
|
||||||
|
self.require_verification = False
|
||||||
|
self.block = None
|
||||||
|
|
||||||
|
async def authenticate(self, *args, **kwargs):
|
||||||
|
if self.require_verification:
|
||||||
|
self.verification_url = "https://api.worldquantbrain.com/authentication/persona/test"
|
||||||
|
raise VerificationRequired(self.verification_url)
|
||||||
|
|
||||||
|
async def verify(self):
|
||||||
|
self.require_verification = False
|
||||||
|
self.verification_url = None
|
||||||
|
|
||||||
|
async def profile(self):
|
||||||
|
return {"id": "user1", "email": "test@example.com", "password": "never-save", "unknown": 1}
|
||||||
|
|
||||||
|
async def alphas(self, submission, hidden, offset, before):
|
||||||
|
self.page_calls.append((submission, hidden, offset, before))
|
||||||
|
if self.block:
|
||||||
|
self.block.set()
|
||||||
|
await asyncio.Future()
|
||||||
|
if submission == "UNSUBMITTED" and not hidden:
|
||||||
|
if offset == 0:
|
||||||
|
return {"results": [alpha("a1"), alpha("a2")], "count": 4}
|
||||||
|
if self.fail_page:
|
||||||
|
raise WqError("模拟第二页网络故障", "network_error")
|
||||||
|
return {"results": [alpha("a2", **{"is": {"sharpe": 7.0}}), alpha("a3")], "count": 4}
|
||||||
|
if submission == "SUBMITTED" and hidden:
|
||||||
|
return {"results": [alpha("hidden1", hidden=True, stage="OS", status="ACTIVE")], "next": None}
|
||||||
|
return {"results": [], "next": None}
|
||||||
|
|
||||||
|
async def alpha(self, alpha_id):
|
||||||
|
if alpha_id == "bad" and self.fail_id:
|
||||||
|
raise WqError("模拟无权访问", "access_denied")
|
||||||
|
return alpha(alpha_id, **{"is": {"sharpe": 8.0}})
|
||||||
|
|
||||||
|
async def pnl(self, alpha_id):
|
||||||
|
return {
|
||||||
|
"schema": {"properties": [{"name": "date"}, {"name": "pnl"}]},
|
||||||
|
"records": [["2025-01-01", 100]],
|
||||||
|
}
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
self.verification_url = None
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def ready_runner(app):
|
||||||
|
async with app.state.sessions() as db:
|
||||||
|
account = await db.get(Account, 1)
|
||||||
|
account.email = "test@example.com"
|
||||||
|
account.password_encrypted = cipher(app.state.settings).encrypt(b"test-password").decode()
|
||||||
|
account.connection_status = "connected"
|
||||||
|
await db.commit()
|
||||||
|
return Runner(app.state.sessions, app.state.settings, FakePlatform())
|
||||||
|
|
||||||
|
|
||||||
|
async def job_for(runner, kind="full_sync", ids=None):
|
||||||
|
async with runner.sessions() as db:
|
||||||
|
return (await create_job(db, kind, {"alpha_ids": ids or []})).id
|
||||||
|
|
||||||
|
|
||||||
|
async def result(runner, job_id):
|
||||||
|
async with runner.sessions() as db:
|
||||||
|
return await db.get(Job, job_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pages_commit_resume_dedupe_and_preserve_research(app):
|
||||||
|
runner = await ready_runner(app)
|
||||||
|
job_id = await job_for(runner)
|
||||||
|
await runner.execute(job_id)
|
||||||
|
failed = await result(runner, job_id)
|
||||||
|
assert failed.status == "failed" and failed.processed == 2
|
||||||
|
assert failed.checkpoint == {"partition": 0, "offset": 2}
|
||||||
|
async with runner.sessions() as db:
|
||||||
|
research = await db.get(Research, "a2")
|
||||||
|
research.note, research.tags, research.state = "keep me", ["keep"], "candidate"
|
||||||
|
await db.commit()
|
||||||
|
runner.client.fail_page = False
|
||||||
|
runner.client.page_calls.clear()
|
||||||
|
await runner.execute(job_id)
|
||||||
|
finished = await result(runner, job_id)
|
||||||
|
assert finished.status == "completed" and finished.processed == 4 and finished.total == 4
|
||||||
|
assert runner.client.page_calls[0][:3] == ("UNSUBMITTED", False, 2)
|
||||||
|
assert len({call[3] for call in runner.client.page_calls}) == 1
|
||||||
|
async with runner.sessions() as db:
|
||||||
|
assert await db.scalar(select(func.count()).select_from(Alpha)) == 4
|
||||||
|
assert (await db.get(Alpha, "a2")).sharpe == 7.0
|
||||||
|
assert (await db.get(Alpha, "hidden1")).hidden is True
|
||||||
|
research = await db.get(Research, "a2")
|
||||||
|
assert (research.note, research.tags, research.state) == ("keep me", ["keep"], "candidate")
|
||||||
|
assert await db.scalar(select(func.count()).select_from(JobItem)) == 4
|
||||||
|
# A later full scan updates existing IDs, without deleting unseen local records.
|
||||||
|
second = await job_for(runner)
|
||||||
|
await runner.execute(second)
|
||||||
|
assert (await result(runner, second)).processed == 4
|
||||||
|
|
||||||
|
|
||||||
|
async def test_partial_ids_retry_only_errors_and_pnl_cache(app):
|
||||||
|
runner = await ready_runner(app)
|
||||||
|
job_id = await job_for(runner, "alpha_refresh", ["good", "bad"])
|
||||||
|
await runner.execute(job_id)
|
||||||
|
partial = await result(runner, job_id)
|
||||||
|
assert (partial.status, partial.processed, partial.failed, partial.total) == (
|
||||||
|
"completed_with_errors",
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
runner.client.fail_id = False
|
||||||
|
await runner.execute(job_id)
|
||||||
|
complete = await result(runner, job_id)
|
||||||
|
assert (complete.status, complete.processed, complete.failed) == ("completed", 2, 0)
|
||||||
|
pnl_job = await job_for(runner, "pnl_refresh", ["good", "missing"])
|
||||||
|
await runner.execute(pnl_job)
|
||||||
|
assert (await result(runner, pnl_job)).failed == 1
|
||||||
|
async with runner.sessions() as db:
|
||||||
|
pnl = await db.get(Pnl, "good")
|
||||||
|
assert pnl.points == [{"date": "2025-01-01", "value": 100.0}]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_human_verification_waits_then_resumes_pending_jobs(app):
|
||||||
|
runner = await ready_runner(app)
|
||||||
|
runner.client.require_verification = True
|
||||||
|
job_id = await job_for(runner, "alpha_refresh", ["good"])
|
||||||
|
await runner.execute(job_id)
|
||||||
|
assert (await result(runner, job_id)).status == "waiting_auth"
|
||||||
|
async with runner.sessions() as db:
|
||||||
|
account = await db.get(Account, 1)
|
||||||
|
assert account.connection_status == "verification_required" and account.verification_url
|
||||||
|
verification = await job_for(runner, "verify")
|
||||||
|
await runner.execute(verification)
|
||||||
|
assert (await result(runner, verification)).status == "completed"
|
||||||
|
assert (await result(runner, job_id)).status == "queued"
|
||||||
|
async with runner.sessions() as db:
|
||||||
|
account = await db.get(Account, 1)
|
||||||
|
assert (
|
||||||
|
account.wq_user_id == "user1"
|
||||||
|
and "password" not in account.profile
|
||||||
|
and "unknown" not in account.profile
|
||||||
|
)
|
||||||
|
await runner.execute(job_id)
|
||||||
|
assert (await result(runner, job_id)).status == "completed"
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_status(runner, job_id, expected):
|
||||||
|
async with asyncio.timeout(5):
|
||||||
|
while (await result(runner, job_id)).status not in expected:
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_cancel_interrupts_request_and_disconnect_pauses(app):
|
||||||
|
runner = await ready_runner(app)
|
||||||
|
runner.client.block = asyncio.Event()
|
||||||
|
job_id = await job_for(runner)
|
||||||
|
await runner.start()
|
||||||
|
await asyncio.wait_for(runner.client.block.wait(), 3)
|
||||||
|
async with runner.sessions() as db:
|
||||||
|
job = await db.get(Job, job_id)
|
||||||
|
job.cancel_requested = True
|
||||||
|
await db.commit()
|
||||||
|
await runner.cancel(job_id)
|
||||||
|
await wait_status(runner, job_id, {"cancelled"})
|
||||||
|
runner.client.block.clear()
|
||||||
|
pending = await job_for(runner)
|
||||||
|
runner.wake.set()
|
||||||
|
await asyncio.wait_for(runner.client.block.wait(), 3)
|
||||||
|
await runner.disconnect()
|
||||||
|
assert (await result(runner, pending)).status == "waiting_connection"
|
||||||
|
async with runner.sessions() as db:
|
||||||
|
assert (await db.get(Account, 1)).connection_status == "disconnected"
|
||||||
|
await runner.stop()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_restart_recovers_running_job_and_keeps_checkpoint(app):
|
||||||
|
runner = await ready_runner(app)
|
||||||
|
runner.client.block = asyncio.Event()
|
||||||
|
job_id = await job_for(runner)
|
||||||
|
await runner.start()
|
||||||
|
await asyncio.wait_for(runner.client.block.wait(), 3)
|
||||||
|
await runner.stop()
|
||||||
|
assert (await result(runner, job_id)).status == "queued"
|
||||||
|
# Simulate abrupt process loss after a committed page, before the running flag was cleared.
|
||||||
|
async with runner.sessions() as db:
|
||||||
|
job = await db.get(Job, job_id)
|
||||||
|
job.status, job.checkpoint = "running", {"partition": 0, "offset": 2}
|
||||||
|
await db.commit()
|
||||||
|
resumed = Runner(runner.sessions, runner.settings, FakePlatform())
|
||||||
|
resumed.client.fail_page = False
|
||||||
|
await resumed.start()
|
||||||
|
await wait_status(resumed, job_id, {"completed", "failed"})
|
||||||
|
await resumed.stop()
|
||||||
|
assert (await result(resumed, job_id)).status == "completed"
|
||||||
|
assert resumed.client.page_calls[0][:3] == ("UNSUBMITTED", False, 2)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_identity_change_fails_without_overwriting_profile(app):
|
||||||
|
runner = await ready_runner(app)
|
||||||
|
async with runner.sessions() as db:
|
||||||
|
account = await db.get(Account, 1)
|
||||||
|
account.wq_user_id = "original-user"
|
||||||
|
await db.commit()
|
||||||
|
job_id = await job_for(runner, "connect")
|
||||||
|
await runner.execute(job_id)
|
||||||
|
assert (await result(runner, job_id)).status == "waiting_connection"
|
||||||
|
async with runner.sessions() as db:
|
||||||
|
assert (await db.get(Account, 1)).wq_user_id == "original-user"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_scheduler_recovers_after_database_outage(app):
|
||||||
|
runner = await ready_runner(app)
|
||||||
|
job_id = await job_for(runner, "alpha_refresh", ["good"])
|
||||||
|
original = runner.run_next
|
||||||
|
attempts = 0
|
||||||
|
|
||||||
|
async def failing_once():
|
||||||
|
nonlocal attempts
|
||||||
|
attempts += 1
|
||||||
|
if attempts == 1:
|
||||||
|
async with runner.sessions() as db:
|
||||||
|
job = await db.get(Job, job_id)
|
||||||
|
job.status = "running"
|
||||||
|
await db.commit()
|
||||||
|
raise OperationalError("", {}, Exception("simulated database restart"))
|
||||||
|
await original()
|
||||||
|
|
||||||
|
runner.run_next = failing_once
|
||||||
|
await runner.start()
|
||||||
|
await wait_status(runner, job_id, {"completed", "failed"})
|
||||||
|
await runner.stop()
|
||||||
|
assert attempts >= 2 and (await result(runner, job_id)).status == "completed"
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from email.utils import format_datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.alphas import pnl_points, sanitize
|
||||||
|
from app.worldquant import VerificationRequired, WqClient, WqError
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cookie_auth_expiry_and_read_only_boundary(settings):
|
||||||
|
calls, auth_count = [], 0
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
nonlocal auth_count
|
||||||
|
calls.append((request.method, request.url.path))
|
||||||
|
if request.url.path == "/authentication":
|
||||||
|
auth_count += 1
|
||||||
|
return httpx.Response(201, headers={"Set-Cookie": f"t=session{auth_count}; Path=/"}, json={})
|
||||||
|
assert "Authorization" not in request.headers
|
||||||
|
if request.headers.get("Cookie") == "t=session1":
|
||||||
|
return httpx.Response(401)
|
||||||
|
return httpx.Response(200, json={"id": "user1"})
|
||||||
|
|
||||||
|
client = WqClient(settings, transport=httpx.MockTransport(handler))
|
||||||
|
await client.authenticate("person@example.com", "secret")
|
||||||
|
assert await client.profile() == {"id": "user1"}
|
||||||
|
assert auth_count == 2
|
||||||
|
await asyncio.gather(*(client.authenticate("person@example.com", "secret") for _ in range(5)))
|
||||||
|
assert auth_count == 2
|
||||||
|
assert all(method == "GET" or path == "/authentication" for method, path in calls)
|
||||||
|
client.disconnect()
|
||||||
|
assert client.credentials is None and not list(client.client.cookies.items())
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_persona_verification_uses_same_cookie_and_safe_location(settings):
|
||||||
|
complete = False
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
if request.url.path == "/authentication":
|
||||||
|
return httpx.Response(
|
||||||
|
401,
|
||||||
|
headers={
|
||||||
|
"WWW-Authenticate": "persona",
|
||||||
|
"Location": "/authentication/persona/challenge",
|
||||||
|
"Set-Cookie": "challenge=abc; Path=/",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert request.headers.get("Cookie") == "challenge=abc"
|
||||||
|
return httpx.Response(201 if complete else 202, json={})
|
||||||
|
|
||||||
|
client = WqClient(settings, transport=httpx.MockTransport(handler))
|
||||||
|
with pytest.raises(VerificationRequired) as error:
|
||||||
|
await client.authenticate("test@example.com", "secret")
|
||||||
|
assert error.value.url.endswith("/authentication/persona/challenge")
|
||||||
|
with pytest.raises(VerificationRequired):
|
||||||
|
await client.verify()
|
||||||
|
complete = True
|
||||||
|
await client.verify()
|
||||||
|
assert client.authenticated and client.verification_url is None
|
||||||
|
await client.close()
|
||||||
|
unsafe = WqClient(
|
||||||
|
settings,
|
||||||
|
transport=httpx.MockTransport(
|
||||||
|
lambda r: httpx.Response(
|
||||||
|
401, headers={"WWW-Authenticate": "persona", "Location": "https://evil.example/secret"}
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with pytest.raises(WqError) as error:
|
||||||
|
await unsafe.authenticate("test@example.com", "secret")
|
||||||
|
assert error.value.code == "invalid_verification"
|
||||||
|
await unsafe.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_retry_after_network_budget_pending_and_permissions(settings):
|
||||||
|
delays, requests = [], 0
|
||||||
|
|
||||||
|
async def sleep(delay):
|
||||||
|
delays.append(delay)
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
nonlocal requests
|
||||||
|
requests += 1
|
||||||
|
if requests == 1:
|
||||||
|
return httpx.Response(429, headers={"Retry-After": "120"})
|
||||||
|
if requests == 2:
|
||||||
|
raise httpx.ConnectError("contains private body", request=request)
|
||||||
|
return httpx.Response(200, json={"id": "ready"})
|
||||||
|
|
||||||
|
client = WqClient(settings, transport=httpx.MockTransport(handler), sleep=sleep)
|
||||||
|
client.credentials, client.authenticated = ("test@example.com", "secret"), True
|
||||||
|
assert await client.profile() == {"id": "ready"}
|
||||||
|
assert delays[0] == 120 and len(delays) == 2
|
||||||
|
date = format_datetime(datetime.now(timezone.utc) + timedelta(seconds=60), usegmt=True)
|
||||||
|
assert 58 < client.retry_delay(date, 0) <= 60
|
||||||
|
await client.close()
|
||||||
|
for status, code in [(403, "access_denied"), (404, "not_found"), (503, "retry_exhausted")]:
|
||||||
|
c = WqClient(
|
||||||
|
settings,
|
||||||
|
transport=httpx.MockTransport(lambda r: httpx.Response(status, text="private data")),
|
||||||
|
sleep=sleep,
|
||||||
|
)
|
||||||
|
c.credentials, c.authenticated = ("test@example.com", "secret"), True
|
||||||
|
with pytest.raises(WqError) as error:
|
||||||
|
await c.profile()
|
||||||
|
assert error.value.code == code and "private" not in str(error.value)
|
||||||
|
await c.close()
|
||||||
|
responses = iter(
|
||||||
|
[httpx.Response(202, headers={"Retry-After": "1"}), httpx.Response(200, json={"records": []})]
|
||||||
|
)
|
||||||
|
c = WqClient(settings, transport=httpx.MockTransport(lambda r: next(responses)), sleep=sleep)
|
||||||
|
c.credentials, c.authenticated = ("test@example.com", "secret"), True
|
||||||
|
assert await c.pnl("abc") == {"records": []}
|
||||||
|
await c.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw,expected",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"schema": {"properties": [{"name": "date"}, {"name": "pnl"}]},
|
||||||
|
"records": [["2025-01-01", 10], ["2025-01-02", None]],
|
||||||
|
},
|
||||||
|
[{"date": "2025-01-01", "value": 10.0}, {"date": "2025-01-02", "value": None}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"schema": {"properties": [{"name": "pnl"}, {"name": "date"}]}, "records": [[20, "2025-01-01"]]},
|
||||||
|
[{"date": "2025-01-01", "value": 20.0}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"schema": {"properties": {"date": {}, "pnl": {}}}, "records": [["2025-01-01", "NaN"]]},
|
||||||
|
[{"date": "2025-01-01", "value": None}],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"records": [{"timestamp": 1735689600000, "value": "5"}]},
|
||||||
|
[{"date": "2025-01-01T00:00:00+00:00", "value": 5.0}],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_pnl_schemas_preserve_null(raw, expected):
|
||||||
|
assert pnl_points(raw) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_pnl_schema_fails_instead_of_fabricating():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
pnl_points({"records": [["2025-01-01", 100]]})
|
||||||
|
|
||||||
|
|
||||||
|
def test_sensitive_response_keys_are_removed_recursively():
|
||||||
|
assert sanitize(
|
||||||
|
{
|
||||||
|
"id": "a1",
|
||||||
|
"accessToken": "secret",
|
||||||
|
"nested": [{"refresh_token": "secret", "client-secret": "secret", "value": 1}],
|
||||||
|
"Set-Cookie": "secret",
|
||||||
|
}
|
||||||
|
) == {"id": "a1", "nested": [{"value": 1}]}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_concurrent_expiry_authenticates_once(settings):
|
||||||
|
auth_count = 0
|
||||||
|
first_requests = 0
|
||||||
|
both_expired = asyncio.Event()
|
||||||
|
|
||||||
|
async def handler(request):
|
||||||
|
nonlocal auth_count, first_requests
|
||||||
|
if request.method == "POST":
|
||||||
|
auth_count += 1
|
||||||
|
return httpx.Response(201, json={})
|
||||||
|
if first_requests < 2:
|
||||||
|
first_requests += 1
|
||||||
|
if first_requests == 2:
|
||||||
|
both_expired.set()
|
||||||
|
await both_expired.wait()
|
||||||
|
return httpx.Response(401)
|
||||||
|
return httpx.Response(200, json={"id": "user"})
|
||||||
|
|
||||||
|
client = WqClient(settings, transport=httpx.MockTransport(handler))
|
||||||
|
await client.authenticate("test@example.com", "secret")
|
||||||
|
assert await asyncio.gather(client.profile(), client.profile()) == [{"id": "user"}, {"id": "user"}]
|
||||||
|
assert auth_count == 2 # Initial connection plus one shared reauthentication.
|
||||||
|
await client.close()
|
||||||
Generated
+699
@@ -0,0 +1,699 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 2
|
||||||
|
requires-python = "==3.12.*"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aiosqlite"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alembic"
|
||||||
|
version = "1.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mako" },
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/34/10/181eecdd552217d0342492bd6f3b8a96e973083379aace3d3402830ddc03/alembic-1.19.2.tar.gz", hash = "sha256:297950a8a91f6770eb82bfbce9bea55c728b90a5386c6e81430191a319d138b0", size = 2082643, upload-time = "2026-09-04T17:10:11.212Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/cb/9014784dcb0585977ae23b6f43331d0a33c51ac0d692506d12b4f5ee9f3b/alembic-1.19.2-py3-none-any.whl", hash = "sha256:32d553dcd577e6fe5c3c63e91468526d35e4dcecafe865d7db4e9b328fa93cb2", size = 267399, upload-time = "2026-09-04T17:10:12.796Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-doc"
|
||||||
|
version = "0.0.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-types"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyio"
|
||||||
|
version = "4.15.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2-cffi"
|
||||||
|
version = "25.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "argon2-cffi-bindings" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2-cffi-bindings"
|
||||||
|
version = "26.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cffi" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d", size = 1790807, upload-time = "2026-08-20T07:44:22.492Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2", size = 25521, upload-time = "2026-08-20T07:32:43.019Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69", size = 27177, upload-time = "2026-08-20T07:32:44.158Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29", size = 26597, upload-time = "2026-08-20T07:32:45.172Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d", size = 27403, upload-time = "2026-08-20T07:32:46.13Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728", size = 27132, upload-time = "2026-08-20T07:32:47.13Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81", size = 27588, upload-time = "2026-08-20T07:32:48.087Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4", size = 26785, upload-time = "2026-08-20T07:32:48.977Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb", size = 23898, upload-time = "2026-08-20T07:32:50.114Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e", size = 25730, upload-time = "2026-08-20T07:32:51.091Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638", size = 24478, upload-time = "2026-08-20T07:32:52.111Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "asyncpg"
|
||||||
|
version = "0.31.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.7.22"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cffi"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cryptography"
|
||||||
|
version = "49.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastapi"
|
||||||
|
version = "0.141.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-doc" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "starlette" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "greenlet"
|
||||||
|
version = "3.5.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/7e/9ecd0285e3153532ae07aeb88063c43c72b4221cf0d4d123b02f3682e3ff/greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380", size = 295809, upload-time = "2026-08-10T13:25:34.023Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/73/60e4bbcc89252037b18087f2ec16405d5b2d5be42dde191bbf3667e96102/greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053", size = 611910, upload-time = "2026-08-10T14:14:35.18Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/17/cd5134be659cd4a443e7a61ae670dabec165a814c51162916d637b6dd38e/greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95", size = 624198, upload-time = "2026-08-10T14:27:25.229Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/30/87c212b5c684d0e72974f1063b7a9687631e8985902c06e1016542c874e7/greenlet-3.5.5-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ca5d6ae0739e5764f2cfcfaa562ac5a990cbdaedca93251c5e3cf07c362371f", size = 629504, upload-time = "2026-08-10T14:30:07.967Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/ac/5c5b959999b6f09c3026b5dfe171575bc3121c5236ce74f495096f25b203/greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d", size = 621439, upload-time = "2026-08-10T13:40:49.391Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/2c/eb487fafc9f50ffff2b1e0b697f70fb34bf150821c08ab225aacf5583a7e/greenlet-3.5.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:1b5ed9162c0c098e0bbc2cf88a94f433c1b8926f831745252e099e5d83e17759", size = 432462, upload-time = "2026-08-10T14:30:02.309Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/8b/6acf112ed8aee499f25b4d6949820fb02ac950ff9c1f3d793bd5be0599f2/greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b", size = 1581342, upload-time = "2026-08-10T14:15:05.653Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/d7/734e5f198888876b42d7616ff6644c075baf6b8a2412deadd6b0e1b8b20c/greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2", size = 1645744, upload-time = "2026-08-10T13:40:30.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/30/1f42b88dc587b5899ee50616ad56ee40cafaf225df4fb829f10183c62a5c/greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18", size = 324171, upload-time = "2026-08-10T13:28:44.472Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/e5/4dee4d8d2e603fe5fdd7b444e63219f7b9bd852c60c6214511c7157cbe88/greenlet-3.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52", size = 308362, upload-time = "2026-08-10T13:26:46.839Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h11"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httptools"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.19"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mako"
|
||||||
|
version = "1.4.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165, upload-time = "2026-08-05T06:10:56.611Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010, upload-time = "2026-08-05T06:10:58.248Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markupsafe"
|
||||||
|
version = "3.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pycparser"
|
||||||
|
version = "3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic"
|
||||||
|
version = "2.13.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-types" },
|
||||||
|
{ name = "pydantic-core" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-core"
|
||||||
|
version = "2.46.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-settings"
|
||||||
|
version = "2.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.21.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-asyncio"
|
||||||
|
version = "1.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dotenv"
|
||||||
|
version = "1.2.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
version = "6.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.16.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlalchemy"
|
||||||
|
version = "2.0.52"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/d5/1b77a026d161f98a08f11af1a5f6c47b98ee7c7e2648af525a1004826c78/sqlalchemy-2.0.52-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be8c49131665dfe2cc74c498aa1240ffb548d0fd901325dd11c2c7a18956f727", size = 2170940, upload-time = "2026-08-11T20:58:11.25Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/bd/f444444adb37b5d53753fb1730ee7a421628e2e3b756c4da461af7e6394a/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b2d9e507a458832adcfbd8af6e2036ddf069b7710b799448542ebccae2dceee", size = 3383415, upload-time = "2026-08-11T21:02:38.534Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/57/2eadf93a552568c57e8680b7e58bb5e9770d80942a1bdbaf4f2f63f0d7c8/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8738008376d22f30f411ea3efecf39b51110b6996d80bb73786f30bcfdd5fd3b", size = 3398577, upload-time = "2026-08-11T21:16:59.092Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/c3/2887cf9dd111d1fbf05d22165b404c221ef43e029f7a2695e7302f27a7cc/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37a4d548327b6cab9c7d8cdb4e0e82feabee0110c4d150059068e2d1cfbd99ee", size = 3328225, upload-time = "2026-08-11T21:02:40.183Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/0f/466bdf9e1feeeef5587f868c187d8687e21ff8c85b1775e9041130181132/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e49f51a5d59857a7a0dcaf9469febf7197d9394bd88f00d69c2c4e848112cdbf", size = 3357374, upload-time = "2026-08-11T21:17:01.076Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/20/5c2b4583904af4173076dda1c9e53c9e2ffc7a702d2efde0216bbacbf7cb/sqlalchemy-2.0.52-cp312-cp312-win32.whl", hash = "sha256:afda3ec521d0517d0de783fc70030775841900896d832de5bbd066549290470e", size = 2129366, upload-time = "2026-08-11T21:14:50.991Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/06/543dab8ef62d4e9fb96fb31a30c2b8b14a8763bccf48d428294d6b3041c0/sqlalchemy-2.0.52-cp312-cp312-win_amd64.whl", hash = "sha256:2d5e53e36e37129fe0be8b9d08b6e4052c10a963ee6cda56c8c10dcc194b99ca", size = 2157344, upload-time = "2026-08-11T21:14:52.453Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89", size = 1950700, upload-time = "2026-08-11T20:47:21.603Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
asyncio = [
|
||||||
|
{ name = "greenlet" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "starlette"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-inspection"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvicorn"
|
||||||
|
version = "0.52.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
standard = [
|
||||||
|
{ name = "httptools" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||||
|
{ name = "watchfiles" },
|
||||||
|
{ name = "websockets" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvloop"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "watchfiles"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "websockets"
|
||||||
|
version = "17.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload-time = "2026-08-26T17:25:33.063Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/0d/098f23c4c858e5de9459ffc554fa07d5493fbcfca7f040b5800cf1cecc35/websockets-17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76dd004f59115087c7b700474cb18f01325e37250032e19396c08ae41448e4b3", size = 217015, upload-time = "2026-08-26T14:55:45.194Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/86/bc1317b1a4d8c4688e2a7e564b5e004dab44c2534d7ca05de6ae9a863fca/websockets-17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:581fa678ef46f4277cc8491312468e582f8ad609dbab907ba6096a08c6a0ff98", size = 214692, upload-time = "2026-08-26T14:55:46.366Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/e7/df821761772beaa48c211ee0e234930b35c1473778470773823f56d3911b/websockets-17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87f0d5e77548b0c40c8464cdb6108792e7e53f487c6400028a4ec28a8afbe5ab", size = 214959, upload-time = "2026-08-26T14:55:47.885Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/92/c3fb72f11764812fc648bf3838d224972427b348e8b3989d9e0a9df87da3/websockets-17.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:882af300d2c6a092b93767d5de03c7bb56dfb06314140c8e872d3f48e09f7b74", size = 224278, upload-time = "2026-08-26T14:55:49.241Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/05/9f82d090c8d2d861604147ef6dfb938a90b039f9358d5193f1df62558593/websockets-17.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c863507ada5805517ca6dff1c524dcd42942efe6304dacf06700878398d21a6", size = 224557, upload-time = "2026-08-26T14:55:50.348Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/50/5cbf677b865290fe36819ff00615826e7edc1df38786f770123ff39a933d/websockets-17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d41ef69d5416fbc1d98cf96c37be6192d10fd101c3e0f8b3ddc36e09432b3c08", size = 225791, upload-time = "2026-08-26T14:55:51.75Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/1c/eb8a032285243381b09a221ae384c972d5000453ad136add4d1595cec798/websockets-17.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5aefe78e6a3077fe22b5e64b04666a85a3eb8b934d40e8595a693adcbceb6f11", size = 228574, upload-time = "2026-08-26T14:55:52.922Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/85/413736251cb3ac04ce84cbd90e893d9a36a9698d4820b323aff3aa187e50/websockets-17.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f64e001bb7fa89b9f32cfa600bf8e9ac8ca26759d9b92ae01453ee303d9cd7b4", size = 226428, upload-time = "2026-08-26T14:55:54.263Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/2b/a08bcc7fa1ca81a10f84ba32b6e6edd73a913f4b0c2640eed1fd626efacd/websockets-17.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:677014a073bcb1fbaa7e21144786864f16c08f856d66834f611eceb9006cbab8", size = 225184, upload-time = "2026-08-26T14:55:55.943Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/8a/3bd2d0cf6b148c8c866d5d9fdcde30c04bfd81fdfac86813e69377eb4448/websockets-17.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0de501b7f2db11e83739ac20e2d33d46da4604b829f506c24be80e7def069391", size = 222430, upload-time = "2026-08-26T14:55:57.103Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/c9/8e891ae342668735eabbbc669895e15195e4b45f24a4beeb58af76f414c7/websockets-17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f62114a54117e4948a1e414e89521f7fe1e3c2f83f2a571a06a4fc6718b0900a", size = 225227, upload-time = "2026-08-26T14:55:58.375Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/6f/c816f332dca11425e9bda7c07f7573eb5c5f8a735849d02b0d81e8ee20fa/websockets-17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eec113a5b41d124ef42ff56b0d74a6da3fd986400038eab9e58ee42a4024e837", size = 223831, upload-time = "2026-08-26T14:55:59.664Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/67/5e91d5308ce24fc1ec74f56536c12f4888bad45ff5ea50f3180f8c518c57/websockets-17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5f051f8030a51815dc00e24bd2e5f1435af095c1cc111d747ac6e2a3620d7641", size = 224600, upload-time = "2026-08-26T14:56:00.873Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/96/faa298ecf2570d35b0eb37caddf4992178d907e108ed74bfffb6bc092c29/websockets-17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:655a8e28010f09fd6fa317e857afab3af7647f33e41dee88fa421e92086d1090", size = 225707, upload-time = "2026-08-26T14:56:02.001Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/12/5710d2482ca5061c1eec5eb46f6313837c760d4115b1795c85b6c08be4e3/websockets-17.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dc2b79afc074d2f3e64b26539350f697fe1b85ea1c49ea24eb588f247b053ce1", size = 223263, upload-time = "2026-08-26T14:56:03.092Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/47/0c30f4eebfd1d93fae779d268f678d48847fb98516f5200849574eee8820/websockets-17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e4bd7eacb87d8cf3ed70d6392c770a0d92441f05d7d2a3efafb5bc171d5e3067", size = 224244, upload-time = "2026-08-26T14:56:04.321Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/33/46c256195a1255079ae23d1b1267b2e1843dc5f46a67f973cdf2a3523dff/websockets-17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ccbf3f4a9890d50b3a08ee04029fde30a03bfdeffaa19977628bf17251764e60", size = 224520, upload-time = "2026-08-26T14:56:05.521Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/9a/aef0792731df4352e5f417369b532b3325fe434765ca90c193f594ae1e67/websockets-17.1-cp312-cp312-win32.whl", hash = "sha256:7e724f843fa6a0614aece65a7c73e51d0f4412ca41dccac13c3caf98e69536bb", size = 217485, upload-time = "2026-08-26T14:56:06.715Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/23/493ecfdaf32898e5ea24dc900e33e5e317f9662d5d9ab2d44b2e111b4e1c/websockets-17.1-cp312-cp312-win_amd64.whl", hash = "sha256:617243e19a0992095956f406ee9cd3bc4ba92862d83cb1d83bb59ce574412bec", size = 217786, upload-time = "2026-08-26T14:56:08.055Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/3d/91954e2f7876f74ce1213e9b92c65a63b559cc4b942a931ebeb351cd9932/websockets-17.1-cp312-cp312-win_arm64.whl", hash = "sha256:9f4a08ff7cb68c27b18e09223cc6304e01d0f82d5a240d251266dfd2e6e44729", size = 217711, upload-time = "2026-08-26T14:56:09.267Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wq-alpha-system"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "alembic" },
|
||||||
|
{ name = "argon2-cffi" },
|
||||||
|
{ name = "asyncpg" },
|
||||||
|
{ name = "cryptography" },
|
||||||
|
{ name = "fastapi" },
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "sqlalchemy", extra = ["asyncio"] },
|
||||||
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "aiosqlite" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-asyncio" },
|
||||||
|
{ name = "ruff" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "alembic", specifier = ">=1.15,<2" },
|
||||||
|
{ name = "argon2-cffi", specifier = ">=23.1,<26" },
|
||||||
|
{ name = "asyncpg", specifier = ">=0.30,<1" },
|
||||||
|
{ name = "cryptography", specifier = ">=44,<50" },
|
||||||
|
{ name = "fastapi", specifier = ">=0.115,<1" },
|
||||||
|
{ name = "httpx", specifier = ">=0.28,<1" },
|
||||||
|
{ name = "pydantic-settings", specifier = ">=2.8,<3" },
|
||||||
|
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.38,<2.1" },
|
||||||
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34,<1" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "aiosqlite", specifier = ">=0.21,<1" },
|
||||||
|
{ name = "pytest", specifier = ">=8,<10" },
|
||||||
|
{ name = "pytest-asyncio", specifier = ">=0.25,<2" },
|
||||||
|
{ name = "ruff", specifier = ">=0.11,<1" },
|
||||||
|
]
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Standalone public deployment: docker compose -f compose.public.yaml up -d --build
|
||||||
|
name: wq-alpha
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: wq
|
||||||
|
POSTGRES_DB: wq
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: [CMD-SHELL, "pg_isready -U wq -d wq"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
restart: unless-stopped
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.backend
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://wq:${POSTGRES_PASSWORD:?required}@db:5432/wq
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?required}
|
||||||
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?required}
|
||||||
|
PUBLIC_ORIGIN: https://${DOMAIN:?Set DOMAIN to your real hostname}
|
||||||
|
COOKIE_SECURE: "true"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health', timeout=3)"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
restart: unless-stopped
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.frontend
|
||||||
|
environment:
|
||||||
|
SITE_ADDRESS: ${DOMAIN:?required}
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
- "443:443/udp"
|
||||||
|
volumes:
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
name: wq-alpha
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: wq
|
||||||
|
POSTGRES_DB: wq
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Run scripts/init_env.py first}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: [CMD-SHELL, "pg_isready -U wq -d wq"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
restart: unless-stopped
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.backend
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://wq:${POSTGRES_PASSWORD:?required}@db:5432/wq
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?required}
|
||||||
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?required}
|
||||||
|
PUBLIC_ORIGIN: http://localhost:${LOCAL_PORT:-8080}
|
||||||
|
COOKIE_SECURE: "false"
|
||||||
|
WQ_BASE_URL: ${WQ_BASE_URL:-https://api.worldquantbrain.com}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health', timeout=3)"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
restart: unless-stopped
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.frontend
|
||||||
|
environment:
|
||||||
|
SITE_ADDRESS: http://:80
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${LOCAL_PORT:-8080}:80"
|
||||||
|
volumes:
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# WorldQuant Alpha 研究系统
|
||||||
|
|
||||||
|
确认日期:2026-09-07。项目位于 `wq-alpha-system`,面向个人单个 WorldQuant 账户。
|
||||||
|
|
||||||
|
## 已确认范围
|
||||||
|
|
||||||
|
- 首期:个人信息与会话、Alpha 列表与详情、本地研究记录、可靠同步、Docker 部署。
|
||||||
|
- Python + React + TypeScript + Semi Design,前后端分别位于 backend/ 和 frontend/,独立依赖与测试。
|
||||||
|
- PostgreSQL 存储数据,FastAPI 提供 OpenAPI 契约,HTTPX 统一异步调用 WorldQuant。
|
||||||
|
- React 19 使用 @douyinfe/semi-ui-19。Caddy 提供静态资源、API 代理与公网 HTTPS。
|
||||||
|
- 根目录包含两套完整 Compose、Dockerfile、Caddyfile、环境模板和部署说明。
|
||||||
|
- 从 WorldQuant 重新建立数据,不迁移旧库,不承担旧系统的兼容或维护。
|
||||||
|
- 平台数据只读;不回测、不触发检查、不修改平台属性、不正式提交。
|
||||||
|
|
||||||
|
## 首期产品能力
|
||||||
|
|
||||||
|
### 个人信息
|
||||||
|
|
||||||
|
系统登录与平台连接分开。单管理员,无开放注册;部署时设置初始密码,服务端哈希保存,HttpOnly Cookie 管理会话。
|
||||||
|
在个人页配置平台邮箱及密码,连接、重新连接、人工验证、断开与刷新资料。
|
||||||
|
平台凭据加密存储,解密密钥独立配置,不在前端持久化,也不进入日志或响应。
|
||||||
|
展示平台 ID、昵称、邮箱、身份与权限(以返回字段为准)、连接状态、最后同步时间。
|
||||||
|
提供本地显示名称、主题、时区和分页偏好,默认中文、浅色、Asia/Shanghai、25 条/页。
|
||||||
|
缺失资料显示“未提供”,不得虚构数据。
|
||||||
|
|
||||||
|
### Alpha 管理
|
||||||
|
|
||||||
|
- 主界面为侧栏、筛选区、可排序分页表格、列显隐、多选、详情抽屉;任务进度在独立面板查看。
|
||||||
|
- 全量同步已提交/未提交、隐藏/可见 Alpha;指定 ID 导入及选中刷新;仅手动触发。
|
||||||
|
- 筛选 ID/名称/表达式、地区、Universe、类型、语言、平台状态、隐藏、日期、核心指标、本地标签/状态/收藏。
|
||||||
|
- 展示 REGULAR/SUPER、FASTEXPR/PYTHON、Selection/Combo、完整 settings、IS/OS、已有 checks。
|
||||||
|
- PnL 按需获取、缓存、曲线展示、刷新;可打开 BRAIN 原页面。
|
||||||
|
- 本地备注、标签、收藏与研究状态单独保存,同步不能覆盖。研究状态:inbox/candidate/optimizing/archived。
|
||||||
|
- 批量加减标签及改研究状态;CSV 按当前筛选与排序导出全部结果,不限制为 500 条。
|
||||||
|
- 指标缺失保留 null,不伪装成零;本地研究状态与平台状态、检查结果分开。
|
||||||
|
|
||||||
|
## 模块与接口
|
||||||
|
|
||||||
|
模块为账户、Alpha、同步任务、WorldQuant 集成。所有上游认证、会话、分页和退避集中封装。
|
||||||
|
页面读取本地数据库。`/api/v1/auth` 管理登录,`/account` 管理配置与资料,`/alphas` 管理查询及研究记录,`/alphas/{id}/pnl` 读取缓存,`/sync-jobs` 创建、查询、取消和重试任务。
|
||||||
|
长任务返回 job ID;前端轮询。首期单后端进程运行异步任务,任务及分页检查点持久化。
|
||||||
|
每页原子落库、按 Alpha ID 更新、失败重试及重启恢复;429 遵守 Retry-After,其余暂时性错误有界退避。
|
||||||
|
原始业务响应与结构化摘要分别保存,不存认证敏感字段。上游数据移动可能影响 offset 分页,通过 ID 去重及再次全量同步校正,不因一次未查到就删除本地记录。
|
||||||
|
|
||||||
|
## 路线图
|
||||||
|
|
||||||
|
| 阶段 | 能力 | 依据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 一 | 账户、列表、研究记录、同步、部署 | 本文件首期范围 |
|
||||||
|
| 二 | 数据集/字段/算子、模板、批次队列、AST 校验、实验去重、暂停恢复 | 旧系统采样→密度→深度回测,以及 [回测台账](https://mail.google.com/mail/#all/19ea68a7dde5ceaa) |
|
||||||
|
| 三 | PnL 稳定性、比较、相关性、稳健性、跨区变体、Super Alpha 组合 | 旧系统有效分析能力 |
|
||||||
|
| 四 | 假设与实验记录、CLI/MCP、论坛检索、模型接入、预算及停止条件 | [决策摘要](https://mail.google.com/mail/#all/19fc16ce3f17311c)、[可复盘流程](https://mail.google.com/mail/#all/1a00ee69df671d4c) |
|
||||||
|
| 后续 | 平台回写、检查、提交、顾问表现 | 另行确认业务范围 |
|
||||||
|
|
||||||
|
AI 复用系统接口,不直接写数据库,模型可替换。论坛效果与阈值必须验证后才可成为规则。首期不提前建设后续空模块。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
1. 登录成功/失败、退出、越权防护;凭据不泄露,公网 HTTPS。
|
||||||
|
2. 平台连接、过期、人工验证、拒绝及网络错误。
|
||||||
|
3. 多页、重复、更新、429、部分失败、取消与重启恢复;同步保留本地研究记录。
|
||||||
|
4. 各 Alpha/语言类型、缺失指标和不同 PnL schema。
|
||||||
|
5. 筛选、分页、排序、批量编辑和超 500 条 CSV 导出一致。
|
||||||
|
6. Docker 空环境启动、重建持久化、备份恢复。
|
||||||
|
7. 模拟上游自动化测试后,使用真实账户只读联调;未验证部分如实记录。
|
||||||
|
|
||||||
|
完成标准:空部署后连接个人账户、同步 Alpha、查询和查看详情、维护本地研究记录,重启与再次同步后数据仍然可靠。
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# 首期验收记录
|
||||||
|
|
||||||
|
日期:2026-09-07。验证范围为本地实现、模拟 WorldQuant 上游、实际 PostgreSQL/Docker。未访问真实 WorldQuant 账户,没有调用平台回测、检查、属性修改或提交接口。
|
||||||
|
|
||||||
|
## 自动化与运行实测
|
||||||
|
|
||||||
|
| 检查 | 结果与证据 |
|
||||||
|
| --- | --- |
|
||||||
|
| 后端静态检查 | `uv run ruff check app tests` 通过 |
|
||||||
|
| 后端自动化测试 | `uv run pytest -q`:31 项通过 |
|
||||||
|
| 前端类型与生产构建 | `pnpm build` 通过;React 19.2.8、Semi UI 19 2.103.0,含 React 19 adapter |
|
||||||
|
| 浏览器验收 | `pnpm test`:2 项端到端测试通过,使用 620 条合成记录 |
|
||||||
|
| 空库部署与迁移 | Docker `web + backend + db` 从新卷启动成功,`alembic upgrade head` 成功 |
|
||||||
|
| 迁移与模型一致性 | PostgreSQL 上 `alembic check` 返回 `No new upgrade operations detected` |
|
||||||
|
| 持久化 | 强制替换三个容器后,服务端登录会话、Alpha、备注、标签与研究状态保留 |
|
||||||
|
| 备份恢复 | `pg_dump -Fc` 备份恢复到独立数据库,研究记录内容一致 |
|
||||||
|
| 数据库中断 | 后端不重启,单独停止/启动 PostgreSQL 后仍能消费新任务 |
|
||||||
|
| 公网配置 | 独立 Compose 正确配置 HTTPS Origin、Secure Cookie;db/backend 无宿主机端口;Caddy 配置校验通过 |
|
||||||
|
| 本机隔离 | Web 仅绑定 `127.0.0.1:8080`,数据库与后端只有容器网络端口 |
|
||||||
|
|
||||||
|
Docker 运行检查脚本为 `backend/tests/docker_acceptance.py`,仅允许操作独立 `wq-alpha-acceptance*` 项目。测试容器与测试卷已清理;合成数据不进入正式本机实例。
|
||||||
|
|
||||||
|
正式本机项目 `wq-alpha` 已启动于 `http://localhost:8080`,空库登录/退出、迁移一致性和健康检查通过;Alpha 与任务表初始均为 0,WorldQuant 尚未配置。初始凭据保存在权限为 `0600` 的项目 `.env` 中。
|
||||||
|
|
||||||
|
## 已覆盖行为
|
||||||
|
|
||||||
|
- 系统正确/错误登录、速率限制、未授权访问、退出失效、Cookie 到期、HttpOnly/SameSite/Secure、请求来源校验。
|
||||||
|
- WorldQuant 密码密文保存、不返回认证信息;嵌套 raw 业务数据过滤常见 token/cookie/secret 字段。
|
||||||
|
- 平台认证、Cookie 失效后共享重新认证、人工验证保留同一挑战会话、拒绝访问、网络错误、429/5xx、Retry-After 与数据准备等待。
|
||||||
|
- 多页同步、重叠 ID 去重、已有指标更新、每页检查点、部分失败/重试、取消、断开暂停、进程恢复及数据库恢复。
|
||||||
|
- 平台快照更新后,本地备注、标签、收藏、研究状态不变。固定状态与平台状态分别展示。
|
||||||
|
- REGULAR/SUPER、FASTEXPR/PYTHON、Selection/Combo、完整 settings、IS/OS、已有检查结果、缺失指标为 null。
|
||||||
|
- 不同 PnL 列顺序、对象型 schema、对象记录、毫秒时间戳、空值保留、未知 schema 报错;浏览器实际获取并绘制缓存曲线。
|
||||||
|
- 筛选、排序、分页与导出共用查询逻辑;后端筛选导出 599 条、浏览器下载 620 条,均不受列表页大小限制;CSV 文本公式转义。
|
||||||
|
- 批量标签、列显隐、固定列、分页、个人偏好保存与页面刷新保留;同步后台更新不会覆盖详情里尚未保存的备注。
|
||||||
|
- OpenAPI 记录 Cookie 鉴权和响应类型,包括错误、CSV、任务与 PnL。
|
||||||
|
|
||||||
|
## 页面核验
|
||||||
|
|
||||||
|
通过浏览器实际检查登录页、Alpha 列表、详情/PnL、个人信息页。截图在忽略目录 `output/playwright/`,截图中的 `TEST` Alpha、测试邮箱和统计数均为合成数据。生产 Web 使用 Caddy 提供构建后的静态文件。
|
||||||
|
|
||||||
|
构建仍有 Semi 间接依赖 `lottie-web` 的 `eval` 提示,构建成功;当前页面不使用该表达式动画能力,未放宽生产 CSP 的 `script-src`。端到端测试未发现 JavaScript 运行异常。
|
||||||
|
|
||||||
|
## 待真实环境验证
|
||||||
|
|
||||||
|
这些项目需要用户自己的账户或域名,尚未取得实测证据:
|
||||||
|
|
||||||
|
1. 当前 WorldQuant 账户的实际认证、人工验证页面及权限限制。
|
||||||
|
2. 真实账户的完整 Alpha 分页、实际 REGULAR/SUPER/PYTHON 字段和 PnL schema。
|
||||||
|
3. 真实域名的 DNS、ACME 证书签发/续期和公网 HTTPS 访问。
|
||||||
|
|
||||||
|
只读联调步骤:在正式本机页面登录并配置 WorldQuant,连接成功后先刷新个人资料、导入一个已知 Alpha ID、获取其 PnL,再执行全量同步。对一条记录保存本地备注后再次刷新,并重启后检查记录和任务。未完成上述步骤前,不把模拟测试结论视为真实平台兼容性保证。
|
||||||
|
|
||||||
|
## 已知架构边界
|
||||||
|
|
||||||
|
首期只有一个任务执行进程。长 Retry-After 会让后续任务排队,任务面板显示下次尝试时间并支持取消;不会为了缩短等待而提前请求平台。offset 分页遇到平台记录移动时可能遗漏,后续全量同步校正,单次未见记录不自动删除。数据库保存原始业务快照,后续研究、回测和 MCP 能力尚未实现。
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#171c2d" />
|
||||||
|
<title>Alpha Workspace · WorldQuant 研究系统</title>
|
||||||
|
</head>
|
||||||
|
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "wq-alpha-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "pnpm@9.15.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"typecheck": "tsc -b --pretty false",
|
||||||
|
"test": "playwright test",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@douyinfe/semi-icons": "2.103.0",
|
||||||
|
"@douyinfe/semi-ui-19": "2.103.0",
|
||||||
|
"react": "19.2.8",
|
||||||
|
"react-dom": "19.2.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.55.0",
|
||||||
|
"@types/node": "^22",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"prettier": "3.6.2",
|
||||||
|
"typescript": "^5.9.0",
|
||||||
|
"vite": "8.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests",
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
timeout: 60000,
|
||||||
|
use: {
|
||||||
|
actionTimeout: 10000,
|
||||||
|
baseURL: "http://127.0.0.1:5179",
|
||||||
|
viewport: { width: 1440, height: 1000 },
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
},
|
||||||
|
webServer: [
|
||||||
|
{
|
||||||
|
command:
|
||||||
|
"cd ../backend && uv run uvicorn tests.browser_server:create_test_app --factory --host 127.0.0.1 --port 18000",
|
||||||
|
url: "http://127.0.0.1:18000/api/v1/health",
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 30000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: "WQ_DEV_API=http://127.0.0.1:18000 pnpm dev --port 5179",
|
||||||
|
url: "http://127.0.0.1:5179",
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 30000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
Generated
+3068
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,328 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
Banner,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
LocaleProvider,
|
||||||
|
Spin,
|
||||||
|
Toast,
|
||||||
|
} from "@douyinfe/semi-ui-19";
|
||||||
|
import {
|
||||||
|
IconGridView,
|
||||||
|
IconUser,
|
||||||
|
IconBell,
|
||||||
|
IconExit,
|
||||||
|
IconArrowRight,
|
||||||
|
IconPulse,
|
||||||
|
} from "@douyinfe/semi-icons";
|
||||||
|
import zhCN from "@douyinfe/semi-ui-19/lib/es/locale/source/zh_CN";
|
||||||
|
import { api, post } from "./api";
|
||||||
|
import type { Account, Job } from "./types";
|
||||||
|
import { AccountPage } from "./pages/AccountPage";
|
||||||
|
import { AlphaPage } from "./pages/AlphaPage";
|
||||||
|
import { JobPanel } from "./components/JobPanel";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||||
|
const [account, setAccount] = useState<Account | null>(null);
|
||||||
|
const [jobs, setJobs] = useState<Job[]>([]);
|
||||||
|
const [page, setPage] = useState(
|
||||||
|
location.hash === "#account" ? "account" : "alphas",
|
||||||
|
);
|
||||||
|
const [showJobs, setShowJobs] = useState(false);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const [pollError, setPollError] = useState("");
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [nextAccount, nextJobs] = await Promise.all([
|
||||||
|
api<Account>("/account"),
|
||||||
|
api<Job[]>("/sync-jobs"),
|
||||||
|
]);
|
||||||
|
setAccount(nextAccount);
|
||||||
|
setJobs(nextJobs);
|
||||||
|
setPollError("");
|
||||||
|
} catch (error) {
|
||||||
|
setPollError((error as Error).message);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api("/auth/me")
|
||||||
|
.then(() => setAuthenticated(true))
|
||||||
|
.catch(() => setAuthenticated(false));
|
||||||
|
const expired = () => {
|
||||||
|
setAuthenticated(false);
|
||||||
|
setAccount(null);
|
||||||
|
setJobs([]);
|
||||||
|
};
|
||||||
|
window.addEventListener("session-expired", expired);
|
||||||
|
const hash = () =>
|
||||||
|
setPage(location.hash === "#account" ? "account" : "alphas");
|
||||||
|
window.addEventListener("hashchange", hash);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("session-expired", expired);
|
||||||
|
window.removeEventListener("hashchange", hash);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authenticated) return;
|
||||||
|
void refresh();
|
||||||
|
const timer = window.setInterval(() => void refresh(), 3000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [authenticated, refresh]);
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.setAttribute("theme-mode", account?.theme ?? "light");
|
||||||
|
}, [account?.theme]);
|
||||||
|
|
||||||
|
const completedVersion = jobs
|
||||||
|
.filter(
|
||||||
|
(j) => j.status === "completed" || j.status === "completed_with_errors",
|
||||||
|
)
|
||||||
|
.map((j) => `${j.id}:${j.updated_at}`)
|
||||||
|
.join("|");
|
||||||
|
const pending = jobs.filter((j) =>
|
||||||
|
["queued", "running", "waiting_auth", "waiting_connection"].includes(
|
||||||
|
j.status,
|
||||||
|
),
|
||||||
|
).length;
|
||||||
|
const actionDone = () => {
|
||||||
|
void refresh();
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
|
};
|
||||||
|
const taskCreated = () => {
|
||||||
|
actionDone();
|
||||||
|
setShowJobs(true);
|
||||||
|
};
|
||||||
|
const changePage = (next: string) => {
|
||||||
|
location.hash = next;
|
||||||
|
setPage(next);
|
||||||
|
};
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
await post("/auth/logout");
|
||||||
|
setAuthenticated(false);
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error((e as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LocaleProvider locale={zhCN}>
|
||||||
|
{authenticated === null ? (
|
||||||
|
<div className="screen-center">
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
) : !authenticated ? (
|
||||||
|
<Login
|
||||||
|
onLogin={() => {
|
||||||
|
setAuthenticated(true);
|
||||||
|
void refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="workspace">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="brand">
|
||||||
|
<span className="brand-mark">α</span>
|
||||||
|
<div>
|
||||||
|
ALPHA<span>RESEARCH WORKSPACE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="nav-label">工作空间</div>
|
||||||
|
<button
|
||||||
|
aria-label="Alpha 管理"
|
||||||
|
className={`nav-item ${page === "alphas" ? "active" : ""}`}
|
||||||
|
onClick={() => changePage("alphas")}
|
||||||
|
>
|
||||||
|
<IconGridView size="large" />
|
||||||
|
Alpha 管理
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="个人信息"
|
||||||
|
className={`nav-item ${page === "account" ? "active" : ""}`}
|
||||||
|
onClick={() => changePage("account")}
|
||||||
|
>
|
||||||
|
<IconUser size="large" />
|
||||||
|
个人信息
|
||||||
|
</button>
|
||||||
|
<div className="sidebar-bottom">
|
||||||
|
<div className="connection-line">
|
||||||
|
<i
|
||||||
|
className={
|
||||||
|
account?.connection_status === "connected"
|
||||||
|
? "connected"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{account?.connection_status === "connected"
|
||||||
|
? "WorldQuant 已连接"
|
||||||
|
: "WorldQuant 未连接"}
|
||||||
|
</div>
|
||||||
|
<span>个人研究空间 · v0.1</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div className="main-shell">
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="breadcrumbs">
|
||||||
|
工作空间 <span>/</span>{" "}
|
||||||
|
{page === "alphas" ? "Alpha 管理" : "个人信息"}
|
||||||
|
</div>
|
||||||
|
<div className="top-actions">
|
||||||
|
<Badge count={pending}>
|
||||||
|
<Button
|
||||||
|
theme="borderless"
|
||||||
|
icon={<IconBell />}
|
||||||
|
onClick={() => setShowJobs(true)}
|
||||||
|
>
|
||||||
|
同步任务
|
||||||
|
</Button>
|
||||||
|
</Badge>
|
||||||
|
<div className="top-divider" />
|
||||||
|
<Avatar size="small" color="purple">
|
||||||
|
{account?.display_name.slice(0, 1) || "研"}
|
||||||
|
</Avatar>
|
||||||
|
<span className="account-name">
|
||||||
|
{account?.display_name ?? "研究员"}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
aria-label="退出登录"
|
||||||
|
theme="borderless"
|
||||||
|
icon={<IconExit />}
|
||||||
|
onClick={() => void logout()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="page-content">
|
||||||
|
{pollError && (
|
||||||
|
<Banner
|
||||||
|
type="warning"
|
||||||
|
description={`后台状态暂时不可用:${pollError}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{page === "account" ? (
|
||||||
|
<AccountPage
|
||||||
|
account={account}
|
||||||
|
onChange={actionDone}
|
||||||
|
onTask={taskCreated}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AlphaPage
|
||||||
|
taskPanelOpen={showJobs}
|
||||||
|
account={account}
|
||||||
|
version={`${refreshKey}:${completedVersion}`}
|
||||||
|
onTask={taskCreated}
|
||||||
|
onAccount={() => changePage("account")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<JobPanel
|
||||||
|
visible={showJobs}
|
||||||
|
jobs={jobs}
|
||||||
|
timezone={account?.timezone}
|
||||||
|
onClose={() => setShowJobs(false)}
|
||||||
|
onChange={actionDone}
|
||||||
|
onAccount={() => {
|
||||||
|
setShowJobs(false);
|
||||||
|
changePage("account");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</LocaleProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Login({ onLogin }: { onLogin: () => void }) {
|
||||||
|
const [username, setUsername] = useState("admin");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
async function submit(event: React.FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await post("/auth/login", { username, password });
|
||||||
|
setPassword("");
|
||||||
|
onLogin();
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<section className="login-story">
|
||||||
|
<div className="brand">
|
||||||
|
<span className="brand-mark">α</span>
|
||||||
|
<div>
|
||||||
|
ALPHA<span>RESEARCH WORKSPACE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="story-copy">
|
||||||
|
<div className="eyebrow">WORLDQUANT RESEARCH</div>
|
||||||
|
<h1>
|
||||||
|
让每一次研究,
|
||||||
|
<br />
|
||||||
|
都有迹可循。
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
从想法到验证,从信号到洞察。
|
||||||
|
<br />
|
||||||
|
在一个工作空间里,管理你的 Alpha 研究。
|
||||||
|
</p>
|
||||||
|
<div className="story-signal">
|
||||||
|
<IconPulse size="extra-large" />
|
||||||
|
<span>观察 · 记录 · 迭代</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="login-footnote">你的个人 Alpha 研究工作空间</div>
|
||||||
|
</section>
|
||||||
|
<section className="login-form-shell">
|
||||||
|
<form className="login-form" onSubmit={submit}>
|
||||||
|
<div className="eyebrow">欢迎回来</div>
|
||||||
|
<h2>登录研究工作空间</h2>
|
||||||
|
<p>使用部署时设置的系统账户登录。</p>
|
||||||
|
{error && <Banner type="danger" description={error} />}
|
||||||
|
<label>
|
||||||
|
用户名
|
||||||
|
<Input
|
||||||
|
aria-label="用户名"
|
||||||
|
value={username}
|
||||||
|
onChange={setUsername}
|
||||||
|
autoComplete="username"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
密码
|
||||||
|
<Input
|
||||||
|
aria-label="密码"
|
||||||
|
mode="password"
|
||||||
|
value={password}
|
||||||
|
onChange={setPassword}
|
||||||
|
autoComplete="current-password"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
htmlType="submit"
|
||||||
|
theme="solid"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
loading={busy}
|
||||||
|
icon={<IconArrowRight />}
|
||||||
|
iconPosition="right"
|
||||||
|
>
|
||||||
|
进入工作空间
|
||||||
|
</Button>
|
||||||
|
<div className="login-note">WorldQuant 账户在登录后单独连接。</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public status: number,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function api<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await fetch(`/api/v1${path}`, {
|
||||||
|
...options,
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-WQ-Request": "1",
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
if (response.status === 401 && path !== "/auth/login")
|
||||||
|
window.dispatchEvent(new Event("session-expired"));
|
||||||
|
throw new ApiError(
|
||||||
|
typeof body.detail === "string"
|
||||||
|
? body.detail
|
||||||
|
: `请求失败(${response.status})`,
|
||||||
|
response.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const post = <T>(path: string, body?: unknown) =>
|
||||||
|
api<T>(path, {
|
||||||
|
method: "POST",
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
export const patch = <T>(path: string, body: unknown) =>
|
||||||
|
api<T>(path, { method: "PATCH", body: JSON.stringify(body) });
|
||||||
|
|
||||||
|
export function formatNumber(value: unknown, digits = 2): string {
|
||||||
|
return typeof value === "number" && Number.isFinite(value)
|
||||||
|
? value.toFixed(digits)
|
||||||
|
: "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined || value === "") return "未提供";
|
||||||
|
if (typeof value === "object") return JSON.stringify(value);
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(
|
||||||
|
value: string | null | undefined,
|
||||||
|
timezone = "Asia/Shanghai",
|
||||||
|
): string {
|
||||||
|
if (!value) return "尚未同步";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (!Number.isFinite(date.valueOf())) return "时间未知";
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
timeZone: timezone,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stateLabels = {
|
||||||
|
inbox: "待研究",
|
||||||
|
candidate: "候选",
|
||||||
|
optimizing: "优化中",
|
||||||
|
archived: "归档",
|
||||||
|
};
|
||||||
|
export const stateOptions = Object.entries(stateLabels).map(
|
||||||
|
([value, label]) => ({ value, label }),
|
||||||
|
);
|
||||||
|
export const jobLabels: Record<string, string> = {
|
||||||
|
full_sync: "全量同步 Alpha",
|
||||||
|
alpha_refresh: "导入 / 刷新 Alpha",
|
||||||
|
pnl_refresh: "获取 PnL",
|
||||||
|
connect: "连接 WorldQuant",
|
||||||
|
verify: "继续人工验证",
|
||||||
|
profile: "刷新个人资料",
|
||||||
|
};
|
||||||
|
export const jobStateLabels: Record<string, string> = {
|
||||||
|
queued: "排队中",
|
||||||
|
running: "执行中",
|
||||||
|
waiting_auth: "等待人工验证",
|
||||||
|
waiting_connection: "等待连接",
|
||||||
|
completed: "已完成",
|
||||||
|
completed_with_errors: "部分失败",
|
||||||
|
failed: "失败",
|
||||||
|
cancelled: "已取消",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function queryString(
|
||||||
|
values: Record<string, string | number | boolean | undefined>,
|
||||||
|
): string {
|
||||||
|
return new URLSearchParams(
|
||||||
|
Object.entries(values)
|
||||||
|
.filter(([, v]) => v !== undefined && v !== "")
|
||||||
|
.map(([k, v]) => [k, String(v)]),
|
||||||
|
).toString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Banner,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Descriptions,
|
||||||
|
Empty,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
SideSheet,
|
||||||
|
Spin,
|
||||||
|
TabPane,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
TextArea,
|
||||||
|
Toast,
|
||||||
|
} from "@douyinfe/semi-ui-19";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
displayValue,
|
||||||
|
formatTime,
|
||||||
|
patch,
|
||||||
|
post,
|
||||||
|
stateOptions,
|
||||||
|
} from "../api";
|
||||||
|
import type {
|
||||||
|
AlphaDetail as Detail,
|
||||||
|
Pnl,
|
||||||
|
Research,
|
||||||
|
ResearchState,
|
||||||
|
} from "../types";
|
||||||
|
import { PnlChart } from "./PnlChart";
|
||||||
|
|
||||||
|
export function AlphaDetail({
|
||||||
|
id,
|
||||||
|
version,
|
||||||
|
timezone,
|
||||||
|
taskPanelOpen,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
onTask,
|
||||||
|
}: {
|
||||||
|
id: string | null;
|
||||||
|
version: string;
|
||||||
|
timezone?: string;
|
||||||
|
taskPanelOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
onTask: () => void;
|
||||||
|
}) {
|
||||||
|
const [detail, setDetail] = useState<Detail | null>(null);
|
||||||
|
const [pnl, setPnl] = useState<Pnl | null>(null);
|
||||||
|
const [research, setResearch] = useState<Research | null>(null);
|
||||||
|
const [tagText, setTagText] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [tab, setTab] = useState("overview");
|
||||||
|
const dirty = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
dirty.current = false;
|
||||||
|
setDetail(null);
|
||||||
|
setResearch(null);
|
||||||
|
setPnl(null);
|
||||||
|
setError("");
|
||||||
|
setTab("overview");
|
||||||
|
}, [id]);
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
if (id)
|
||||||
|
api<Detail>(`/alphas/${id}`)
|
||||||
|
.then((value) => {
|
||||||
|
if (active) {
|
||||||
|
setDetail(value);
|
||||||
|
setError("");
|
||||||
|
if (!dirty.current) {
|
||||||
|
setResearch(value.research);
|
||||||
|
setTagText(value.research.tags.join(", "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (active) setError(e.message);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [id, version]);
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
if (id && tab === "pnl")
|
||||||
|
api<Pnl>(`/alphas/${id}/pnl`)
|
||||||
|
.then((p) => {
|
||||||
|
if (active) setPnl(p);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (active) Toast.error(e.message);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [id, tab, version]);
|
||||||
|
async function fetchPnl() {
|
||||||
|
try {
|
||||||
|
await post("/sync-jobs", { kind: "pnl_refresh", alpha_ids: [id] });
|
||||||
|
onTask();
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function save() {
|
||||||
|
if (!research) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const { updated_at: _, ...editable } = research;
|
||||||
|
await patch(`/alphas/${id}/research`, {
|
||||||
|
...editable,
|
||||||
|
tags: tagText
|
||||||
|
.split(/[,,]/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
});
|
||||||
|
dirty.current = false;
|
||||||
|
Toast.success("研究记录已保存");
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<SideSheet
|
||||||
|
closeOnEsc={!taskPanelOpen}
|
||||||
|
className="alpha-detail"
|
||||||
|
visible={Boolean(id)}
|
||||||
|
width={800}
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
Alpha 详情 <code className="muted">{id}</code>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
onCancel={onClose}
|
||||||
|
>
|
||||||
|
{error ? (
|
||||||
|
<Banner type="danger" description={error} />
|
||||||
|
) : !detail || !research ? (
|
||||||
|
<Spin />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="detail-title">
|
||||||
|
<div>
|
||||||
|
<h2>{detail.name || detail.id}</h2>
|
||||||
|
<div className="inline-actions">
|
||||||
|
<Tag>{detail.alpha_type ?? "类型未提供"}</Tag>
|
||||||
|
<Tag>{detail.language ?? "语言未提供"}</Tag>
|
||||||
|
<Tag>{detail.status ?? "状态未提供"}</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`https://platform.worldquantbrain.com/alpha/${encodeURIComponent(detail.id)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<Button>在 BRAIN 中打开 ↗</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<Tabs activeKey={tab} onChange={setTab}>
|
||||||
|
<TabPane tab="概览与表达式" itemKey="overview">
|
||||||
|
<div className="detail-section">
|
||||||
|
<Descriptions
|
||||||
|
data={["region", "universe", "stage"].map((key) => ({
|
||||||
|
key,
|
||||||
|
value: displayValue(detail[key as keyof Detail]),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<p className="muted">
|
||||||
|
平台创建:{formatTime(detail.date_created, timezone)} ·
|
||||||
|
最近同步:{formatTime(detail.synced_at, timezone)}
|
||||||
|
</p>
|
||||||
|
{detail.expression !== null && (
|
||||||
|
<CodeBlock
|
||||||
|
title={
|
||||||
|
detail.language === "PYTHON" ? "Python 代码" : "表达式"
|
||||||
|
}
|
||||||
|
value={detail.expression}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{detail.selection !== null && (
|
||||||
|
<CodeBlock title="Selection" value={detail.selection} />
|
||||||
|
)}
|
||||||
|
{detail.combo !== null && (
|
||||||
|
<CodeBlock title="Combo" value={detail.combo} />
|
||||||
|
)}
|
||||||
|
<h3>回测设置</h3>
|
||||||
|
<Descriptions
|
||||||
|
align="left"
|
||||||
|
data={Object.entries(detail.settings).map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value: displayValue(value),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabPane>
|
||||||
|
<TabPane tab="指标与检查" itemKey="metrics">
|
||||||
|
<MetricTable title="IS 指标" values={detail.is_metrics} />
|
||||||
|
<MetricTable title="OS 指标" values={detail.os_metrics} />
|
||||||
|
<h3>平台已有检查结果</h3>
|
||||||
|
<p className="muted">
|
||||||
|
展示已同步结果,查看此页不会触发新的平台检查。
|
||||||
|
</p>
|
||||||
|
{detail.checks.length ? (
|
||||||
|
detail.checks.map((check, i) => (
|
||||||
|
<div className="check-row" key={i}>
|
||||||
|
<span>{displayValue(check.name)}</span>
|
||||||
|
<span>
|
||||||
|
{displayValue(check.value)} / {displayValue(check.limit)}
|
||||||
|
</span>
|
||||||
|
<Tag
|
||||||
|
color={
|
||||||
|
check.result === "PASS"
|
||||||
|
? "green"
|
||||||
|
: check.result === "FAIL"
|
||||||
|
? "red"
|
||||||
|
: "grey"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{displayValue(check.result)}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Empty description="平台尚未返回检查结果" />
|
||||||
|
)}
|
||||||
|
</TabPane>
|
||||||
|
<TabPane tab="PnL 曲线" itemKey="pnl">
|
||||||
|
<div className="section-toolbar">
|
||||||
|
<div>
|
||||||
|
<h3>累计 PnL</h3>
|
||||||
|
<p className="muted">
|
||||||
|
{pnl?.fetched_at
|
||||||
|
? `缓存更新于 ${formatTime(pnl.fetched_at, timezone)}`
|
||||||
|
: "尚未获取 PnL"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => void fetchPnl()}>
|
||||||
|
{pnl?.cached ? "刷新 PnL" : "获取 PnL"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{pnl?.cached ? (
|
||||||
|
<>
|
||||||
|
<PnlChart points={pnl.points} />
|
||||||
|
<p className="muted">
|
||||||
|
{pnl.points.length} 条记录 · 缺失数据保留为断点
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Empty description="点击获取 PnL,从平台读取并缓存曲线数据" />
|
||||||
|
)}
|
||||||
|
</TabPane>
|
||||||
|
<TabPane tab="研究记录" itemKey="research">
|
||||||
|
<div className="detail-section">
|
||||||
|
<p className="muted">
|
||||||
|
这些记录只保存在你的工作空间,平台同步不会覆盖。
|
||||||
|
</p>
|
||||||
|
<label>
|
||||||
|
研究状态
|
||||||
|
<Select
|
||||||
|
aria-label="研究状态"
|
||||||
|
optionList={stateOptions}
|
||||||
|
value={research.state}
|
||||||
|
onChange={(state) => {
|
||||||
|
dirty.current = true;
|
||||||
|
setResearch({
|
||||||
|
...research,
|
||||||
|
state: state as ResearchState,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
标签
|
||||||
|
<Input
|
||||||
|
aria-label="研究标签"
|
||||||
|
value={tagText}
|
||||||
|
onChange={(value) => {
|
||||||
|
dirty.current = true;
|
||||||
|
setTagText(value);
|
||||||
|
}}
|
||||||
|
placeholder="以逗号分隔,例如 低换手, 分析师预期"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
研究备注
|
||||||
|
<TextArea
|
||||||
|
aria-label="研究备注"
|
||||||
|
value={research.note}
|
||||||
|
onChange={(note) => {
|
||||||
|
dirty.current = true;
|
||||||
|
setResearch({ ...research, note });
|
||||||
|
}}
|
||||||
|
rows={8}
|
||||||
|
maxCount={20000}
|
||||||
|
placeholder="记录假设、观察、下一步实验……"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Checkbox
|
||||||
|
checked={research.favorite}
|
||||||
|
onChange={(e) => {
|
||||||
|
dirty.current = true;
|
||||||
|
setResearch({
|
||||||
|
...research,
|
||||||
|
favorite: Boolean(e.target.checked),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
收藏此 Alpha
|
||||||
|
</Checkbox>
|
||||||
|
<div className="detail-save">
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
loading={saving}
|
||||||
|
onClick={() => void save()}
|
||||||
|
>
|
||||||
|
保存研究记录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabPane>
|
||||||
|
</Tabs>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SideSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeBlock({ title, value }: { title: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<pre className="code-block">
|
||||||
|
<code>{value}</code>
|
||||||
|
</pre>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function MetricTable({
|
||||||
|
title,
|
||||||
|
values,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
values: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
const entries = Object.entries(values).filter(([key]) => key !== "checks");
|
||||||
|
return (
|
||||||
|
<div className="detail-section">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
{entries.length ? (
|
||||||
|
<Descriptions
|
||||||
|
data={entries.map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value: displayValue(value),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="muted">未提供</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Empty,
|
||||||
|
Progress,
|
||||||
|
SideSheet,
|
||||||
|
Tag,
|
||||||
|
Toast,
|
||||||
|
} from "@douyinfe/semi-ui-19";
|
||||||
|
import { api, formatTime, jobLabels, jobStateLabels, post } from "../api";
|
||||||
|
import type { Job } from "../types";
|
||||||
|
|
||||||
|
export function JobPanel({
|
||||||
|
visible,
|
||||||
|
jobs,
|
||||||
|
timezone,
|
||||||
|
onClose,
|
||||||
|
onChange,
|
||||||
|
onAccount,
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
jobs: Job[];
|
||||||
|
timezone?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onChange: () => void;
|
||||||
|
onAccount: () => void;
|
||||||
|
}) {
|
||||||
|
const [errors, setErrors] = useState<
|
||||||
|
Record<string, { alpha_id: string; error: string }[]>
|
||||||
|
>({});
|
||||||
|
const [busy, setBusy] = useState("");
|
||||||
|
async function action(job: Job, name: string) {
|
||||||
|
setBusy(job.id);
|
||||||
|
try {
|
||||||
|
await post(`/sync-jobs/${job.id}/${name}`);
|
||||||
|
onChange();
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<SideSheet
|
||||||
|
closeOnEsc
|
||||||
|
className="job-panel"
|
||||||
|
title="同步任务"
|
||||||
|
visible={visible}
|
||||||
|
width={520}
|
||||||
|
onCancel={onClose}
|
||||||
|
>
|
||||||
|
<p className="muted">同步进度保存在服务端,关闭此面板不会中断任务。</p>
|
||||||
|
{!jobs.length && <Empty description="暂时没有同步任务" />}
|
||||||
|
{jobs.map((job) => (
|
||||||
|
<article className="job-card" key={job.id}>
|
||||||
|
<div className="job-header">
|
||||||
|
<strong>{jobLabels[job.kind] ?? job.kind}</strong>
|
||||||
|
<Tag
|
||||||
|
color={
|
||||||
|
job.status === "completed"
|
||||||
|
? "green"
|
||||||
|
: ["failed", "completed_with_errors"].includes(job.status)
|
||||||
|
? "red"
|
||||||
|
: "blue"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{jobStateLabels[job.status] ?? job.status}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
<p className="muted">{formatTime(job.created_at, timezone)}</p>
|
||||||
|
<div className="job-numbers">
|
||||||
|
<span>
|
||||||
|
已处理 <b>{job.processed}</b>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
失败 <b>{job.failed}</b>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
总数 <b>{job.total ?? "计算中"}</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{job.total !== null && job.total > 0 && (
|
||||||
|
<Progress
|
||||||
|
percent={Math.min(
|
||||||
|
100,
|
||||||
|
Math.round(((job.processed + job.failed) / job.total) * 100),
|
||||||
|
)}
|
||||||
|
showInfo={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{job.error && <p className="error-text">{job.error}</p>}
|
||||||
|
{job.next_retry_at && (
|
||||||
|
<p className="muted">
|
||||||
|
下次重试:{formatTime(job.next_retry_at, timezone)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="inline-actions">
|
||||||
|
{[
|
||||||
|
"queued",
|
||||||
|
"running",
|
||||||
|
"waiting_auth",
|
||||||
|
"waiting_connection",
|
||||||
|
].includes(job.status) && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
loading={busy === job.id}
|
||||||
|
onClick={() => void action(job, "cancel")}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{["failed", "cancelled", "completed_with_errors"].includes(
|
||||||
|
job.status,
|
||||||
|
) && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
loading={busy === job.id}
|
||||||
|
onClick={() => void action(job, "retry")}
|
||||||
|
>
|
||||||
|
重试未完成部分
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{["waiting_auth", "waiting_connection"].includes(job.status) && (
|
||||||
|
<Button size="small" onClick={onAccount}>
|
||||||
|
处理账户连接
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{job.failed > 0 && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
theme="borderless"
|
||||||
|
onClick={() =>
|
||||||
|
api<{ alpha_id: string; error: string }[]>(
|
||||||
|
`/sync-jobs/${job.id}/errors`,
|
||||||
|
)
|
||||||
|
.then((rows) => setErrors({ ...errors, [job.id]: rows }))
|
||||||
|
.catch((e) => Toast.error(e.message))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
查看失败记录
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{errors[job.id]?.map((e) => (
|
||||||
|
<p className="error-text" key={e.alpha_id}>
|
||||||
|
{e.alpha_id}:{e.error}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</SideSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { Pnl } from "../types";
|
||||||
|
|
||||||
|
export function PnlChart({ points }: { points: Pnl["points"] }) {
|
||||||
|
const values = points
|
||||||
|
.map((p) => p.value)
|
||||||
|
.filter((v): v is number => v !== null && Number.isFinite(v));
|
||||||
|
if (!values.length)
|
||||||
|
return <div className="chart-empty">暂无可绘制的 PnL 数据</div>;
|
||||||
|
const low = Math.min(...values),
|
||||||
|
high = Math.max(...values),
|
||||||
|
range = high - low || Math.max(1, Math.abs(high) * 0.1);
|
||||||
|
const x = (index: number) =>
|
||||||
|
62 + (index / Math.max(1, points.length - 1)) * 610;
|
||||||
|
const y = (v: number) => 24 + ((high - v) / range) * 180;
|
||||||
|
let segments = "";
|
||||||
|
let connected = false;
|
||||||
|
points.forEach((point, i) => {
|
||||||
|
if (point.value === null) {
|
||||||
|
connected = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
segments += `${connected ? " L" : " M"}${x(i)} ${y(point.value)}`;
|
||||||
|
connected = true;
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="pnl-chart"
|
||||||
|
viewBox="0 0 700 240"
|
||||||
|
role="img"
|
||||||
|
aria-label={`PnL 曲线,共 ${points.length} 条记录`}
|
||||||
|
>
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<g key={i}>
|
||||||
|
<line
|
||||||
|
x1="62"
|
||||||
|
x2="672"
|
||||||
|
y1={24 + i * 60}
|
||||||
|
y2={24 + i * 60}
|
||||||
|
stroke="var(--line)"
|
||||||
|
/>
|
||||||
|
<text x="52" y={28 + i * 60} textAnchor="end">
|
||||||
|
{(high - (i * range) / 3).toLocaleString(undefined, {
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
})}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
<path d={segments} fill="none" stroke="var(--accent)" strokeWidth="2" />
|
||||||
|
{points.length === 1 && points[0].value !== null && (
|
||||||
|
<circle cx={x(0)} cy={y(points[0].value)} r="3" fill="var(--accent)" />
|
||||||
|
)}
|
||||||
|
<text x="62" y="230">
|
||||||
|
{points[0]?.date.slice(0, 10)}
|
||||||
|
</text>
|
||||||
|
<text x="672" y="230" textAnchor="end">
|
||||||
|
{points.at(-1)?.date.slice(0, 10)}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import "@douyinfe/semi-ui-19/react19-adapter";
|
||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./style.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Banner,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Spin,
|
||||||
|
Tag,
|
||||||
|
Toast,
|
||||||
|
} from "@douyinfe/semi-ui-19";
|
||||||
|
import {
|
||||||
|
IconRefresh,
|
||||||
|
IconLink,
|
||||||
|
IconShield,
|
||||||
|
IconUser,
|
||||||
|
} from "@douyinfe/semi-icons";
|
||||||
|
import { api, displayValue, formatTime, patch, post } from "../api";
|
||||||
|
import type { Account } from "../types";
|
||||||
|
|
||||||
|
const connectionLabels: Record<string, string> = {
|
||||||
|
connected: "已连接",
|
||||||
|
disconnected: "未连接",
|
||||||
|
expired: "会话待恢复",
|
||||||
|
connecting: "连接中",
|
||||||
|
verification_required: "需要人工验证",
|
||||||
|
error: "连接异常",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AccountPage({
|
||||||
|
account,
|
||||||
|
onChange,
|
||||||
|
onTask,
|
||||||
|
}: {
|
||||||
|
account: Account | null;
|
||||||
|
onChange: () => void;
|
||||||
|
onTask: () => void;
|
||||||
|
}) {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [busy, setBusy] = useState("");
|
||||||
|
const [preferences, setPreferences] = useState({
|
||||||
|
display_name: "研究员",
|
||||||
|
theme: "light",
|
||||||
|
timezone: "Asia/Shanghai",
|
||||||
|
page_size: 25,
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (account) setEmail(account.email ?? "");
|
||||||
|
}, [account?.email]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (account)
|
||||||
|
setPreferences({
|
||||||
|
display_name: account.display_name,
|
||||||
|
theme: account.theme,
|
||||||
|
timezone: account.timezone,
|
||||||
|
page_size: account.page_size,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
account?.display_name,
|
||||||
|
account?.theme,
|
||||||
|
account?.timezone,
|
||||||
|
account?.page_size,
|
||||||
|
]);
|
||||||
|
if (!account) return <Spin size="large" />;
|
||||||
|
async function action(key: string, fn: () => Promise<unknown>, task = false) {
|
||||||
|
setBusy(key);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
if (task) onTask();
|
||||||
|
else {
|
||||||
|
onChange();
|
||||||
|
Toast.success("已保存");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const profile = account.profile;
|
||||||
|
const profileRows = [
|
||||||
|
["用户 ID", account.wq_user_id],
|
||||||
|
["昵称", profile.name ?? profile.username ?? profile.firstName],
|
||||||
|
["平台邮箱", profile.email ?? account.email],
|
||||||
|
["身份", profile.role ?? profile.roles ?? profile.type],
|
||||||
|
["权限", profile.permissions],
|
||||||
|
[
|
||||||
|
"资料同步时间",
|
||||||
|
account.last_synced_at
|
||||||
|
? formatTime(account.last_synced_at, account.timezone)
|
||||||
|
: null,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-heading">
|
||||||
|
<div>
|
||||||
|
<div className="eyebrow">PERSONAL WORKSPACE</div>
|
||||||
|
<h1>个人信息</h1>
|
||||||
|
<p>管理账户连接和工作空间偏好。</p>
|
||||||
|
</div>
|
||||||
|
<Tag
|
||||||
|
color={account.connection_status === "connected" ? "green" : "grey"}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
{connectionLabels[account.connection_status] ??
|
||||||
|
account.connection_status}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
{account.connection_error && (
|
||||||
|
<Banner
|
||||||
|
type={
|
||||||
|
account.connection_status === "verification_required"
|
||||||
|
? "warning"
|
||||||
|
: "danger"
|
||||||
|
}
|
||||||
|
description={account.connection_error}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{account.verification_url && (
|
||||||
|
<div className="verification-callout">
|
||||||
|
<strong>请完成 WorldQuant 人工验证</strong>
|
||||||
|
<p>在新窗口完成平台验证后,回到这里继续。验证链接只属于当前会话。</p>
|
||||||
|
<div className="inline-actions">
|
||||||
|
<a href={account.verification_url} target="_blank" rel="noreferrer">
|
||||||
|
<Button theme="solid">打开验证页面</Button>
|
||||||
|
</a>
|
||||||
|
<Button
|
||||||
|
loading={busy === "verify"}
|
||||||
|
onClick={() =>
|
||||||
|
void action("verify", () => post("/account/verify"), true)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
已完成,继续验证
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="account-grid">
|
||||||
|
<section className="panel">
|
||||||
|
<div className="panel-title">
|
||||||
|
<IconLink />
|
||||||
|
<div>
|
||||||
|
<h2>WorldQuant 连接</h2>
|
||||||
|
<p>连接你的个人 BRAIN 账户</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void action("save", async () => {
|
||||||
|
await api("/account/credentials", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
setPassword("");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
WorldQuant 邮箱
|
||||||
|
<Input
|
||||||
|
aria-label="WorldQuant 邮箱"
|
||||||
|
value={email}
|
||||||
|
onChange={setEmail}
|
||||||
|
placeholder="name@example.com"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
WorldQuant 密码
|
||||||
|
<Input
|
||||||
|
aria-label="WorldQuant 密码"
|
||||||
|
mode="password"
|
||||||
|
value={password}
|
||||||
|
onChange={setPassword}
|
||||||
|
placeholder={
|
||||||
|
account.configured
|
||||||
|
? "已保存 · 输入新密码可更新配置"
|
||||||
|
: "输入你的平台密码"
|
||||||
|
}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="credential-note">
|
||||||
|
<IconShield />
|
||||||
|
密码加密存储,仅用于服务端连接平台。
|
||||||
|
</div>
|
||||||
|
<div className="inline-actions">
|
||||||
|
<Button
|
||||||
|
htmlType="submit"
|
||||||
|
disabled={!email || !password || Boolean(busy)}
|
||||||
|
loading={busy === "save"}
|
||||||
|
>
|
||||||
|
保存凭据
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
disabled={!account.configured || Boolean(busy)}
|
||||||
|
loading={busy === "connect"}
|
||||||
|
onClick={() =>
|
||||||
|
void action("connect", () => post("/account/connect"), true)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{account.connection_status === "connected"
|
||||||
|
? "重新连接"
|
||||||
|
: "连接 WorldQuant"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
theme="borderless"
|
||||||
|
disabled={
|
||||||
|
Boolean(busy) || account.connection_status === "disconnected"
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
void action("disconnect", () => post("/account/disconnect"))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
断开连接
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section className="panel">
|
||||||
|
<div className="panel-title">
|
||||||
|
<IconUser />
|
||||||
|
<div>
|
||||||
|
<h2>平台个人资料</h2>
|
||||||
|
<p>展示最近一次成功获取的信息</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
theme="borderless"
|
||||||
|
icon={<IconRefresh />}
|
||||||
|
aria-label="刷新个人资料"
|
||||||
|
disabled={!account.configured || Boolean(busy)}
|
||||||
|
onClick={() =>
|
||||||
|
void action("profile", () => post("/account/refresh"), true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<dl className="profile-grid">
|
||||||
|
{profileRows.map(([key, value]) => (
|
||||||
|
<div key={String(key)}>
|
||||||
|
<dt>{String(key)}</dt>
|
||||||
|
<dd>{displayValue(value)}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
<section className="panel full-width">
|
||||||
|
<div className="panel-title">
|
||||||
|
<div>
|
||||||
|
<h2>工作空间偏好</h2>
|
||||||
|
<p>只影响你的本地研究界面</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void action("preferences", () =>
|
||||||
|
patch("/account/preferences", preferences),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="preferences-grid">
|
||||||
|
<label>
|
||||||
|
显示名称
|
||||||
|
<Input
|
||||||
|
aria-label="显示名称"
|
||||||
|
value={preferences.display_name}
|
||||||
|
onChange={(display_name) =>
|
||||||
|
setPreferences({ ...preferences, display_name })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
主题
|
||||||
|
<Select
|
||||||
|
aria-label="主题"
|
||||||
|
value={preferences.theme}
|
||||||
|
optionList={[
|
||||||
|
{ value: "light", label: "浅色" },
|
||||||
|
{ value: "dark", label: "深色" },
|
||||||
|
]}
|
||||||
|
onChange={(theme) =>
|
||||||
|
setPreferences({ ...preferences, theme: String(theme) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
时区
|
||||||
|
<Input
|
||||||
|
aria-label="时区"
|
||||||
|
value={preferences.timezone}
|
||||||
|
onChange={(timezone) =>
|
||||||
|
setPreferences({ ...preferences, timezone })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
每页数量
|
||||||
|
<Select
|
||||||
|
aria-label="每页数量"
|
||||||
|
value={preferences.page_size}
|
||||||
|
optionList={[25, 50, 100].map((value) => ({
|
||||||
|
value,
|
||||||
|
label: `${value} 条`,
|
||||||
|
}))}
|
||||||
|
onChange={(page_size) =>
|
||||||
|
setPreferences({
|
||||||
|
...preferences,
|
||||||
|
page_size: Number(page_size),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
htmlType="submit"
|
||||||
|
theme="solid"
|
||||||
|
loading={busy === "preferences"}
|
||||||
|
disabled={Boolean(busy)}
|
||||||
|
>
|
||||||
|
保存偏好
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,781 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Banner,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
CheckboxGroup,
|
||||||
|
Empty,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Popover,
|
||||||
|
Select,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
TextArea,
|
||||||
|
Toast,
|
||||||
|
} from "@douyinfe/semi-ui-19";
|
||||||
|
import {
|
||||||
|
IconSearch,
|
||||||
|
IconRefresh,
|
||||||
|
IconDownload,
|
||||||
|
IconPlus,
|
||||||
|
IconSetting,
|
||||||
|
IconStar,
|
||||||
|
} from "@douyinfe/semi-icons";
|
||||||
|
import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
formatNumber,
|
||||||
|
formatTime,
|
||||||
|
patch,
|
||||||
|
post,
|
||||||
|
queryString,
|
||||||
|
stateLabels,
|
||||||
|
stateOptions,
|
||||||
|
} from "../api";
|
||||||
|
import type { Account, Alpha, AlphaPage as Page, Facets } from "../types";
|
||||||
|
import { AlphaDetail } from "../components/AlphaDetail";
|
||||||
|
|
||||||
|
const metricLabels = {
|
||||||
|
sharpe: "Sharpe",
|
||||||
|
fitness: "Fitness",
|
||||||
|
returns: "Returns",
|
||||||
|
turnover: "Turnover",
|
||||||
|
margin: "Margin",
|
||||||
|
drawdown: "Drawdown",
|
||||||
|
};
|
||||||
|
const initialColumns = [
|
||||||
|
"name",
|
||||||
|
"expression",
|
||||||
|
"region",
|
||||||
|
"status",
|
||||||
|
"sharpe",
|
||||||
|
"fitness",
|
||||||
|
"turnover",
|
||||||
|
"research",
|
||||||
|
];
|
||||||
|
const columnLabels: Record<string, string> = {
|
||||||
|
name: "Alpha",
|
||||||
|
expression: "表达式",
|
||||||
|
region: "地区 / Universe",
|
||||||
|
status: "平台状态",
|
||||||
|
sharpe: "Sharpe",
|
||||||
|
fitness: "Fitness",
|
||||||
|
returns: "Returns",
|
||||||
|
turnover: "Turnover",
|
||||||
|
margin: "Margin",
|
||||||
|
drawdown: "Drawdown",
|
||||||
|
research: "研究状态",
|
||||||
|
tags: "本地标签",
|
||||||
|
language: "语言",
|
||||||
|
created: "创建时间",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AlphaPage({
|
||||||
|
account,
|
||||||
|
taskPanelOpen,
|
||||||
|
version,
|
||||||
|
onTask,
|
||||||
|
onAccount,
|
||||||
|
}: {
|
||||||
|
account: Account | null;
|
||||||
|
taskPanelOpen: boolean;
|
||||||
|
version: string;
|
||||||
|
onTask: () => void;
|
||||||
|
onAccount: () => void;
|
||||||
|
}) {
|
||||||
|
const [data, setData] = useState<Page>({
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
limit: 25,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
const [facets, setFacets] = useState<Facets>({});
|
||||||
|
const [draft, setDraft] = useState<Record<string, string>>({});
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
const [sort, setSort] = useState("date_created");
|
||||||
|
const [direction, setDirection] = useState("desc");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
const [visibleColumns, setVisibleColumns] = useState<string[]>(() => {
|
||||||
|
try {
|
||||||
|
const stored: unknown = JSON.parse(
|
||||||
|
localStorage.getItem("alpha-columns") || "null",
|
||||||
|
);
|
||||||
|
return Array.isArray(stored)
|
||||||
|
? [
|
||||||
|
...new Set([
|
||||||
|
"name",
|
||||||
|
...stored.filter(
|
||||||
|
(v): v is string => typeof v === "string" && v in columnLabels,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
: initialColumns;
|
||||||
|
} catch {
|
||||||
|
return initialColumns;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const [detailId, setDetailId] = useState<string | null>(null);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [idText, setIdText] = useState("");
|
||||||
|
const [busy, setBusy] = useState("");
|
||||||
|
const [bulkOpen, setBulkOpen] = useState(false);
|
||||||
|
const [bulk, setBulk] = useState({
|
||||||
|
add_tags: "",
|
||||||
|
remove_tags: "",
|
||||||
|
state: "",
|
||||||
|
});
|
||||||
|
const [localVersion, setLocalVersion] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (account) {
|
||||||
|
setPageSize(account.page_size);
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}, [account?.page_size]);
|
||||||
|
const params = useMemo(
|
||||||
|
() => ({
|
||||||
|
...filters,
|
||||||
|
sort,
|
||||||
|
direction,
|
||||||
|
limit: pageSize,
|
||||||
|
offset: (page - 1) * pageSize,
|
||||||
|
}),
|
||||||
|
[filters, sort, direction, pageSize, page],
|
||||||
|
);
|
||||||
|
const query = queryString(params);
|
||||||
|
const refresh = useCallback(() => setLocalVersion((n) => n + 1), []);
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
Promise.all([
|
||||||
|
api<Page>(`/alphas?${query}`, { signal: controller.signal }),
|
||||||
|
api<Facets>("/alphas/facets", { signal: controller.signal }),
|
||||||
|
])
|
||||||
|
.then(([results, nextFacets]) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setData(results);
|
||||||
|
setFacets(nextFacets);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!controller.signal.aborted) setError(e.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!controller.signal.aborted) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [query, version, localVersion]);
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected([]);
|
||||||
|
}, [query]);
|
||||||
|
function updateDraft(key: string, value: unknown) {
|
||||||
|
setDraft((previous) => ({
|
||||||
|
...previous,
|
||||||
|
[key]: value === undefined || value === null ? "" : String(value),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
function applyFilters() {
|
||||||
|
setFilters({ ...draft });
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
function clearFilters() {
|
||||||
|
setDraft({});
|
||||||
|
setFilters({});
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
async function newTask(kind: string, ids: string[] = []) {
|
||||||
|
setBusy(kind);
|
||||||
|
try {
|
||||||
|
await post("/sync-jobs", { kind, alpha_ids: ids });
|
||||||
|
setImporting(false);
|
||||||
|
setIdText("");
|
||||||
|
onTask();
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function bulkSave() {
|
||||||
|
setBusy("bulk");
|
||||||
|
const split = (value: string) =>
|
||||||
|
value
|
||||||
|
.split(/[,,]/)
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
try {
|
||||||
|
await patch("/alphas/research/bulk", {
|
||||||
|
alpha_ids: selected,
|
||||||
|
add_tags: split(bulk.add_tags),
|
||||||
|
remove_tags: split(bulk.remove_tags),
|
||||||
|
state: bulk.state || null,
|
||||||
|
});
|
||||||
|
setBulkOpen(false);
|
||||||
|
setBulk({ add_tags: "", remove_tags: "", state: "" });
|
||||||
|
refresh();
|
||||||
|
Toast.success("批量研究记录已更新");
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const columnDefinitions: ColumnProps<Alpha>[] = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
title: "Alpha",
|
||||||
|
dataIndex: "name",
|
||||||
|
fixed: "left",
|
||||||
|
width: 190,
|
||||||
|
render: (_, row) => (
|
||||||
|
<button className="alpha-link" onClick={() => setDetailId(row!.id)}>
|
||||||
|
<span>
|
||||||
|
{row!.research.favorite && <IconStar className="favorite-icon" />}
|
||||||
|
{row!.name || row!.id}
|
||||||
|
</span>
|
||||||
|
<small>
|
||||||
|
{row!.name ? row!.id : (row!.alpha_type ?? "未提供类型")}
|
||||||
|
</small>
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "expression",
|
||||||
|
title: "表达式",
|
||||||
|
width: 260,
|
||||||
|
render: (_, row) => (
|
||||||
|
<code className="expression-cell" title={row!.expression_preview}>
|
||||||
|
{row!.expression_preview || "—"}
|
||||||
|
</code>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "region",
|
||||||
|
title: "地区 / Universe",
|
||||||
|
width: 155,
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="stacked-cell">
|
||||||
|
<span>{row!.region ?? "—"}</span>
|
||||||
|
<small>{row!.universe ?? "—"}</small>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
title: "平台状态",
|
||||||
|
width: 150,
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="stacked-cell">
|
||||||
|
<span>{row!.status ?? "未提供"}</span>
|
||||||
|
<small>
|
||||||
|
{row!.stage ?? "—"}
|
||||||
|
{row!.hidden ? " · 已隐藏" : ""}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...Object.entries(metricLabels).map(
|
||||||
|
([key, title]): ColumnProps<Alpha> => ({
|
||||||
|
key,
|
||||||
|
title,
|
||||||
|
width: 112,
|
||||||
|
align: "right",
|
||||||
|
render: (_, row) => (
|
||||||
|
<span className="numeric">
|
||||||
|
{formatNumber(row![key as keyof Alpha], key === "margin" ? 6 : 3)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
key: "research",
|
||||||
|
fixed: "right",
|
||||||
|
title: "研究状态",
|
||||||
|
width: 115,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Tag
|
||||||
|
color={
|
||||||
|
row!.research.state === "candidate"
|
||||||
|
? "green"
|
||||||
|
: row!.research.state === "optimizing"
|
||||||
|
? "blue"
|
||||||
|
: "grey"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{stateLabels[row!.research.state]}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tags",
|
||||||
|
title: "本地标签",
|
||||||
|
width: 180,
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="inline-tags">
|
||||||
|
{row!.research.tags.map((t) => (
|
||||||
|
<Tag key={t}>{t}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "language", title: "语言", width: 125, dataIndex: "language" },
|
||||||
|
{
|
||||||
|
key: "created",
|
||||||
|
title: "创建时间",
|
||||||
|
width: 165,
|
||||||
|
render: (_, row) => formatTime(row!.date_created, account?.timezone),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const columns = columnDefinitions.filter((column) =>
|
||||||
|
visibleColumns.includes(String(column.key)),
|
||||||
|
);
|
||||||
|
const activeFilterCount = Object.values(filters).filter(Boolean).length;
|
||||||
|
const options = (key: string) =>
|
||||||
|
(Array.isArray(facets[key]) ? (facets[key] as string[]) : []).map(
|
||||||
|
(value) => ({ value, label: value }),
|
||||||
|
);
|
||||||
|
const connected =
|
||||||
|
account?.connection_status === "connected" ||
|
||||||
|
account?.connection_status === "expired";
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-heading">
|
||||||
|
<div>
|
||||||
|
<div className="eyebrow">ALPHA LIBRARY</div>
|
||||||
|
<h1>Alpha 管理</h1>
|
||||||
|
<p>集中查看平台信号,记录每一步研究判断。</p>
|
||||||
|
</div>
|
||||||
|
<div className="inline-actions">
|
||||||
|
<Button icon={<IconPlus />} onClick={() => setImporting(true)}>
|
||||||
|
导入 Alpha ID
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
icon={<IconRefresh />}
|
||||||
|
loading={busy === "full_sync"}
|
||||||
|
disabled={!connected}
|
||||||
|
onClick={() => void newTask("full_sync")}
|
||||||
|
>
|
||||||
|
同步平台数据
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="library-stats">
|
||||||
|
<div>
|
||||||
|
<span>已同步 Alpha</span>
|
||||||
|
<strong>{Number(facets.total ?? 0).toLocaleString()}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>我的收藏</span>
|
||||||
|
<strong>{Number(facets.favorites ?? 0).toLocaleString()}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>覆盖地区</span>
|
||||||
|
<strong>
|
||||||
|
{Array.isArray(facets.region) ? facets.region.length : 0}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="last-sync">
|
||||||
|
<span>最近数据更新</span>
|
||||||
|
<strong>
|
||||||
|
{formatTime(
|
||||||
|
typeof facets.last_sync === "string" ? facets.last_sync : null,
|
||||||
|
account?.timezone,
|
||||||
|
)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!connected && (
|
||||||
|
<Banner
|
||||||
|
type="info"
|
||||||
|
description={
|
||||||
|
<span>
|
||||||
|
连接 WorldQuant 后即可同步平台数据。
|
||||||
|
<button className="text-link" onClick={onAccount}>
|
||||||
|
前往个人信息
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{error && <Banner type="danger" description={error} />}
|
||||||
|
<section className="library-panel">
|
||||||
|
<form
|
||||||
|
className="filter-panel"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
applyFilters();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="primary-filters">
|
||||||
|
<Input
|
||||||
|
aria-label="搜索 Alpha"
|
||||||
|
prefix={<IconSearch />}
|
||||||
|
placeholder="搜索 ID、名称或表达式"
|
||||||
|
value={draft.q ?? ""}
|
||||||
|
onChange={(v) => updateDraft("q", v)}
|
||||||
|
showClear
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
aria-label="地区"
|
||||||
|
placeholder="全部地区"
|
||||||
|
showClear
|
||||||
|
value={draft.region || undefined}
|
||||||
|
optionList={options("region")}
|
||||||
|
onChange={(v) => updateDraft("region", v)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
aria-label="平台状态"
|
||||||
|
placeholder="全部平台状态"
|
||||||
|
showClear
|
||||||
|
value={draft.status || undefined}
|
||||||
|
optionList={options("status")}
|
||||||
|
onChange={(v) => updateDraft("status", v)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
aria-label="研究状态筛选"
|
||||||
|
placeholder="全部研究状态"
|
||||||
|
showClear
|
||||||
|
value={draft.research_state || undefined}
|
||||||
|
optionList={stateOptions}
|
||||||
|
onChange={(v) => updateDraft("research_state", v)}
|
||||||
|
/>
|
||||||
|
<Button htmlType="submit" theme="solid">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button theme="borderless" onClick={clearFilters}>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<details className="advanced-filters">
|
||||||
|
<summary>
|
||||||
|
更多筛选{" "}
|
||||||
|
{activeFilterCount ? `· ${activeFilterCount} 项已应用` : ""}
|
||||||
|
</summary>
|
||||||
|
<div className="advanced-grid">
|
||||||
|
{[
|
||||||
|
["universe", "Universe"],
|
||||||
|
["alpha_type", "Alpha 类型"],
|
||||||
|
["language", "语言"],
|
||||||
|
["stage", "IS / OS"],
|
||||||
|
].map(([key, label]) => (
|
||||||
|
<label key={key}>
|
||||||
|
{label}
|
||||||
|
<Select
|
||||||
|
showClear
|
||||||
|
aria-label={label}
|
||||||
|
value={draft[key] || undefined}
|
||||||
|
optionList={options(key)}
|
||||||
|
placeholder="全部"
|
||||||
|
onChange={(v) => updateDraft(key, v)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<label>
|
||||||
|
平台隐藏状态
|
||||||
|
<Select
|
||||||
|
showClear
|
||||||
|
value={draft.hidden || undefined}
|
||||||
|
optionList={[
|
||||||
|
{ value: "false", label: "可见" },
|
||||||
|
{ value: "true", label: "隐藏" },
|
||||||
|
]}
|
||||||
|
placeholder="全部"
|
||||||
|
onChange={(v) => updateDraft("hidden", v)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
本地标签
|
||||||
|
<Select
|
||||||
|
showClear
|
||||||
|
filter
|
||||||
|
value={draft.tag || undefined}
|
||||||
|
optionList={options("tags")}
|
||||||
|
placeholder="全部标签"
|
||||||
|
onChange={(v) => updateDraft("tag", v)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
创建时间起(UTC)
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={draft.created_from?.slice(0, 10) || ""}
|
||||||
|
onChange={(v) =>
|
||||||
|
updateDraft("created_from", v ? `${v}T00:00:00Z` : "")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
创建时间止(UTC)
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={draft.created_to?.slice(0, 10) || ""}
|
||||||
|
onChange={(v) =>
|
||||||
|
updateDraft("created_to", v ? `${v}T23:59:59.999999Z` : "")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{Object.entries(metricLabels).map(([key, label]) => (
|
||||||
|
<label key={key}>
|
||||||
|
{label}
|
||||||
|
<div className="range-inputs">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
aria-label={`${label} 最小值`}
|
||||||
|
placeholder="最小值"
|
||||||
|
value={draft[`${key}_min`] || ""}
|
||||||
|
onChange={(v) => updateDraft(`${key}_min`, v)}
|
||||||
|
/>
|
||||||
|
<span>—</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
aria-label={`${label} 最大值`}
|
||||||
|
placeholder="最大值"
|
||||||
|
value={draft[`${key}_max`] || ""}
|
||||||
|
onChange={(v) => updateDraft(`${key}_max`, v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<Checkbox
|
||||||
|
checked={draft.favorite === "true"}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateDraft("favorite", e.target.checked ? "true" : "")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
只看收藏
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
<p className="muted">
|
||||||
|
数值筛选使用平台原始值,例如 Turnover 0.15 表示 15%。
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
</form>
|
||||||
|
<div className="table-toolbar">
|
||||||
|
<div>
|
||||||
|
<strong>全部 Alpha</strong>
|
||||||
|
<span className="count-pill">{data.total.toLocaleString()}</span>
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<span className="selection-text">
|
||||||
|
已选择 {selected.length} 条
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="inline-actions">
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Button size="small" onClick={() => setBulkOpen(true)}>
|
||||||
|
批量编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
disabled={!connected}
|
||||||
|
loading={busy === "alpha_refresh"}
|
||||||
|
onClick={() => void newTask("alpha_refresh", selected)}
|
||||||
|
>
|
||||||
|
刷新选中
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Select
|
||||||
|
aria-label="排序字段"
|
||||||
|
size="small"
|
||||||
|
value={sort}
|
||||||
|
optionList={[
|
||||||
|
{ value: "date_created", label: "创建时间" },
|
||||||
|
{ value: "date_submitted", label: "提交时间" },
|
||||||
|
{ value: "synced_at", label: "同步时间" },
|
||||||
|
...Object.entries(metricLabels).map(([value, label]) => ({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
})),
|
||||||
|
{ value: "name", label: "名称" },
|
||||||
|
]}
|
||||||
|
onChange={(v) => {
|
||||||
|
setSort(String(v));
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
aria-label="排序方向"
|
||||||
|
size="small"
|
||||||
|
value={direction}
|
||||||
|
optionList={[
|
||||||
|
{ value: "desc", label: "降序" },
|
||||||
|
{ value: "asc", label: "升序" },
|
||||||
|
]}
|
||||||
|
onChange={(v) => {
|
||||||
|
setDirection(String(v));
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<a href={`/api/v1/alphas/export?${query}`}>
|
||||||
|
<Button icon={<IconDownload />} size="small">
|
||||||
|
导出
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
<Popover
|
||||||
|
trigger="click"
|
||||||
|
position="bottomRight"
|
||||||
|
content={
|
||||||
|
<div className="columns-picker">
|
||||||
|
<strong>显示列</strong>
|
||||||
|
<CheckboxGroup
|
||||||
|
direction="vertical"
|
||||||
|
value={visibleColumns}
|
||||||
|
options={Object.entries(columnLabels).map(
|
||||||
|
([value, label]) => ({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
disabled: value === "name",
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
onChange={(values) => {
|
||||||
|
const next = values.map(String);
|
||||||
|
setVisibleColumns(next);
|
||||||
|
localStorage.setItem(
|
||||||
|
"alpha-columns",
|
||||||
|
JSON.stringify(next),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
aria-label="显示列设置"
|
||||||
|
icon={<IconSetting />}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Table<Alpha>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data.items}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
size="middle"
|
||||||
|
scroll={{
|
||||||
|
y: "max(260px, calc(100vh - 670px))",
|
||||||
|
x: columns.reduce((sum, c) => sum + Number(c.width || 120), 0) + 60,
|
||||||
|
}}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys: selected,
|
||||||
|
onChange: (keys) => setSelected((keys ?? []).map(String)),
|
||||||
|
fixed: true,
|
||||||
|
}}
|
||||||
|
pagination={{
|
||||||
|
currentPage: page,
|
||||||
|
pageSize,
|
||||||
|
total: data.total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOpts: [25, 50, 100],
|
||||||
|
onPageChange: (next) => setPage(next),
|
||||||
|
onPageSizeChange: (size) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
empty={
|
||||||
|
<Empty
|
||||||
|
className="alpha-empty"
|
||||||
|
image={<div className="empty-alpha">α</div>}
|
||||||
|
title={
|
||||||
|
activeFilterCount
|
||||||
|
? "没有符合条件的 Alpha"
|
||||||
|
: "从第一个 Alpha 开始"
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
activeFilterCount
|
||||||
|
? "调整筛选条件,探索更多信号。"
|
||||||
|
: "连接 WorldQuant 并同步数据,或通过 Alpha ID 导入已有研究。"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<p className="page-footnote">
|
||||||
|
平台数据与本地研究记录独立保存 · 记录不因再次同步而覆盖
|
||||||
|
</p>
|
||||||
|
<Modal
|
||||||
|
title="导入 Alpha ID"
|
||||||
|
visible={importing}
|
||||||
|
onCancel={() => setImporting(false)}
|
||||||
|
confirmLoading={busy === "alpha_refresh"}
|
||||||
|
okText="开始导入"
|
||||||
|
onOk={() => {
|
||||||
|
const ids = [...new Set(idText.split(/[\s,,]+/).filter(Boolean))];
|
||||||
|
if (!ids.length || ids.length > 100) {
|
||||||
|
Toast.warning("每次请输入 1–100 个 Alpha ID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void newTask("alpha_refresh", ids);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
读取指定 Alpha 的平台详情。每次最多 100 个,使用空格、逗号或换行分隔。
|
||||||
|
</p>
|
||||||
|
<TextArea
|
||||||
|
aria-label="Alpha IDs"
|
||||||
|
value={idText}
|
||||||
|
onChange={setIdText}
|
||||||
|
rows={6}
|
||||||
|
placeholder="例如:aB12cD34"
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
title={`批量编辑 ${selected.length} 条研究记录`}
|
||||||
|
visible={bulkOpen}
|
||||||
|
onCancel={() => setBulkOpen(false)}
|
||||||
|
confirmLoading={busy === "bulk"}
|
||||||
|
onOk={() => void bulkSave()}
|
||||||
|
okText="保存修改"
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
添加标签
|
||||||
|
<Input
|
||||||
|
aria-label="批量添加标签"
|
||||||
|
placeholder="用逗号分隔"
|
||||||
|
value={bulk.add_tags}
|
||||||
|
onChange={(add_tags) => setBulk({ ...bulk, add_tags })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
移除标签
|
||||||
|
<Input
|
||||||
|
aria-label="批量移除标签"
|
||||||
|
value={bulk.remove_tags}
|
||||||
|
onChange={(remove_tags) => setBulk({ ...bulk, remove_tags })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
研究状态
|
||||||
|
<Select
|
||||||
|
showClear
|
||||||
|
placeholder="保持现有状态"
|
||||||
|
value={bulk.state || undefined}
|
||||||
|
optionList={stateOptions}
|
||||||
|
onChange={(state) =>
|
||||||
|
setBulk({ ...bulk, state: state ? String(state) : "" })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</Modal>
|
||||||
|
<AlphaDetail
|
||||||
|
taskPanelOpen={taskPanelOpen}
|
||||||
|
id={detailId}
|
||||||
|
version={`${version}:${localVersion}`}
|
||||||
|
timezone={account?.timezone}
|
||||||
|
onClose={() => setDetailId(null)}
|
||||||
|
onSaved={refresh}
|
||||||
|
onTask={onTask}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,905 @@
|
|||||||
|
:root {
|
||||||
|
font-family:
|
||||||
|
Inter,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
"Segoe UI",
|
||||||
|
"PingFang SC",
|
||||||
|
sans-serif;
|
||||||
|
color: #293346;
|
||||||
|
background: #f5f6fa;
|
||||||
|
font-synthesis: none;
|
||||||
|
--accent: #6355d9;
|
||||||
|
--surface: #fff;
|
||||||
|
--canvas: #f5f6fa;
|
||||||
|
--line: #e8ebf1;
|
||||||
|
--ink: #293346;
|
||||||
|
--muted: #81899a;
|
||||||
|
--semi-color-primary: var(--accent);
|
||||||
|
--semi-color-primary-hover: #7567e5;
|
||||||
|
--semi-color-primary-active: #5143c5;
|
||||||
|
--semi-color-primary-light-default: #efedfb;
|
||||||
|
--semi-border-radius-small: 6px;
|
||||||
|
--semi-border-radius-medium: 8px;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body[theme-mode] {
|
||||||
|
--semi-color-primary: var(--accent);
|
||||||
|
--semi-color-primary-hover: #7567e5;
|
||||||
|
--semi-color-primary-active: #5143c5;
|
||||||
|
--semi-color-primary-light-default: #efedfb;
|
||||||
|
margin: 0;
|
||||||
|
background: var(--canvas);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
body[theme-mode="dark"] {
|
||||||
|
--surface: #242630;
|
||||||
|
--canvas: #191b24;
|
||||||
|
--line: #343744;
|
||||||
|
--ink: #e0e4ee;
|
||||||
|
--muted: #9ba3b6;
|
||||||
|
--semi-color-primary-light-default: #343046;
|
||||||
|
}
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
p {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
letter-spacing: -0.7px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 24px 0 12px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
label:not(.semi-checkbox):not(.semi-radio) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 1.9px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 9px;
|
||||||
|
}
|
||||||
|
.inline-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.screen-center {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.workspace {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
background: #191e30;
|
||||||
|
color: #e5e6f3;
|
||||||
|
width: 224px;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
padding: 31px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
.brand > div > span {
|
||||||
|
display: block;
|
||||||
|
font-size: 7px;
|
||||||
|
letter-spacing: 1.8px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-top: 5px;
|
||||||
|
color: #8f95b3;
|
||||||
|
}
|
||||||
|
.brand-mark {
|
||||||
|
height: 36px;
|
||||||
|
width: 36px;
|
||||||
|
border-radius: 11px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 30px;
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
background: #6d60d9;
|
||||||
|
color: white;
|
||||||
|
line-height: 1;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
}
|
||||||
|
.nav-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #7e86a2;
|
||||||
|
margin: 51px 15px 13px;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
}
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 13px;
|
||||||
|
align-items: center;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #a3abc4;
|
||||||
|
padding: 13px 15px;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.nav-item:hover {
|
||||||
|
background: #232a40;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.nav-item.active {
|
||||||
|
background: #30304c;
|
||||||
|
color: #c7bffe;
|
||||||
|
}
|
||||||
|
.sidebar-bottom {
|
||||||
|
margin-top: auto;
|
||||||
|
color: #717c99;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 20px 12px 0;
|
||||||
|
line-height: 2;
|
||||||
|
}
|
||||||
|
.connection-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #a8b1c8;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.connection-line i {
|
||||||
|
height: 6px;
|
||||||
|
width: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #727d91;
|
||||||
|
}
|
||||||
|
.connection-line i.connected {
|
||||||
|
background: #6cc1a0;
|
||||||
|
}
|
||||||
|
.main-shell {
|
||||||
|
margin-left: 224px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
height: 72px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 0 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.breadcrumbs {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.breadcrumbs span {
|
||||||
|
margin: 0 13px;
|
||||||
|
color: #c1c7d1;
|
||||||
|
}
|
||||||
|
.top-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 13px;
|
||||||
|
}
|
||||||
|
.account-name {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.top-divider {
|
||||||
|
height: 21px;
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
margin: 0 6px;
|
||||||
|
}
|
||||||
|
.page-content {
|
||||||
|
padding: 36px;
|
||||||
|
max-width: 1800px;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
.page-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 26px;
|
||||||
|
}
|
||||||
|
.page-heading p {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.library-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr 1.6fr;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 23px 0;
|
||||||
|
}
|
||||||
|
.library-stats > div {
|
||||||
|
padding: 0 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.library-stats > div:last-child {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.library-stats span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.library-stats strong {
|
||||||
|
font-size: 25px;
|
||||||
|
font-weight: 550;
|
||||||
|
letter-spacing: -0.6px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.library-stats .last-sync strong {
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
margin-top: 9px;
|
||||||
|
font-weight: 450;
|
||||||
|
}
|
||||||
|
.library-panel {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.filter-panel {
|
||||||
|
padding: 21px 22px 17px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.primary-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.primary-filters > .semi-input-wrapper {
|
||||||
|
flex: 2;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
.primary-filters > .semi-select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 122px;
|
||||||
|
}
|
||||||
|
.advanced-filters {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.advanced-filters > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
.advanced-grid {
|
||||||
|
padding-top: 20px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 4px 16px;
|
||||||
|
}
|
||||||
|
.advanced-grid label {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
.range-inputs {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.range-inputs > span {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.table-toolbar {
|
||||||
|
padding: 20px 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.table-toolbar strong {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
.table-toolbar .semi-select {
|
||||||
|
min-width: 86px;
|
||||||
|
}
|
||||||
|
.count-pill {
|
||||||
|
background: var(--canvas);
|
||||||
|
padding: 3px 8px;
|
||||||
|
margin-left: 9px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.selection-text {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-left: 14px;
|
||||||
|
}
|
||||||
|
.columns-picker {
|
||||||
|
padding: 18px;
|
||||||
|
min-width: 190px;
|
||||||
|
}
|
||||||
|
.columns-picker > strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.alpha-link {
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
max-width: 165px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.alpha-link > span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 550;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.alpha-link > small,
|
||||||
|
.stacked-cell > small {
|
||||||
|
display: block;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 5px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.favorite-icon {
|
||||||
|
color: #d8aa45;
|
||||||
|
}
|
||||||
|
.stacked-cell {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.expression-cell {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
word-break: break-all;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.numeric {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.inline-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.semi-table-thead > .semi-table-row > .semi-table-row-head {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--canvas);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.semi-table-row-cell {
|
||||||
|
border-color: var(--line) !important;
|
||||||
|
}
|
||||||
|
.semi-table-pagination-outer {
|
||||||
|
padding: 8px 22px;
|
||||||
|
}
|
||||||
|
.page-footnote {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 19px 0;
|
||||||
|
}
|
||||||
|
.semi-banner {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
.text-link {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: inherit;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
.alpha-empty {
|
||||||
|
padding: 52px 18px 60px;
|
||||||
|
}
|
||||||
|
.empty-alpha {
|
||||||
|
width: 70px;
|
||||||
|
height: 70px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: var(--canvas);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: #c1beda;
|
||||||
|
font:
|
||||||
|
52px Georgia,
|
||||||
|
serif;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
.account-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 26px;
|
||||||
|
}
|
||||||
|
.panel.full-width {
|
||||||
|
grid-column: 1/-1;
|
||||||
|
}
|
||||||
|
.panel-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 27px;
|
||||||
|
}
|
||||||
|
.panel-title > .semi-icon {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--semi-color-primary-light-default);
|
||||||
|
padding: 11px;
|
||||||
|
box-sizing: content-box;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.panel-title > div {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.panel-title h2 {
|
||||||
|
font-size: 15px;
|
||||||
|
margin: 0 0 3px;
|
||||||
|
}
|
||||||
|
.panel-title p {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.credential-note {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
gap: 7px;
|
||||||
|
align-items: center;
|
||||||
|
margin: -2px 0 24px;
|
||||||
|
}
|
||||||
|
.profile-grid {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.profile-grid > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 100px 1fr;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
gap: 15px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.profile-grid > div:last-child {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.profile-grid dt {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.profile-grid dd {
|
||||||
|
margin: 0;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.preferences-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
.verification-callout {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid #e7d2a0;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 22px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.verification-callout p {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 7px 0 15px;
|
||||||
|
}
|
||||||
|
.job-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 18px 0;
|
||||||
|
}
|
||||||
|
.job-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.job-header strong {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.job-card p {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.job-numbers {
|
||||||
|
display: flex;
|
||||||
|
gap: 28px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
.job-numbers b {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
.job-card .inline-actions {
|
||||||
|
margin-top: 17px;
|
||||||
|
}
|
||||||
|
.error-text {
|
||||||
|
color: #d35968;
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.detail-title {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.detail-title h2 {
|
||||||
|
margin-bottom: 9px;
|
||||||
|
}
|
||||||
|
.detail-section {
|
||||||
|
padding: 18px 0;
|
||||||
|
}
|
||||||
|
.detail-section .semi-descriptions {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.detail-section .semi-descriptions-item {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.code-block {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--canvas);
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.75;
|
||||||
|
max-height: 500px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.check-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
font-size: 11px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.check-row > span:first-child {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.detail-save {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
.section-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.section-toolbar h3 {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.pnl-chart {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 350px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.pnl-chart text {
|
||||||
|
font: 10px system-ui;
|
||||||
|
fill: var(--muted);
|
||||||
|
}
|
||||||
|
.chart-empty {
|
||||||
|
padding: 65px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.login-story {
|
||||||
|
background: #191e30;
|
||||||
|
color: #e5e6f3;
|
||||||
|
padding: 48px 64px;
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.login-story:after {
|
||||||
|
content: "α";
|
||||||
|
position: absolute;
|
||||||
|
bottom: 45px;
|
||||||
|
right: -60px;
|
||||||
|
font:
|
||||||
|
600px Georgia,
|
||||||
|
serif;
|
||||||
|
color: #ffffff03;
|
||||||
|
line-height: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.story-copy {
|
||||||
|
margin: auto 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.story-copy .eyebrow {
|
||||||
|
color: #8f89c9;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 2.5px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.story-copy h1 {
|
||||||
|
font-size: 42px;
|
||||||
|
line-height: 1.55;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-weight: 550;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.story-copy p {
|
||||||
|
color: #949db6;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 2.1;
|
||||||
|
}
|
||||||
|
.story-signal {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
color: #aaa1eb;
|
||||||
|
margin-top: 40px;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
.login-footnote {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #747e9b;
|
||||||
|
}
|
||||||
|
.login-form-shell {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 48px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.login-form {
|
||||||
|
max-width: 365px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.login-form h2 {
|
||||||
|
font-size: 26px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.login-form > p {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 33px;
|
||||||
|
}
|
||||||
|
.login-form label {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.login-form label > .semi-input-wrapper {
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
.login-note {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
.semi-sidesheet-inner {
|
||||||
|
max-width: 100vw;
|
||||||
|
}
|
||||||
|
.semi-tabs-content {
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
@media (min-width: 1500px) {
|
||||||
|
.page-content {
|
||||||
|
padding: 42px 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.sidebar {
|
||||||
|
width: 190px;
|
||||||
|
padding: 26px 14px;
|
||||||
|
}
|
||||||
|
.main-shell {
|
||||||
|
margin-left: 190px;
|
||||||
|
}
|
||||||
|
.page-content {
|
||||||
|
padding: 26px 22px;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
padding: 0 22px;
|
||||||
|
}
|
||||||
|
.page-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.library-stats > div {
|
||||||
|
padding: 0 18px;
|
||||||
|
}
|
||||||
|
.advanced-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.preferences-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.login-story {
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
.story-copy h1 {
|
||||||
|
font-size: 34px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.sidebar {
|
||||||
|
width: 64px;
|
||||||
|
padding: 23px 8px;
|
||||||
|
}
|
||||||
|
.sidebar .brand > div,
|
||||||
|
.nav-label,
|
||||||
|
.sidebar-bottom {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.sidebar .brand {
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.nav-item {
|
||||||
|
font-size: 0;
|
||||||
|
padding: 13px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.nav-item .semi-icon {
|
||||||
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
.main-shell {
|
||||||
|
margin-left: 64px;
|
||||||
|
}
|
||||||
|
.page-content {
|
||||||
|
padding: 22px 16px;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
.account-name,
|
||||||
|
.breadcrumbs {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.top-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.page-heading {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.library-stats {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
row-gap: 22px;
|
||||||
|
}
|
||||||
|
.library-stats > div:nth-child(2) {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.library-stats .last-sync strong {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.primary-filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.primary-filters > .semi-input-wrapper {
|
||||||
|
grid-column: 1/-1;
|
||||||
|
}
|
||||||
|
.primary-filters > .semi-select {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.advanced-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.account-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.preferences-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.login-page {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.login-story {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.login-form-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
.table-toolbar {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.table-toolbar > .inline-actions {
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.profile-grid > div {
|
||||||
|
grid-template-columns: 80px 1fr;
|
||||||
|
}
|
||||||
|
.detail-title {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
export type ResearchState = "inbox" | "candidate" | "optimizing" | "archived";
|
||||||
|
export type Research = {
|
||||||
|
note: string;
|
||||||
|
tags: string[];
|
||||||
|
favorite: boolean;
|
||||||
|
state: ResearchState;
|
||||||
|
updated_at?: string;
|
||||||
|
};
|
||||||
|
export type Alpha = {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
expression_preview: string;
|
||||||
|
alpha_type: string | null;
|
||||||
|
language: string | null;
|
||||||
|
stage: string | null;
|
||||||
|
status: string | null;
|
||||||
|
hidden: boolean;
|
||||||
|
region: string | null;
|
||||||
|
universe: string | null;
|
||||||
|
sharpe: number | null;
|
||||||
|
fitness: number | null;
|
||||||
|
returns: number | null;
|
||||||
|
turnover: number | null;
|
||||||
|
margin: number | null;
|
||||||
|
drawdown: number | null;
|
||||||
|
date_created: string | null;
|
||||||
|
date_submitted: string | null;
|
||||||
|
synced_at: string;
|
||||||
|
research: Research;
|
||||||
|
};
|
||||||
|
export type AlphaDetail = Alpha & {
|
||||||
|
expression: string | null;
|
||||||
|
selection: string | null;
|
||||||
|
combo: string | null;
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
is_metrics: Record<string, unknown>;
|
||||||
|
os_metrics: Record<string, unknown>;
|
||||||
|
checks: Record<string, unknown>[];
|
||||||
|
};
|
||||||
|
export type Account = {
|
||||||
|
email: string | null;
|
||||||
|
configured: boolean;
|
||||||
|
wq_user_id: string | null;
|
||||||
|
profile: Record<string, unknown>;
|
||||||
|
connection_status: string;
|
||||||
|
connection_error: string | null;
|
||||||
|
verification_url: string | null;
|
||||||
|
last_synced_at: string | null;
|
||||||
|
display_name: string;
|
||||||
|
theme: "light" | "dark";
|
||||||
|
timezone: string;
|
||||||
|
page_size: number;
|
||||||
|
};
|
||||||
|
export type Job = {
|
||||||
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
processed: number;
|
||||||
|
failed: number;
|
||||||
|
total: number | null;
|
||||||
|
error: string | null;
|
||||||
|
next_retry_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
export type AlphaPage = {
|
||||||
|
items: Alpha[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
export type Pnl = {
|
||||||
|
cached: boolean;
|
||||||
|
points: { date: string; value: number | null }[];
|
||||||
|
fetched_at: string | null;
|
||||||
|
};
|
||||||
|
export type Facets = Record<string, string[] | number | string | null>;
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
test("account → full sync → research → resync → PnL → filtered export → logout", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const failures: string[] = [];
|
||||||
|
page.on("pageerror", (error) => failures.push(error.message));
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "登录研究工作空间" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByLabel("密码", { exact: true }).fill("incorrect");
|
||||||
|
await page.getByRole("button", { name: "进入工作空间" }).click();
|
||||||
|
await expect(page.getByText("用户名或密码不正确")).toBeVisible();
|
||||||
|
await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
|
||||||
|
await page.getByRole("button", { name: "进入工作空间" }).click();
|
||||||
|
await page.getByRole("button", { name: "个人信息", exact: true }).click();
|
||||||
|
await page
|
||||||
|
.getByRole("textbox", { name: "WorldQuant 邮箱" })
|
||||||
|
.fill("test@example.com");
|
||||||
|
await page
|
||||||
|
.getByLabel("WorldQuant 密码", { exact: true })
|
||||||
|
.fill("synthetic-password");
|
||||||
|
await page.getByRole("button", { name: "保存凭据" }).click();
|
||||||
|
await expect(page.getByText("已保存", { exact: true })).toBeVisible();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "连接 WorldQuant", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(await (await page.request.get("/api/v1/account")).json())
|
||||||
|
.connection_status,
|
||||||
|
)
|
||||||
|
.toBe("connected");
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.locator(".job-panel")).not.toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Alpha 管理", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: "同步平台数据" }).click();
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(await (await page.request.get("/api/v1/alphas")).json()).total,
|
||||||
|
{ timeout: 20000 },
|
||||||
|
)
|
||||||
|
.toBe(620);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.locator(".job-panel")).not.toBeVisible();
|
||||||
|
await expect(page.locator(".library-stats strong").first()).toHaveText("620");
|
||||||
|
await page.screenshot({
|
||||||
|
path: "../output/playwright/alpha-library.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0007");
|
||||||
|
await page.getByRole("button", { name: "查询", exact: true }).click();
|
||||||
|
await expect(page.locator(".alpha-link")).toHaveCount(1);
|
||||||
|
await page.locator(".alpha-link").click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Selection", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Combo", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByRole("tab", { name: "研究记录" }).click();
|
||||||
|
await page
|
||||||
|
.getByRole("textbox", { name: "研究备注" })
|
||||||
|
.fill("需要检验跨区域稳定性;保留这条研究假设。");
|
||||||
|
await page
|
||||||
|
.getByRole("textbox", { name: "研究标签" })
|
||||||
|
.fill("组合研究, 低换手");
|
||||||
|
await page.getByText("收藏此 Alpha", { exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: "保存研究记录" }).click();
|
||||||
|
await expect(page.getByText("研究记录已保存")).toBeVisible();
|
||||||
|
await page.getByRole("tab", { name: "PnL 曲线" }).click();
|
||||||
|
await page.getByRole("button", { name: "获取 PnL", exact: true }).click();
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(await (await page.request.get("/api/v1/alphas/TEST0007/pnl")).json())
|
||||||
|
.cached,
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.locator(".job-panel")).not.toBeVisible();
|
||||||
|
await expect(page.locator(".pnl-chart")).toBeVisible();
|
||||||
|
await page.screenshot({
|
||||||
|
path: "../output/playwright/alpha-detail.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
// Use the browser's authenticated API context to await the same durable task contract.
|
||||||
|
const job = await (
|
||||||
|
await page.request.post("/api/v1/sync-jobs", {
|
||||||
|
headers: { "X-WQ-Request": "1" },
|
||||||
|
data: { kind: "alpha_refresh", alpha_ids: ["TEST0007"] },
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(await (await page.request.get(`/api/v1/sync-jobs/${job.id}`)).json())
|
||||||
|
.status,
|
||||||
|
)
|
||||||
|
.toBe("completed");
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0007");
|
||||||
|
await page.getByRole("button", { name: "查询", exact: true }).click();
|
||||||
|
await expect(page.locator(".alpha-link")).toHaveCount(1);
|
||||||
|
await page.locator(".alpha-link").click();
|
||||||
|
await page.getByRole("tab", { name: "研究记录" }).click();
|
||||||
|
await expect(page.getByRole("textbox", { name: "研究备注" })).toHaveValue(
|
||||||
|
"需要检验跨区域稳定性;保留这条研究假设。",
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("checkbox", { name: "收藏此 Alpha" }),
|
||||||
|
).toBeChecked();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await page.getByRole("button", { name: "重置", exact: true }).click();
|
||||||
|
await expect(page.locator(".alpha-link")).toHaveCount(25);
|
||||||
|
const downloading = page.waitForEvent("download");
|
||||||
|
await page.getByRole("button", { name: /导出/ }).click();
|
||||||
|
const download = await downloading;
|
||||||
|
const contents = await readFile((await download.path())!, "utf-8");
|
||||||
|
expect(contents).toContain("TEST0619");
|
||||||
|
expect(contents).toContain("TEST0000");
|
||||||
|
expect((contents.match(/TEST\d{4}/g) ?? []).length).toBe(620);
|
||||||
|
await page.getByRole("button", { name: "退出登录" }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "登录研究工作空间" }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect((await page.request.get("/api/v1/alphas")).status()).toBe(401);
|
||||||
|
expect(failures).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("batch tags, column visibility, server pagination and saved preferences", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
|
||||||
|
await page.getByRole("button", { name: "进入工作空间" }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Alpha 管理", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
// Each test can run alone against the isolated browser acceptance server.
|
||||||
|
if ((await (await page.request.get("/api/v1/alphas")).json()).total !== 620) {
|
||||||
|
const headers = { "X-WQ-Request": "1" };
|
||||||
|
await page.request.put("/api/v1/account/credentials", {
|
||||||
|
headers,
|
||||||
|
data: { email: "test@example.com", password: "synthetic-password" },
|
||||||
|
});
|
||||||
|
await page.request.post("/api/v1/account/connect", { headers });
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(await (await page.request.get("/api/v1/account")).json())
|
||||||
|
.connection_status,
|
||||||
|
)
|
||||||
|
.toBe("connected");
|
||||||
|
await page.request.post("/api/v1/sync-jobs", {
|
||||||
|
headers,
|
||||||
|
data: { kind: "full_sync" },
|
||||||
|
});
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(await (await page.request.get("/api/v1/alphas")).json()).total,
|
||||||
|
{ timeout: 20000 },
|
||||||
|
)
|
||||||
|
.toBe(620);
|
||||||
|
}
|
||||||
|
await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST006");
|
||||||
|
await page.getByRole("button", { name: "查询", exact: true }).click();
|
||||||
|
await expect(page.locator(".alpha-link")).toHaveCount(10);
|
||||||
|
const selectAll = page.getByRole("checkbox", { name: "Select all rows" });
|
||||||
|
await selectAll.focus();
|
||||||
|
await selectAll.press("Space");
|
||||||
|
await expect(selectAll).toBeChecked();
|
||||||
|
await page.getByRole("button", { name: "批量编辑", exact: true }).click();
|
||||||
|
await page.getByRole("textbox", { name: "批量添加标签" }).fill("批量验收");
|
||||||
|
await page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.getByRole("button", { name: "confirm", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(
|
||||||
|
await (
|
||||||
|
await page.request.get(
|
||||||
|
"/api/v1/alphas?tag=" + encodeURIComponent("批量验收"),
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
).total,
|
||||||
|
)
|
||||||
|
.toBe(10);
|
||||||
|
await page.getByRole("button", { name: "显示列设置" }).click();
|
||||||
|
await page
|
||||||
|
.locator(".columns-picker")
|
||||||
|
.getByText("本地标签", { exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("columnheader", { name: "本地标签", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "显示列设置" }).click();
|
||||||
|
await page.getByRole("button", { name: "重置", exact: true }).click();
|
||||||
|
await expect(page.locator(".alpha-link")).toHaveCount(25);
|
||||||
|
const firstId = await page.locator(".alpha-link").first().innerText();
|
||||||
|
await page.locator(".semi-page-item").filter({ hasText: /^2$/ }).click();
|
||||||
|
await expect(page.locator(".alpha-link").first()).not.toHaveText(firstId);
|
||||||
|
await page.getByRole("button", { name: "个人信息", exact: true }).click();
|
||||||
|
await page.getByRole("textbox", { name: "显示名称" }).fill("Alpha 研究室");
|
||||||
|
await page.getByRole("button", { name: "保存偏好", exact: true }).click();
|
||||||
|
await expect(page.locator(".account-name")).toHaveText("Alpha 研究室");
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole("textbox", { name: "显示名称" })).toHaveValue(
|
||||||
|
"Alpha 研究室",
|
||||||
|
);
|
||||||
|
await page.screenshot({
|
||||||
|
path: "../output/playwright/account.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"allowImportingTsExtensions": true
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: { "/api": process.env.WQ_DEV_API ?? "http://127.0.0.1:8000" },
|
||||||
|
},
|
||||||
|
build: { chunkSizeWarningLimit: 1500 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Create deployment secrets locally, without printing or replacing existing secrets."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--output", type=Path, default=Path(__file__).resolve().parent.parent / ".env")
|
||||||
|
args = parser.parse_args()
|
||||||
|
content = "\n".join([
|
||||||
|
"ADMIN_USERNAME=admin",
|
||||||
|
f"ADMIN_PASSWORD={secrets.token_urlsafe(24)}",
|
||||||
|
f"POSTGRES_PASSWORD={secrets.token_hex(24)}",
|
||||||
|
f"ENCRYPTION_KEY={base64.urlsafe_b64encode(secrets.token_bytes(32)).decode()}",
|
||||||
|
"LOCAL_PORT=8080", "DOMAIN=alpha.example.com", "",
|
||||||
|
])
|
||||||
|
try:
|
||||||
|
fd = os.open(args.output, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||||
|
except FileExistsError:
|
||||||
|
raise SystemExit(f"Already exists; left unchanged: {args.output}") from None
|
||||||
|
with os.fdopen(fd, "w") as output:
|
||||||
|
output.write(content)
|
||||||
|
print(f"Created {args.output}; read ADMIN_PASSWORD there for the initial login.")
|
||||||
Reference in New Issue
Block a user