feat: 全屏个股详情预览 + Tailwind 改版 + 账号鉴权
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -25,7 +25,10 @@ frontend/dist/
|
||||
.cache/
|
||||
|
||||
# ---------- Env / Secrets ----------
|
||||
# .env 直接提交(私有仓库,配置含连接串即开即用)
|
||||
.env
|
||||
*.env
|
||||
!.env.example
|
||||
!*.env.example
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"mcpServers": {
|
||||
"tushareMcp": {
|
||||
"type": "http",
|
||||
"url": "https://api.tushare.pro/mcp/token=d0bc5620d6523ae40f379ed4415576f58dca2361f2f47a68cdcd0a98"
|
||||
"url": "https://api.tushare.pro/mcp/token=22edda0afe44c0609a187ff1ac0bb2a8fc61430f490ec19f7fec8390"
|
||||
}
|
||||
}
|
||||
}
|
||||
38
README.md
38
README.md
@@ -131,11 +131,15 @@ pnpm dev # http://localhost:5173
|
||||
后端配置通过 `backend/.env`(复制 `.env.example`)或环境变量:
|
||||
|
||||
```bash
|
||||
# 数据库(MVP 默认 SQLite,零配置)
|
||||
DATABASE_URL=sqlite+aiosqlite:///./stock.db
|
||||
# 数据库(开发、测试、生产统一使用 PostgreSQL)
|
||||
DATABASE_URL=postgresql+asyncpg://user:password@host:5432/stock
|
||||
|
||||
# 切到你自己的 PostgreSQL / TimescaleDB
|
||||
# DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/stock
|
||||
# 鉴权:本地 HTTP 为 false;线上 HTTPS 必须为 true
|
||||
AUTH_COOKIE_SECURE=false
|
||||
AUTH_SESSION_HOURS=12
|
||||
CORS_ORIGINS=http://localhost:5173
|
||||
# 生产建议关闭接口文档
|
||||
EXPOSE_API_DOCS=true
|
||||
|
||||
# 真实数据源(Tushare Pro;免费版即可。留空则仅 DEMO 合成数据可用)
|
||||
TUSHARE_TOKEN=你的token
|
||||
@@ -149,6 +153,31 @@ DATA_DEFAULT_START=20200101
|
||||
# COMMISSION_MIN=5.0 # 最低 5 元
|
||||
```
|
||||
|
||||
### 初始化登录系统
|
||||
|
||||
数据库结构由 Alembic 管理。首次部署或更新代码后执行:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv run alembic upgrade head
|
||||
```
|
||||
|
||||
系统不提供注册接口。使用服务器交互式命令创建唯一用户;再次执行会重置密码并吊销该用户的所有旧会话:
|
||||
|
||||
```bash
|
||||
uv run python -m app.cli.create_user --username admin
|
||||
```
|
||||
|
||||
密码使用 Argon2id 保存。浏览器只接收 `HttpOnly` 会话 Cookie,数据库只保存随机会话 Token 的 SHA-256 摘要。默认会话有效期 12 小时,连续 5 次登录失败后锁定 15 分钟。
|
||||
|
||||
生产环境必须启用 HTTPS,并设置:
|
||||
|
||||
```bash
|
||||
AUTH_COOKIE_SECURE=true
|
||||
CORS_ORIGINS=https://你的域名
|
||||
EXPOSE_API_DOCS=false
|
||||
```
|
||||
|
||||
### 切到 PostgreSQL + TimescaleDB
|
||||
|
||||
1. 目标库执行 `CREATE EXTENSION IF NOT EXISTS timescaledb;`
|
||||
@@ -202,6 +231,7 @@ pnpm preview # 本地预览构建产物
|
||||
### 后端生产运行
|
||||
```bash
|
||||
cd backend
|
||||
uv run alembic upgrade head
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||
# 或 gunicorn(Linux):uv run gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker
|
||||
```
|
||||
|
||||
10
backend/.env
10
backend/.env
@@ -1,10 +0,0 @@
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:Cirry0115@cirry.cn:5432/stock
|
||||
TUSHARE_TOKEN=22edda0afe44c0609a187ff1ac0bb2a8fc61430f490ec19f7fec8390
|
||||
DATA_ADJUST=qfq
|
||||
DATA_DEFAULT_START=20200101
|
||||
|
||||
# ---- LLM(智能选股;智谱 GLM,OpenAI 兼容协议)----
|
||||
# key 在 https://bigmodel.cn 控制台获取,格式形如 xxxxxxxx.yyyyyyyy(id.secret)
|
||||
LLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4
|
||||
LLM_API_KEY=ea24bbdd3d2d4dd2b8f03de4c9a5d984.9X1Hz1yKx0VKSnrU
|
||||
LLM_MODEL=glm-5.2
|
||||
@@ -1,9 +1,16 @@
|
||||
# ---- Database ----
|
||||
# MVP 默认 SQLite,零配置即可跑
|
||||
DATABASE_URL=sqlite+aiosqlite:///./stock.db
|
||||
# ---- Database(开发、测试、生产都使用 PostgreSQL)----
|
||||
DATABASE_URL=postgresql+asyncpg://user:password@host:5432/stock
|
||||
|
||||
# 切到你自己的 PostgreSQL(TimescaleDB 是 Postgres 扩展,目标库执行 CREATE EXTENSION timescaledb; 即可)
|
||||
# DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/stock
|
||||
# ---- Authentication ----
|
||||
# 本地 HTTP 开发为 false;线上 HTTPS 必须为 true。
|
||||
AUTH_COOKIE_SECURE=false
|
||||
AUTH_SESSION_HOURS=12
|
||||
AUTH_MAX_FAILED_LOGINS=5
|
||||
AUTH_LOCK_MINUTES=15
|
||||
AUTH_MIN_PASSWORD_LENGTH=12
|
||||
CORS_ORIGINS=http://localhost:5173
|
||||
# 生产建议 false,关闭 /docs 与 /openapi.json。
|
||||
EXPOSE_API_DOCS=true
|
||||
|
||||
# ---- 真实数据源(Tushare Pro;免费版即可。留空则仅 DEMO 合成数据可用)----
|
||||
TUSHARE_TOKEN=你的token
|
||||
|
||||
38
backend/alembic.ini
Normal file
38
backend/alembic.ini
Normal 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
60
backend/alembic/env.py
Normal 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()
|
||||
24
backend/alembic/script.py.mako
Normal file
24
backend/alembic/script.py.mako
Normal 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"}
|
||||
64
backend/alembic/versions/20260814_01_add_auth.py
Normal file
64
backend/alembic/versions/20260814_01_add_auth.py
Normal 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")
|
||||
37
backend/alembic/versions/208b0c5d302a_temp_full_schema.py
Normal file
37
backend/alembic/versions/208b0c5d302a_temp_full_schema.py
Normal 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
|
||||
@@ -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
105
backend/app/auth.py
Normal 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
155
backend/app/auth_api.py
Normal 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
|
||||
1
backend/app/cli/__init__.py
Normal file
1
backend/app/cli/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""服务器侧管理命令。"""
|
||||
79
backend/app/cli/create_user.py
Normal file
79
backend/app/cli/create_user.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,100 +1,117 @@
|
||||
"""开发自检脚本:跑一遍 /health 与 /backtest,打印结果。
|
||||
"""使用线上 PostgreSQL 跑鉴权与核心 API 自检,临时用户会自动清理。
|
||||
|
||||
用 FastAPI TestClient(无需起服务器,同进程验证全链路)。
|
||||
用法: uv run --with httpx --directory backend python smoke_test.py
|
||||
用法:uv run --directory backend python smoke_test.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
import httpx
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.auth import hash_password, token_digest
|
||||
from app.db import async_session, engine
|
||||
from app.main import app
|
||||
from app.models import AuthSession, MarketDaily, User
|
||||
|
||||
# 必须用 with:lifespan(建表)只在进入上下文时执行
|
||||
with TestClient(app) as c:
|
||||
r = c.get("/api/health")
|
||||
print("== /api/health ==", r.status_code, r.json())
|
||||
|
||||
r = c.post(
|
||||
"/api/backtest",
|
||||
json={
|
||||
"symbol": "DEMO",
|
||||
"strategy": "macd_cross",
|
||||
"params": {"fast": 12, "slow": 26, "signal": 9},
|
||||
"initial_cash": 100000.0,
|
||||
"fast_mode": False,
|
||||
},
|
||||
)
|
||||
print("== /api/backtest ==", r.status_code)
|
||||
if r.status_code != 200:
|
||||
print("ERROR:", r.text)
|
||||
raise SystemExit(1)
|
||||
async def main() -> None:
|
||||
username = f"smoke_{secrets.token_hex(6)}"
|
||||
password = secrets.token_urlsafe(24)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
d = r.json()
|
||||
print("candles :", len(d["candles"]))
|
||||
print("signals :", len(d["signals"]), "(买卖点)")
|
||||
print("equity pts :", len(d["equity"]))
|
||||
print("final_cash :", round(d["final_cash"], 2))
|
||||
print("final_pos :", d["final_position"])
|
||||
print("metrics :", json.dumps(d["metrics"], ensure_ascii=False, indent=2))
|
||||
print("first signal :", d["signals"][0] if d["signals"] else None)
|
||||
async with async_session() as db:
|
||||
db.add(
|
||||
User(
|
||||
username=username,
|
||||
password_hash=hash_password(password),
|
||||
password_algo="argon2id",
|
||||
is_active=True,
|
||||
failed_login_count=0,
|
||||
password_changed_at=now,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
assert len(d["candles"]) > 100
|
||||
try:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||
health = await client.get("/api/health")
|
||||
protected = await client.post("/api/backtest", json={})
|
||||
bad_login = await client.post(
|
||||
"/api/auth/login", json={"username": username, "password": "wrong-password"}
|
||||
)
|
||||
login = await client.post(
|
||||
"/api/auth/login", json={"username": username, "password": password}
|
||||
)
|
||||
print("health:", health.status_code)
|
||||
print("protected without login:", protected.status_code)
|
||||
print("bad login:", bad_login.status_code)
|
||||
print("login:", login.status_code)
|
||||
assert health.status_code == 200
|
||||
assert protected.status_code == 401
|
||||
assert bad_login.status_code == 401
|
||||
assert login.status_code == 200
|
||||
assert client.cookies.get("stock_session")
|
||||
|
||||
# 周期聚合:周线 K 线数应明显少于日线
|
||||
rw = c.post(
|
||||
"/api/backtest",
|
||||
json={"symbol": "DEMO", "timeframe": "1w", "strategy": "macd_cross",
|
||||
"params": {"fast": 12, "slow": 26, "signal": 9}, "initial_cash": 100000.0},
|
||||
)
|
||||
wd = rw.json()
|
||||
print("== weekly ==", rw.status_code, "candles:", len(wd["candles"]), "vs daily", len(d["candles"]))
|
||||
assert rw.status_code == 200
|
||||
assert len(wd["candles"]) < len(d["candles"])
|
||||
me = await client.get("/api/auth/me")
|
||||
assert me.status_code == 200 and me.json()["username"] == username
|
||||
|
||||
print("\n✅ 后端全链路自检通过(含周期聚合)")
|
||||
daily = await client.post(
|
||||
"/api/backtest",
|
||||
json={
|
||||
"symbol": "DEMO",
|
||||
"strategy": "macd_cross",
|
||||
"params": {"fast": 12, "slow": 26, "signal": 9},
|
||||
"initial_cash": 100000.0,
|
||||
"fast_mode": False,
|
||||
},
|
||||
)
|
||||
assert daily.status_code == 200, daily.text
|
||||
result = daily.json()
|
||||
assert len(result["candles"]) > 100
|
||||
print("backtest metrics:", json.dumps(result["metrics"], ensure_ascii=False))
|
||||
|
||||
# ---------- 智能选股 ----------
|
||||
print("\n== 智能选股 ==")
|
||||
token = client.cookies.get("stock_session")
|
||||
assert token
|
||||
async with async_session() as db:
|
||||
auth_session = (
|
||||
await db.execute(
|
||||
select(AuthSession).where(AuthSession.token_hash == token_digest(token))
|
||||
)
|
||||
).scalar_one()
|
||||
auth_session.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||
await db.commit()
|
||||
assert (await client.get("/api/auth/me")).status_code == 401
|
||||
print("expired session: 401")
|
||||
|
||||
# 1) JSON 提取容错(不联网):代码围栏 / 多余文本
|
||||
from app.screener.llm import _extract_json
|
||||
assert _extract_json('```json\n{"a": 1}\n```') == {"a": 1}
|
||||
assert _extract_json('好的,结果如下:{"indicator": [], "snapshot": []} 谢谢')["indicator"] == []
|
||||
print("_extract_json 围栏/噪音容错 ✅")
|
||||
login = await client.post(
|
||||
"/api/auth/login", json={"username": username, "password": password}
|
||||
)
|
||||
assert login.status_code == 200
|
||||
logout = await client.post("/api/auth/logout")
|
||||
assert logout.status_code == 204
|
||||
assert (await client.get("/api/auth/me")).status_code == 401
|
||||
print("logout and revoke: ok")
|
||||
|
||||
# 2) 未配置 LLM_API_KEY 时 /run 返回 503(确定性,不联网)
|
||||
from app.config import settings as _s
|
||||
r = c.post("/api/screener/run", json={"text": "这两天 KDJ 的 J 小于 10"})
|
||||
if not _s.llm_api_key:
|
||||
assert r.status_code == 503, f"无 key 应 503,实际 {r.status_code}"
|
||||
print("无 LLM_API_KEY -> 503 ✅")
|
||||
else:
|
||||
print("已配置 LLM_API_KEY,跳过 503 用例")
|
||||
async with async_session() as db:
|
||||
has_market_data = bool(await db.scalar(select(MarketDaily.id).limit(1)))
|
||||
print("market data present:", has_market_data)
|
||||
finally:
|
||||
async with async_session() as db:
|
||||
user_id = await db.scalar(select(User.id).where(User.username == username))
|
||||
if user_id is not None:
|
||||
await db.execute(delete(AuthSession).where(AuthSession.user_id == user_id))
|
||||
await db.execute(delete(User).where(User.id == user_id))
|
||||
await db.commit()
|
||||
await engine.dispose()
|
||||
print("temporary user cleaned")
|
||||
|
||||
# 3) 直传条件选股(不依赖 LLM;依赖已同步的全市场数据)
|
||||
from app.models import MarketDaily # noqa: F401
|
||||
from sqlalchemy import select, func
|
||||
from app.db import async_session
|
||||
import asyncio
|
||||
|
||||
async def _has_data() -> bool:
|
||||
async with async_session() as session:
|
||||
return (await session.scalar(select(func.count()).select_from(MarketDaily))) or 0 > 0
|
||||
|
||||
if asyncio.run(_has_data()):
|
||||
r = c.post("/api/screener/run", json={
|
||||
"text": "测试直传",
|
||||
"conditions": {
|
||||
"indicator": [{"indicator": "kdj_j", "params": {"n": 9, "m1": 3, "m2": 3},
|
||||
"op": "lt", "value": 0, "lookback": 1, "match": "all"}],
|
||||
"snapshot": [],
|
||||
},
|
||||
})
|
||||
assert r.status_code == 200, f"直传选股失败 {r.status_code}: {r.text}"
|
||||
d = r.json()
|
||||
assert d["total"] >= 0
|
||||
print(f"KDJ J<0 选股 ✅ 命中 {d['total']} 只,基准日 {(d['trade_date'] or '')[:10]}")
|
||||
else:
|
||||
print("(未同步全市场数据,跳过直传选股用例;运行 POST /api/screener/sync 后再试)")
|
||||
|
||||
print("\n✅ 智能选股自检通过")
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
231
backend/uv.lock
generated
231
backend/uv.lock
generated
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
538
frontend/pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)[]>>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
180
frontend/src/components/DetailKLine.vue
Normal file
180
frontend/src/components/DetailKLine.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
286
frontend/src/components/StockDetailOverlay.vue
Normal file
286
frontend/src/components/StockDetailOverlay.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
60
frontend/src/stores/auth.ts
Normal file
60
frontend/src/stores/auth.ts
Normal 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 };
|
||||
});
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
80
frontend/src/views/LoginView.vue
Normal file
80
frontend/src/views/LoginView.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)) },
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user