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:
yuxuanhui
2026-09-07 14:54:20 +08:00
commit 3cd280d068
53 changed files with 10888 additions and 0 deletions
+19
View File
@@ -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
+11
View File
@@ -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
View File
@@ -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
+18
View File
@@ -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
}
}
+15
View File
@@ -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"]
+12
View File
@@ -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
+179
View File
@@ -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)。
+3
View File
@@ -0,0 +1,3 @@
[alembic]
script_location = migrations
prepend_sys_path = .
View File
+199
View File
@@ -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"])
+33
View File
@@ -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())
+28
View File
@@ -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
+8
View File
@@ -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)
+390
View File
@@ -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()
+498
View File
@@ -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
+123
View File
@@ -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)
+281
View File
@@ -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
+65
View File
@@ -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, "系统登录已过期,请重新登录")
+211
View File
@@ -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")
+28
View File
@@ -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())
+14
View File
@@ -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 ###
+27
View File
@@ -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"]
View File
+135
View File
@@ -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
+71
View File
@@ -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}
+198
View File
@@ -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()
+232
View File
@@ -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
+251
View File
@@ -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"
+186
View File
@@ -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()
+699
View File
@@ -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" },
]
+59
View File
@@ -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:
+57
View File
@@ -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:
+67
View File
@@ -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、查询和查看详情、维护本地研究记录,重启与再次同步后数据仍然可靠。
+56
View File
@@ -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 能力尚未实现。
+10
View File
@@ -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>
+30
View File
@@ -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"
}
}
+30
View File
@@ -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,
},
],
});
+3068
View File
File diff suppressed because it is too large Load Diff
+328
View File
@@ -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>
);
}
+111
View File
@@ -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();
}
+369
View File
@@ -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>
);
}
+153
View File
@@ -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>
);
}
+60
View File
@@ -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>
);
}
+11
View File
@@ -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>,
);
+330
View File
@@ -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>
</>
);
}
+781
View File
@@ -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}
/>
</>
);
}
+905
View File
@@ -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;
}
}
+77
View File
@@ -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>;
+223
View File
@@ -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,
});
});
+19
View File
@@ -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"]
}
+10
View File
@@ -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 },
});
+25
View File
@@ -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.")