From 4c2ea5521d21618b68fade23511d5fd31e262039 Mon Sep 17 00:00:00 2001 From: cirry <812852553@qq.com> Date: Fri, 14 Aug 2026 22:37:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=A8=E5=B1=8F=E4=B8=AA=E8=82=A1?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A2=84=E8=A7=88=20+=20Tailwind=20=E6=94=B9?= =?UTF-8?q?=E7=89=88=20+=20=E8=B4=A6=E5=8F=B7=E9=89=B4=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +- .mcp.json | 2 +- README.md | 38 +- backend/.env | 10 - backend/.env.example | 17 +- backend/alembic.ini | 38 ++ backend/alembic/env.py | 60 ++ backend/alembic/script.py.mako | 24 + .../alembic/versions/20260814_01_add_auth.py | 64 +++ .../versions/208b0c5d302a_temp_full_schema.py | 37 ++ backend/app/api.py | 8 +- backend/app/auth.py | 105 ++++ backend/app/auth_api.py | 155 +++++ backend/app/cli/__init__.py | 1 + backend/app/cli/create_user.py | 79 +++ backend/app/config.py | 27 +- backend/app/main.py | 28 +- backend/app/models.py | 46 +- backend/app/schemas.py | 18 + backend/pyproject.toml | 3 +- backend/smoke_test.py | 183 +++--- backend/uv.lock | 231 +++++++- frontend/index.html | 6 +- frontend/package.json | 7 +- frontend/pnpm-lock.yaml | 538 ++++++++++++------ frontend/src/App.vue | 51 +- frontend/src/api/client.ts | 120 ++-- frontend/src/api/types.ts | 49 ++ frontend/src/components/BacktestForm.vue | 117 ++-- frontend/src/components/ConditionChips.vue | 35 +- frontend/src/components/DetailKLine.vue | 180 ++++++ frontend/src/components/EquityChart.vue | 21 +- frontend/src/components/KLineChart.vue | 394 ++++++++----- frontend/src/components/MetricsPanel.vue | 35 +- frontend/src/components/ScreenerForm.vue | 54 +- frontend/src/components/ScreenerTable.vue | 177 ++++-- .../src/components/StockDetailOverlay.vue | 286 ++++++++++ frontend/src/components/SyncStatusBar.vue | 46 +- frontend/src/main.ts | 23 +- frontend/src/router.ts | 19 + frontend/src/stores/auth.ts | 60 ++ frontend/src/style.css | 261 ++++----- frontend/src/views/BacktestView.vue | 18 +- frontend/src/views/HomeView.vue | 43 +- frontend/src/views/LoginView.vue | 80 +++ frontend/src/views/ScreenerView.vue | 28 +- frontend/vite.config.ts | 3 +- 47 files changed, 2937 insertions(+), 893 deletions(-) delete mode 100644 backend/.env create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/20260814_01_add_auth.py create mode 100644 backend/alembic/versions/208b0c5d302a_temp_full_schema.py create mode 100644 backend/app/auth.py create mode 100644 backend/app/auth_api.py create mode 100644 backend/app/cli/__init__.py create mode 100644 backend/app/cli/create_user.py create mode 100644 frontend/src/components/DetailKLine.vue create mode 100644 frontend/src/components/StockDetailOverlay.vue create mode 100644 frontend/src/stores/auth.ts create mode 100644 frontend/src/views/LoginView.vue diff --git a/.gitignore b/.gitignore index bd87e5f..6cf1ed1 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,10 @@ frontend/dist/ .cache/ # ---------- Env / Secrets ---------- -# .env 直接提交(私有仓库,配置含连接串即开即用) +.env +*.env +!.env.example +!*.env.example *.pem *.key diff --git a/.mcp.json b/.mcp.json index 9d966d6..7c86651 100644 --- a/.mcp.json +++ b/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "tushareMcp": { "type": "http", - "url": "https://api.tushare.pro/mcp/token=d0bc5620d6523ae40f379ed4415576f58dca2361f2f47a68cdcd0a98" + "url": "https://api.tushare.pro/mcp/token=22edda0afe44c0609a187ff1ac0bb2a8fc61430f490ec19f7fec8390" } } } \ No newline at end of file diff --git a/README.md b/README.md index 592056b..057ee8d 100644 --- a/README.md +++ b/README.md @@ -131,11 +131,15 @@ pnpm dev # http://localhost:5173 后端配置通过 `backend/.env`(复制 `.env.example`)或环境变量: ```bash -# 数据库(MVP 默认 SQLite,零配置) -DATABASE_URL=sqlite+aiosqlite:///./stock.db +# 数据库(开发、测试、生产统一使用 PostgreSQL) +DATABASE_URL=postgresql+asyncpg://user:password@host:5432/stock -# 切到你自己的 PostgreSQL / TimescaleDB -# DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/stock +# 鉴权:本地 HTTP 为 false;线上 HTTPS 必须为 true +AUTH_COOKIE_SECURE=false +AUTH_SESSION_HOURS=12 +CORS_ORIGINS=http://localhost:5173 +# 生产建议关闭接口文档 +EXPOSE_API_DOCS=true # 真实数据源(Tushare Pro;免费版即可。留空则仅 DEMO 合成数据可用) TUSHARE_TOKEN=你的token @@ -149,6 +153,31 @@ DATA_DEFAULT_START=20200101 # COMMISSION_MIN=5.0 # 最低 5 元 ``` +### 初始化登录系统 + +数据库结构由 Alembic 管理。首次部署或更新代码后执行: + +```bash +cd backend +uv run alembic upgrade head +``` + +系统不提供注册接口。使用服务器交互式命令创建唯一用户;再次执行会重置密码并吊销该用户的所有旧会话: + +```bash +uv run python -m app.cli.create_user --username admin +``` + +密码使用 Argon2id 保存。浏览器只接收 `HttpOnly` 会话 Cookie,数据库只保存随机会话 Token 的 SHA-256 摘要。默认会话有效期 12 小时,连续 5 次登录失败后锁定 15 分钟。 + +生产环境必须启用 HTTPS,并设置: + +```bash +AUTH_COOKIE_SECURE=true +CORS_ORIGINS=https://你的域名 +EXPOSE_API_DOCS=false +``` + ### 切到 PostgreSQL + TimescaleDB 1. 目标库执行 `CREATE EXTENSION IF NOT EXISTS timescaledb;` @@ -202,6 +231,7 @@ pnpm preview # 本地预览构建产物 ### 后端生产运行 ```bash cd backend +uv run alembic upgrade head uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 # 或 gunicorn(Linux):uv run gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker ``` diff --git a/backend/.env b/backend/.env deleted file mode 100644 index d6aeddc..0000000 --- a/backend/.env +++ /dev/null @@ -1,10 +0,0 @@ -DATABASE_URL=postgresql+asyncpg://postgres:Cirry0115@cirry.cn:5432/stock -TUSHARE_TOKEN=22edda0afe44c0609a187ff1ac0bb2a8fc61430f490ec19f7fec8390 -DATA_ADJUST=qfq -DATA_DEFAULT_START=20200101 - -# ---- LLM(智能选股;智谱 GLM,OpenAI 兼容协议)---- -# key 在 https://bigmodel.cn 控制台获取,格式形如 xxxxxxxx.yyyyyyyy(id.secret) -LLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4 -LLM_API_KEY=ea24bbdd3d2d4dd2b8f03de4c9a5d984.9X1Hz1yKx0VKSnrU -LLM_MODEL=glm-5.2 diff --git a/backend/.env.example b/backend/.env.example index a12d6b4..2fbbddd 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,9 +1,16 @@ -# ---- Database ---- -# MVP 默认 SQLite,零配置即可跑 -DATABASE_URL=sqlite+aiosqlite:///./stock.db +# ---- Database(开发、测试、生产都使用 PostgreSQL)---- +DATABASE_URL=postgresql+asyncpg://user:password@host:5432/stock -# 切到你自己的 PostgreSQL(TimescaleDB 是 Postgres 扩展,目标库执行 CREATE EXTENSION timescaledb; 即可) -# DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/stock +# ---- Authentication ---- +# 本地 HTTP 开发为 false;线上 HTTPS 必须为 true。 +AUTH_COOKIE_SECURE=false +AUTH_SESSION_HOURS=12 +AUTH_MAX_FAILED_LOGINS=5 +AUTH_LOCK_MINUTES=15 +AUTH_MIN_PASSWORD_LENGTH=12 +CORS_ORIGINS=http://localhost:5173 +# 生产建议 false,关闭 /docs 与 /openapi.json。 +EXPOSE_API_DOCS=true # ---- 真实数据源(Tushare Pro;免费版即可。留空则仅 DEMO 合成数据可用)---- TUSHARE_TOKEN=你的token diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..ff1aa6e --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = %(here)s/alembic +prepend_sys_path = . +path_separator = os + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..83bbdb6 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import async_engine_from_config + +from app.config import settings +from app.db import Base +from app import models # noqa: F401 + + +config = context.config +config.set_main_option("sqlalchemy.url", settings.database_url.replace("%", "%%")) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=settings.database_url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata, compare_type=True) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..553d678 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/20260814_01_add_auth.py b/backend/alembic/versions/20260814_01_add_auth.py new file mode 100644 index 0000000..e2fd907 --- /dev/null +++ b/backend/alembic/versions/20260814_01_add_auth.py @@ -0,0 +1,64 @@ +"""add users and database-backed auth sessions + +Revision ID: 20260814_01 +Revises: +Create Date: 2026-08-14 18:00:00 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260814_01" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("username", sa.String(length=64), nullable=False), + sa.Column("password_hash", sa.String(length=255), nullable=False), + sa.Column("password_algo", sa.String(length=16), nullable=False, server_default="argon2id"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("failed_login_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True), + sa.Column("password_changed_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("username", name="uq_users_username"), + ) + op.create_index("ix_users_is_active", "users", ["is_active"]) + + op.create_table( + "auth_sessions", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.Column("token_hash", sa.String(length=64), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("ip_address", sa.String(length=45), nullable=True), + sa.Column("user_agent", sa.String(length=512), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("token_hash", name="uq_auth_sessions_token_hash"), + ) + op.create_index("ix_auth_sessions_user_id", "auth_sessions", ["user_id"]) + op.create_index("ix_auth_sessions_expires_at", "auth_sessions", ["expires_at"]) + op.create_index("ix_auth_sessions_revoked_at", "auth_sessions", ["revoked_at"]) + + +def downgrade() -> None: + op.drop_index("ix_auth_sessions_revoked_at", table_name="auth_sessions") + op.drop_index("ix_auth_sessions_expires_at", table_name="auth_sessions") + op.drop_index("ix_auth_sessions_user_id", table_name="auth_sessions") + op.drop_table("auth_sessions") + op.drop_index("ix_users_is_active", table_name="users") + op.drop_table("users") diff --git a/backend/alembic/versions/208b0c5d302a_temp_full_schema.py b/backend/alembic/versions/208b0c5d302a_temp_full_schema.py new file mode 100644 index 0000000..217b395 --- /dev/null +++ b/backend/alembic/versions/208b0c5d302a_temp_full_schema.py @@ -0,0 +1,37 @@ +"""baseline existing business tables for fresh PostgreSQL databases + +Revision ID: 208b0c5d302a +Revises: 20260814_01 +Create Date: 2026-08-14 18:14:07.548098 +""" +from typing import Sequence, Union + +from alembic import op + +from app.db import Base +from app import models # noqa: F401 + + +revision: str = '208b0c5d302a' +down_revision: Union[str, Sequence[str], None] = '20260814_01' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + # 线上库在引入 Alembic 前已有这些表,checkfirst 保证迁移无损;空库则完整创建。 + for table_name in ( + "candles", + "backtest_runs", + "stock_basic", + "market_daily", + "daily_snapshot", + "trade_calendar", + ): + Base.metadata.tables[table_name].create(bind=bind, checkfirst=True) + + +def downgrade() -> None: + # 这些表可能包含迁移接管前的数据,禁止自动降级删除。 + pass diff --git a/backend/app/api.py b/backend/app/api.py index 9a6a266..d6e8bfa 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -17,6 +17,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from .backtest.engine import BacktestConfig, run_backtest +from .auth import require_user from .backtest.strategies import build_strategy from .config import settings from .data import fetcher, repository @@ -48,7 +49,7 @@ from .screener import engine, market_sync from .screener.engine import DataNotReadyError from .screener.llm import ScreenerError, parse_conditions -router = APIRouter(prefix="/api") +router = APIRouter(prefix="/api", dependencies=[Depends(require_user)]) def _series_to_jsonable(s: pd.Series) -> list[float | None]: @@ -66,11 +67,6 @@ def _rows_to_bars(rows) -> list[Bar]: return [Bar(ts=r.ts, open=r.open, high=r.high, low=r.low, close=r.close, volume=r.volume) for r in rows] -@router.get("/health") -async def health() -> dict: - return {"status": "ok"} - - @router.get("/candles/{symbol}", response_model=list[CandleOut]) async def get_candles( symbol: str, diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..fabb6fa --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,105 @@ +"""密码校验、数据库会话与 FastAPI 鉴权依赖。""" +from __future__ import annotations + +import hashlib +import secrets +from datetime import datetime, timedelta, timezone + +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHashError, VerificationError +from fastapi import Cookie, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from .config import settings +from .db import get_session +from .models import AuthSession, User + + +password_hasher = PasswordHasher( + time_cost=2, + memory_cost=19_456, + parallelism=1, + hash_len=32, + salt_len=16, +) + +# 不存在的用户名也执行一次 Argon2,降低用户名枚举与计时攻击差异。 +_DUMMY_HASH = password_hasher.hash("not-a-real-password") + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def hash_password(password: str) -> str: + return password_hasher.hash(password) + + +def verify_password(password_hash: str, password: str) -> bool: + try: + return password_hasher.verify(password_hash, password) + except (VerificationError, InvalidHashError): + return False + + +def verify_dummy_password(password: str) -> None: + verify_password(_DUMMY_HASH, password) + + +def new_session_token() -> str: + return secrets.token_urlsafe(48) + + +def token_digest(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def session_expiry() -> datetime: + return utcnow() + timedelta(hours=settings.auth_session_hours) + + +def unauthorized(detail: str = "登录状态无效或已过期") -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=detail, + headers={"WWW-Authenticate": "Session"}, + ) + + +async def get_auth_session( + token: str, + db: AsyncSession, +) -> AuthSession | None: + now = utcnow() + stmt = ( + select(AuthSession) + .options(selectinload(AuthSession.user)) + .where( + AuthSession.token_hash == token_digest(token), + AuthSession.revoked_at.is_(None), + AuthSession.expires_at > now, + ) + ) + auth_session = (await db.execute(stmt)).scalar_one_or_none() + if auth_session is None or not auth_session.user.is_active: + return None + + # 避免每个 API 请求都写数据库;最多每 5 分钟刷新一次活动时间。 + if auth_session.last_seen_at < now - timedelta(minutes=5): + auth_session.last_seen_at = now + await db.commit() + return auth_session + + +async def require_user( + stock_session: str | None = Cookie(default=None, alias=settings.auth_cookie_name), + db: AsyncSession = Depends(get_session), +) -> User: + if not stock_session: + raise unauthorized() + auth_session = await get_auth_session(stock_session, db) + if auth_session is None: + raise unauthorized() + return auth_session.user diff --git a/backend/app/auth_api.py b/backend/app/auth_api.py new file mode 100644 index 0000000..85c2150 --- /dev/null +++ b/backend/app/auth_api.py @@ -0,0 +1,155 @@ +"""只登录、不注册的鉴权 API。""" +from __future__ import annotations + +from datetime import timedelta + +from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status +from sqlalchemy import delete, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from .auth import ( + get_auth_session, + new_session_token, + require_user, + session_expiry, + token_digest, + utcnow, + verify_dummy_password, + verify_password, +) +from .config import settings +from .db import get_session +from .models import AuthSession, User +from .schemas import CurrentUserOut, LoginRequest, LoginResponse + + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +def set_session_cookie(response: Response, token: str) -> None: + response.set_cookie( + key=settings.auth_cookie_name, + value=token, + max_age=settings.auth_session_hours * 60 * 60, + path="/api", + secure=settings.auth_cookie_secure, + httponly=True, + samesite="strict", + ) + + +def clear_session_cookie(response: Response) -> None: + response.delete_cookie( + key=settings.auth_cookie_name, + path="/api", + secure=settings.auth_cookie_secure, + httponly=True, + samesite="strict", + ) + + +@router.post("/login", response_model=LoginResponse) +async def login( + payload: LoginRequest, + request: Request, + response: Response, + db: AsyncSession = Depends(get_session), +) -> LoginResponse: + now = utcnow() + username = payload.username.strip() + user = (await db.execute(select(User).where(User.username == username))).scalar_one_or_none() + + if user is None: + verify_dummy_password(payload.password) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误") + + if not user.is_active: + verify_dummy_password(payload.password) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误") + + if user.locked_until is not None and user.locked_until > now: + raise HTTPException( + status_code=status.HTTP_423_LOCKED, + detail=f"登录失败次数过多,请在 {user.locked_until.isoformat()} 后重试", + ) + + if not verify_password(user.password_hash, payload.password): + user.failed_login_count += 1 + if user.failed_login_count >= settings.auth_max_failed_logins: + user.failed_login_count = 0 + user.locked_until = now + timedelta(minutes=settings.auth_lock_minutes) + user.updated_at = now + await db.commit() + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误") + + user.failed_login_count = 0 + user.locked_until = None + user.last_login_at = now + user.updated_at = now + + # 登录时顺便清理过期或已吊销会话,避免单用户长期运行累积垃圾数据。 + await db.execute( + delete(AuthSession).where( + (AuthSession.expires_at <= now) | (AuthSession.revoked_at.is_not(None)) + ) + ) + + token = new_session_token() + expires_at = session_expiry() + db.add( + AuthSession( + user_id=user.id, + token_hash=token_digest(token), + created_at=now, + expires_at=expires_at, + last_seen_at=now, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent", "")[:512] or None, + ) + ) + await db.commit() + set_session_cookie(response, token) + response.headers["Cache-Control"] = "no-store" + return LoginResponse(user=CurrentUserOut.model_validate(user), expires_at=expires_at) + + +@router.get("/me", response_model=CurrentUserOut) +async def me(user: User = Depends(require_user)) -> CurrentUserOut: + return CurrentUserOut.model_validate(user) + + +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +async def logout( + response: Response, + stock_session: str | None = Cookie(default=None, alias=settings.auth_cookie_name), + db: AsyncSession = Depends(get_session), +) -> Response: + if stock_session: + await db.execute( + update(AuthSession) + .where(AuthSession.token_hash == token_digest(stock_session)) + .values(revoked_at=utcnow()) + ) + await db.commit() + clear_session_cookie(response) + response.status_code = status.HTTP_204_NO_CONTENT + response.headers["Cache-Control"] = "no-store" + return response + + +@router.post("/logout-all", status_code=status.HTTP_204_NO_CONTENT) +async def logout_all( + response: Response, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_session), +) -> Response: + await db.execute( + update(AuthSession) + .where(AuthSession.user_id == user.id, AuthSession.revoked_at.is_(None)) + .values(revoked_at=utcnow()) + ) + await db.commit() + clear_session_cookie(response) + response.status_code = status.HTTP_204_NO_CONTENT + response.headers["Cache-Control"] = "no-store" + return response diff --git a/backend/app/cli/__init__.py b/backend/app/cli/__init__.py new file mode 100644 index 0000000..867532f --- /dev/null +++ b/backend/app/cli/__init__.py @@ -0,0 +1 @@ +"""服务器侧管理命令。""" diff --git a/backend/app/cli/create_user.py b/backend/app/cli/create_user.py new file mode 100644 index 0000000..5bced5a --- /dev/null +++ b/backend/app/cli/create_user.py @@ -0,0 +1,79 @@ +"""创建唯一后台用户或重置其密码,不提供 HTTP 注册入口。""" +from __future__ import annotations + +import argparse +import asyncio +import getpass +from datetime import datetime, timezone + +from sqlalchemy import delete, select + +from app.auth import hash_password +from app.config import settings +from app.db import async_session, engine +from app.models import AuthSession, User + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="创建后台用户或重置密码") + parser.add_argument("--username", default="admin", help="登录用户名,默认 admin") + return parser.parse_args() + + +async def upsert_user(username: str, password: str) -> str: + now = datetime.now(timezone.utc) + async with async_session() as db: + user = (await db.execute(select(User).where(User.username == username))).scalar_one_or_none() + if user is None: + user = User( + username=username, + password_hash=hash_password(password), + password_algo="argon2id", + is_active=True, + failed_login_count=0, + password_changed_at=now, + created_at=now, + updated_at=now, + ) + db.add(user) + action = "created" + else: + user.password_hash = hash_password(password) + user.password_algo = "argon2id" + user.is_active = True + user.failed_login_count = 0 + user.locked_until = None + user.password_changed_at = now + user.updated_at = now + await db.execute(delete(AuthSession).where(AuthSession.user_id == user.id)) + action = "updated" + await db.commit() + return action + + +async def async_main() -> None: + args = parse_args() + username = args.username.strip() + if not username or len(username) > 64: + raise SystemExit("用户名长度必须为 1-64 个字符") + + password = getpass.getpass("Password: ") + confirm = getpass.getpass("Confirm password: ") + if password != confirm: + raise SystemExit("两次密码输入不一致") + if len(password) < settings.auth_min_password_length: + raise SystemExit(f"密码至少需要 {settings.auth_min_password_length} 个字符") + + try: + action = await upsert_user(username, password) + print(f"User {username!r} {action}. All previous sessions were revoked.") + finally: + await engine.dispose() + + +def main() -> None: + asyncio.run(async_main()) + + +if __name__ == "__main__": + main() diff --git a/backend/app/config.py b/backend/app/config.py index 7e65ec5..7e8111b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,4 +1,5 @@ """应用配置(pydantic-settings)。可由 .env / 环境变量覆盖。""" +from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -7,8 +8,18 @@ class Settings(BaseSettings): app_name: str = "Stock Backtest" - # 默认 SQLite 零配置;切 Postgres/TimescaleDB 只改这一行 - database_url: str = "sqlite+aiosqlite:///./stock.db" + # 开发、测试、生产统一使用 PostgreSQL,避免不同数据库行为产生偏差。 + database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/stock" + + # ---- 鉴权 ---- + auth_cookie_name: str = "stock_session" + auth_cookie_secure: bool = False # 生产 HTTPS 必须覆盖为 true + auth_session_hours: int = 12 + auth_max_failed_logins: int = 5 + auth_lock_minutes: int = 15 + auth_min_password_length: int = 12 + cors_origins: str = "http://localhost:5173" + expose_api_docs: bool = True # 真实数据源 tushare_token: str = "" # Tushare Pro token(主数据源) @@ -33,5 +44,17 @@ class Settings(BaseSettings): commission_min: float = 5.0 # 最低 5 元 slippage_rate: float = 0.0005 # 滑点近似(按价格比例) + @model_validator(mode="after") + def require_postgresql(self) -> "Settings": + if not self.database_url.startswith("postgresql+asyncpg://"): + raise ValueError("DATABASE_URL 必须使用 postgresql+asyncpg://,本项目不再支持 SQLite") + if self.auth_session_hours <= 0: + raise ValueError("AUTH_SESSION_HOURS 必须大于 0") + return self + + @property + def allowed_origins(self) -> list[str]: + return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] + settings = Settings() diff --git a/backend/app/main.py b/backend/app/main.py index f107368..e11ec3c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,22 +1,22 @@ -"""FastAPI 入口。 - -启动时自动建表(MVP 用 create_all;阶段1 切 Alembic 迁移,含 TimescaleDB hypertable)。 -""" +"""FastAPI 入口。数据库结构统一由 Alembic 管理。""" from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import text from .api import router -from .db import Base, engine -from . import models # noqa: F401 —— 注册 ORM 到 Base.metadata +from .auth_api import router as auth_router +from .config import settings +from .db import engine @asynccontextmanager async def lifespan(app: FastAPI): - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) + async with engine.connect() as conn: + await conn.execute(text("SELECT 1")) yield + await engine.dispose() app = FastAPI( @@ -24,20 +24,28 @@ app = FastAPI( description="历史回测 + 回放式模拟平台(A 股为主,不做实盘)", version="0.1.0", lifespan=lifespan, + docs_url="/docs" if settings.expose_api_docs else None, + redoc_url=None, + openapi_url="/openapi.json" if settings.expose_api_docs else None, ) -# 开发期允许前端 dev server 跨域;上线收窄 origins app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=settings.allowed_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) +app.include_router(auth_router) app.include_router(router) +@app.get("/api/health") +async def health() -> dict: + return {"status": "ok"} + + @app.get("/") async def root() -> dict: return {"name": "Stock Backtest API", "docs": "/docs"} diff --git a/backend/app/models.py b/backend/app/models.py index 6146b08..135c344 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -9,8 +9,8 @@ Candle 表设计与 TimescaleDB hypertable 完全兼容:将来在目标 PG 库 """ from datetime import datetime -from sqlalchemy import DateTime, Float, Integer, String, UniqueConstraint -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import BigInteger, Boolean, DateTime, Float, ForeignKey, Integer, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship from .db import Base @@ -130,3 +130,45 @@ class TradeCalendar(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) trade_date: Mapped[str] = mapped_column(String(8), unique=True, index=True) # YYYYMMDD + + +class User(Base): + """后台登录用户。系统不提供注册接口,只能通过服务器命令创建或改密。""" + + __tablename__ = "users" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + username: Mapped[str] = mapped_column(String(64), unique=True) + password_hash: Mapped[str] = mapped_column(String(255)) + password_algo: Mapped[str] = mapped_column(String(16), default="argon2id") + is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + failed_login_count: Mapped[int] = mapped_column(Integer, default=0) + locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + password_changed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + + sessions: Mapped[list["AuthSession"]] = relationship( + back_populates="user", cascade="all, delete-orphan" + ) + + +class AuthSession(Base): + """服务端会话。数据库只保存随机 Token 的 SHA-256 摘要。""" + + __tablename__ = "auth_sessions" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + token_hash: Mapped[str] = mapped_column(String(64), unique=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) + ip_address: Mapped[str | None] = mapped_column(String(45)) + user_agent: Mapped[str | None] = mapped_column(String(512)) + + user: Mapped[User] = relationship(back_populates="sessions") diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 119a5d5..f5867dd 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -202,3 +202,21 @@ class PreviewResponse(BaseModel): candles: list[CandleOut] indicators: dict[str, dict[str, list[float | None]]] = Field(default_factory=dict) # indicators 形如 {"ma": {"ma5": [...], ...}, "macd": {"dif": ...}, "kdj": {...}, "rsi": {...}, "boll": {...}} + + +# ---------- Auth ---------- +class LoginRequest(BaseModel): + username: str = Field(min_length=1, max_length=64) + password: str = Field(min_length=1, max_length=1024) + + +class CurrentUserOut(BaseModel): + id: int + username: str + + model_config = {"from_attributes": True} + + +class LoginResponse(BaseModel): + user: CurrentUserOut + expires_at: datetime diff --git a/backend/pyproject.toml b/backend/pyproject.toml index ae8e314..25341fb 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -9,12 +9,13 @@ dependencies = [ "pydantic>=2.7", "pydantic-settings>=2.3", "sqlalchemy>=2.0", - "aiosqlite>=0.20", "asyncpg>=0.29", # PostgreSQL 异步驱动(连你已有的 Postgres / TimescaleDB) "numpy>=1.26", "pandas>=2.2", "tushare>=1.4", "httpx>=0.28.1", + "argon2-cffi>=25.1.0", + "alembic>=1.19.1", ] [tool.uv] diff --git a/backend/smoke_test.py b/backend/smoke_test.py index cd62161..45a9b98 100644 --- a/backend/smoke_test.py +++ b/backend/smoke_test.py @@ -1,100 +1,117 @@ -"""开发自检脚本:跑一遍 /health 与 /backtest,打印结果。 +"""使用线上 PostgreSQL 跑鉴权与核心 API 自检,临时用户会自动清理。 -用 FastAPI TestClient(无需起服务器,同进程验证全链路)。 -用法: uv run --with httpx --directory backend python smoke_test.py +用法:uv run --directory backend python smoke_test.py """ +from __future__ import annotations + +import asyncio import json +import secrets +from datetime import datetime, timedelta, timezone -from fastapi.testclient import TestClient +import httpx +from sqlalchemy import delete, select +from app.auth import hash_password, token_digest +from app.db import async_session, engine from app.main import app +from app.models import AuthSession, MarketDaily, User -# 必须用 with:lifespan(建表)只在进入上下文时执行 -with TestClient(app) as c: - r = c.get("/api/health") - print("== /api/health ==", r.status_code, r.json()) - r = c.post( - "/api/backtest", - json={ - "symbol": "DEMO", - "strategy": "macd_cross", - "params": {"fast": 12, "slow": 26, "signal": 9}, - "initial_cash": 100000.0, - "fast_mode": False, - }, - ) - print("== /api/backtest ==", r.status_code) - if r.status_code != 200: - print("ERROR:", r.text) - raise SystemExit(1) +async def main() -> None: + username = f"smoke_{secrets.token_hex(6)}" + password = secrets.token_urlsafe(24) + now = datetime.now(timezone.utc) - d = r.json() - print("candles :", len(d["candles"])) - print("signals :", len(d["signals"]), "(买卖点)") - print("equity pts :", len(d["equity"])) - print("final_cash :", round(d["final_cash"], 2)) - print("final_pos :", d["final_position"]) - print("metrics :", json.dumps(d["metrics"], ensure_ascii=False, indent=2)) - print("first signal :", d["signals"][0] if d["signals"] else None) + async with async_session() as db: + db.add( + User( + username=username, + password_hash=hash_password(password), + password_algo="argon2id", + is_active=True, + failed_login_count=0, + password_changed_at=now, + created_at=now, + updated_at=now, + ) + ) + await db.commit() - assert len(d["candles"]) > 100 + try: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + health = await client.get("/api/health") + protected = await client.post("/api/backtest", json={}) + bad_login = await client.post( + "/api/auth/login", json={"username": username, "password": "wrong-password"} + ) + login = await client.post( + "/api/auth/login", json={"username": username, "password": password} + ) + print("health:", health.status_code) + print("protected without login:", protected.status_code) + print("bad login:", bad_login.status_code) + print("login:", login.status_code) + assert health.status_code == 200 + assert protected.status_code == 401 + assert bad_login.status_code == 401 + assert login.status_code == 200 + assert client.cookies.get("stock_session") - # 周期聚合:周线 K 线数应明显少于日线 - rw = c.post( - "/api/backtest", - json={"symbol": "DEMO", "timeframe": "1w", "strategy": "macd_cross", - "params": {"fast": 12, "slow": 26, "signal": 9}, "initial_cash": 100000.0}, - ) - wd = rw.json() - print("== weekly ==", rw.status_code, "candles:", len(wd["candles"]), "vs daily", len(d["candles"])) - assert rw.status_code == 200 - assert len(wd["candles"]) < len(d["candles"]) + me = await client.get("/api/auth/me") + assert me.status_code == 200 and me.json()["username"] == username - print("\n✅ 后端全链路自检通过(含周期聚合)") + daily = await client.post( + "/api/backtest", + json={ + "symbol": "DEMO", + "strategy": "macd_cross", + "params": {"fast": 12, "slow": 26, "signal": 9}, + "initial_cash": 100000.0, + "fast_mode": False, + }, + ) + assert daily.status_code == 200, daily.text + result = daily.json() + assert len(result["candles"]) > 100 + print("backtest metrics:", json.dumps(result["metrics"], ensure_ascii=False)) - # ---------- 智能选股 ---------- - print("\n== 智能选股 ==") + token = client.cookies.get("stock_session") + assert token + async with async_session() as db: + auth_session = ( + await db.execute( + select(AuthSession).where(AuthSession.token_hash == token_digest(token)) + ) + ).scalar_one() + auth_session.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) + await db.commit() + assert (await client.get("/api/auth/me")).status_code == 401 + print("expired session: 401") - # 1) JSON 提取容错(不联网):代码围栏 / 多余文本 - from app.screener.llm import _extract_json - assert _extract_json('```json\n{"a": 1}\n```') == {"a": 1} - assert _extract_json('好的,结果如下:{"indicator": [], "snapshot": []} 谢谢')["indicator"] == [] - print("_extract_json 围栏/噪音容错 ✅") + login = await client.post( + "/api/auth/login", json={"username": username, "password": password} + ) + assert login.status_code == 200 + logout = await client.post("/api/auth/logout") + assert logout.status_code == 204 + assert (await client.get("/api/auth/me")).status_code == 401 + print("logout and revoke: ok") - # 2) 未配置 LLM_API_KEY 时 /run 返回 503(确定性,不联网) - from app.config import settings as _s - r = c.post("/api/screener/run", json={"text": "这两天 KDJ 的 J 小于 10"}) - if not _s.llm_api_key: - assert r.status_code == 503, f"无 key 应 503,实际 {r.status_code}" - print("无 LLM_API_KEY -> 503 ✅") - else: - print("已配置 LLM_API_KEY,跳过 503 用例") + async with async_session() as db: + has_market_data = bool(await db.scalar(select(MarketDaily.id).limit(1))) + print("market data present:", has_market_data) + finally: + async with async_session() as db: + user_id = await db.scalar(select(User.id).where(User.username == username)) + if user_id is not None: + await db.execute(delete(AuthSession).where(AuthSession.user_id == user_id)) + await db.execute(delete(User).where(User.id == user_id)) + await db.commit() + await engine.dispose() + print("temporary user cleaned") - # 3) 直传条件选股(不依赖 LLM;依赖已同步的全市场数据) - from app.models import MarketDaily # noqa: F401 - from sqlalchemy import select, func - from app.db import async_session - import asyncio - async def _has_data() -> bool: - async with async_session() as session: - return (await session.scalar(select(func.count()).select_from(MarketDaily))) or 0 > 0 - - if asyncio.run(_has_data()): - r = c.post("/api/screener/run", json={ - "text": "测试直传", - "conditions": { - "indicator": [{"indicator": "kdj_j", "params": {"n": 9, "m1": 3, "m2": 3}, - "op": "lt", "value": 0, "lookback": 1, "match": "all"}], - "snapshot": [], - }, - }) - assert r.status_code == 200, f"直传选股失败 {r.status_code}: {r.text}" - d = r.json() - assert d["total"] >= 0 - print(f"KDJ J<0 选股 ✅ 命中 {d['total']} 只,基准日 {(d['trade_date'] or '')[:10]}") - else: - print("(未同步全市场数据,跳过直传选股用例;运行 POST /api/screener/sync 后再试)") - - print("\n✅ 智能选股自检通过") +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/uv.lock b/backend/uv.lock index 8983a59..3dd9950 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -11,12 +11,17 @@ resolution-markers = [ ] [[package]] -name = "aiosqlite" -version = "0.22.1" +name = "alembic" +version = "1.19.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" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/2b/e4153978368de59918115c9e01d3ebf58a558a7285efa7e960c383c4b59a/alembic-1.19.1.tar.gz", hash = "sha256:e0fca0518118c78acc493e31bcb5402f190057aaf6df8b5b95ce94c4789cf648", size = 2070816, upload-time = "2026-08-08T16:32:01.565Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/20/89/e62cc37b69ad357cc8ecd6e7367f5245f523d3cbb338a66197212bdf6749/alembic-1.19.1-py3-none-any.whl", hash = "sha256:b39018cb3d9413a19cbd54cf3c02ad33998641f0538eb77413a488a21c3e14be", size = 265946, upload-time = "2026-08-08T16:32:03.153Z" }, ] [[package]] @@ -50,6 +55,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] +[[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 = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + [[package]] name = "asyncpg" version = "0.31.0" @@ -124,6 +172,91 @@ 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" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.9" @@ -439,6 +572,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" }, ] +[[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" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "numpy" version = "2.5.1" @@ -536,6 +744,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, ] +[[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.4" @@ -852,7 +1069,8 @@ name = "stock-backend" version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "aiosqlite" }, + { name = "alembic" }, + { name = "argon2-cffi" }, { name = "asyncpg" }, { name = "fastapi" }, { name = "httpx" }, @@ -867,7 +1085,8 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "aiosqlite", specifier = ">=0.20" }, + { name = "alembic", specifier = ">=1.19.1" }, + { name = "argon2-cffi", specifier = ">=25.1.0" }, { name = "asyncpg", specifier = ">=0.29" }, { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.28.1" }, diff --git a/frontend/index.html b/frontend/index.html index f95168f..fced06a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,11 +1,11 @@ - + - 股市回测平台 + 量化选股与回测平台 diff --git a/frontend/package.json b/frontend/package.json index e0f88fa..0f52173 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,18 +10,17 @@ "type-check": "vue-tsc --noEmit" }, "dependencies": { - "@primeuix/themes": "^1.0.0", "echarts": "^6.0.0", - "lightweight-charts": "^5.0.0", + "klinecharts": "^10.0.2", "pinia": "^2.3.0", - "primeicons": "^7.0.0", - "primevue": "^5.0.0", "vue": "^3.5.0", "vue-router": "^4.6.4" }, "devDependencies": { + "@tailwindcss/vite": "^4.3.3", "@types/node": "^22.0.0", "@vitejs/plugin-vue": "^5.2.0", + "tailwindcss": "^4.3.3", "typescript": "^5.6.0", "vite": "^6.0.0", "vue-tsc": "^2.1.0" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5d1a1e4..5660df4 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,24 +8,15 @@ importers: .: dependencies: - '@primeuix/themes': - specifier: ^1.0.0 - version: 1.0.0 echarts: specifier: ^6.0.0 version: 6.1.0 - lightweight-charts: - specifier: ^5.0.0 - version: 5.2.0 + klinecharts: + specifier: ^10.0.2 + version: 10.0.2 pinia: specifier: ^2.3.0 version: 2.3.0(typescript@5.6.2)(vue@3.5.41(typescript@5.6.2)) - primeicons: - specifier: ^7.0.0 - version: 7.0.0 - primevue: - specifier: ^5.0.0 - version: 5.0.0(vue@3.5.41(typescript@5.6.2)) vue: specifier: ^3.5.0 version: 3.5.41(typescript@5.6.2) @@ -33,18 +24,24 @@ importers: specifier: ^4.6.4 version: 4.6.4(vue@3.5.41(typescript@5.6.2)) devDependencies: + '@tailwindcss/vite': + specifier: ^4.3.3 + version: 4.3.3(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)) '@types/node': specifier: ^22.0.0 version: 22.20.1 '@vitejs/plugin-vue': specifier: ^5.2.0 - version: 5.2.0(vite@6.4.3(@types/node@22.20.1))(vue@3.5.41(typescript@5.6.2)) + version: 5.2.0(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.41(typescript@5.6.2)) + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 typescript: specifier: ^5.6.0 version: 5.6.2 vite: specifier: ^6.0.0 - version: 6.4.3(@types/node@22.20.1) + version: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0) vue-tsc: specifier: ^2.1.0 version: 2.1.0(typescript@5.6.2) @@ -218,9 +215,27 @@ packages: cpu: [x64] os: [win32] + '@jridgewell/gen-mapping@0.3.5': + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + engines: {node: '>=6.0.0'} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} engines: {node: ^22.20 || ^24.12 || >=25} @@ -228,60 +243,6 @@ packages: os: [linux] libc: [glibc] - '@noble/ed25519@2.3.0': - resolution: {integrity: sha512-M7dvXL2B92/M7dw9+gzuydL8qn/jiqNHaoR3Q+cb1q1GHV7uwE17WCyFMG+Y+TZb5izcaXk5TdJRrDUxHXL78A==} - - '@noble/hashes@2.2.0': - resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} - engines: {node: '>= 20.19.0'} - - '@primeicons/core@8.0.0': - resolution: {integrity: sha512-vPif+sXxajXCSL2bmNBP0QYliJONVRgFVpCg9e7hGn7IG18JkMAm4fF5HVld1N4sDATkZQM7jKVxdZ8EK09Giw==} - - '@primeicons/vue@8.0.0': - resolution: {integrity: sha512-sIqk+kZB9Bn0FqODjDAnNvRXY4Ypky2TInY/oIC3aAJOud1A3FoKruMC8IwiRDz1uHzxYnQqWwtFsfZAgC7qXQ==} - peerDependencies: - vue: ^3 - - '@primeui/license-manager@1.0.0': - resolution: {integrity: sha512-89J7r8cclEqoHVwjOX1adPcB/blFSocpzuYlzmd+6r0gxzg2Vd4jM54TKXt9/qwAT/kOv4vYnHKZ6hcP2GFtfA==} - - '@primeuix/motion@1.0.0': - resolution: {integrity: sha512-rqpFRKmUVp9BI3YQKdok0rLUwzYShXzuuxy72Kq74xYRV14eR+4+XrIXOdyVs7j7CYV0ajYowiRpJCrVgdrq6A==} - engines: {node: '>=12.11.0'} - - '@primeuix/styled@0.5.0': - resolution: {integrity: sha512-k5CTQ+10cXIXxZTep7sktmYe8lJkjmUaFVDAc1OCsWTJR+bhBy/s6zWIatGljVtuf3RmTSxtlrHQeFLjPmdUNQ==} - engines: {node: '>=12.11.0'} - - '@primeuix/styled@1.0.0': - resolution: {integrity: sha512-6SXoeQWwKswSqTv1ygaXNfY7otHo6Rb9mxbKJK5WF8adWlZ6CtwmlNfIP+p8cH/zGccsei/5Bu7IetXlMoZGjQ==} - engines: {node: '>=12.11.0'} - - '@primeuix/styles@3.0.0': - resolution: {integrity: sha512-nFsG2V0kbn3Q/fr0EV0Lxy2cKeW1F+WFXyxWI1CxWAAXW34mTCLkDd6OFbN5dP9hrSJOVaeXWzTdoonh1sdklQ==} - - '@primeuix/themes@1.0.0': - resolution: {integrity: sha512-fxUgcAP9H6FeytbE8c4QvRt8aBnoyZJqvtnnVwHT8PHr1dNSnC1nYKGrXpebcx3SpNy9Hp9oVidGsl6u61+pXQ==} - - '@primeuix/utils@0.5.0': - resolution: {integrity: sha512-LwbO4LBf3NM794ZKhI9XyPjnSIIaCGL+gcAsjNqZxalYAwtNO5E38duaBkBDUHh6/CnHab13ZshLur/xyc+KkQ==} - engines: {node: '>=12.11.0'} - - '@primeuix/utils@0.8.0': - resolution: {integrity: sha512-dZSMKU2XJ7W7VDLuFMS/o4k+QYw4SYIpqSNhR/jdasMaaXl8eq0Gt7fTzTwwq7hgfmfOsI6V5xtzUUIvybOJ/w==} - engines: {node: '>=12.11.0'} - - '@primevue/core@5.0.0': - resolution: {integrity: sha512-VGw1a5m3bSHosAQScg7huvD8ZFQ4cfltmbIuOSx7gsMS2NnYno9BCIq7O2L2H3zmB0pBLfcpdawb3EPeg1LIxw==} - engines: {node: '>=12.11.0'} - peerDependencies: - vue: ^3.5.0 - - '@primevue/icons@5.0.0': - resolution: {integrity: sha512-DyrtQhmLRiIkldjm5jmuqiFeNN05n4VWd1sw4+WfJ0Zlb+5T4YASw6CGtCITPzHVYyx6XuuEMifruzc8OVpQrw==} - engines: {node: '>=12.11.0'} - '@rollup/rollup-android-arm-eabi@4.62.4': resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} cpu: [arm] @@ -420,6 +381,100 @@ packages: cpu: [x64] os: [win32] + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -501,9 +556,17 @@ packages: de-indent@1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + echarts@6.1.0: resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==} + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} + engines: {node: '>=10.13.0'} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -516,9 +579,6 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - fancy-canvas@2.1.0: - resolution: {integrity: sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -533,12 +593,93 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + he@1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true - lightweight-charts@5.2.0: - resolution: {integrity: sha512-ey3Vas8UhV06ni+LT9TA1nEe4y8So4Mi6CL/oarNHFMyTktz/xy8e8+oh04Q//eO3t6etvFXgayz2fClyFQb5w==} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + klinecharts@10.0.2: + resolution: {integrity: sha512-OJmaG047vd6RPKXxQAnbGPnfUQ3tKCKBlxk/oMvRJBXO6CyaZuqFGQ8ccoU6YTNew7DV8ynbsfti/JERe+M5IQ==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -578,13 +719,6 @@ packages: resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} - primeicons@7.0.0: - resolution: {integrity: sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==} - - primevue@5.0.0: - resolution: {integrity: sha512-o0MtP6Dxa5QFZuskBWoiLD7aM/V6emqqJJnd+L2nNSfH4PrQsNpqSVPZODSdNJfKXAD9ekjTAJ+QyHycKCmF3Q==} - engines: {node: '>=12.11.0'} - rollup@4.62.4: resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -599,6 +733,13 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -780,69 +921,31 @@ snapshots: '@esbuild/win32-x64@0.25.0': optional: true + '@jridgewell/gen-mapping@0.3.5': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true - '@noble/ed25519@2.3.0': {} - - '@noble/hashes@2.2.0': {} - - '@primeicons/core@8.0.0': - dependencies: - '@primeuix/utils': 0.8.0 - - '@primeicons/vue@8.0.0(vue@3.5.41(typescript@5.6.2))': - dependencies: - '@primeicons/core': 8.0.0 - '@primeuix/utils': 0.8.0 - vue: 3.5.41(typescript@5.6.2) - - '@primeui/license-manager@1.0.0': - dependencies: - '@noble/ed25519': 2.3.0 - '@noble/hashes': 2.2.0 - - '@primeuix/motion@1.0.0': - dependencies: - '@primeuix/utils': 0.8.0 - - '@primeuix/styled@0.5.0': - dependencies: - '@primeuix/utils': 0.5.0 - - '@primeuix/styled@1.0.0': - dependencies: - '@primeui/license-manager': 1.0.0 - '@primeuix/utils': 0.8.0 - - '@primeuix/styles@3.0.0': - dependencies: - '@primeuix/styled': 1.0.0 - - '@primeuix/themes@1.0.0': - dependencies: - '@primeuix/styled': 0.5.0 - - '@primeuix/utils@0.5.0': {} - - '@primeuix/utils@0.8.0': {} - - '@primevue/core@5.0.0(vue@3.5.41(typescript@5.6.2))': - dependencies: - '@primeui/license-manager': 1.0.0 - '@primeuix/styled': 1.0.0 - '@primeuix/utils': 0.8.0 - vue: 3.5.41(typescript@5.6.2) - - '@primevue/icons@5.0.0(vue@3.5.41(typescript@5.6.2))': - dependencies: - '@primeuix/utils': 0.8.0 - '@primevue/core': 5.0.0(vue@3.5.41(typescript@5.6.2)) - transitivePeerDependencies: - - vue - '@rollup/rollup-android-arm-eabi@4.62.4': optional: true @@ -918,15 +1021,83 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.4': optional: true + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.5 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0) + '@types/estree@1.0.9': {} '@types/node@22.20.1': dependencies: undici-types: 6.21.0 - '@vitejs/plugin-vue@5.2.0(vite@6.4.3(@types/node@22.20.1))(vue@3.5.41(typescript@5.6.2))': + '@vitejs/plugin-vue@5.2.0(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.41(typescript@5.6.2))': dependencies: - vite: 6.4.3(@types/node@22.20.1) + vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0) vue: 3.5.41(typescript@5.6.2) '@volar/language-core@2.4.28': @@ -1029,11 +1200,18 @@ snapshots: de-indent@1.0.2: {} + detect-libc@2.1.2: {} + echarts@6.1.0: dependencies: tslib: 2.3.0 zrender: 6.1.0 + enhanced-resolve@5.24.5: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + entities@7.0.1: {} esbuild@0.25.0: @@ -1066,8 +1244,6 @@ snapshots: estree-walker@2.0.2: {} - fancy-canvas@2.1.0: {} - fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -1075,11 +1251,62 @@ snapshots: fsevents@2.3.3: optional: true + graceful-fs@4.2.11: {} + he@1.2.0: {} - lightweight-charts@5.2.0: + jiti@2.7.0: {} + + klinecharts@10.0.2: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: dependencies: - fancy-canvas: 2.1.0 + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 magic-string@0.30.21: dependencies: @@ -1115,21 +1342,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - primeicons@7.0.0: {} - - primevue@5.0.0(vue@3.5.41(typescript@5.6.2)): - dependencies: - '@primeicons/vue': 8.0.0(vue@3.5.41(typescript@5.6.2)) - '@primeui/license-manager': 1.0.0 - '@primeuix/motion': 1.0.0 - '@primeuix/styled': 1.0.0 - '@primeuix/styles': 3.0.0 - '@primeuix/utils': 0.8.0 - '@primevue/core': 5.0.0(vue@3.5.41(typescript@5.6.2)) - '@primevue/icons': 5.0.0(vue@3.5.41(typescript@5.6.2)) - transitivePeerDependencies: - - vue - rollup@4.62.4: dependencies: '@types/estree': 1.0.9 @@ -1166,6 +1378,10 @@ snapshots: source-map-js@1.2.1: {} + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.5) @@ -1177,7 +1393,7 @@ snapshots: undici-types@6.21.0: {} - vite@6.4.3(@types/node@22.20.1): + vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0): dependencies: esbuild: 0.25.0 fdir: 6.5.0(picomatch@4.0.5) @@ -1188,6 +1404,8 @@ snapshots: optionalDependencies: '@types/node': 22.20.1 fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 vscode-uri@3.1.0: {} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c06dfef..af5310e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,18 +1,45 @@ diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 67f13b8..bf8415d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,6 +1,10 @@ import type { BacktestRequest, BacktestResponse, + CurrentUser, + LoginRequest, + LoginResponse, + PreviewResponse, ScreenerRunRequest, ScreenerRunResponse, ScreenerSyncRequest, @@ -12,71 +16,91 @@ import type { // dev 用 Vite 代理(/api -> :8000);生产构建设 VITE_API_BASE 指向后端地址。 const BASE = import.meta.env.VITE_API_BASE ?? ''; -export async function postBacktest(req: BacktestRequest): Promise { - const res = await fetch(`${BASE}/api/backtest`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(req), - }); - if (!res.ok) { - throw new Error(`回测请求失败 (HTTP ${res.status}): ${await res.text()}`); +export class ApiError extends Error { + constructor( + message: string, + public readonly status: number, + ) { + super(message); + this.name = 'ApiError'; } +} + +async function readError(res: Response, fallback: string): Promise { + const body = await res.text(); + if (!body) return fallback; + try { + const data = JSON.parse(body) as { detail?: string }; + return data.detail || fallback; + } catch { + return body; + } +} + +async function apiFetch(path: string, init: RequestInit = {}): Promise { + const res = await fetch(`${BASE}${path}`, { + ...init, + credentials: 'include', + headers: { + ...(init.body ? { 'Content-Type': 'application/json' } : {}), + ...init.headers, + }, + }); + if (res.status === 401 && !path.startsWith('/api/auth/login')) { + window.dispatchEvent(new CustomEvent('stock:unauthorized')); + } + return res; +} + +export async function login(req: LoginRequest): Promise { + const res = await apiFetch('/api/auth/login', { method: 'POST', body: JSON.stringify(req) }); + if (!res.ok) throw new ApiError(await readError(res, '登录失败'), res.status); + return (await res.json()) as LoginResponse; +} + +export async function getCurrentUser(): Promise { + const res = await apiFetch('/api/auth/me'); + if (!res.ok) throw new ApiError(await readError(res, '登录状态无效'), res.status); + return (await res.json()) as CurrentUser; +} + +export async function logout(): Promise { + const res = await apiFetch('/api/auth/logout', { method: 'POST' }); + if (!res.ok && res.status !== 401) throw new ApiError(await readError(res, '退出登录失败'), res.status); +} + +export async function postBacktest(req: BacktestRequest): Promise { + const res = await apiFetch('/api/backtest', { method: 'POST', body: JSON.stringify(req) }); + if (!res.ok) throw new ApiError(`回测请求失败 (HTTP ${res.status}): ${await res.text()}`, res.status); return (await res.json()) as BacktestResponse; } export async function syncData(req: SyncRequest): Promise { - const res = await fetch(`${BASE}/api/data/sync`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(req), - }); - if (!res.ok) { - throw new Error(`数据拉取失败 (HTTP ${res.status}): ${await res.text()}`); - } + const res = await apiFetch('/api/data/sync', { method: 'POST', body: JSON.stringify(req) }); + if (!res.ok) throw new ApiError(`数据拉取失败 (HTTP ${res.status}): ${await res.text()}`, res.status); return (await res.json()) as SyncResponse; } -// ---------- 智能选股 ---------- export async function runScreener(req: ScreenerRunRequest): Promise { - const res = await fetch(`${BASE}/api/screener/run`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(req), - }); - if (!res.ok) { - // 后端 detail 字段携带可读中文原因(503 未配 key / 409 未同步 / 502 LLM 错) - let detail = ''; - try { - detail = (await res.json())?.detail ?? ''; - } catch { - detail = await res.text(); - } - throw new Error(detail || `选股失败 (HTTP ${res.status})`); - } + const res = await apiFetch('/api/screener/run', { method: 'POST', body: JSON.stringify(req) }); + if (!res.ok) throw new ApiError(await readError(res, `选股失败 (HTTP ${res.status})`), res.status); return (await res.json()) as ScreenerRunResponse; } export async function startScreenerSync(req: ScreenerSyncRequest = {}): Promise { - const res = await fetch(`${BASE}/api/screener/sync`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(req), - }); - if (!res.ok) { - let detail = ''; - try { - detail = (await res.json())?.detail ?? ''; - } catch { - detail = await res.text(); - } - throw new Error(detail || `启动同步失败 (HTTP ${res.status})`); - } + const res = await apiFetch('/api/screener/sync', { method: 'POST', body: JSON.stringify(req) }); + if (!res.ok) throw new ApiError(await readError(res, `启动同步失败 (HTTP ${res.status})`), res.status); return (await res.json()) as ScreenerSyncStatus; } export async function getScreenerSyncStatus(): Promise { - const res = await fetch(`${BASE}/api/screener/sync/status`); - if (!res.ok) throw new Error(`获取同步状态失败 (HTTP ${res.status})`); + const res = await apiFetch('/api/screener/sync/status'); + if (!res.ok) throw new ApiError(`获取同步状态失败 (HTTP ${res.status})`, res.status); return (await res.json()) as ScreenerSyncStatus; } +export async function getStockPreview(tsCode: string, limit = 260): Promise { + const res = await apiFetch(`/api/screener/preview/${encodeURIComponent(tsCode)}?limit=${limit}`); + if (!res.ok) throw new ApiError(await readError(res, `获取个股详情失败 (HTTP ${res.status})`), res.status); + return (await res.json()) as PreviewResponse; +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 4943a3e..e360f32 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -1,6 +1,21 @@ // 与后端 app/schemas.py 一一对应的 TypeScript 类型(OpenAPI 契约的前端镜像)。 // 后续可由 openapi-typescript-codegen 自动生成;MVP 先手写保持同步。 +export interface CurrentUser { + id: number; + username: string; +} + +export interface LoginRequest { + username: string; + password: string; +} + +export interface LoginResponse { + user: CurrentUser; + expires_at: string; +} + export interface Candle { ts: string; open: number; @@ -147,3 +162,37 @@ export interface ScreenerSyncStatus { last_synced_at?: string | null; stats: { stocks: number; daily_rows: number; snapshot_rows: number; dates: number }; } + +// ---------- 个股详情预览(镜像 app/schemas.py) ---------- +export interface PreviewInfo { + ts_code: string; + symbol: string; + name: string; + industry?: string | null; + area?: string | null; + market?: string | null; + list_date?: string | null; + trade_date?: string | null; + open?: number | null; + high?: number | null; + low?: number | null; + close?: number | null; + pre_close?: number | null; + pct_chg?: number | null; + volume_hand?: number | null; // 手 + amount_yi?: number | null; // 亿元 + turnover_rate?: number | null; // % + pe_ttm?: number | null; + pb?: number | null; + total_mv?: number | null; // 亿元 + circ_mv?: number | null; // 亿元 +} + +export interface PreviewResponse { + ts_code: string; + symbol: string; + source: string; // qfq | market + info: PreviewInfo; + candles: Candle[]; + indicators: Record>; +} diff --git a/frontend/src/components/BacktestForm.vue b/frontend/src/components/BacktestForm.vue index 8b5862f..6d11f1a 100644 --- a/frontend/src/components/BacktestForm.vue +++ b/frontend/src/components/BacktestForm.vue @@ -1,10 +1,5 @@ diff --git a/frontend/src/components/ConditionChips.vue b/frontend/src/components/ConditionChips.vue index 4253c9c..35e0d9f 100644 --- a/frontend/src/components/ConditionChips.vue +++ b/frontend/src/components/ConditionChips.vue @@ -5,14 +5,13 @@ defineProps<{ conditions: ScreenConditions }>(); const OP_TEXT: Record = { gt: '>', ge: '≥', lt: '<', le: '≤', between: '区间' }; const FIELD_TEXT: Record = { - total_mv: '总市值', circ_mv: '流通市值', pe_ttm: '市盈率TTM', - pb: '市净率', turnover_rate: '换手率', close: '最新价', + total_mv: '总市值(亿)', circ_mv: '流通市值(亿)', pe_ttm: '市盈率TTM', + pb: '市净率', turnover_rate: '换手率%', close: '最新价', }; function paramsStr(p?: Record) { if (!p || Object.keys(p).length === 0) return ''; - const vals = Object.values(p).map((v) => (Number.isInteger(v) ? String(v) : String(v))); - return `(${vals.join(',')})`; + return `(${Object.values(p).map((v) => (Number.isInteger(v) ? v : v)).join(',')})`; } function lookbackText(c: { lookback?: number; match?: string }) { @@ -23,28 +22,36 @@ function lookbackText(c: { lookback?: number; match?: string }) {