feat: 全屏个股详情预览 + Tailwind 改版 + 账号鉴权

This commit is contained in:
2026-08-14 22:37:18 +08:00
parent 0f8b9a7255
commit 4c2ea5521d
47 changed files with 2937 additions and 893 deletions

5
.gitignore vendored
View File

@@ -25,7 +25,10 @@ frontend/dist/
.cache/
# ---------- Env / Secrets ----------
# .env 直接提交(私有仓库,配置含连接串即开即用)
.env
*.env
!.env.example
!*.env.example
*.pem
*.key

View File

@@ -2,7 +2,7 @@
"mcpServers": {
"tushareMcp": {
"type": "http",
"url": "https://api.tushare.pro/mcp/token=d0bc5620d6523ae40f379ed4415576f58dca2361f2f47a68cdcd0a98"
"url": "https://api.tushare.pro/mcp/token=22edda0afe44c0609a187ff1ac0bb2a8fc61430f490ec19f7fec8390"
}
}
}

View File

@@ -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
# 或 gunicornLinuxuv run gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker
```

View File

@@ -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智能选股智谱 GLMOpenAI 兼容协议)----
# key 在 https://bigmodel.cn 控制台获取,格式形如 xxxxxxxx.yyyyyyyyid.secret
LLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4
LLM_API_KEY=ea24bbdd3d2d4dd2b8f03de4c9a5d984.9X1Hz1yKx0VKSnrU
LLM_MODEL=glm-5.2

View File

@@ -1,9 +1,16 @@
# ---- Database ----
# MVP 默认 SQLite零配置即可跑
DATABASE_URL=sqlite+aiosqlite:///./stock.db
# ---- Database(开发、测试、生产都使用 PostgreSQL----
DATABASE_URL=postgresql+asyncpg://user:password@host:5432/stock
# 切到你自己的 PostgreSQLTimescaleDB 是 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

38
backend/alembic.ini Normal file
View File

@@ -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

60
backend/alembic/env.py Normal file
View File

@@ -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()

View File

@@ -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"}

View File

@@ -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")

View File

@@ -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

View File

@@ -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,

105
backend/app/auth.py Normal file
View File

@@ -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

155
backend/app/auth_api.py Normal file
View File

@@ -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

View File

@@ -0,0 +1 @@
"""服务器侧管理命令。"""

View File

@@ -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()

View File

@@ -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()

View File

@@ -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"}

View File

@@ -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")

View File

@@ -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

View File

@@ -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]

View File

@@ -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
# 必须用 withlifespan建表只在进入上下文时执行
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())

231
backend/uv.lock generated
View File

@@ -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" },

View File

@@ -1,11 +1,11 @@
<!doctype html>
<html lang="zh-CN" class="app-dark">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>股市回测平台</title>
<title>量化选股与回测平台</title>
<style>
html, body { background-color: #0b0e14; margin: 0; }
html, body { background-color: #f8fafc; margin: 0; }
</style>
</head>
<body>

View File

@@ -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"

538
frontend/pnpm-lock.yaml generated
View File

@@ -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: {}

View File

@@ -1,18 +1,45 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router';
import { computed, ref } from 'vue';
import { RouterView } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const isHome = computed(() => route.name === 'home');
const isLogin = computed(() => route.name === 'login');
const loggingOut = ref(false);
async function signOut() {
loggingOut.value = true;
try {
await auth.logout();
await router.replace({ name: 'login' });
} finally {
loggingOut.value = false;
}
}
</script>
<template>
<div class="app-shell">
<header class="app-header">
<h1>股市量化平台</h1>
<span class="sub">智能选股 · 历史回测 · A股红涨绿跌</span>
<nav class="app-nav">
<RouterLink to="/" class="nav-link">首页</RouterLink>
<RouterLink to="/screener" class="nav-link">智能选股</RouterLink>
<RouterLink to="/backtest" class="nav-link">策略回测</RouterLink>
</nav>
</header>
<RouterView />
<div v-if="!auth.initialized" class="grid min-h-screen place-items-center bg-slate-50" aria-label="正在验证登录状态">
<span class="login-spinner border-slate-300 border-t-blue-600" aria-hidden="true" />
</div>
<RouterView v-else-if="isLogin" />
<div v-else class="min-h-screen">
<div class="fixed right-5 top-4 z-30 flex items-center gap-3 rounded-md border border-slate-200 bg-white/90 px-3 py-2 text-xs text-slate-400 shadow-sm backdrop-blur">
<span>{{ auth.user?.username }}</span>
<button type="button" class="font-medium text-slate-500 hover:text-slate-900 disabled:opacity-50" :disabled="loggingOut" @click="signOut">
{{ loggingOut ? '退出中' : '退出' }}
</button>
</div>
<main :class="isHome ? 'flex min-h-screen items-center justify-center px-5 py-10' : 'mx-auto max-w-[1400px] px-5 py-6'">
<RouterView />
</main>
<footer v-if="!isHome" class="mx-auto max-w-[1400px] px-5 pb-8 pt-2 text-center text-xs text-slate-400">
数据来源 Tushare · 仅供研究学习不构成投资建议
</footer>
</div>
</template>

View File

@@ -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<BacktestResponse> {
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<string> {
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<Response> {
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<LoginResponse> {
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<CurrentUser> {
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<void> {
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<BacktestResponse> {
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<SyncResponse> {
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<ScreenerRunResponse> {
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<ScreenerSyncStatus> {
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<ScreenerSyncStatus> {
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<PreviewResponse> {
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;
}

View File

@@ -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<string, Record<string, (number | null)[]>>;
}

View File

@@ -1,10 +1,5 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber';
import Select from 'primevue/select';
import ToggleSwitch from 'primevue/toggleswitch';
import Button from 'primevue/button';
import type { BacktestRequest } from '@/api/types';
defineProps<{ loading: boolean }>();
@@ -16,7 +11,19 @@ const STRATS: { id: string; label: string; params: ParamDef[] }[] = [
{ id: 'single_ma', label: '单均线(价格穿越)', params: [{ k: 'period', label: '均线周期', def: 20 }] },
{ id: 'macd_cross', label: 'MACD 金叉死叉', params: [{ k: 'fast', label: '快线', def: 12 }, { k: 'slow', label: '慢线', def: 26 }, { k: 'signal', label: '信号线', def: 9 }] },
];
const stratOptions = STRATS.map((s) => ({ label: s.label, value: s.id }));
const TF_OPTIONS = [
{ label: '日线', value: '1d' },
{ label: '周线', value: '1w' },
{ label: '月线', value: '1M' },
{ label: '年线', value: '1y' },
];
const QUICK = [
{ code: '000001', name: '平安银行' },
{ code: '600519', name: '贵州茅台' },
{ code: '000858', name: '五粮液' },
{ code: '601318', name: '中国平安' },
{ code: 'DEMO', name: '合成数据' },
];
const form = reactive({
symbol: '000001',
@@ -49,42 +56,68 @@ function onRun() {
</script>
<template>
<div class="toolbar">
<div class="field">
<label>策略</label>
<Select v-model="form.strategy" :options="stratOptions" optionLabel="label" optionValue="value" size="small" style="width: 170px" />
</div>
<div class="field" v-for="p in currentParams" :key="p.k">
<label>{{ p.label }}</label>
<InputNumber v-model="form.params[p.k]" :min="1" :max="250" size="small" inputStyle="width:64px" />
</div>
<div class="field">
<label>周期</label>
<Select v-model="form.timeframe" :options="[{label:'日线',value:'1d'},{label:'周线',value:'1w'},{label:'月线',value:'1M'},{label:'年线',value:'1y'}]" optionLabel="label" optionValue="value" size="small" style="width: 100px" />
</div>
<div class="field">
<label>标的</label>
<InputText v-model="form.symbol" size="small" style="width: 110px" placeholder="如 000001" />
</div>
<div class="field">
<label>初始资金</label>
<InputNumber v-model="form.initial_cash" :min="1000" :step="100000" size="small" mode="currency" currency="CNY" inputStyle="width:130px" />
</div>
<div class="field">
<label>fast 模式</label>
<ToggleSwitch v-model="form.fast_mode" />
</div>
<div class="spacer"></div>
<Button label="开始回测" icon="pi pi-play" :loading="loading" size="small" @click="onRun" />
</div>
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="flex flex-wrap items-end gap-x-4 gap-y-3">
<div>
<label class="lbl">策略</label>
<select v-model="form.strategy" class="ipt w-40">
<option v-for="s in STRATS" :key="s.id" :value="s.id">{{ s.label }}</option>
</select>
</div>
<div class="quick">
<span class="qlabel">快捷</span>
<button v-for="q in [{code:'000001',name:'平安银行'},{code:'600519',name:'贵州茅台'},{code:'000858',name:'五粮液'},{code:'601318',name:'中国平安'},{code:'DEMO',name:'合成数据'}]" :key="q.code" class="qchip" :class="{ active: form.symbol === q.code }" type="button" @click="form.symbol = q.code">
{{ q.code }} <span class="qname">{{ q.name }}</span>
</button>
</div>
<div class="hint">
策略可选 双均线 / 单均线 / MACD参数随策略自适应<code>DEMO</code> 为合成数据其余为真实 A 首次自动经 Tushare 拉取并缓存
<div v-for="p in currentParams" :key="p.k">
<label class="lbl">{{ p.label }}</label>
<input v-model.number="form.params[p.k]" type="number" min="1" max="250" class="ipt w-[76px]" />
</div>
<div>
<label class="lbl">周期</label>
<select v-model="form.timeframe" class="ipt w-24">
<option v-for="t in TF_OPTIONS" :key="t.value" :value="t.value">{{ t.label }}</option>
</select>
</div>
<div>
<label class="lbl">标的</label>
<input v-model="form.symbol" type="text" class="ipt w-[110px]" placeholder="如 000001" />
</div>
<div>
<label class="lbl">初始资金</label>
<input v-model.number="form.initial_cash" type="number" min="1000" step="100000" class="ipt w-[140px]" />
</div>
<label class="flex cursor-pointer select-none items-center gap-2 pb-1.5 text-sm text-slate-600">
<input v-model="form.fast_mode" type="checkbox" class="h-4 w-4 rounded border-slate-300 accent-blue-600" />
fast 模式
</label>
<div class="ml-auto">
<button type="button" class="btn-primary" :disabled="loading" @click="onRun">
<svg v-if="loading" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
<svg v-else class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
{{ loading ? '回测中…' : '开始回测' }}
</button>
</div>
</div>
<div class="mt-3 flex flex-wrap items-center gap-1.5">
<span class="mr-1 text-xs text-slate-400">快捷</span>
<button
v-for="q in QUICK"
:key="q.code"
type="button"
class="rounded-full border px-3 py-1 text-xs transition-colors"
:class="form.symbol === q.code
? 'border-blue-600 bg-blue-600 text-white'
: 'border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-slate-100'"
@click="form.symbol = q.code"
>
{{ q.code }} <span :class="form.symbol === q.code ? 'text-blue-200' : 'text-slate-400'">{{ q.name }}</span>
</button>
</div>
<p class="mt-2 text-xs text-slate-400">
策略可选 双均线 / 单均线 / MACD参数随策略自适应<code class="rounded border border-slate-200 bg-slate-50 px-1">DEMO</code> 为合成数据其余为真实 A 首次自动经 Tushare 拉取并缓存
</p>
</div>
</template>

View File

@@ -5,14 +5,13 @@ defineProps<{ conditions: ScreenConditions }>();
const OP_TEXT: Record<string, string> = { gt: '>', ge: '≥', lt: '<', le: '≤', between: '区间' };
const FIELD_TEXT: Record<string, string> = {
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<string, number>) {
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 }) {
</script>
<template>
<div class="cond-chips">
<span class="clabel">解析条件</span>
<div class="flex flex-wrap items-center gap-1.5">
<span class="mr-1 text-xs text-slate-400">解析条件</span>
<span v-for="(c, i) in conditions.indicator" :key="'i' + i" class="cond-chip ci">
<i class="pi pi-bolt" style="font-size: 10px;"></i>
<span
v-for="(c, i) in conditions.indicator"
:key="'i' + i"
class="inline-flex items-center gap-1.5 rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-xs text-blue-900"
>
<span class="h-1.5 w-1.5 rounded-full bg-blue-500"></span>
{{ c.indicator }}{{ paramsStr(c.params) }}
<span class="arrow">{{ OP_TEXT[c.op] }}</span>
<span class="text-blue-400">{{ OP_TEXT[c.op] }}</span>
<template v-if="c.value_indicator">{{ c.value_indicator }}{{ paramsStr(c.value_params) }}</template>
<template v-else-if="c.op === 'between' && c.value2">{{ c.value }} ~ {{ c.value2 }}</template>
<template v-else>{{ c.value }}</template>
<span style="color: var(--ink-3);"> · {{ lookbackText(c) }}</span>
<span class="text-blue-300">· {{ lookbackText(c) }}</span>
</span>
<span v-for="(c, i) in conditions.snapshot" :key="'s' + i" class="cond-chip cs">
<i class="pi pi-table" style="font-size: 10px;"></i>
<span
v-for="(c, i) in conditions.snapshot"
:key="'s' + i"
class="inline-flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs text-amber-900"
>
<span class="h-1.5 w-1.5 rounded-full bg-amber-500"></span>
{{ FIELD_TEXT[c.field] ?? c.field }}
<span class="arrow">{{ OP_TEXT[c.op] }}</span>
<span class="text-amber-400">{{ OP_TEXT[c.op] }}</span>
<template v-if="c.op === 'between' && c.value2">{{ c.value }} ~ {{ c.value2 }}</template>
<template v-else>{{ c.value }}</template>
</span>
<span class="cond-chip cx">
<span class="inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs text-slate-400">
排除ST · 退市<span v-if="conditions.exclude_bj"> · 北交所</span>
</span>
</div>

View File

@@ -0,0 +1,180 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { dispose, init, registerIndicator, type Chart, type KLineData } from 'klinecharts';
import type { Candle } from '@/api/types';
const props = defineProps<{
ticker: string;
candles: Candle[];
indicators: Record<string, Record<string, (number | null)[]>>;
/** 副图指标及顺序('vol' 用内置;其余为后端序列) */
subPanes: string[];
/** 主图是否叠加布林带 */
showBoll: boolean;
}>();
// A股语义色浅色
const UP = '#dc2626';
const DOWN = '#16a34a';
const C1 = '#2563eb'; // 蓝
const C2 = '#f59e0b'; // 橙
const C3 = '#a855f7'; // 紫
const C4 = '#10b981'; // 绿青
// ---------- 后端序列注入(单一事实源,按索引对齐) ----------
let PV: Record<string, (number | null)[]> = {};
const g = (k: string) => (i: number) => PV[k]?.[i] ?? undefined;
registerIndicator({
name: 'pv-ma',
shortName: 'MA',
figures: [
{ key: 'ma5', title: 'MA5', type: 'line', styles: () => ({ color: C1 }) },
{ key: 'ma10', title: 'MA10', type: 'line', styles: () => ({ color: C2 }) },
{ key: 'ma20', title: 'MA20', type: 'line', styles: () => ({ color: C3 }) },
{ key: 'ma60', title: 'MA60', type: 'line', styles: () => ({ color: C4 }) },
],
calc: (d: KLineData[]) => d.map((_, i) => ({ ma5: g('ma5')(i), ma10: g('ma10')(i), ma20: g('ma20')(i), ma60: g('ma60')(i) })),
});
registerIndicator({
name: 'pv-boll',
shortName: 'BOLL',
figures: [
{ key: 'upper', title: 'UP', type: 'line', styles: () => ({ color: C3 }) },
{ key: 'mid', title: 'MB', type: 'line', styles: () => ({ color: C2 }) },
{ key: 'lower', title: 'DN', type: 'line', styles: () => ({ color: C3 }) },
],
calc: (d: KLineData[]) => d.map((_, i) => ({ upper: g('upper')(i), mid: g('mid')(i), lower: g('lower')(i) })),
});
registerIndicator({
name: 'pv-macd',
shortName: 'MACD',
figures: [
{ key: 'dif', title: 'DIF', type: 'line', styles: () => ({ color: C1 }) },
{ key: 'dea', title: 'DEA', type: 'line', styles: () => ({ color: C2 }) },
{
key: 'hist', title: 'HIST', type: 'bar', baseValue: 0, // 零轴柱,缺省会从面板底部画起
styles: (p) => {
const v = (p.data.current as { hist?: number } | null)?.hist ?? 0;
return { color: v >= 0 ? UP : DOWN };
},
},
],
calc: (d: KLineData[]) => d.map((_, i) => ({ dif: g('dif')(i), dea: g('dea')(i), hist: g('hist')(i) })),
});
registerIndicator({
name: 'pv-kdj',
shortName: 'KDJ',
figures: [
{ key: 'k', title: 'K', type: 'line', styles: () => ({ color: C1 }) },
{ key: 'd', title: 'D', type: 'line', styles: () => ({ color: C2 }) },
{ key: 'j', title: 'J', type: 'line', styles: () => ({ color: UP }) },
],
calc: (d: KLineData[]) => d.map((_, i) => ({ k: g('k')(i), d: g('d')(i), j: g('j')(i) })),
});
registerIndicator({
name: 'pv-rsi',
shortName: 'RSI',
figures: [
{ key: 'rsi6', title: 'RSI6', type: 'line', styles: () => ({ color: C1 }) },
{ key: 'rsi12', title: 'RSI12', type: 'line', styles: () => ({ color: C2 }) },
{ key: 'rsi24', title: 'RSI24', type: 'line', styles: () => ({ color: C3 }) },
],
calc: (d: KLineData[]) => d.map((_, i) => ({ rsi6: g('rsi6')(i), rsi12: g('rsi12')(i), rsi24: g('rsi24')(i) })),
});
const container = ref<HTMLDivElement | null>(null);
let chart: Chart | null = null;
const LIGHT_STYLES = {
grid: { horizontal: { color: '#eef2f7' }, vertical: { color: '#eef2f7' } },
candle: {
bar: {
upColor: UP, downColor: DOWN,
upBorderColor: UP, downBorderColor: DOWN,
upWickColor: UP, downWickColor: DOWN,
},
priceMark: {
high: { color: '#94a3b8' }, low: { color: '#94a3b8' },
last: { upColor: UP, downColor: DOWN },
},
},
xAxis: { axisLine: { color: '#e2e8f0' }, tickText: { color: '#64748b' }, tickLine: { color: '#e2e8f0' } },
yAxis: { axisLine: { color: '#e2e8f0' }, tickText: { color: '#64748b' }, tickLine: { color: '#e2e8f0' } },
crosshair: {
horizontal: { text: { backgroundColor: '#1e293b' } },
vertical: { text: { backgroundColor: '#1e293b' } },
},
separator: { color: '#e2e8f0' },
};
// 副图默认高度
const SUB_HEIGHT: Record<string, number> = { vol: 64, macd: 100, kdj: 96, rsi: 84 };
function build() {
if (!container.value || props.candles.length === 0) return;
PV = {};
for (const [group, series] of Object.entries(props.indicators)) {
for (const [key, arr] of Object.entries(series)) PV[key] = arr;
}
const ch = init(container.value, { styles: LIGHT_STYLES });
if (!ch) return;
chart = ch;
const data: KLineData[] = props.candles.map((k) => ({
timestamp: new Date(k.ts).getTime(),
open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume,
}));
ch.setDataLoader({
getBars: ({ type, callback }) => {
if (type === 'update') {
const last = data[data.length - 1];
callback(last ? [last] : [], { backward: false, forward: false });
} else if (type === 'init') {
callback(data, { backward: false, forward: false });
} else {
callback([], { backward: false, forward: false });
}
},
});
// v10 要求 symbol+period+dataLoader 三者齐备才触发 'init' 加载,缺一则 getBars 永不调用、图表空白
ch.setSymbol({ ticker: props.ticker });
ch.setPeriod({ type: 'day', span: 1 });
// 主图MA 恒开BOLL 可选
ch.createIndicator({ name: 'pv-ma', paneId: 'candle_pane' });
if (props.showBoll) ch.createIndicator({ name: 'pv-boll', paneId: 'candle_pane' });
// 副图按用户顺序创建,并压矮;主图吃剩余高度
const subHeights = props.subPanes.reduce((s, k) => s + (SUB_HEIGHT[k] ?? 90), 0);
const total = container.value.clientHeight || 560;
ch.setPaneOptions({ id: 'candle_pane', height: Math.max(220, total - subHeights - 24) });
for (const key of props.subPanes) {
if (key === 'vol') ch.createIndicator('VOL');
else ch.createIndicator(`pv-${key}`);
const paneId = ch.getIndicators().find((i) => i.name === (key === 'vol' ? 'VOL' : `pv-${key}`))?.paneId;
if (paneId) ch.setPaneOptions({ id: paneId, height: SUB_HEIGHT[key] ?? 90 });
}
ch.setOffsetRightDistance(28);
ch.scrollToRealTime();
}
function teardown() {
if (container.value) dispose(container.value);
chart = null;
}
onMounted(build);
onBeforeUnmount(teardown);
watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll], () => { teardown(); build(); }, { deep: true });
</script>
<template>
<div ref="container" class="h-full w-full"></div>
</template>

View File

@@ -8,38 +8,41 @@ const props = defineProps<{ equity: EquityPoint[] }>();
const container = ref<HTMLDivElement | null>(null);
let chart: echarts.ECharts | null = null;
const UP = '#dc2626';
const DOWN = '#16a34a';
function buildOption() {
const dates = props.equity.map(p => p.ts.slice(0, 10));
const vals = props.equity.map(p => Number(p.value.toFixed(2)));
const first = vals.length ? vals[0] : 0;
const last = vals.length ? vals[vals.length - 1] : 0;
const lineColor = last >= first ? '#f6465d' : '#0ecb81'; // A股盈利红、亏损绿
const lineColor = last >= first ? UP : DOWN; // A股盈利红、亏损绿
return {
backgroundColor: 'transparent',
grid: { left: 64, right: 18, top: 14, bottom: 26 },
tooltip: {
trigger: 'axis' as const,
backgroundColor: '#1b2230', borderColor: 'rgba(255,255,255,0.1)', borderWidth: 1,
textStyle: { color: '#e6edf3' },
backgroundColor: '#ffffff', borderColor: '#e2e8f0', borderWidth: 1,
textStyle: { color: '#0f172a' },
valueFormatter: (v: number) => (v ?? 0).toLocaleString(undefined, { maximumFractionDigits: 0 }),
},
xAxis: {
type: 'category', data: dates, boundaryGap: false,
axisLine: { lineStyle: { color: 'rgba(255,255,255,0.1)' } },
axisLabel: { color: '#5c6675' }, axisTick: { show: false },
axisLine: { lineStyle: { color: '#e2e8f0' } },
axisLabel: { color: '#94a3b8' }, axisTick: { show: false },
},
yAxis: {
type: 'value', scale: true,
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.05)' } },
axisLabel: { color: '#5c6675' },
splitLine: { lineStyle: { color: '#f1f5f9' } },
axisLabel: { color: '#94a3b8' },
},
series: [{
type: 'line', data: vals, symbol: 'none', smooth: false,
lineStyle: { color: lineColor, width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: lineColor + '40' },
{ offset: 0, color: lineColor + '33' },
{ offset: 1, color: lineColor + '00' },
]),
},
@@ -56,5 +59,5 @@ watch(() => props.equity, () => chart?.setOption(buildOption(), true), { deep: t
</script>
<template>
<div ref="container" class="chart-equity"></div>
<div ref="container" class="h-[240px] w-full"></div>
</template>

View File

@@ -1,10 +1,6 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import {
createChart, CandlestickSeries, HistogramSeries, LineSeries,
createSeriesMarkers, CrosshairMode, LineStyle,
type IChartApi, type ISeriesApi, type ISeriesMarkersPluginApi, type SeriesMarker, type Time,
} from 'lightweight-charts';
import { dispose, init, registerIndicator, type Chart, type Crosshair, type KLineData } from 'klinecharts';
import type { Candle, IndicatorOut, SignalOut } from '@/api/types';
const props = defineProps<{
@@ -20,70 +16,114 @@ const TF_LABEL: Record<string, string> = { '1d': '日线', '1w': '周线', '1M':
const STRAT_LABEL: Record<string, string> = { macd_cross: 'MACD', ma_cross: '双均线', single_ma: '单均线' };
const WD = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const UP = '#f6465d';
const DOWN = '#0ecb81';
const DIF = '#5b8ff9';
const DEA = '#f6bd16';
const MA_COLORS = ['#5b8ff9', '#f6bd16', '#c084fc', '#34d399'];
// A股语义色浅色主题
const UP = '#dc2626';
const DOWN = '#16a34a';
const DIF_C = '#2563eb';
const DEA_C = '#f59e0b';
const MA_COLORS = ['#2563eb', '#f59e0b', '#a855f7', '#10b981'];
const IND_LABEL: Record<string, string> = { macd: 'DIF', signal: 'DEA', hist: 'MACD', fast: '快线', slow: '慢线', ma: '均线' };
const IND_COLOR: Record<string, string> = {
macd: DIF, signal: DEA, hist: '#9aa4b2', fast: DIF, slow: DEA, ma: '#c084fc',
macd: DIF_C, signal: DEA_C, hist: '#94a3b8', fast: DIF_C, slow: DEA_C, ma: '#a855f7',
};
const container = ref<HTMLDivElement | null>(null);
let chart: IChartApi | null = null;
let candleSeries: ISeriesApi<'Candlestick'> | null = null;
let volumeSeries: ISeriesApi<'Histogram'> | null = null;
let difSeries: ISeriesApi<'Line'> | null = null;
let deaSeries: ISeriesApi<'Line'> | null = null;
let histSeries: ISeriesApi<'Histogram'> | null = null;
let maSeriesArr: { key: string; series: ISeriesApi<'Line'> }[] = [];
let markersApi: ISeriesMarkersPluginApi<Time> | null = null;
interface DayRec {
open: number; high: number; low: number; close: number; volume: number;
prevClose: number | null; ind: Record<string, number | null>;
}
let byTime: Record<string, DayRec> = {};
const tip = ref<{ visible: boolean; x: number; y: number }>({ visible: false, x: 0, y: 0 });
const tipData = ref<ReturnType<typeof buildTip> | null>(null);
const isMACD = computed(() => props.strategy === 'macd_cross' || 'hist' in (props.indicators.data ?? {}));
const legendChips = computed(() => {
const keys = Object.keys(props.indicators.data ?? {});
if (isMACD.value) return [{ label: 'DIF', color: DIF }, { label: 'DEA', color: DEA }];
if (isMACD.value) return [{ label: 'DIF', color: DIF_C }, { label: 'DEA', color: DEA_C }];
return keys.map((k, i) => ({ label: IND_LABEL[k] ?? k, color: MA_COLORS[i % MA_COLORS.length] }));
});
const t = (ts: string): Time => ts.slice(0, 10) as Time;
function timeKey(time: Time): string {
if (typeof time === 'string') return time.slice(0, 10);
const bd = time as { year: number; month: number; day: number };
if (bd && typeof bd === 'object' && 'year' in bd) {
return `${bd.year}-${String(bd.month).padStart(2, '0')}-${String(bd.day).padStart(2, '0')}`;
}
return String(time);
// ---------- 后端指标数据注入(单一事实源:不在前端重算指标) ----------
// calc 回调按索引回读这些序列,与 K 线严格对齐
let BE_SERIES: Record<string, (number | null)[]> = {};
registerIndicator({
name: 'be-macd',
shortName: 'MACD',
figures: [
{ key: 'dif', title: 'DIF', type: 'line', styles: () => ({ color: DIF_C }) },
{ key: 'dea', title: 'DEA', type: 'line', styles: () => ({ color: DEA_C }) },
{
key: 'hist', title: 'HIST', type: 'bar', baseValue: 0, // 零轴柱,缺省会从面板底部画起
styles: (p) => {
const v = (p.data.current as { hist?: number } | null)?.hist ?? 0;
return { color: v >= 0 ? UP : DOWN };
},
},
],
calc: (dataList: KLineData[]) =>
dataList.map((_, i) => ({
dif: BE_SERIES.macd?.[i] ?? undefined,
dea: BE_SERIES.signal?.[i] ?? undefined,
hist: BE_SERIES.hist?.[i] ?? undefined,
})),
});
registerIndicator({
name: 'be-lines',
shortName: 'MA',
figures: [
{ key: 'fast', title: '快线', type: 'line', styles: () => ({ color: DIF_C }) },
{ key: 'slow', title: '慢线', type: 'line', styles: () => ({ color: DEA_C }) },
{ key: 'ma', title: '均线', type: 'line', styles: () => ({ color: MA_COLORS[2] }) },
],
calc: (dataList: KLineData[]) =>
dataList.map((_, i) => ({
fast: BE_SERIES.fast?.[i] ?? undefined,
slow: BE_SERIES.slow?.[i] ?? undefined,
ma: BE_SERIES.ma?.[i] ?? undefined,
})),
});
// ---------- 画线工具 ----------
const TOOLS: { name: string | null; label: string; title: string }[] = [
{ name: null, label: '指针', title: '浏览模式(点击已画图形可选中/拖动/编辑)' },
{ name: 'segment', label: '线段', title: '线段' },
{ name: 'horizontalStraightLine', label: '水平线', title: '水平直线' },
{ name: 'verticalStraightLine', label: '垂直线', title: '垂直直线' },
{ name: 'rectangle', label: '矩形', title: '矩形区域' },
{ name: 'fibonacciSegment', label: '斐波那契', title: '斐波那契回调' },
{ name: 'priceChannelLine', label: '通道线', title: '价格通道线' },
];
const activeTool = ref<string | null>(null);
function pickTool(name: string) {
activeTool.value = name;
chart?.createOverlay({ name });
}
function clearDrawings() {
if (!chart) return;
chart.removeOverlay(); // 清除全部(含买卖点标注),随后重建标注
drawSignalAnnotations();
}
// ---------- 图表 ----------
const container = ref<HTMLDivElement | null>(null);
let chart: Chart | null = null;
interface DayRec {
open: number; high: number; low: number; close: number; volume: number;
prevClose: number | null; ind: Record<string, number | null>;
}
let byIndex: DayRec[] = [];
let candleData: KLineData[] = [];
const tip = ref<{ visible: boolean; x: number; y: number }>({ visible: false, x: 0, y: 0 });
const tipData = ref<ReturnType<typeof buildTip> | null>(null);
const fmt2 = (v: number | null) => (v == null ? '—' : v.toFixed(2));
const fmt3 = (v: number | null) => (v == null ? '—' : v.toFixed(3));
function weekdayOf(s: string) { return WD[new Date(s + 'T00:00:00').getDay()] ?? ''; }
function hexA(hex: string, a: number) {
const n = hex.replace('#', '');
return `rgba(${parseInt(n.slice(0, 2), 16)},${parseInt(n.slice(2, 4), 16)},${parseInt(n.slice(4, 6), 16)},${a})`;
}
function toLine(arr: (number | null)[], c: Candle[]) {
return arr.map((v, i) => (v == null ? { time: t(c[i].ts) } : { time: t(c[i].ts), value: v }));
}
function buildTip(rec: DayRec, key: string) {
function buildTip(rec: DayRec, ts: string) {
const prev = rec.prevClose ?? rec.open;
const change = rec.close - prev;
return {
date: key, weekday: weekdayOf(key),
date: ts.slice(0, 10), weekday: weekdayOf(ts.slice(0, 10)),
open: rec.open, high: rec.high, low: rec.low, close: rec.close,
change, chgPct: prev ? (change / prev) * 100 : 0,
amplitude: prev ? ((rec.high - rec.low) / prev) * 100 : 0,
@@ -92,111 +132,127 @@ function buildTip(rec: DayRec, key: string) {
};
}
function build() {
if (!container.value) return;
const ch = createChart(container.value, {
autoSize: true,
layout: { background: { color: 'transparent' }, textColor: '#9aa4b2', fontSize: 11, attributionLogo: false },
grid: { vertLines: { color: 'rgba(255,255,255,0.04)' }, horzLines: { color: 'rgba(255,255,255,0.04)' } },
crosshair: {
mode: CrosshairMode.Normal,
vertLine: { color: 'rgba(255,255,255,0.25)', width: 1, style: LineStyle.Dashed, labelBackgroundColor: '#2a2e39' },
horzLine: { color: 'rgba(255,255,255,0.25)', width: 1, style: LineStyle.Dashed, labelBackgroundColor: '#2a2e39' },
// 浅色主题 + A股红涨绿跌与默认样式深合并
const LIGHT_STYLES = {
grid: { horizontal: { color: '#eef2f7' }, vertical: { color: '#eef2f7' } },
candle: {
bar: {
upColor: UP, downColor: DOWN,
upBorderColor: UP, downBorderColor: DOWN,
upWickColor: UP, downWickColor: DOWN,
},
rightPriceScale: { borderColor: 'rgba(255,255,255,0.08)', scaleMargins: { top: 0.08, bottom: 0.28 } },
timeScale: { borderColor: 'rgba(255,255,255,0.08)', rightOffset: 6, barSpacing: 8 },
});
priceMark: {
high: { color: '#94a3b8' }, low: { color: '#94a3b8' },
last: { upColor: UP, downColor: DOWN },
},
},
xAxis: { axisLine: { color: '#e2e8f0' }, tickText: { color: '#64748b' }, tickLine: { color: '#e2e8f0' } },
yAxis: { axisLine: { color: '#e2e8f0' }, tickText: { color: '#64748b' }, tickLine: { color: '#e2e8f0' } },
crosshair: {
horizontal: { text: { backgroundColor: '#1e293b' } },
vertical: { text: { backgroundColor: '#1e293b' } },
},
separator: { color: '#e2e8f0' },
};
function build() {
if (!container.value || props.candles.length === 0) return;
BE_SERIES = props.indicators.data ?? {};
const ch = init(container.value, { styles: LIGHT_STYLES });
if (!ch) return;
chart = ch;
candleSeries = ch.addSeries(CandlestickSeries, {
upColor: UP, downColor: DOWN, borderUpColor: UP, borderDownColor: DOWN, wickUpColor: UP, wickDownColor: DOWN,
priceFormat: { type: 'price', precision: 2, minMove: 0.01 },
}, 0);
volumeSeries = ch.addSeries(HistogramSeries, { priceFormat: { type: 'volume' }, priceScaleId: 'vol' }, 0);
volumeSeries.priceScale().applyOptions({ scaleMargins: { top: 0.82, bottom: 0 } });
const keys = Object.keys(props.indicators.data ?? {});
if (isMACD.value && props.indicators.data?.hist) {
// MACD 进副图(震荡指标,独立刻度)
difSeries = ch.addSeries(LineSeries, { color: DIF, lineWidth: 2, priceScaleId: 'macd', priceLineVisible: false, lastValueVisible: true }, 1);
deaSeries = ch.addSeries(LineSeries, { color: DEA, lineWidth: 2, priceScaleId: 'macd', priceLineVisible: false, lastValueVisible: true }, 1);
histSeries = ch.addSeries(HistogramSeries, { priceScaleId: 'macd', priceLineVisible: false, lastValueVisible: false }, 1);
try { ch.panes()[1]?.setHeight(140); } catch { /* pane 未就绪 */ }
} else {
// 均线叠加在主图(价格刻度,与 K 线同坐标系)
maSeriesArr = keys.map((k, i) => ({
key: k,
series: ch.addSeries(LineSeries, {
color: MA_COLORS[i % MA_COLORS.length], lineWidth: 1, priceLineVisible: false,
lastValueVisible: false, crosshairMarkerVisible: true,
}, 0),
}));
}
markersApi = createSeriesMarkers(candleSeries, []);
ch.subscribeCrosshairMove((param) => {
const pt = param.point;
if (!param.time || !pt || !container.value) { tip.value.visible = false; return; }
const key = timeKey(param.time);
const rec = byTime[key];
if (!rec) { tip.value.visible = false; return; }
tipData.value = buildTip(rec, key);
const W = container.value.clientWidth, H = container.value.clientHeight;
const TW = 220, TH = 188;
let x = pt.x + 16; if (x + TW > W) x = pt.x - TW - 16; if (x < 4) x = 4;
let y = pt.y + 16; if (y + TH > H) y = H - TH - 6; if (y < 4) y = 4;
tip.value = { visible: true, x, y };
});
fillData();
ch.timeScale().fitContent();
}
function fillData() {
if (!chart || !candleSeries) return;
// 逐日记录(悬停详情)
const c = props.candles;
const keys = Object.keys(props.indicators.data ?? {});
byTime = {};
c.forEach((k, i) => {
const keys = Object.keys(BE_SERIES);
byIndex = c.map((k, i) => {
const ind: Record<string, number | null> = {};
keys.forEach((key) => { ind[key] = props.indicators.data[key]?.[i] ?? null; });
byTime[t(k.ts) as unknown as string] = {
keys.forEach((key) => { ind[key] = BE_SERIES[key]?.[i] ?? null; });
return {
open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume,
prevClose: i > 0 ? c[i - 1].close : null, ind,
};
});
candleData = c.map((k) => ({
timestamp: new Date(k.ts).getTime(),
open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume,
}));
const tsList = c.map((k) => k.ts);
candleSeries.setData(c.map(k => ({ time: t(k.ts), open: k.open, high: k.high, low: k.low, close: k.close })));
volumeSeries?.setData(c.map(k => ({
time: t(k.ts), value: k.volume, color: k.close >= k.open ? hexA(UP, 0.5) : hexA(DOWN, 0.5),
})));
// v10 数据接入DataLoader 一次性提供全量(回测结果静态数据,无分页)
// 注意v10 要求 symbol+period+dataLoader 三者齐备才触发 'init' 加载,缺一图表空白
ch.setDataLoader({
getBars: ({ type, callback }) => {
if (type === 'update') {
const last = candleData[candleData.length - 1];
callback(last ? [last] : [], { backward: false, forward: false });
} else if (type === 'init') {
callback(candleData, { backward: false, forward: false });
} else {
callback([], { backward: false, forward: false });
}
},
});
ch.setSymbol({ ticker: props.symbol ?? 'BACKTEST' });
ch.setPeriod({ type: 'day', span: 1 });
if (difSeries && deaSeries && histSeries && props.indicators.data?.hist) {
difSeries.setData(toLine(props.indicators.data.macd ?? [], c));
deaSeries.setData(toLine(props.indicators.data.signal ?? [], c));
histSeries.setData((props.indicators.data.hist ?? []).map((v, i) => ({
time: t(c[i].ts), value: v ?? 0, color: (v ?? 0) >= 0 ? hexA(UP, 0.6) : hexA(DOWN, 0.6),
})));
// 副图/叠加MACD 独立 pane均线叠加主图
ch.createIndicator('VOL');
if (isMACD.value) {
ch.createIndicator('be-macd');
} else {
maSeriesArr.forEach((m) => m.series.setData(toLine(props.indicators.data[m.key] ?? [], c)));
ch.createIndicator({ name: 'be-lines', paneId: 'candle_pane' });
}
// 副图压矮,主图占大头
for (const ind of ch.getIndicators()) {
if (ind.name === 'VOL') ch.setPaneOptions({ id: ind.paneId, height: 84 });
if (ind.name === 'be-macd') ch.setPaneOptions({ id: ind.paneId, height: 120 });
}
const markers: SeriesMarker<Time>[] = props.signals.map(s => ({
time: t(s.ts),
position: s.side === 'buy' ? 'belowBar' : 'aboveBar',
color: s.side === 'buy' ? UP : DOWN,
shape: s.side === 'buy' ? 'arrowUp' : 'arrowDown',
text: s.side === 'buy' ? 'B' : 'S',
}));
markersApi?.setMarkers(markers);
drawSignalAnnotations();
// 悬停详情crosshair 事件自带数据索引与像素坐标
ch.subscribeAction('onCrosshairChange', (d) => {
const data = d as Crosshair | undefined;
const i = data?.dataIndex;
if (data == null || i == null || i < 0 || i >= byIndex.length) {
tip.value.visible = false;
return;
}
tipData.value = buildTip(byIndex[i], tsList[i]);
const el = container.value;
if (el && data.x != null && data.y != null) {
const TW = 224, TH = 196;
let x = data.x + 16; if (x + TW > el.clientWidth) x = data.x - TW - 16; if (x < 4) x = 4;
let y = data.y + 16; if (y + TH > el.clientHeight) y = data.y - TH - 24; if (y < 4) y = 4;
tip.value = { visible: true, x, y };
}
});
}
function drawSignalAnnotations() {
if (!chart) return;
const c = props.candles;
const idxOfTs = new Map<number, number>();
c.forEach((k, i) => idxOfTs.set(new Date(k.ts).getTime(), i));
for (const s of props.signals) {
const i = idxOfTs.get(new Date(s.ts).getTime());
if (i == null) continue;
const k = c[i];
const buy = s.side === 'buy';
chart.createOverlay({
name: 'simpleAnnotation',
points: [{ dataIndex: i, value: buy ? k.low : k.high }],
extendData: buy ? 'B' : 'S',
styles: { text: { color: buy ? UP : DOWN, size: 11, weight: 'bold' } },
});
}
}
function teardown() {
chart?.remove();
chart = null; candleSeries = null; volumeSeries = null;
difSeries = deaSeries = histSeries = null; maSeriesArr = []; markersApi = null;
if (container.value) dispose(container.value);
chart = null;
}
onMounted(build);
@@ -205,33 +261,59 @@ watch(() => [props.candles, props.indicators, props.signals, props.strategy], ()
</script>
<template>
<div class="kline-wrap">
<div class="lc-legend">
<span class="sym">{{ symbol ?? '—' }}<small>{{ TF_LABEL[timeframe ?? '1d'] ?? timeframe }} · {{ STRAT_LABEL[strategy ?? 'macd_cross'] ?? strategy }}</small></span>
<span v-for="(chip, i) in legendChips" :key="i" class="chip"><i :style="{ background: chip.color }"></i>{{ chip.label }}</span>
<div class="relative">
<!-- 图例 + 画线工具栏 -->
<div class="mb-1 flex flex-wrap items-center gap-x-3 gap-y-1 px-2">
<span class="text-[13px] font-semibold text-slate-900">
{{ symbol ?? '—' }}
<small class="ml-1.5 font-normal text-slate-400">
{{ TF_LABEL[timeframe ?? '1d'] ?? timeframe }} · {{ STRAT_LABEL[strategy ?? 'macd_cross'] ?? strategy }}
</small>
</span>
<span v-for="(chip, i) in legendChips" :key="i" class="flex items-center gap-1 text-xs text-slate-500">
<i class="inline-block h-0.5 w-3 rounded" :style="{ background: chip.color }"></i>{{ chip.label }}
</span>
<span class="ml-auto flex items-center gap-0.5 rounded-lg border border-slate-200 bg-slate-50 p-0.5">
<span class="px-1.5 text-[10px] text-slate-400">画线</span>
<button
v-for="tool in TOOLS"
:key="tool.label"
type="button"
:title="tool.title"
class="rounded-md px-2 py-1 text-xs transition-colors"
:class="activeTool === tool.name ? 'bg-blue-600 text-white' : 'text-slate-600 hover:bg-white hover:shadow-sm'"
@click="tool.name === null ? (activeTool = null) : pickTool(tool.name)"
>{{ tool.label }}</button>
<button type="button" class="rounded-md px-2 py-1 text-xs text-slate-400 transition-colors hover:bg-white hover:text-red-600" title="清除所有画线与标注" @click="clearDrawings">清除</button>
</span>
</div>
<div v-if="tip.visible && tipData" class="lc-tooltip" :style="{ left: tip.x + 'px', top: tip.y + 'px' }">
<div class="tt-date">{{ tipData.date }} <span class="tt-wd">{{ tipData.weekday }}</span></div>
<div class="tt-grid">
<div> <b :class="tipData.up ? 'pos' : 'neg'">{{ fmt2(tipData.open) }}</b></div>
<div> <b class="pos">{{ fmt2(tipData.high) }}</b></div>
<div> <b class="neg">{{ fmt2(tipData.low) }}</b></div>
<div> <b :class="tipData.up ? 'pos' : 'neg'">{{ fmt2(tipData.close) }}</b></div>
<!-- 悬停详情同花顺式 -->
<div
v-if="tip.visible && tipData"
class="pointer-events-none absolute z-20 w-[224px] rounded-lg border border-slate-200 bg-white/95 px-3 py-2 text-[11.5px] leading-relaxed text-slate-600 shadow-lg"
:style="{ left: tip.x + 'px', top: tip.y + 'px' }"
>
<div class="mb-0.5 font-semibold text-slate-900">{{ tipData.date }} <span class="ml-1 font-normal text-slate-400">{{ tipData.weekday }}</span></div>
<div class="grid grid-cols-2 gap-x-4">
<div> <b :class="tipData.up ? 'text-up' : 'text-down'">{{ fmt2(tipData.open) }}</b></div>
<div> <b class="text-up">{{ fmt2(tipData.high) }}</b></div>
<div> <b class="text-down">{{ fmt2(tipData.low) }}</b></div>
<div> <b :class="tipData.up ? 'text-up' : 'text-down'">{{ fmt2(tipData.close) }}</b></div>
</div>
<div class="tt-row">
涨跌 <b :class="tipData.up ? 'pos' : 'neg'">{{ tipData.change >= 0 ? '+' : '' }}{{ fmt2(tipData.change) }}</b>
· 涨幅 <b :class="tipData.up ? 'pos' : 'neg'">{{ tipData.chgPct.toFixed(2) }}%</b>
<div>
涨跌 <b :class="tipData.up ? 'text-up' : 'text-down'">{{ tipData.change >= 0 ? '+' : '' }}{{ fmt2(tipData.change) }}</b>
· 涨幅 <b :class="tipData.up ? 'text-up' : 'text-down'">{{ tipData.chgPct.toFixed(2) }}%</b>
</div>
<div class="tt-row">振幅 {{ tipData.amplitude.toFixed(2) }}% · {{ tipData.volLots.toLocaleString() }} </div>
<div class="tt-sep"></div>
<div class="tt-ind">
<span v-for="(v, k) in tipData.ind" :key="k" :style="{ color: IND_COLOR[k] ?? '#9aa4b2' }">
<div>振幅 {{ tipData.amplitude.toFixed(2) }}% · {{ tipData.volLots.toLocaleString() }} </div>
<div class="mt-1 border-t border-slate-100 pt-1">
<span v-for="(v, k) in tipData.ind" :key="k" class="mr-3" :style="{ color: IND_COLOR[k] ?? '#64748b' }">
{{ IND_LABEL[k] ?? k }} {{ fmt3(v) }}
</span>
</div>
</div>
<div ref="container" class="chart-kline"></div>
<div ref="container" class="h-[520px] w-full"></div>
</div>
</template>

View File

@@ -5,16 +5,35 @@ defineProps<{ metrics: MetricsOut }>();
const pct = (x: number) => `${(x * 100).toFixed(2)}%`;
const num = (x: number) => x.toFixed(2);
const sign = (x: number) => (x >= 0 ? 'pos' : 'neg'); // A股正=红、负=绿
// A股语义:正=红、负=绿
const sign = (x: number) => (x >= 0 ? 'text-up' : 'text-down');
</script>
<template>
<div class="stats">
<div class="stat"><span class="label">总收益</span><span class="value" :class="sign(metrics.total_return)">{{ pct(metrics.total_return) }}</span></div>
<div class="stat"><span class="label">最大回撤</span><span class="value neg">{{ pct(metrics.max_drawdown) }}</span></div>
<div class="stat"><span class="label">夏普</span><span class="value" :class="sign(metrics.sharpe)">{{ num(metrics.sharpe) }}</span></div>
<div class="stat"><span class="label">年化波动</span><span class="value">{{ pct(metrics.volatility) }}</span></div>
<div class="stat"><span class="label">胜率</span><span class="value">{{ pct(metrics.win_rate) }}</span></div>
<div class="stat"><span class="label">交易数</span><span class="value">{{ metrics.num_trades }}</span></div>
<div class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-xs text-slate-400">总收益</div>
<div class="mt-1 text-lg font-semibold" :class="sign(metrics.total_return)">{{ pct(metrics.total_return) }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-xs text-slate-400">最大回撤</div>
<div class="mt-1 text-lg font-semibold text-down">{{ pct(metrics.max_drawdown) }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-xs text-slate-400">夏普比率</div>
<div class="mt-1 text-lg font-semibold" :class="sign(metrics.sharpe)">{{ num(metrics.sharpe) }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-xs text-slate-400">年化波动</div>
<div class="mt-1 text-lg font-semibold text-slate-900">{{ pct(metrics.volatility) }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-xs text-slate-400">胜率</div>
<div class="mt-1 text-lg font-semibold text-slate-900">{{ pct(metrics.win_rate) }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-xs text-slate-400">交易次数</div>
<div class="mt-1 text-lg font-semibold text-slate-900">{{ metrics.num_trades }}</div>
</div>
</div>
</template>

View File

@@ -1,7 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue';
import Button from 'primevue/button';
import Textarea from 'primevue/textarea';
defineProps<{ loading: boolean }>();
const emit = defineEmits<{ (e: 'run', text: string): void }>();
@@ -22,29 +20,37 @@ function run() {
</script>
<template>
<div class="toolbar" style="flex-direction: column; align-items: stretch; gap: 10px;">
<div class="field" style="gap: 7px;">
<label>用一句话描述你的选股条件</label>
<Textarea
v-model="text"
class="screener-input"
:auto-resize="true"
rows="2"
size="small"
placeholder="例如:这两天 KDJ 的 J 小于 10市值 100~200 亿的公司"
@keyup.ctrl.enter="run"
/>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<label class="lbl">用一句话描述你的选股条件</label>
<textarea
v-model="text"
rows="2"
class="ipt w-full resize-y leading-relaxed"
placeholder="例如:这两天 KDJ 的 J 小于 10市值 100~200 亿的公司"
@keyup.ctrl.enter="run"
/>
<div class="mt-3 flex flex-wrap items-center gap-1.5">
<span class="mr-1 text-xs text-slate-400">示例</span>
<button
v-for="(ex, i) in examples"
:key="i"
type="button"
class="max-w-full truncate rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs text-slate-600 transition-colors hover:border-slate-300 hover:bg-slate-100"
@click="text = ex"
>{{ ex }}</button>
</div>
<div class="quick">
<span class="qlabel">示例</span>
<span v-for="(ex, i) in examples" :key="i" class="qchip" @click="text = ex">{{ ex }}</span>
</div>
<div class="hint">
支持 KDJ / RSI / MACD / 布林 / 均线指标条件市值 / 市盈率 / 换手率等快照条件以及连续 N N 天任一天等时间窗口
<code>Ctrl+Enter</code> 快速筛选
</div>
<div>
<Button label="开始筛选" icon="pi pi-search" size="small" :loading="loading" :disabled="!text.trim()" @click="run" />
<div class="mt-4 flex items-center justify-between gap-3">
<p class="text-xs leading-relaxed text-slate-400">
支持 KDJ / RSI / MACD / 布林 / 均线指标条件市值 / 市盈率 / 换手率等快照条件以及连续 N N 天任一天时间窗口
<kbd class="rounded border border-slate-200 bg-slate-50 px-1">Ctrl</kbd>+<kbd class="rounded border border-slate-200 bg-slate-50 px-1">Enter</kbd> 快速筛选
</p>
<button type="button" class="btn-primary shrink-0 disabled:opacity-50" :disabled="loading || !text.trim()" @click="run">
<svg v-if="loading" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
<svg v-else class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" /></svg>
{{ loading ? '筛选中…' : '开始筛选' }}
</button>
</div>
</div>
</template>

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import { computed, ref } from 'vue';
import type { ScreenerItemOut, ScreenerRunResponse } from '@/api/types';
const props = defineProps<{ result: ScreenerRunResponse }>();
const emit = defineEmits<{ (e: 'preview', item: ScreenerItemOut): void }>();
const items = computed(() => props.result.items);
// 动态指标列:[{ key: 'kdj_j', label: 'KDJ J(9,3,3)' }]
@@ -12,64 +11,132 @@ const indCols = computed(() =>
Object.entries(props.result.indicator_labels).map(([key, label]) => ({ key, label })),
);
function pctClass(v: number | null) {
if (v == null) return '';
return v > 0 ? 'pos' : v < 0 ? 'neg' : '';
// ---------- 排序(点击表头切换 升/降) ----------
type FieldOf = (it: ScreenerItemOut) => number | string | null;
const FIXED_COLS: { key: string; label: string; get: FieldOf; num?: boolean; fmt?: (v: number | null) => string; cls?: (v: number | null) => string }[] = [
{ key: 'ts_code', label: '代码', get: (it) => it.ts_code },
{ key: 'name', label: '名称', get: (it) => it.name },
{ key: 'close', label: '最新价', get: (it) => it.close, num: true, fmt: fmt2 },
{
key: 'pct_chg', label: '涨跌幅%', get: (it) => it.pct_chg, num: true,
fmt: (v) => (v == null ? '—' : (v > 0 ? '+' : '') + v.toFixed(2)),
cls: (v) => (v == null ? '' : v > 0 ? 'text-up' : v < 0 ? 'text-down' : ''),
},
{ key: 'total_mv', label: '总市值(亿)', get: (it) => it.total_mv, num: true, fmt: fmt2 },
{ key: 'circ_mv', label: '流通市值(亿)', get: (it) => it.circ_mv, num: true, fmt: fmt2 },
{ key: 'pe_ttm', label: 'PE-TTM', get: (it) => it.pe_ttm, num: true, fmt: fmt2 },
{ key: 'pb', label: 'PB', get: (it) => it.pb, num: true, fmt: fmt2 },
{ key: 'turnover_rate', label: '换手率%', get: (it) => it.turnover_rate, num: true, fmt: fmt2 },
];
const sortKey = ref<string>('total_mv');
const sortDir = ref<'asc' | 'desc'>('desc');
function getVal(it: ScreenerItemOut, key: string): number | string | null {
const fixed = FIXED_COLS.find((c) => c.key === key);
if (fixed) return fixed.get(it);
const ind = indCols.value.find((c) => c.key === key);
return ind ? (it.indicators?.[ind.key] ?? null) : null;
}
function fmt(v: number | null, digits = 2) {
return v == null ? '—' : v.toFixed(digits);
function toggleSort(key: string) {
if (sortKey.value === key) {
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
} else {
sortKey.value = key;
sortDir.value = 'desc'; // 默认数值列降序更直觉
}
}
const sortedItems = computed(() => {
const key = sortKey.value;
const dir = sortDir.value === 'asc' ? 1 : -1;
return [...items.value].sort((a, b) => {
const va = getVal(a, key);
const vb = getVal(b, key);
if (va == null) return 1; // 空值排最后
if (vb == null) return -1;
if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * dir;
return String(va).localeCompare(String(vb)) * dir;
});
});
function fmt2(v: number | null) {
return v == null ? '—' : v.toFixed(2);
}
function fmtInd(it: ScreenerItemOut, key: string) {
const v = it.indicators?.[key];
return v == null ? '—' : v.toFixed(2);
}
</script>
<template>
<div class="panel">
<div class="panel-title">
命中 {{ result.total }} {{ result.total > items.length ? `(仅显示前 ${items.length}` : '' }}
<span v-if="result.trade_date" style="color: var(--ink-3); margin-left: 8px;">· 数据基准 {{ result.trade_date.slice(0, 10) }}</span>
<div class="mt-4 overflow-hidden rounded-xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-4 py-3 text-[13px] text-slate-600">
命中 <span class="font-semibold text-slate-900">{{ result.total }}</span>
<span v-if="result.total > items.length" class="text-slate-400">仅显示前 {{ items.length }}</span>
<span v-if="result.trade_date" class="ml-2 text-slate-400">· 数据基准 {{ result.trade_date.slice(0, 10) }}</span>
</div>
<div class="max-h-[560px] overflow-auto">
<table class="w-full border-collapse text-[13px]">
<thead class="sticky top-0 z-10 bg-slate-50 text-slate-500">
<tr class="border-b border-slate-200">
<th
v-for="c in FIXED_COLS"
:key="c.key"
class="cursor-pointer select-none whitespace-nowrap px-3 py-2 text-left font-medium hover:text-slate-900"
@click="toggleSort(c.key)"
>
{{ c.label }}
<span v-if="sortKey === c.key" class="text-blue-600">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th
v-for="col in indCols"
:key="col.key"
class="cursor-pointer select-none whitespace-nowrap px-3 py-2 text-left font-medium hover:text-slate-900"
@click="toggleSort(col.key)"
>
{{ col.label }}
<span v-if="sortKey === col.key" class="text-blue-600">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th class="whitespace-nowrap px-3 py-2 text-right font-medium">操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="it in sortedItems"
:key="it.ts_code"
class="cursor-pointer border-b border-slate-50 transition-colors last:border-0 hover:bg-blue-50/40"
@click="emit('preview', it)"
>
<td class="whitespace-nowrap px-3 py-1.5 font-medium text-slate-900">{{ it.ts_code }}</td>
<td class="whitespace-nowrap px-3 py-1.5 text-slate-700">{{ it.name }}</td>
<td class="whitespace-nowrap px-3 py-1.5">{{ fmt2(it.close) }}</td>
<td class="whitespace-nowrap px-3 py-1.5" :class="it.pct_chg != null && FIXED_COLS[3].cls ? FIXED_COLS[3].cls!(it.pct_chg) : ''">
{{ it.pct_chg == null ? '—' : (it.pct_chg > 0 ? '+' : '') + it.pct_chg.toFixed(2) }}
</td>
<td class="whitespace-nowrap px-3 py-1.5 text-slate-700">{{ fmt2(it.total_mv) }}</td>
<td class="whitespace-nowrap px-3 py-1.5 text-slate-700">{{ fmt2(it.circ_mv) }}</td>
<td class="whitespace-nowrap px-3 py-1.5 text-slate-700">{{ fmt2(it.pe_ttm) }}</td>
<td class="whitespace-nowrap px-3 py-1.5 text-slate-700">{{ fmt2(it.pb) }}</td>
<td class="whitespace-nowrap px-3 py-1.5 text-slate-700">{{ fmt2(it.turnover_rate) }}</td>
<td v-for="col in indCols" :key="col.key" class="whitespace-nowrap px-3 py-1.5 text-slate-700">
{{ fmtInd(it, col.key) }}
</td>
<td class="whitespace-nowrap px-3 py-1.5 text-right">
<button
type="button"
class="rounded-md border border-slate-200 px-2 py-0.5 text-xs text-blue-600 transition-colors hover:border-blue-300 hover:bg-blue-50"
@click.stop="emit('preview', it)"
>详情</button>
</td>
</tr>
<tr v-if="sortedItems.length === 0">
<td :colspan="FIXED_COLS.length + indCols.length + 1" class="px-3 py-12 text-center text-slate-400">没有符合条件的股票</td>
</tr>
</tbody>
</table>
</div>
<DataTable :value="items" size="small" striped-rows :scrollable="true" scroll-height="560px" sort-mode="single">
<Column field="ts_code" header="代码" sortable style="min-width: 92px;">
<template #body="{ data }">
<span style="font-variant-numeric: tabular-nums; color: var(--ink);">{{ data.ts_code }}</span>
</template>
</Column>
<Column field="name" header="名称" sortable style="min-width: 96px;" />
<Column field="close" header="最新价" sortable style="min-width: 84px;">
<template #body="{ data }">{{ fmt(data.close) }}</template>
</Column>
<Column field="pct_chg" header="涨跌幅%" sortable style="min-width: 88px;">
<template #body="{ data }">
<span :class="pctClass(data.pct_chg)">{{ data.pct_chg == null ? '—' : (data.pct_chg > 0 ? '+' : '') + data.pct_chg.toFixed(2) }}</span>
</template>
</Column>
<Column field="total_mv" header="总市值(亿)" sortable style="min-width: 100px;">
<template #body="{ data }">{{ fmt(data.total_mv) }}</template>
</Column>
<Column field="circ_mv" header="流通市值(亿)" sortable style="min-width: 106px;">
<template #body="{ data }">{{ fmt(data.circ_mv) }}</template>
</Column>
<Column field="pe_ttm" header="PE-TTM" sortable style="min-width: 84px;">
<template #body="{ data }">{{ fmt(data.pe_ttm) }}</template>
</Column>
<Column field="pb" header="PB" sortable style="min-width: 70px;">
<template #body="{ data }">{{ fmt(data.pb) }}</template>
</Column>
<Column field="turnover_rate" header="换手率%" sortable style="min-width: 88px;">
<template #body="{ data }">{{ fmt(data.turnover_rate) }}</template>
</Column>
<Column
v-for="col in indCols"
:key="col.key"
:field="`indicators.${col.key}`"
:header="col.label"
sortable
style="min-width: 110px;"
>
<template #body="{ data }">
<span style="font-variant-numeric: tabular-nums;">{{ data.indicators?.[col.key] == null ? '—' : data.indicators[col.key].toFixed(2) }}</span>
</template>
</Column>
</DataTable>
</div>
</template>

View File

@@ -0,0 +1,286 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { getStockPreview } from '@/api/client';
import type { PreviewResponse, ScreenerItemOut } from '@/api/types';
import DetailKLine from './DetailKLine.vue';
const props = defineProps<{
items: ScreenerItemOut[];
initial: string; // ts_code
}>();
const emit = defineEmits<{ (e: 'close'): void }>();
// ---------- 状态 ----------
const active = ref(props.initial);
const data = ref<PreviewResponse | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const filter = ref('');
// 副图指标:点击开关 / 拖拽排序
const SUBS = [
{ key: 'vol', label: 'VOL' },
{ key: 'macd', label: 'MACD' },
{ key: 'kdj', label: 'KDJ' },
{ key: 'rsi', label: 'RSI' },
];
const subPanes = ref<string[]>(['vol', 'macd', 'kdj', 'rsi']);
const showBoll = ref(false);
const filteredItems = computed(() => {
const q = filter.value.trim().toLowerCase();
if (!q) return props.items;
return props.items.filter(
(it) => it.ts_code.toLowerCase().includes(q) || it.name.toLowerCase().includes(q),
);
});
const activeItem = computed(
() => props.items.find((it) => it.ts_code === active.value) ?? null,
);
// 头部/右侧展示值:优先预览信息(最新),否则用选股行数据兜底
const header = computed(() => {
const info = data.value?.info;
const item = activeItem.value;
return {
name: info?.name ?? item?.name ?? active.value,
close: info?.close ?? item?.close ?? null,
pct: info?.pct_chg ?? item?.pct_chg ?? null,
};
});
// ---------- 数据加载 ----------
let fetchToken = 0;
async function load(code: string) {
const token = ++fetchToken;
loading.value = true;
error.value = null;
data.value = null;
try {
const res = await getStockPreview(code);
if (token === fetchToken) data.value = res;
} catch (e) {
if (token === fetchToken) error.value = e instanceof Error ? e.message : '加载失败';
} finally {
if (token === fetchToken) loading.value = false;
}
}
watch(active, (code) => load(code), { immediate: true });
function moveActive(delta: number) {
const list = filteredItems.value;
const idx = list.findIndex((it) => it.ts_code === active.value);
if (list.length === 0) return;
const next = idx < 0 ? 0 : Math.min(list.length - 1, Math.max(0, idx + delta));
active.value = list[next].ts_code;
}
// ---------- 键盘 / 滚动锁 ----------
function onKeydown(e: KeyboardEvent) {
// 输入法组合态 / 焦点在输入框时不拦截否则搜索框打字会切股、Esc 关浮层)
if (e.isComposing) return;
const t = e.target as HTMLElement | null;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
if (e.key === 'Escape') emit('close');
else if (e.key === 'ArrowUp') { e.preventDefault(); moveActive(-1); }
else if (e.key === 'ArrowDown') { e.preventDefault(); moveActive(1); }
}
onMounted(() => {
window.addEventListener('keydown', onKeydown);
document.body.style.overflow = 'hidden';
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown);
document.body.style.overflow = '';
});
// ---------- 副图 chips开关 + 拖拽排序 ----------
let dragKey: string | null = null;
function toggleSub(key: string) {
subPanes.value = subPanes.value.includes(key)
? subPanes.value.filter((k) => k !== key)
: [...subPanes.value, key];
}
function onDragStart(e: DragEvent, key: string) {
dragKey = key;
// Firefox/Safari 要求 dragstart 写入数据才会真正发起拖拽
e.dataTransfer?.setData('text/plain', key);
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
}
function onDrop(target: string) {
if (!dragKey || dragKey === target) return;
const arr = [...subPanes.value];
const from = arr.indexOf(dragKey);
if (from >= 0) arr.splice(from, 1);
const to = arr.indexOf(target);
arr.splice(to >= 0 ? to : arr.length, 0, dragKey);
subPanes.value = arr;
dragKey = null;
}
// ---------- 格式化 ----------
const fmt = (v: number | null | undefined, d = 2) => (v == null ? '—' : v.toFixed(d));
const fmtInt = (v: number | null | undefined) =>
v == null ? '—' : Math.round(v).toLocaleString();
const pctClass = (v: number | null | undefined) =>
v == null ? '' : v > 0 ? 'text-up' : v < 0 ? 'text-down' : '';
const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : s ?? '—');
</script>
<template>
<div class="fixed inset-0 z-40 flex flex-col bg-slate-100">
<!-- 顶栏 -->
<header class="flex h-12 shrink-0 items-center gap-4 border-b border-slate-200 bg-white px-4">
<div class="flex items-baseline gap-2">
<span class="text-base font-semibold text-slate-900">{{ header.name }}</span>
<span class="text-xs text-slate-400">{{ active }}</span>
</div>
<div class="flex items-baseline gap-2">
<span class="text-lg font-semibold" :class="pctClass(header.pct)">{{ fmt(header.close) }}</span>
<span v-if="header.pct != null" class="text-sm" :class="pctClass(header.pct)">
{{ header.pct > 0 ? '+' : '' }}{{ fmt(header.pct) }}%
</span>
</div>
<span v-if="data?.source === 'market'" class="rounded bg-amber-50 px-2 py-0.5 text-[11px] text-amber-600">
近段未复权数据
</span>
<span v-else-if="data" class="rounded bg-blue-50 px-2 py-0.5 text-[11px] text-blue-600">前复权</span>
<span class="ml-auto text-xs text-slate-400"> 切换 · Esc 关闭</span>
<button type="button" class="btn-ghost !px-2.5 !py-1" title="关闭 (Esc)" @click="emit('close')">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
</button>
</header>
<!-- 三栏主体 -->
<div class="flex min-h-0 flex-1">
<!-- 命中列表 -->
<aside class="flex w-56 shrink-0 flex-col border-r border-slate-200 bg-white">
<div class="border-b border-slate-100 p-2">
<input v-model="filter" type="text" class="ipt w-full !py-1 text-xs" placeholder="搜索代码 / 名称" />
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
<button
v-for="it in filteredItems"
:key="it.ts_code"
type="button"
class="flex w-full items-center gap-2 border-b border-slate-50 px-3 py-2 text-left transition-colors"
:class="it.ts_code === active ? 'bg-blue-50' : 'hover:bg-slate-50'"
@click="active = it.ts_code"
>
<span class="min-w-0 flex-1">
<span class="block truncate text-[13px] font-medium text-slate-800">{{ it.name }}</span>
<span class="block text-[11px] text-slate-400">{{ it.ts_code }}</span>
</span>
<span class="text-right">
<span class="block text-[13px]">{{ fmt(it.close) }}</span>
<span class="block text-[11px]" :class="pctClass(it.pct_chg)">
{{ it.pct_chg == null ? '—' : (it.pct_chg > 0 ? '+' : '') + it.pct_chg.toFixed(2) + '%' }}
</span>
</span>
</button>
<div v-if="filteredItems.length === 0" class="px-3 py-8 text-center text-xs text-slate-400">无匹配</div>
</div>
<div class="border-t border-slate-100 px-3 py-2 text-[11px] text-slate-400"> {{ filteredItems.length }} </div>
</aside>
<!-- K线 + 指标面板 -->
<section class="flex min-w-0 flex-1 flex-col">
<!-- 指标开关 / 排序 -->
<div class="flex shrink-0 flex-wrap items-center gap-1.5 bg-white px-3 py-2">
<span class="text-[11px] text-slate-400">副图</span>
<button
v-for="s in SUBS"
:key="s.key"
type="button"
draggable="true"
class="cursor-grab rounded-md border px-2.5 py-1 text-xs transition-colors active:cursor-grabbing"
:class="subPanes.includes(s.key)
? 'border-blue-600 bg-blue-600 text-white'
: 'border-slate-200 bg-white text-slate-400 line-through'"
:title="subPanes.includes(s.key) ? '点击隐藏 · 拖动排序' : '点击显示'"
@click="toggleSub(s.key)"
@dragstart="onDragStart($event, s.key)"
@dragover.prevent
@drop="onDrop(s.key)"
>
{{ s.label }}
</button>
<button
type="button"
class="rounded-md border px-2.5 py-1 text-xs transition-colors"
:class="showBoll ? 'border-purple-500 bg-purple-500 text-white' : 'border-slate-200 bg-white text-slate-400'"
title="主图叠加布林带"
@click="showBoll = !showBoll"
>BOLL</button>
<span class="ml-2 text-[11px] text-slate-400">点击开关副图 · 拖动排序 · 滚轮缩放 · 拖拽平移</span>
</div>
<!-- 图表 -->
<div class="relative min-h-0 flex-1 bg-white p-1">
<div v-if="loading" class="absolute inset-0 z-10 flex flex-col items-center justify-center bg-white/80 text-sm text-slate-400">
<svg class="mb-2 h-6 w-6 animate-spin text-blue-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
{{ active }} 首次查看需拉取全量日线
</div>
<div v-else-if="error" class="flex h-full items-center justify-center text-sm text-red-600">{{ error }}</div>
<DetailKLine
v-else-if="data && data.candles.length"
:ticker="data.ts_code"
:candles="data.candles"
:indicators="data.indicators"
:sub-panes="subPanes"
:show-boll="showBoll"
/>
<div v-else class="flex h-full items-center justify-center text-sm text-slate-400">无数据</div>
</div>
</section>
<!-- 个股信息通达信式 -->
<aside v-if="data" class="w-72 shrink-0 overflow-y-auto border-l border-slate-200 bg-white p-4">
<div class="border-b border-slate-100 pb-3">
<div class="text-[15px] font-semibold text-slate-900">{{ data.info.name }}</div>
<div class="mt-0.5 text-xs text-slate-400">
{{ data.info.ts_code }}
<span v-if="data.info.market" class="ml-1 rounded bg-slate-100 px-1.5 py-0.5">{{ data.info.market }}</span>
</div>
<div class="mt-2 flex items-baseline gap-2">
<span class="text-2xl font-semibold" :class="pctClass(data.info.pct_chg)">{{ fmt(data.info.close) }}</span>
<span v-if="data.info.pct_chg != null" class="text-sm" :class="pctClass(data.info.pct_chg)">
{{ data.info.pct_chg > 0 ? '+' : '' }}{{ fmt(data.info.pct_chg) }}%
</span>
</div>
</div>
<div class="mt-3 grid grid-cols-2 gap-y-2 text-[13px]">
<template v-for="(row, i) in [
['今开', fmt(data.info.open)],
['昨收', fmt(data.info.pre_close)],
['最高', fmt(data.info.high)],
['最低', fmt(data.info.low)],
['成交量', fmtInt(data.info.volume_hand) + ' 手'],
['成交额', fmt(data.info.amount_yi) + ' 亿'],
['换手率', fmt(data.info.turnover_rate) + '%'],
['市盈率TTM', fmt(data.info.pe_ttm)],
['市净率', fmt(data.info.pb)],
['总市值', fmt(data.info.total_mv) + ' 亿'],
['流通市值', fmt(data.info.circ_mv) + ' 亿'],
['上市日期', fmtListDate(data.info.list_date)],
['数据日期', (data.info.trade_date ?? '').slice(0, 10) || '—'],
]" :key="i">
<span class="text-slate-400">{{ row[0] }}</span>
<span class="text-right text-slate-800">{{ row[1] }}</span>
</template>
</div>
<div class="mt-4 border-t border-slate-100 pt-3 text-[13px]">
<div class="mb-2 text-xs text-slate-400">归属</div>
<div class="flex flex-wrap gap-1.5">
<span v-if="data.info.industry" class="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs text-slate-600">{{ data.info.industry }}</span>
<span v-if="data.info.area" class="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs text-slate-600">{{ data.info.area }}</span>
</div>
</div>
</aside>
</div>
</div>
</template>

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import Button from 'primevue/button';
import type { ScreenerSyncStatus } from '@/api/types';
const props = defineProps<{ status: ScreenerSyncStatus | null }>();
@@ -15,7 +14,7 @@ function fmtDate(s?: string | null) {
const freshness = computed(() => {
const s = props.status;
if (!s) return { tone: 'none', text: '正在检查数据状态…' };
if (!s.ready) return { tone: 'warn', text: '全市场数据尚未同步,请先点击右侧「同步数据」' };
if (!s.ready) return { tone: 'warn', text: '全市场数据尚未同步,请先点击右侧「同步市场数据」' };
const snapMissing = s.stats.snapshot_rows === 0;
const d = s.stats.dates;
const base = `数据截至 ${fmtDate(s.last_trade_date)} · 共 ${d} 个交易日 · ${s.stats.stocks} 只股票`;
@@ -25,32 +24,41 @@ const freshness = computed(() => {
</script>
<template>
<div class="sync-bar">
<span :class="freshness.tone === 'ok' ? 'ok' : freshness.tone === 'warn' ? 'warn' : ''">
<i class="pi" :class="freshness.tone === 'ok' ? 'pi-check-circle' : 'pi-info-circle'" style="margin-right: 5px;"></i>
<div class="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border border-slate-200 bg-white px-4 py-3 text-[13px] text-slate-600">
<span :class="freshness.tone === 'ok' ? 'text-emerald-600' : freshness.tone === 'warn' ? 'text-amber-600' : 'text-slate-400'">
<svg class="mr-1 inline h-4 w-4 align-[-3px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<template v-if="freshness.tone === 'ok'">
<path d="M22 11.1V12a10 10 0 11-5.9-9.1" />
<path d="M22 4L12 14l-3-3-7 7" />
</template>
<template v-else>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" />
</template>
</svg>
{{ freshness.text }}
</span>
<template v-if="status && status.running">
<span class="sync-progress">
<i class="pi pi-spin pi-spinner"></i>
<span class="txt">{{ status.step || '同步中…' }}{{ status.done_days }}/{{ status.total_days }}</span>
<span class="flex min-w-[200px] flex-1 items-center gap-2">
<svg class="h-4 w-4 shrink-0 animate-spin text-blue-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
<span class="whitespace-nowrap text-xs text-slate-500">{{ status.step || '同步中…' }}{{ status.done_days }}/{{ status.total_days }}</span>
<span class="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100">
<span class="block h-full rounded-full bg-blue-500 transition-all" :style="{ width: (status.total_days ? Math.min(100, (status.done_days / status.total_days) * 100) : 0) + '%' }" />
</span>
</span>
</template>
<template v-else>
<span class="spacer"></span>
<Button
label="同步市场数据"
icon="pi pi-refresh"
size="small"
severity="secondary"
:disabled="status?.running"
@click="emit('sync')"
/>
<span class="flex-1"></span>
<button type="button" class="btn-ghost shrink-0 disabled:opacity-50" :disabled="status?.running" @click="emit('sync')">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-2.6-6.4M21 3v6h-6" /></svg>
同步市场数据
</button>
</template>
<div v-if="status && status.error" class="warn" style="flex-basis: 100%; margin-top: 4px;">
<i class="pi pi-exclamation-triangle" style="margin-right: 5px;"></i>{{ status.error }}
<div v-if="status && status.error" class="w-full text-amber-600">
<svg class="mr-1 inline h-4 w-4 align-[-3px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
{{ status.error }}
</div>
</div>
</template>

View File

@@ -1,24 +1,23 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import PrimeVue from 'primevue/config';
import Aura from '@primeuix/themes/aura';
import 'primeicons/primeicons.css';
import './style.css';
import App from './App.vue';
import router from './router';
import { useAuthStore } from '@/stores/auth';
const app = createApp(App);
app.use(createPinia());
const pinia = createPinia();
app.use(pinia);
app.use(router);
app.use(PrimeVue, {
theme: {
preset: Aura,
options: {
// 以后想做深色:给 <html> 加 .app-dark 即可
darkModeSelector: '.app-dark',
},
},
window.addEventListener('stock:unauthorized', () => {
const auth = useAuthStore(pinia);
auth.clear();
if (router.currentRoute.value.name !== 'login') {
void router.replace({ name: 'login', query: { redirect: router.currentRoute.value.fullPath } });
}
});
app.mount('#app');

View File

@@ -1,9 +1,11 @@
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '@/views/HomeView.vue';
import { useAuthStore } from '@/stores/auth';
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', name: 'login', component: () => import('@/views/LoginView.vue'), meta: { public: true } },
{ path: '/', name: 'home', component: HomeView },
{ path: '/screener', name: 'screener', component: () => import('@/views/ScreenerView.vue') },
{ path: '/backtest', name: 'backtest', component: () => import('@/views/BacktestView.vue') },
@@ -11,4 +13,21 @@ const router = createRouter({
],
});
router.beforeEach(async (to) => {
const auth = useAuthStore();
await auth.restore();
if (to.meta.public) {
if (to.name === 'login' && auth.isAuthenticated) {
return typeof to.query.redirect === 'string' ? to.query.redirect : '/';
}
return true;
}
if (!auth.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } };
}
return true;
});
export default router;

View File

@@ -0,0 +1,60 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { ApiError, getCurrentUser, login as requestLogin, logout as requestLogout } from '@/api/client';
import type { CurrentUser } from '@/api/types';
export const useAuthStore = defineStore('auth', () => {
const user = ref<CurrentUser | null>(null);
const initialized = ref(false);
const loading = ref(false);
let restorePromise: Promise<void> | null = null;
const isAuthenticated = computed(() => user.value !== null);
async function restore() {
if (initialized.value) return;
if (restorePromise) return restorePromise;
restorePromise = (async () => {
try {
user.value = await getCurrentUser();
} catch (error) {
if (error instanceof ApiError && error.status !== 401) {
console.warn('Unable to restore login session:', error.message);
}
user.value = null;
} finally {
initialized.value = true;
restorePromise = null;
}
})();
return restorePromise;
}
async function login(username: string, password: string) {
loading.value = true;
try {
const result = await requestLogin({ username, password });
user.value = result.user;
initialized.value = true;
} finally {
loading.value = false;
}
}
async function logout() {
try {
await requestLogout();
} finally {
user.value = null;
initialized.value = true;
}
}
function clear() {
user.value = null;
initialized.value = true;
}
return { user, initialized, loading, isAuthenticated, restore, login, logout, clear };
});

View File

@@ -1,186 +1,117 @@
:root {
/* 深色专业交易终端配色A股红涨绿跌 */
--bg: #0b0e14;
--surface: #11151c;
--surface-2: #161c26;
--border: rgba(255, 255, 255, 0.07);
--border-2: rgba(255, 255, 255, 0.12);
@import "tailwindcss";
--ink: #e6edf3;
--ink-2: #9aa4b2;
--ink-3: #5c6675;
--up: #f6465d; /* A股涨 / 买入 = 红 */
--down: #0ecb81; /* A股跌 / 卖出 = 绿 */
--dif: #5b8ff9; /* MACD DIF */
--dea: #f6bd16; /* MACD DEA */
--radius: 12px;
/* ---------- 主题浅色简洁A股语义红涨绿跌 ---------- */
@theme {
--color-up: #dc2626; /* 涨 / 买入 = 红 */
--color-down: #16a34a; /* 跌 / 卖出 = 绿 */
--color-accent: #2563eb;
--font-sans: system-ui, -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
* { box-sizing: border-box; }
html, body, #app { margin: 0; min-height: 100%; }
body {
@apply bg-slate-50 text-slate-900 antialiased;
font-variant-numeric: tabular-nums;
}
/* ---------- 登录:个人量化终端入口 ---------- */
.login-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(1200px 560px at 78% -12%, #182030 0%, rgba(24, 32, 48, 0) 55%),
var(--bg);
color: var(--ink);
font-family: system-ui, -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
font-variant-numeric: tabular-nums;
-webkit-font-smoothing: antialiased;
linear-gradient(#e2e8f0 1px, transparent 1px),
linear-gradient(90deg, #e2e8f0 1px, transparent 1px),
#f8fafc;
background-size: 40px 40px;
}
.app-shell { max-width: 1500px; margin: 0 auto; padding: 16px 20px 40px; }
.app-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.app-header h1 { font-size: 16px; margin: 0; font-weight: 600; letter-spacing: 0.3px; }
.app-header .sub { color: var(--ink-3); font-size: 12px; }
/* 参数工具栏 */
.toolbar {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 11px 14px;
display: flex; flex-wrap: wrap; align-items: flex-end; gap: 16px;
.login-panel {
width: min(100%, 420px);
border: 1px solid #cbd5e1;
border-top: 3px solid #2563eb;
border-radius: 6px;
background: #fff;
padding: 30px;
box-shadow: 0 18px 50px rgb(15 23 42 / 10%);
}
.field { display: flex; flex-direction: column; gap: 5px; }
.field label { font-size: 11px; color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.5px; }
.spacer { flex: 1 1 auto; }
.hint { margin-top: 8px; font-size: 12px; color: var(--ink-3); }
.hint code { color: var(--ink-2); background: rgba(255,255,255,0.05); padding: 1px 5px; border-radius: 4px; }
/* 标的快捷选择 */
.quick { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 10px; }
.quick .qlabel { font-size: 12px; color: var(--ink-3); margin-right: 2px; }
.qchip {
font-size: 12px; color: var(--ink-2); background: var(--surface-2);
border: 1px solid var(--border); border-radius: 999px; padding: 3px 10px; cursor: pointer;
font-variant-numeric: tabular-nums; transition: 0.15s;
.login-brand { display: flex; align-items: center; gap: 12px; }
.login-brand-mark {
display: grid; place-items: center; width: 38px; height: 38px; border-radius: 5px;
background: #0f172a; color: #fff; font-size: 17px; font-weight: 700;
}
.qchip:hover { color: var(--ink); border-color: var(--border-2); }
.qchip.active { color: #fff; background: var(--dif); border-color: var(--dif); }
.qchip .qname { color: var(--ink-3); margin-left: 4px; }
.qchip.active .qname { color: rgba(255, 255, 255, 0.85); }
.login-brand-name { color: #0f172a; font-size: 14px; font-weight: 650; }
.login-brand-meta { margin-top: 2px; color: #64748b; font-size: 9px; font-family: ui-monospace, monospace; }
/* 绩效指标:紧凑单行数据条 */
.stats { display: flex; gap: 8px; margin-top: 12px; }
.stat {
flex: 1 1 0; min-width: 0;
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 8px 11px; display: flex; align-items: baseline; gap: 7px; white-space: nowrap;
}
.stat .label { font-size: 11px; color: var(--ink-3); }
.stat .value { font-size: 14px; font-weight: 600; }
.stat .value.pos { color: var(--up); }
.stat .value.neg { color: var(--down); }
.market-track { display: grid; grid-template-columns: repeat(13, 1fr); height: 17px; margin: 26px 0 20px; border-bottom: 1px solid #cbd5e1; }
.market-track span { width: 1px; height: 5px; align-self: end; background: #94a3b8; }
.market-track span.major { height: 10px; background: #2563eb; }
/* 图表面板 */
.panel {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 10px 10px 6px; margin-top: 14px;
}
.panel-title { font-size: 12px; color: var(--ink-2); margin: 4px 4px 6px; }
.login-heading h1 { margin-top: 7px; color: #0f172a; font-size: 28px; line-height: 1.2; font-weight: 680; }
.login-heading > p:last-child { margin-top: 8px; color: #64748b; font-size: 13px; line-height: 1.7; }
.login-status { display: flex; align-items: center; gap: 7px; color: #475569; font-size: 11px; font-weight: 600; }
.login-status span { width: 7px; height: 7px; border-radius: 50%; background: #16a34a; box-shadow: 0 0 0 3px #dcfce7; }
.kline-wrap { position: relative; }
.chart-kline { height: 520px; }
.chart-equity { height: 240px; }
.login-form { display: grid; gap: 18px; margin-top: 26px; }
.login-form label > span { display: block; margin-bottom: 7px; color: #475569; font-size: 12px; font-weight: 600; }
.login-form input {
width: 100%; height: 42px; border: 1px solid #cbd5e1; border-radius: 5px; background: #fff;
padding: 0 12px; color: #0f172a; font-size: 14px; outline: none; transition: border-color .15s, box-shadow .15s;
}
.login-form input:focus { border-color: #2563eb; box-shadow: 0 0 0 3px #dbeafe; }
.password-field { position: relative; }
.password-field input { padding-right: 42px; }
.password-toggle {
position: absolute; top: 3px; right: 3px; display: grid; place-items: center; width: 36px; height: 36px;
border-radius: 4px; color: #64748b;
}
.password-toggle:hover { background: #f1f5f9; color: #0f172a; }
.password-toggle:focus-visible { outline: 2px solid #2563eb; outline-offset: 1px; }
.password-toggle svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
/* K 线左上角标识 + 系列图例 */
.lc-legend {
position: absolute; top: 6px; left: 10px; z-index: 5;
display: flex; gap: 14px; align-items: center;
font-size: 12px; color: var(--ink-2); pointer-events: none;
.login-error { border-left: 3px solid #dc2626; background: #fef2f2; padding: 9px 11px; color: #b91c1c; font-size: 12px; line-height: 1.5; }
.login-submit {
display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px;
border-radius: 5px; background: #2563eb; color: #fff; font-size: 14px; font-weight: 650;
transition: background .15s, transform .1s;
}
.lc-legend .sym { color: var(--ink); font-weight: 600; }
.lc-legend .sym small { color: var(--ink-3); font-weight: 400; margin-left: 6px; }
.lc-legend .chip { display: inline-flex; align-items: center; gap: 5px; }
.lc-legend .chip i { width: 12px; height: 3px; border-radius: 2px; display: inline-block; }
.login-submit:hover:not(:disabled) { background: #1d4ed8; }
.login-submit:active:not(:disabled) { transform: translateY(1px); }
.login-submit:focus-visible { outline: 2px solid #2563eb; outline-offset: 3px; }
.login-submit:disabled { cursor: not-allowed; opacity: .55; }
.login-spinner { width: 14px; height: 14px; border: 2px solid rgb(255 255 255 / 45%); border-top-color: #fff; border-radius: 50%; animation: login-spin .7s linear infinite; }
.login-footnote { margin-top: 22px; border-top: 1px solid #e2e8f0; padding-top: 16px; color: #94a3b8; font-size: 10px; text-align: center; }
/* 悬停弹框(同花顺式) */
.lc-tooltip {
position: absolute; z-index: 20; pointer-events: none; min-width: 196px;
background: rgba(17, 21, 28, 0.97); border: 1px solid var(--border-2);
border-radius: 8px; padding: 8px 11px; font-size: 11.5px; color: var(--ink-2);
line-height: 1.7; box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
@keyframes login-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .login-spinner { animation: none; } }
@media (max-width: 480px) {
.login-shell { padding: 14px; background-size: 32px 32px; }
.login-panel { padding: 24px 20px; }
}
.lc-tooltip .tt-date { color: var(--ink); font-weight: 600; margin-bottom: 3px; }
.lc-tooltip .tt-date .tt-wd { color: var(--ink-3); font-weight: 400; margin-left: 5px; }
.lc-tooltip .tt-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; }
.lc-tooltip .tt-grid b { color: var(--ink); font-weight: 500; float: right; }
.lc-tooltip .tt-row b { color: var(--ink); font-weight: 500; }
.lc-tooltip .tt-sep { height: 1px; background: var(--border); margin: 5px 0; }
.lc-tooltip .tt-ind span { margin-right: 10px; }
.lc-tooltip .pos { color: var(--up); }
.lc-tooltip .neg { color: var(--down); }
.error-banner {
background: rgba(246, 70, 93, 0.08); color: var(--up);
border: 1px solid rgba(246, 70, 93, 0.3); border-radius: var(--radius);
padding: 10px 14px; font-size: 13px; margin-top: 14px;
}
.placeholder { color: var(--ink-3); font-size: 13px; padding: 56px 0; text-align: center; }
/* ---------- 少量可复用控件样式(其余全部用 Tailwind 原子类) ---------- */
@layer components {
/* 表单输入 */
.ipt {
@apply rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-sm text-slate-900
outline-none transition-colors placeholder:text-slate-400
focus:border-blue-500 focus:ring-2 focus:ring-blue-100;
}
select.ipt { @apply pr-7 appearance-none bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20fill%3D%22%2364748b%22%3E%3Cpath%20d%3D%22M4.5%206l3.5%203.5L11.5%206z%22%2F%3E%3C%2Fsvg%3E')] bg-[length:16px] bg-[right_0.4rem_center] bg-no-repeat; }
/* ---------- 顶部导航 ---------- */
.app-nav { display: flex; gap: 4px; margin-left: 18px; }
.nav-link {
font-size: 13px; color: var(--ink-2); text-decoration: none;
padding: 5px 12px; border-radius: 8px; transition: 0.15s;
}
.nav-link:hover { color: var(--ink); background: var(--surface-2); }
.nav-link.router-link-active { color: #fff; background: var(--dif); }
/* 主 / 次按钮 */
.btn-primary {
@apply inline-flex items-center justify-center gap-1.5 rounded-md bg-blue-600 px-3.5 py-1.5 text-sm
font-medium text-white transition-colors hover:bg-blue-700 active:bg-blue-800
disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-ghost {
@apply inline-flex items-center justify-center gap-1.5 rounded-md border border-slate-300 bg-white
px-3.5 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50
active:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50;
}
/* ---------- 首页功能入口卡片 ---------- */
.home-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 16px; margin-top: 22px;
/* 字段标签 */
.lbl { @apply mb-1 block text-xs font-medium tracking-wide text-slate-500; }
}
.feature-card {
display: block; text-decoration: none; color: var(--ink);
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
padding: 26px 24px 20px; transition: 0.18s; position: relative;
}
.feature-card:hover { border-color: var(--border-2); transform: translateY(-2px); box-shadow: 0 14px 34px rgba(0,0,0,0.35); }
.fc-icon {
width: 44px; height: 44px; border-radius: 12px; display: flex; align-items: center; justify-content: center;
background: rgba(91, 143, 249, 0.12); color: var(--dif); font-size: 20px; margin-bottom: 14px;
}
.fc-title { font-size: 18px; font-weight: 600; margin-bottom: 8px; }
.fc-desc { font-size: 13px; color: var(--ink-2); line-height: 1.75; min-height: 66px; }
.fc-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 14px 0 16px; }
.fc-tag {
font-size: 11.5px; color: var(--ink-3); background: var(--surface-2);
border: 1px solid var(--border); border-radius: 999px; padding: 2px 9px;
}
.fc-enter { font-size: 13px; color: var(--dif); display: flex; align-items: center; gap: 6px; }
/* ---------- 智能选股 ---------- */
.screener-input { width: 100%; }
.cond-chips { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; }
.cond-chips .clabel { font-size: 12px; color: var(--ink-3); margin-right: 3px; }
.cond-chip {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12.5px; color: var(--ink); background: var(--surface-2);
border: 1px solid var(--border-2); border-radius: 999px; padding: 4px 11px;
font-variant-numeric: tabular-nums;
}
.cond-chip.ci { border-color: rgba(91, 143, 249, 0.45); color: #cdddfb; }
.cond-chip.cs { border-color: rgba(246, 189, 22, 0.4); color: #f2e3b3; }
.cond-chip.cx { color: var(--ink-3); }
.cond-chip .arrow { color: var(--ink-3); }
/* 数据状态条 */
.sync-bar {
display: flex; flex-wrap: wrap; align-items: center; gap: 12px;
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
padding: 9px 14px; margin-top: 14px; font-size: 12.5px; color: var(--ink-2);
}
.sync-bar .ok { color: var(--down); }
.sync-bar .warn { color: var(--dea); }
.sync-bar .sync-progress { flex: 1 1 200px; min-width: 160px; display: flex; align-items: center; gap: 8px; }
.sync-bar .sync-progress .txt { white-space: nowrap; }
/* 结果表数字着色 */
.pos { color: var(--up); }
.neg { color: var(--down); }

View File

@@ -15,14 +15,20 @@ function onRun(req: BacktestRequest) {
<template>
<BacktestForm :loading="store.loading" @run="onRun" />
<div v-if="store.error" class="error-banner">{{ store.error }}</div>
<div v-if="store.error" class="mt-4 flex items-start gap-2 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">
<svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
{{ store.error }}
</div>
<div v-if="store.loading && store.note" class="placeholder">{{ store.note }}</div>
<div v-if="store.loading && store.note" class="py-16 text-center text-sm text-slate-400">
<svg class="mx-auto mb-3 h-6 w-6 animate-spin text-blue-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
{{ store.note }}
</div>
<template v-if="store.result">
<MetricsPanel :metrics="store.result.metrics" />
<div class="panel">
<div class="mt-4 rounded-xl border border-slate-200 bg-white p-3">
<KLineChart
:candles="store.result.candles"
:indicators="store.result.indicators"
@@ -33,13 +39,13 @@ function onRun(req: BacktestRequest) {
/>
</div>
<div class="panel">
<div class="panel-title">净值曲线</div>
<div class="mt-4 rounded-xl border border-slate-200 bg-white p-3">
<div class="px-2 py-1 text-[13px] text-slate-500">净值曲线</div>
<EquityChart :equity="store.result.equity" />
</div>
</template>
<div v-else-if="!store.loading" class="placeholder">
<div v-else-if="!store.loading" class="py-16 text-center text-sm text-slate-400">
选好周期与参数开始回测先用 DEMO 合成数据跑通
</div>
</template>

View File

@@ -5,31 +5,42 @@ import { RouterLink } from 'vue-router';
const features = [
{
to: '/screener',
icon: 'pi pi-sparkles',
icon: 'M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3z',
accent: 'bg-blue-50 text-blue-600',
title: '智能选股',
desc: '用一句自然语言描述条件(如「这两天 KDJ 的 J 小于 10市值 100~200 亿」AI 解析后全市场筛选。',
tags: ['KDJ / RSI / MACD / 布林', '市值 · 市盈率 · 换手率', 'AI 条件解析'],
desc: '用自然语言描述选股条件,快速完成全市场筛选。',
},
{
to: '/backtest',
icon: 'pi pi-chart-line',
icon: 'M3 17l6-6 4 4 8-8M21 7v6h-6',
accent: 'bg-emerald-50 text-emerald-600',
title: '策略回测',
desc: '选标的、调参数跑历史回测K 线 + 指标 + 买卖点 + 净值曲线与绩效指标A股真实成本建模。',
tags: ['MACD / 双均线 / 单均线', 'T+1 · 印花税 · 佣金', '净值与绩效'],
desc: '选标的与策略参数,查看历史表现和关键绩效指标。',
},
];
</script>
<template>
<div class="home-grid">
<RouterLink v-for="f in features" :key="f.to" :to="f.to" class="feature-card">
<div class="fc-icon"><i :class="f.icon"></i></div>
<div class="fc-title">{{ f.title }}</div>
<div class="fc-desc">{{ f.desc }}</div>
<div class="fc-tags">
<span v-for="t in f.tags" :key="t" class="fc-tag">{{ t }}</span>
</div>
<div class="fc-enter">进入 <i class="pi pi-arrow-right"></i></div>
</RouterLink>
<div class="w-full max-w-5xl">
<div class="grid gap-6 sm:grid-cols-2">
<RouterLink
v-for="f in features"
:key="f.to"
:to="f.to"
class="group flex min-h-72 flex-col rounded-lg border border-slate-200 bg-white p-8 transition-all hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 sm:min-h-80 sm:p-10"
>
<span :class="['flex h-14 w-14 items-center justify-center rounded-lg', f.accent]">
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path :d="f.icon" />
</svg>
</span>
<div class="mt-7 text-2xl font-semibold">{{ f.title }}</div>
<p class="mt-3 max-w-sm text-sm leading-6 text-slate-500">{{ f.desc }}</p>
<div class="mt-auto flex items-center gap-1.5 pt-8 text-sm font-medium text-blue-600">
进入
<svg class="h-4 w-4 transition-transform group-hover:translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</div>
</RouterLink>
</div>
</div>
</template>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ApiError } from '@/api/client';
import { useAuthStore } from '@/stores/auth';
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const username = ref('');
const password = ref('');
const error = ref('');
const passwordVisible = ref(false);
const canSubmit = computed(() => username.value.trim().length > 0 && password.value.length > 0 && !auth.loading);
async function submit() {
if (!canSubmit.value) return;
error.value = '';
try {
await auth.login(username.value.trim(), password.value);
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/';
await router.replace(redirect);
} catch (cause) {
error.value = cause instanceof ApiError ? cause.message : '暂时无法登录,请稍后重试';
}
}
</script>
<template>
<main class="login-shell">
<section class="login-panel" aria-labelledby="login-title">
<div class="login-brand">
<span class="login-brand-mark" aria-hidden="true"></span>
<div>
<p class="login-brand-name">量化选股与回测</p>
<p class="login-brand-meta">PRIVATE RESEARCH TERMINAL</p>
</div>
</div>
<div class="market-track" aria-hidden="true">
<span v-for="n in 13" :key="n" :class="{ major: n === 1 || n === 7 || n === 13 }" />
</div>
<div class="login-heading">
<p class="login-status"><span /> 私有工作区</p>
<h1 id="login-title">登录</h1>
<p>验证身份后进入行情研究与策略回测</p>
</div>
<form class="login-form" @submit.prevent="submit">
<label>
<span>用户名</span>
<input v-model="username" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus />
</label>
<label>
<span>密码</span>
<div class="password-field">
<input v-model="password" :type="passwordVisible ? 'text' : 'password'" name="password" autocomplete="current-password" required />
<button type="button" class="password-toggle" :aria-label="passwordVisible ? '隐藏密码' : '显示密码'" :title="passwordVisible ? '隐藏密码' : '显示密码'" @click="passwordVisible = !passwordVisible">
<svg v-if="!passwordVisible" viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z" /><circle cx="12" cy="12" r="3" /></svg>
<svg v-else viewBox="0 0 24 24" aria-hidden="true"><path d="m3 3 18 18M10.6 10.7a2 2 0 0 0 2.7 2.7M9.9 5.2A10.8 10.8 0 0 1 12 5c6.5 0 10 7 10 7a17.3 17.3 0 0 1-2.1 3M6.6 6.7C3.6 8.7 2 12 2 12s3.5 7 10 7a10.7 10.7 0 0 0 4-.8" /></svg>
</button>
</div>
</label>
<p v-if="error" class="login-error" role="alert">{{ error }}</p>
<button class="login-submit" type="submit" :disabled="!canSubmit">
<span v-if="auth.loading" class="login-spinner" aria-hidden="true" />
{{ auth.loading ? '正在验证' : '进入工作台' }}
</button>
</form>
<p class="login-footnote">会话将在到期后自动失效</p>
</section>
</main>
</template>

View File

@@ -1,12 +1,14 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue';
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { useScreenerStore } from '@/stores/screener';
import ScreenerForm from '@/components/ScreenerForm.vue';
import ConditionChips from '@/components/ConditionChips.vue';
import SyncStatusBar from '@/components/SyncStatusBar.vue';
import ScreenerTable from '@/components/ScreenerTable.vue';
import StockDetailOverlay from '@/components/StockDetailOverlay.vue';
const store = useScreenerStore();
const previewCode = ref<string | null>(null);
onMounted(() => {
// 仅查状态;同步由用户点击「同步市场数据」显式触发(避免反复触发接口限频)
@@ -22,23 +24,33 @@ onBeforeUnmount(() => store.stopPolling());
<SyncStatusBar :status="store.syncStatus" @sync="store.startSync(90)" />
<div v-if="store.error" class="error-banner">
<i class="pi pi-exclamation-triangle" style="margin-right: 6px;"></i>{{ store.error }}
<div v-if="store.error" class="mt-4 flex items-start gap-2 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">
<svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
{{ store.error }}
</div>
<div v-if="store.loading && store.note" class="placeholder">
<i class="pi pi-spin pi-spinner" style="margin-right: 8px;"></i>{{ store.note }}
<div v-if="store.loading && store.note" class="py-16 text-center text-sm text-slate-400">
<svg class="mx-auto mb-3 h-6 w-6 animate-spin text-blue-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
{{ store.note }}
</div>
<template v-else-if="store.result">
<div class="panel" style="padding: 12px 14px; margin-top: 14px;">
<div class="mt-4 rounded-xl border border-slate-200 bg-white px-4 py-3">
<ConditionChips :conditions="store.result.conditions" />
</div>
<ScreenerTable :result="store.result" />
<ScreenerTable :result="store.result" @preview="previewCode = $event.ts_code" />
</template>
<div v-else-if="!store.error" class="placeholder">
<div v-else-if="!store.error" class="py-16 text-center text-sm text-slate-400">
输入选股条件开始筛选即可全市场选股
</div>
<!-- 全屏个股详情同花顺/通达信式 -->
<StockDetailOverlay
v-if="previewCode && store.result"
:items="store.result.items"
:initial="previewCode"
@close="previewCode = null"
/>
</div>
</template>

View File

@@ -1,10 +1,11 @@
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import tailwindcss from '@tailwindcss/vite';
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
plugins: [vue(), tailwindcss()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},