diff --git a/.env.example b/.env.example index 0fac35d..59db6f1 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,7 @@ LOG_LEVEL=INFO # 首次启动时预置访问密码(可选)。公网服务器部署时填入,免去 SSH 端口转发设密码。 # 仅在尚未设置密码时生效(一次性初始化);设过后改密码请用页面 UI, 此处不再读取。 # 建议至少 6 位。.env 文件权限保持 600 且不要提交到 Git。 -AUTH_PASSWORD= +AUTH_PASSWORD='' # Optional backend dependency extras for Docker and ./dev.sh / .\dev.ps1. # Set to legacy-cpu on older CPUs without AVX2/FMA support. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c56eb0e..a9bbdf7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -179,7 +179,7 @@ jobs: run: | brew install create-dmg create-dmg \ - --volname "TickFlow Stock Panel" \ + --volname "Tick Stock Panel" \ --window-pos 200 120 \ --window-size 600 400 \ --icon-size 100 \ diff --git a/AGENTS.md b/AGENTS.md index c8cba0b..0145a14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,8 @@ 修改、调试或审查本仓库前,必须完整阅读并遵循根目录的 [`CONTRIBUTING.md`](CONTRIBUTING.md)。其中定义了项目架构、数据契约、数据源插件化、缓存与性能要求、测试矩阵以及 PR 复审和合并标准。 +涉及代码二次开发、前端插槽、后端可替换策略、扩展注册或上游升级兼容时,还必须阅读 [`docs/secondary-development.md`](docs/secondary-development.md)。该文档区分当前已实现能力与目标扩展契约;不得根据设计示例虚构尚不存在的 API。 + 同时遵守以下规则: - 先理解调用链和现有测试,再进行修改。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7601f87..fecfc7e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,9 +1,11 @@ -# TickFlow Stock Panel 贡献、AI 开发与复审指南 +# Tick Stock Panel 贡献、AI 开发与复审指南 本文档适用于整个仓库,供贡献者、AI 编码代理和 PR 审查者共同使用。目标是让改动落在正确的模块,保持数据口径、插件化和兼容性一致,并通过可复现的验证减少返工。 `CONTRIBUTING.md` 是项目贡献与审查规范,不替代 `README.md`、`docs/` 中的用户文档和领域文档。所有贡献者和 AI 编码代理在修改代码、提交或审查 PR 前都应阅读本文档。 +涉及代码二次开发时,同时遵循 [`docs/secondary-development.md`](docs/secondary-development.md)。前端优先使用真实存在的受控插槽或注册入口,后端优先使用小粒度策略接口和依赖注入;现有扩展点无法表达核心行为变化时允许直接修改源码,但必须保持改动聚焦并补足兼容性验证。 + ## 1. 基本原则 ### 1.1 修改前先理解 diff --git a/Dockerfile b/Dockerfile index cb295bf..dd0d6d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -125,7 +125,8 @@ COPY --from=stocksdk-builder /build/node_modules ./app/plugins/stocksdk/node_mod COPY tiers.yaml /app/tiers.yaml ENV STATIC_DIR=/app/static \ TIERS_YAML=/app/tiers.yaml \ - DATA_DIR=/app/data + DATA_DIR=/app/data \ + TICKFLOW_ENV_FILE=/app/.env # Frontend 静态产物 COPY --from=frontend-builder /build/dist ./static diff --git a/README.md b/README.md index a6b0432..f19b078 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # 📈 A股智能量化工作台 -[![声明:个人开源](https://img.shields.io/badge/⚠️_声明-个人开源_非_TickFlow_官方项目-green?style=for-the-badge&labelColor=red)](https://github.com/shy3130/tickflow-stock-panel) +[![声明:个人开源](https://img.shields.io/badge/⚠️_声明-个人开源_非_TickFlow_官方项目-green?style=for-the-badge&labelColor=red)](https://github.com/shy3130/tick-stock-panel) @@ -16,7 +16,7 @@ [![React](https://img.shields.io/badge/React-18-61dafb.svg)](https://react.dev/) [![Data: TickFlow](https://img.shields.io/badge/Data-TickFlow-00b386.svg)](https://tickflow.org/auth/register?ref=V3KDKGXPEA) [![Deploy: Docker](https://img.shields.io/badge/Deploy-Docker-2496ed.svg)](./Dockerfile) -[![GitHub stars](https://img.shields.io/github/stars/shy3130/tickflow-stock-panel?style=social)](https://github.com/shy3130/tickflow-stock-panel/stargazers) +[![GitHub stars](https://img.shields.io/github/stars/shy3130/tick-stock-panel?style=social)](https://github.com/shy3130/tick-stock-panel/stargazers) @@ -54,11 +54,14 @@ | :--------------- | :--------------------------------------------------------------------- | :-------------------------------- | | 🔍 **选股引擎** | 18 个内置策略 + 自定义信号 + AI 生成 + 代码迁移,Polars 毫秒级扫全 A 股 | [strategy.md](./docs/strategy.md) | | 📊 **指标流水线** | MA/EMA/MACD/RSI/KDJ/布林/量比等,一次扫表落盘 enriched Parquet | [features.md](./docs/features.md) | -| 🧪 **回测引擎** | 三种模式(个股/策略组合/自由信号),T+1/手续费/滑点/止损,SSE 流式进度 | [features.md](./docs/features.md) | +| 🧪 **回测研究** | 因子/策略回测 + 财务快照因子(点时口径),T+1/费用/滑点约束,SSE 持久任务 | [features.md](./docs/features.md) | +| ⛏️ **因子挖掘** | 嵌套样本外搜索多因子排名组合,与自有策略对照,候选库显式发布、永不自动上线 | [mining.md](./docs/mining.md) | +| 🌡️ **市场环境** | 情绪周期 6 阶段(连板梯队驱动)+ 概念/行业主线排名,与 5 档环境分并存 | [market-phase.md](./docs/market-phase.md) | +| 🚨 **异动监控** | 交易所异动规则口径(3/10/30 日偏离值),盘中实时接近度,系统告警与推送接入 | — | | 📡 **监控中心** | 四类监控(策略/个股信号/价格/异动),多条件 AND/OR + 语音播报 + 飞书推送 | [features.md](./docs/features.md) | | 📈 **个股分析** | 9 类关键价位 + AI 四维分析(技术/基本面/财务/消息面) | [features.md](./docs/features.md) | | 🏆 **连板梯队** | 连板层级统计 + 概念涨幅轮动 + 盘后 AI 复盘 + 炸板/翘板预警 | [features.md](./docs/features.md) | -| 🧰 **数据扩展** | TickFlow 多源 + 第三方接入(接口/推送/CSV/JSON)同台分析 | [features.md](./docs/features.md) | +| 🧰 **数据扩展** | 数据源插件化(stock-sdk 示例 + YAML 自定义源),扩展字段配成一级页面同台分析 | [custom-data-source.md](./docs/custom-data-source.md) | @@ -69,7 +72,7 @@ **📊 行情总览** - **看板** Dashboard — 市场情绪评分 + 涨跌/成交额榜单 + 概念领涨领跌 + 大盘异动事件流,一日全貌 -- **自选** Watchlist — 自选股池,表格/卡片双视图,换手/量比/RSI 等实时指标 +- **自选** Watchlist — 自选股池,多分组管理(M:N),表格/卡片双视图,换手/量比/RSI 等实时指标 - **指数** Indices — 沪深指数浏览与同步 **🔍 选股与回测** @@ -77,22 +80,25 @@ - **回测** Backtest — 两种模式: - **因子回测** — IC/IR、分层收益、多空组合,先筛掉无效指标 - **策略回测** — 净值曲线、回撤、夏普、胜率,支持 T+1/手续费/滑点/止损,SSE 流式进度 +- **挖掘** Mining — 嵌套样本外因子与策略挖掘:训练区间因子方向重估 + 相关性去重 + 多因子排名组合搜索,自有策略作对照轨;候选入库,显式确认后才发布,永不自动上线 **📈 个股与板块分析** - **个股分析** Stock Analysis (Beta) — 日K + 9 类关键价位 + AI 四维分析(技术/基本面/财务/消息面) - **财务分析** Financials — 利润表/资负表/现金流/关键指标 + AI 解读 - **概念分析** Concept Analysis — ths 概念涨幅轮动矩阵 + 领涨/领跌主线 + 个股穿透 - **行业分析** Industry Analysis — 行业分层涨幅轮动 + 领涨/领跌主线 + 成分股 +- **市场环境** Regime — 情绪周期 6 阶段(冰点/启动/主升/高潮/退潮/修复,连板梯队驱动,EMA 平滑 + 2 日确认)+ 概念/行业主线排名,与 5 档环境分并存 - **连板梯队** Limit Up Ladder — 连板层级统计 + 概念/行业分布 + 封单监控(可切换连跌梯队) **🔔 监控与复盘** -- **监控中心** Monitor — 策略/个股信号/价格/异动四类规则,盘中实时弹窗 + 语音播报(播报个股名称与信号) + 触发记录持久化 +- **监控中心** Monitor — 策略/个股信号/价格/异动四类规则,支持自选分组作用域,盘中实时弹窗 + 语音播报(播报个股名称与信号) + 触发记录持久化 +- **异动监控** Abnormal Moves — 按交易所异动规则口径(3日 ±20%/±30%/±40% · 10日 +100% · 30日 +200%)实时计算个股偏离值接近度,盯住异动边缘名单;触发记录 + 站内通知/飞书·企微推送 - **复盘** Review (Beta) — 盘后 AI 自动生成市场复盘,可定时执行、推送飞书、下载 Markdown **🗄️ 数据与扩展** - **数据** Data — 本地数据画像与同步状态(维表/日K/除权/Enriched/指数/ETF/分钟K/财务),盘后管道与历史扩展 - **扩展分析** (动态菜单) — 把任意第三方/扩展数据字段配成一级菜单,与内置数据同台分析 -- **设置** Settings — TickFlow Key 与订阅档位、AI 接口、实时监控、扩展页面、信号库、菜单与系统设置 +- **设置** Settings — 数据源与能力检测、AI 接口、实时监控、扩展页面、信号库、菜单与系统设置 @@ -210,7 +216,7 @@ PORT=3018 # 服务端口 | **后端** | FastAPI · Pydantic v2 · APScheduler · sse-starlette | | **数据** | Polars(计算)· DuckDB(查询)· Parquet(存储) | | **回测** | vectorbt(全项目唯一 pandas 边界) | -| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK · 其他数据源后续迭代实装 | +| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK · 插件化扩展(stock-sdk 示例插件 · YAML 自定义源) | | **AI**(可选) | OpenAI 兼容接口(DeepSeek / 通义 / Ollama 等) | | **前端** | React 18 · Vite · TypeScript · Tailwind · Tanstack Query · Lightweight Charts · ECharts · dnd-kit | | **部署** | Docker 两阶段构建,前端 dist 拷进后端镜像,**单容器** | @@ -225,6 +231,7 @@ PORT=3018 # 服务端口 | 2-3 | Polars enriched 流水线 · Screener · vectorbt 回测(T+1/手续费/止损) | ✅ | | 4-5 | 监控引擎 · 四类监控规则 · 实时 SSE 推送 · 持久化记录 | ✅ | | 6 | 个股分析(专用日 K + 9 类关键价位 + AI 四维分析) | ✅ | +| **v0.2** | 因子挖掘全链路 · 市场阶段与主线识别 · 异动监控 · 数据源插件化 | ✅ | | **v2** | Webhook 推送· 板块异动 · 早晚报 · 更多扩展 | 🚧 | --- @@ -238,6 +245,10 @@ PORT=3018 # 服务端口 | [docs/features.md](./docs/features.md) | 各功能模块详细说明(选股/指标/回测/监控/个股分析/数据扩展) | | [docs/custom-data-source.md](./docs/custom-data-source.md) | 自定义数据源接入、YAML 配置与 mock 联调示例 | | [docs/strategy.md](./docs/strategy.md) | 策略体系(18 内置策略 + 三种扩展方式 + 文件结构) | +| [docs/mining.md](./docs/mining.md) | 因子与策略挖掘口径、防泄漏、任务隔离和发布边界 | +| [docs/market-phase.md](./docs/market-phase.md) | 市场情绪周期 6 阶段与概念/行业主线识别的口径与设计 | +| [docs/plugin-development.md](./docs/plugin-development.md) | 数据源插件开发规范(以 stock-sdk 为参考实现) | +| [docs/secondary-development.md](./docs/secondary-development.md) | 代码二次开发、前端插槽、后端策略接口与 AI 开发模板 | | [backend/app/strategy/prompts/strategy-guide.md](./backend/app/strategy/prompts/strategy-guide.md) | 策略开发完整规范(AI 生成与手写) | fork同时请点个star哦,欢迎 Issue 和 PR。 @@ -258,7 +269,7 @@ fork同时请点个star哦,欢迎 Issue 和 PR。 ## 📄 License -[MIT](./LICENSE) © tickflow-stock-panel contributors +[MIT](./LICENSE) © tick-stock-panel contributors 本项目依赖 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 提供数据服务,使用前请遵守其服务条款 diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 515bd75..d9f1257 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,8 +1,8 @@ -"""TickFlow Stock Panel backend.""" +"""Tick Stock Panel backend.""" import sys -__version__ = "0.1.88" +__version__ = "0.2.1" # Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的 # 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。 diff --git a/backend/app/api/abnormal.py b/backend/app/api/abnormal.py new file mode 100644 index 0000000..e4f6027 --- /dev/null +++ b/backend/app/api/abnormal.py @@ -0,0 +1,23 @@ +"""异动边缘监控 API — 按交易所异动规则口径统计接近触发的个股。""" +from __future__ import annotations + +from fastapi import APIRouter, Query, Request + +from app.services.abnormal_moves import build_overview + +router = APIRouter(prefix="/api/abnormal", tags=["abnormal"]) + + +@router.get("/overview") +def abnormal_overview( + request: Request, + min_closeness: float = Query(0.5, ge=0.0, le=1.0), + limit: int = Query(200, ge=1, le=1000), +): + """异动边缘总览: 规则表 + 各窗口实时偏离 + 接近度排序。 + + min_closeness: 0.5=观察 / 0.7=边缘 / 1.0=已触发。 + """ + repo = request.app.state.repo + quote_service = getattr(request.app.state, "quote_service", None) + return build_overview(repo, quote_service, min_closeness=min_closeness, limit=limit) diff --git a/backend/app/api/backtest.py b/backend/app/api/backtest.py index 8c03b5c..2605e73 100644 --- a/backend/app/api/backtest.py +++ b/backend/app/api/backtest.py @@ -134,11 +134,11 @@ def factor_columns(): class FactorBacktestRequest(BaseModel): - factor_name: str + factor_name: str = Field(..., min_length=1, max_length=64) symbols: list[str] | None = None start: date | None = None end: date | None = None - n_groups: int = 5 + n_groups: int = Field(5, ge=2, le=10) rebalance: Literal["daily", "weekly", "monthly"] = "monthly" weight: Literal["equal", "factor_weight"] = "equal" fees_pct: float = 0.0002 @@ -149,7 +149,10 @@ class FactorBacktestRequest(BaseModel): @router.post("/factor/run") def factor_run(req: FactorBacktestRequest, request: Request): """因子回测 — IC/IR 分析 + 分层回测。""" - from app.backtest.factor import FactorBacktestService, FactorConfig + from app.backtest.factor import FACTOR_COLUMNS, FactorBacktestService, FactorConfig + + if req.factor_name not in {item["id"] for item in FACTOR_COLUMNS}: + raise HTTPException(status_code=400, detail=f"不支持的因子: {req.factor_name}") engine = _get_engine(request) svc = FactorBacktestService(engine) @@ -180,6 +183,139 @@ def factor_run(req: FactorBacktestRequest, request: Request): return asdict(result) +class FactorBatchRequest(BaseModel): + factor_names: list[str] = Field(..., min_length=1, max_length=64) + symbols: list[str] | None = None + start: date | None = None + end: date | None = None + n_groups: int = Field(5, ge=2, le=10) + rebalance: Literal["daily", "weekly", "monthly"] = "monthly" + weight: Literal["equal", "factor_weight"] = "equal" + fees_pct: float = 0.0002 + slippage_bps: float = 5.0 + asset_type: str = "stock" + + +@router.post("/factor/batch") +def factor_batch(req: FactorBatchRequest, request: Request): + """批量筛选因子, 同一批次只加载并计算一次数据面板。""" + from app.backtest.factor import ( + FACTOR_COLUMNS, + FactorBacktestService, + FactorBatchConfig, + ) + + factor_names = list(dict.fromkeys(req.factor_names)) + allowed = {item["id"] for item in FACTOR_COLUMNS} + invalid = [name for name in factor_names if name not in allowed] + if invalid: + raise HTTPException(status_code=400, detail=f"不支持的因子: {', '.join(invalid)}") + + end = req.end or date.today() + start = _resolve_start(req, end, STRATEGY_DEFAULT_DAYS) + _guard_server_backtest_range(start, end) + symbols = req.symbols if req.symbols else None + if symbols is not None and len(symbols) > FACTOR_MAX_SYMBOLS: + raise HTTPException( + status_code=400, + detail=f"指定标的最多支持 {FACTOR_MAX_SYMBOLS} 只, 请缩小标的范围。", + ) + + svc = FactorBacktestService(_get_engine(request)) + result = svc.run_batch(FactorBatchConfig( + factor_names=factor_names, + symbols=symbols, + start=start, + end=end, + n_groups=req.n_groups, + rebalance=req.rebalance, + weight=req.weight, + fees_pct=req.fees_pct, + slippage_bps=req.slippage_bps, + asset_type=req.asset_type, + )) + return asdict(result) + + +# ================================================================ +# 研究候选方案 +# ================================================================ + +class CandidateCreateRequest(BaseModel): + kind: Literal["factor", "strategy"] + name: str = Field(..., min_length=1, max_length=80) + source_id: str = Field(..., min_length=1, max_length=120) + config: dict = Field(default_factory=dict) + metrics: dict = Field(default_factory=dict) + data_as_of: date | None = None + status: Literal["pending", "validated", "rejected"] = "pending" + + +class CandidateUpdateRequest(BaseModel): + name: str | None = Field(None, min_length=1, max_length=80) + status: Literal["pending", "validated", "rejected"] | None = None + + +def _candidate_store(): + from app.backtest.candidates import CandidateStore + + return CandidateStore(settings.data_dir) + + +def _raise_candidate_error(exc: Exception) -> None: + from app.backtest.candidates import CandidateValidationError + + status_code = 400 if isinstance(exc, CandidateValidationError) else 500 + raise HTTPException(status_code=status_code, detail=str(exc)) from exc + + +@router.get("/candidates") +def candidates_list(): + try: + return {"items": _candidate_store().list()} + except Exception as exc: + _raise_candidate_error(exc) + + +@router.post("/candidates") +def candidate_create(req: CandidateCreateRequest): + try: + return _candidate_store().create( + kind=req.kind, + name=req.name, + source_id=req.source_id, + config=req.config, + metrics=req.metrics, + data_as_of=req.data_as_of.isoformat() if req.data_as_of else None, + status=req.status, + ) + except Exception as exc: + _raise_candidate_error(exc) + + +@router.patch("/candidates/{candidate_id}") +def candidate_update(candidate_id: str, req: CandidateUpdateRequest): + if req.name is None and req.status is None: + raise HTTPException(status_code=400, detail="至少提供一个需要更新的字段") + try: + return _candidate_store().update(candidate_id, name=req.name, status=req.status) + except KeyError as exc: + raise HTTPException(status_code=404, detail="候选方案不存在") from exc + except Exception as exc: + _raise_candidate_error(exc) + + +@router.delete("/candidates/{candidate_id}") +def candidate_delete(candidate_id: str): + try: + _candidate_store().delete(candidate_id) + return {"ok": True} + except KeyError as exc: + raise HTTPException(status_code=404, detail="候选方案不存在") from exc + except Exception as exc: + _raise_candidate_error(exc) + + # ================================================================ # 策略回测 # ================================================================ @@ -245,7 +381,10 @@ def strategy_run(req: StrategyBacktestRequest, request: Request): regime_filter=req.regime_filter, ) task = make_worker_task("backtest", settings.data_dir, cfg) - return run_worker_task(task) + from app.services.heavy_job_limiter import shared_heavy_job_limiter + + with shared_heavy_job_limiter.slot("normal"): + return run_worker_task(task) # ── SSE 流式回测 (实时进度 + 可取消 + 支持重连) ─────────────────── @@ -273,10 +412,6 @@ _running_jobs: dict[str, _BacktestJob] = {} _jobs_lock = threading.Lock() _JOB_TTL = 300 # 完成后保留 5 分钟 -# 并发回测上限: 多个重回测同时跑会 OOM (服务器内存约 1.8GB)。用信号量限并发, -# 超出的任务在 _run_backtest 里排队, SSE 连接照常保持, run 一开始就有进度。 -_backtest_semaphore = threading.Semaphore(2) - def _cleanup_stale_jobs(): """清理过期任务 (完成超过 TTL 的)。全程持 _jobs_lock: 迭代+pop 与其他访问互斥。""" @@ -452,21 +587,27 @@ async def strategy_stream( ) def _run_backtest(): - # 信号量限并发: 超额任务在此阻塞排队, 不并发吃满内存 (等待期间 cancel_event - # 仍可置位, svc.run 会据此提前返回 cancelled)。持槽跑完在 finally 释放。 - _backtest_semaphore.acquire() + from app.services.heavy_job_limiter import ( + HeavyJobCancelledError, + shared_heavy_job_limiter, + ) + try: - task = make_worker_task("backtest", settings.data_dir, cfg) - result = run_worker_task( - task, - lambda d: job.progress.append(d), - job.cancel_event, - ) + with shared_heavy_job_limiter.slot( + "normal", + cancel_event=job.cancel_event, + ): + task = make_worker_task("backtest", settings.data_dir, cfg) + result = run_worker_task( + task, + lambda d: job.progress.append(d), + job.cancel_event, + ) _finish_job(job, result=result) + except HeavyJobCancelledError: + _finish_job(job, error="回测已取消") except Exception as e: _finish_job(job, error=str(e)) - finally: - _backtest_semaphore.release() # 启动后台线程 (不阻塞事件循环) threading.Thread(target=_run_backtest, daemon=True).start() @@ -750,14 +891,25 @@ async def optimize_stream( ) def _run_opt(): + from app.services.heavy_job_limiter import ( + HeavyJobCancelledError, + shared_heavy_job_limiter, + ) + try: - task = make_worker_task("optimize", settings.data_dir, ocfg) - result = run_worker_task( - task, - lambda d: job.progress.append(d), - job.cancel_event, - ) + with shared_heavy_job_limiter.slot( + "normal", + cancel_event=job.cancel_event, + ): + task = make_worker_task("optimize", settings.data_dir, ocfg) + result = run_worker_task( + task, + lambda d: job.progress.append(d), + job.cancel_event, + ) _finish_job(job, result=result) + except HeavyJobCancelledError: + _finish_job(job, error="优化已取消") except Exception as e: _finish_job(job, error=str(e)) @@ -965,14 +1117,25 @@ async def walkforward_stream( ) def _run_wf(): + from app.services.heavy_job_limiter import ( + HeavyJobCancelledError, + shared_heavy_job_limiter, + ) + try: - task = make_worker_task("walkforward", settings.data_dir, wf_cfg) - result = run_worker_task( - task, - lambda d: job.progress.append(d), - job.cancel_event, - ) + with shared_heavy_job_limiter.slot( + "normal", + cancel_event=job.cancel_event, + ): + task = make_worker_task("walkforward", settings.data_dir, wf_cfg) + result = run_worker_task( + task, + lambda d: job.progress.append(d), + job.cancel_event, + ) _finish_job(job, result=result) + except HeavyJobCancelledError: + _finish_job(job, error="walk-forward 已取消") except Exception as e: _finish_job(job, error=str(e)) diff --git a/backend/app/api/data.py b/backend/app/api/data.py index ce3ec92..3526d7f 100644 --- a/backend/app/api/data.py +++ b/backend/app/api/data.py @@ -11,6 +11,7 @@ from typing import Any, Callable from fastapi import APIRouter, Request +from app.enriched_generation import EnrichedPublication from app.indicators.pipeline import ENRICHED_COLUMNS logger = logging.getLogger(__name__) @@ -504,26 +505,21 @@ def _compute_storage(data_dir: Path) -> dict: stats[f"{key}_files"] = fc stats[f"{key}_size_mb"] = sz - # total: 再加上其他零散文件(pools, financials, capabilities.json 等) - other_dirs = ["pools", "financials", "backtest_results", "screener_results", "ai_cache"] + # total: 再加上其他零散目录 (financials 有下方专属明细统计, 不在此列) + other_dirs = ["pools", "backtest_results", "screener_results", "ai_cache"] for name in other_dirs: d = data_dir / name if d.exists(): _, s = _scan_dir_stats(d) total_size += s - # financials 单独统计 + # financials 单独统计 (明细与 total 各计入一次, 不得与其他目录循环重复累加) fin_dir = data_dir / "financials" if fin_dir.exists(): fc, sz = _scan_dir_stats(fin_dir) stats["financials_files"] = fc stats["financials_size_mb"] = sz total_size += sz - for name in other_dirs: - d = data_dir / name - if d.exists(): - _, s = _scan_dir_stats(d) - total_size += s # 根目录散文件 for entry in os.scandir(data_dir): if entry.is_file(follow_symlinks=False): @@ -626,6 +622,10 @@ def clear_data(request: Request): repo = request.app.state.repo data_dir = repo.store.data_dir deleted = 0 + publications = { + "kline_daily_enriched": EnrichedPublication(data_dir, "stock"), + "kline_etf_enriched": EnrichedPublication(data_dir, "etf"), + } for sub in ( "kline_daily", "kline_daily_enriched", "kline_index_daily", "kline_index_enriched", @@ -634,15 +634,27 @@ def clear_data(request: Request): "backtest_results", "screener_results", "ai_cache", ): d = data_dir / sub - if d.exists(): - # 先删所有 parquet 文件 - for f in d.rglob("*.parquet"): + if not d.exists(): + continue + publication = publications.get(sub) + parquet_files = list(d.rglob("*.parquet")) + if publication is not None and parquet_files: + publication.begin() + try: + for f in parquet_files: f.unlink() deleted += 1 - # 再删除空的日期分区子目录(date=YYYY-MM-DD 等) + if publication is not None: + publication.mark_changed() for child in list(d.iterdir()): if child.is_dir(): shutil.rmtree(child, ignore_errors=True) + if publication is not None: + publication.commit() + except BaseException: + if publication is not None: + publication.abandon() + raise # 清除同步历史(内存 + 磁盘 job_store/ 文件夹) from app.services.pipeline_jobs import job_store diff --git a/backend/app/api/ext_data.py b/backend/app/api/ext_data.py index eae8cbd..6fc005c 100644 --- a/backend/app/api/ext_data.py +++ b/backend/app/api/ext_data.py @@ -79,6 +79,8 @@ class PullConfigReq(BaseModel): field_map: dict[str, str] | None = None # external → internal field name schedule_minutes: int = Field(1440, ge=1) enabled: bool = False + time_window_start: str | None = None # "HH:MM", None=不限 + time_window_end: str | None = None # "HH:MM", None=不限 class DetectUrlReq(BaseModel): @@ -608,6 +610,8 @@ def configure_pull(request: Request, config_id: str, body: PullConfigReq): field_map=body.field_map, schedule_minutes=body.schedule_minutes, enabled=body.enabled, + time_window_start=body.time_window_start, + time_window_end=body.time_window_end, last_run=old_pull.last_run if old_pull else None, last_status=old_pull.last_status if old_pull else None, last_message=old_pull.last_message if old_pull else None, diff --git a/backend/app/api/kline.py b/backend/app/api/kline.py index 5dcde88..e5fd5c0 100644 --- a/backend/app/api/kline.py +++ b/backend/app/api/kline.py @@ -12,7 +12,7 @@ from fastapi import APIRouter, HTTPException, Query, Request from app.indicators.pipeline import compute_enriched, compute_enriched_single from app.market_time import cn_now, cn_today from app.price_limits import is_risk_warning_name, price_limit_pct -from app.db_safe import is_valid_ext_ident, quote_ident +from app.db_safe import is_valid_ext_ident from app.services import kline_sync logger = logging.getLogger(__name__) @@ -175,21 +175,28 @@ def instruments_names(request: Request, symbols: list[str]): def _get_stock_info(repo, symbol: str) -> dict: - """从 instruments 视图查标的名称 + 股本。""" + """从 instruments 内存缓存查标的名称 + 股本。 + + 该接口在个股弹窗打开时每秒被调用 (SSE invalidate 触发重拉), 走 + repo.get_instruments() 的 Polars 内存缓存按 symbol 过滤, 不再每请求 + DuckDB 扫 instruments parquet。列缺失时返回空 dict, 与旧 SQL 报错路径一致。 + """ + import polars as pl try: - row = repo.execute_one( - "SELECT name, total_shares, float_shares FROM instruments WHERE symbol = ? LIMIT 1", - [symbol], - ) + df = repo.get_instruments() + needed = ("symbol", "name", "total_shares", "float_shares") + if df.is_empty() or not all(c in df.columns for c in needed): + return {} + hit = df.filter(pl.col("symbol") == symbol).head(1) + if hit.is_empty(): + return {} + return { + "name": hit["name"][0], + "total_shares": hit["total_shares"][0], + "float_shares": hit["float_shares"][0], + } except Exception: # noqa: BLE001 return {} - if not row: - return {} - return { - "name": row[0], - "total_shares": row[1], - "float_shares": row[2], - } def _get_asset_info(repo, symbol: str, asset_type: str) -> dict: @@ -268,6 +275,47 @@ def _get_price_limit_info( return info +def _get_previous_closes( + repo, + symbol: str, + trade_dates: list[date], + asset_type: str, +) -> dict[date, float | None]: + """Return the previous trading day's adjusted close for each session.""" + if not trade_dates: + return {} + start = min(trade_dates) - timedelta(days=45) + end = max(trade_dates) + try: + daily = repo.get_daily_asset( + asset_type, + symbol, + start, + end, + columns=["date", "close"], + ).sort("date") + except Exception: + daily = None + if daily is None or daily.is_empty(): + return {trade_date: None for trade_date in trade_dates} + + closes: list[tuple[date, float]] = [] + for daily_date, close in daily.select(["date", "close"]).iter_rows(): + if close is None: + continue + numeric = float(close) + if math.isfinite(numeric) and numeric > 0: + closes.append((daily_date, numeric)) + + result: dict[date, float | None] = {} + for trade_date in trade_dates: + result[trade_date] = next( + (close for daily_date, close in reversed(closes) if daily_date < trade_date), + None, + ) + return result + + @router.get("/daily") def get_daily( request: Request, @@ -336,7 +384,8 @@ def _attach_ext(resp: dict, repo, symbol: str, ext_columns: Optional[str]) -> di """按 ext_columns 规格为单只股票 LEFT JOIN 扩展数据,平铺到 stock_info['ext']。 key 形如 "{config_id}__{field_name}",与自选列表 enriched 接口保持一致。 - JOIN 逻辑参考 watchlist.watchlist_enriched;任何 ext 表/字段缺失都静默跳过。 + 委托 screener._load_ext_value_maps 取值: 复用其 (路径,mtime) 签名缓存, + 个股弹窗每秒重拉时不再重复读 ext parquet; 任何 ext 表/字段缺失都静默跳过。 """ if not ext_columns or not ext_columns.strip(): return resp @@ -353,43 +402,17 @@ def _attach_ext(resp: dict, repo, symbol: str, ext_columns: Optional[str]) -> di if not specs: return resp - import polars as pl - data_dir = repo.store.data_dir try: - from app.services.ext_data import ExtConfigStore - from app.api.ext_data import _read_ext_dataframe - ext_store = ExtConfigStore(data_dir) - configs = {c.id: c for c in ext_store.load_all()} + from app.api.screener import _load_ext_value_maps + value_maps = _load_ext_value_maps(repo, ext_columns) except Exception: # noqa: BLE001 - configs = {} + value_maps = {} ext_values: dict = {} for config_id, field_name in specs: ext_col_name = f"{config_id}__{field_name}" - value = None - try: - cfg = configs.get(config_id) - if cfg: - ext_df, _ = _read_ext_dataframe(cfg, data_dir) - else: - ext_df = pl.from_arrow( - repo.store.db.query( - f"SELECT symbol, {quote_ident(field_name)} FROM ext_{config_id}" - ).arrow() - ) - if not ext_df.is_empty() and "symbol" in ext_df.columns and field_name in ext_df.columns: - # 时序表取最新分区,避免一个 symbol 多行 - row = ( - ext_df - .select(["symbol", field_name]) - .unique(subset=["symbol"], keep="last") - .filter(pl.col("symbol") == symbol) - ) - if not row.is_empty(): - value = row[field_name][0] - except Exception as e: # noqa: BLE001 - logger.debug("kline ext join failed for %s.%s: %s", config_id, field_name, e) - ext_values[ext_col_name] = value + vmap = value_maps.get(ext_col_name) or {} + ext_values[ext_col_name] = vmap.get(symbol) stock_info = dict(resp.get("stock_info") or {}) stock_info["ext"] = ext_values @@ -534,12 +557,13 @@ def get_daily_batch(request: Request, body: dict): return {"data": {}} df = pl.concat(frames, how="diagonal_relaxed") - # 按 symbol 分组, 每只取最近 N 条 + # 按 symbol 分组, 每只取最近 N 条。 + # partition_by 一次切分, 避免 N 只自选时对同一批数据做 N 次全帧过滤。 result: dict[str, list[dict]] = {} - for sym in symbols: - sub = df.filter(pl.col("symbol") == sym).sort("date").tail(days) + for part in df.partition_by("symbol", maintain_order=True): + sub = part.sort("date").tail(days) if not sub.is_empty(): - result[sym] = sub.to_dicts() + result[sub["symbol"][0]] = sub.to_dicts() return {"data": result} @@ -621,14 +645,15 @@ def get_minute_batch(request: Request, body: dict): else: expected = 240 - # 按 symbol 分组, 判定哪些不完整需要补拉 + # 按 symbol 分组, 判定哪些不完整需要补拉 (partition_by 一次切分, 同 daily-batch) result: dict[str, list[dict]] = {} incomplete: list[str] = [] + local_parts: dict[str, pl.DataFrame] = {} + if not df_local.is_empty(): + for part in df_local.partition_by("symbol", maintain_order=True): + local_parts[part["symbol"][0]] = part.sort("datetime") for sym in symbols: - if df_local.is_empty(): - sub = pl.DataFrame() - else: - sub = df_local.filter(pl.col("symbol") == sym).sort("datetime") + sub = local_parts.get(sym, pl.DataFrame()) if expected > 0 and (sub.is_empty() or len(sub) < expected * 0.9): incomplete.append(sym) elif not sub.is_empty(): @@ -671,14 +696,85 @@ def get_minute_batch(request: Request, body: dict): live_parts.append(df_e) if live_parts: live_df = pl.concat(live_parts, how="diagonal_relaxed") + live_map: dict[str, pl.DataFrame] = { + part["symbol"][0]: part.sort("datetime") + for part in live_df.partition_by("symbol", maintain_order=True) + } for sym in incomplete: - sub = live_df.filter(pl.col("symbol") == sym).sort("datetime") - if not sub.is_empty(): + sub = live_map.get(sym) + if sub is not None and not sub.is_empty(): result[sym] = sub.to_dicts() return {"data": result} +@router.get("/minute-range") +def get_minute_range( + request: Request, + symbol: str = Query(..., description="标的代码"), + days: int = Query(10, ge=1, le=20, description="最近交易日数量"), +): + """读取单只标的最近 N 个已落库交易日的分钟 K。""" + import polars as pl + + repo = request.app.state.repo + asset_type = repo.resolve_asset_type(symbol) + stock_info = ( + _get_stock_info(repo, symbol) + if asset_type == "stock" + else _get_asset_info(repo, symbol, asset_type) + ) + base_response = { + "symbol": symbol, + "name": stock_info.get("name"), + "asset_type": asset_type, + "requested_days": days, + } + + # 指数分钟 K 不落本地仓库, 最新分时仍由 /api/index/minute 实时读取。 + if asset_type == "index": + return {**base_response, "sessions": [], "source": "none"} + + end = cn_today() + start = end - timedelta(days=days * 3 + 20) + minute = repo.get_minute_range([symbol], start, end, asset_type=asset_type) + if minute.is_empty() or "datetime" not in minute.columns: + return {**base_response, "sessions": [], "source": "none"} + + minute = minute.with_columns( + pl.col("datetime").dt.date().alias("_trade_date"), + ) + trade_dates = sorted(minute["_trade_date"].unique().to_list())[-days:] + previous_closes = _get_previous_closes(repo, symbol, trade_dates, asset_type) + row_columns = [ + column + for column in ( + "datetime", "open", "high", "low", "close", "volume", "amount" + ) + if column in minute.columns + ] + sessions = [] + for trade_date in trade_dates: + rows = ( + minute.filter(pl.col("_trade_date") == trade_date) + .sort("datetime") + .select(row_columns) + .to_dicts() + ) + if rows: + sessions.append({ + "date": trade_date.isoformat(), + "prev_close": previous_closes.get(trade_date), + "rows": rows, + }) + + return { + **base_response, + "sessions": sessions, + "source": "local" if sessions else "none", + } + + @router.get("/minute") def get_minute( request: Request, @@ -721,13 +817,20 @@ def get_minute( price_limit = _get_price_limit_info( repo, symbol, trade_date, asset_type, stock_name, ) + prev_close = _get_previous_closes( + repo, symbol, [trade_date], asset_type, + ).get(trade_date) return { "symbol": symbol, "name": stock_name, "stock_info": stock_info, "date": str(trade_date), "rows": df.to_dicts(), "source": "live", "asset_type": asset_type, "price_limit": price_limit, + "prev_close": prev_close, } + prev_close = _get_previous_closes( + repo, symbol, [trade_date], asset_type, + ).get(trade_date) price_limit = _get_price_limit_info( repo, symbol, trade_date, asset_type, stock_name, ) @@ -758,6 +861,7 @@ def get_minute( "date": str(trade_date), "rows": df.to_dicts(), "source": "local", "asset_type": asset_type, "price_limit": price_limit, + "prev_close": prev_close, } # 本地不完整或无数据 → 从 TickFlow 实时拉取 @@ -768,6 +872,7 @@ def get_minute( "source": "live" if not live_df.is_empty() else "none", "asset_type": asset_type, "price_limit": price_limit, + "prev_close": prev_close, } @@ -813,7 +918,7 @@ async def sync_minute(request: Request): """ import asyncio - from app.services.pipeline_jobs import job_store, release_run_slot, try_acquire_run_slot, LONG_JOB_TIMEOUT_S + from app.services.pipeline_jobs import job_store, release_run_slot, try_acquire_run_slot from app.api.data import invalidate_storage_cache from app.services.preferences import get_minute_sync_days from app.tickflow.capabilities import Cap @@ -836,7 +941,7 @@ async def sync_minute(request: Request): extend_flag = body.get("extend") # 分钟K全市场同步是长任务(数据量是日K的 ~240 倍),用更宽松的卡死阈值 - job_id, is_new = job_store.create(timeout_s=LONG_JOB_TIMEOUT_S) + job_id, is_new = job_store.create(long_running=True) if not is_new: return {"status": "reused", "job_id": job_id} @@ -909,13 +1014,21 @@ async def sync_minute_single(request: Request, body: dict): body: { "symbol": "000001.SZ" } 用于个股分时图"获取数据"按钮: 本地无数据时单独拉取并持久化。 """ + import asyncio + from app.services.preferences import get_minute_sync_days - from app.tickflow.capabilities import Cap symbol = body.get("symbol", "").strip() if not symbol: raise HTTPException(status_code=400, detail="symbol 不能为空") + requested_days = body.get("days") + if requested_days is not None: + if isinstance(requested_days, bool) or not isinstance(requested_days, int): + raise HTTPException(status_code=400, detail="days 必须是整数") + if requested_days < 1 or requested_days > 30: + raise HTTPException(status_code=400, detail="days 必须在 1 到 30 之间") + repo = request.app.state.repo capset = request.app.state.capabilities @@ -927,11 +1040,11 @@ async def sync_minute_single(request: Request, body: dict): if not _minute_allowed(capset): raise HTTPException(status_code=403, detail="需要 Pro+ 权限") - days = get_minute_sync_days() + days = requested_days if requested_days is not None else get_minute_sync_days() loop = asyncio.get_event_loop() def _run(): - return kline_sync.sync_and_persist_minute([symbol], repo, capset, days=days) + return kline_sync.sync_and_persist_minute([symbol], repo, capset, days=days, force_full_days=True) written = await loop.run_in_executor(_long_task_executor, _run) diff --git a/backend/app/api/mining.py b/backend/app/api/mining.py new file mode 100644 index 0000000..b028f0e --- /dev/null +++ b/backend/app/api/mining.py @@ -0,0 +1,736 @@ +"""Persistent factor and strategy mining HTTP API.""" +from __future__ import annotations + +import asyncio +import json +import math +from collections.abc import AsyncIterator, Mapping, Sequence +from datetime import date +from typing import Annotated, Any, Literal + +import polars as pl +from fastapi import APIRouter, Header, HTTPException, Query, Request +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from sse_starlette.sse import EventSourceResponse + +from app.backtest.factor import FACTOR_COLUMNS +from app.backtest.mining import ( + MAX_BEAM_WIDTH, + MAX_COMBINATION_SIZE, + MAX_FINALISTS, + evaluate_candidate_gate, +) +from app.services import preferences +from app.services.mining_jobs import ( + RUN_STATUSES, + SUCCESS_RUN_STATUSES, + TERMINAL_RUN_STATUSES, + MiningRunStore, + MiningRunStoreError, + MiningRunValidationError, +) +from app.services.mining_preflight import ( + mining_availability, + require_mining_availability, +) +from app.services.mining_schedule import ( + MINING_ALGORITHM_VERSION, + build_data_fingerprint, +) + +router = APIRouter(prefix="/api/backtest/mining", tags=["backtest"]) +_FACTOR_IDS = frozenset(str(item["id"]) for item in FACTOR_COLUMNS) +_MAX_ARTIFACT_BYTES = 64 * 1024 * 1024 +_SSE_POLL_SECONDS = 0.5 +_SSE_HEARTBEAT_SECONDS = 15.0 + + +class MiningStartRequest(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + + factor_names: list[str] = Field(min_length=1, max_length=48) + strategy_ids: list[str] = Field(default_factory=list, max_length=8) + symbols: list[str] | None = None + asset_type: Literal["stock", "etf"] = "stock" + start: date | None = None + end: date | None = None + budget_profile: Literal["exploratory", "balanced", "strict"] = "balanced" + commission_pct: float = Field(0.0002, ge=0.0, le=0.05, allow_inf_nan=False) + stamp_tax_pct: float = Field(0.0005, ge=0.0, le=0.05, allow_inf_nan=False) + slippage_bps: float = Field(5.0, ge=0.0, le=1000.0, allow_inf_nan=False) + correlation_threshold: float = Field(0.75, gt=0.0, le=1.0, allow_inf_nan=False) + max_combination_factors: int = Field(4, ge=1, le=MAX_COMBINATION_SIZE) + beam_width: int = Field(12, ge=1, le=MAX_BEAM_WIDTH) + max_finalists: int = Field(MAX_FINALISTS, ge=1, le=MAX_FINALISTS) + force: bool = False + + @field_validator("start", "end", mode="before") + @classmethod + def _iso_dates(cls, value: Any) -> Any: + if isinstance(value, str): + try: + return date.fromisoformat(value) + except ValueError as exc: + raise ValueError("dates must use ISO YYYY-MM-DD format") from exc + return value + + @field_validator("factor_names", "strategy_ids") + @classmethod + def _unique_ids(cls, values: list[str]) -> list[str]: + if any(not value or len(value) > 120 for value in values): + raise ValueError("IDs must contain 1 to 120 characters") + if len(set(values)) != len(values): + raise ValueError("IDs must be unique") + return values + + @field_validator("factor_names") + @classmethod + def _known_factors(cls, values: list[str]) -> list[str]: + unknown = sorted(set(values) - _FACTOR_IDS) + if unknown: + raise ValueError(f"unknown mining factors: {unknown}") + return values + + @field_validator("symbols") + @classmethod + def _symbols(cls, values: list[str] | None) -> list[str] | None: + if values is None: + return None + cleaned = [value for value in values if value] + if len(cleaned) > 10_000: + raise ValueError("symbols contains more than 10000 entries") + if len(set(cleaned)) != len(cleaned): + raise ValueError("symbols must be unique") + return cleaned or None + + @model_validator(mode="after") + def _date_range(self) -> MiningStartRequest: + if self.start is not None and self.end is not None and self.start > self.end: + raise ValueError("start must not be after end") + return self + + +class MiningSchedulePatch(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + + mining_schedule_enabled: bool | None = None + mining_schedule_weekday: int | None = Field(None, ge=0, le=4) + mining_budget_profile: Literal["balanced", "strict"] | None = None + + +@router.get("/availability") +def get_availability( + request: Request, + asset_type: Annotated[Literal["stock", "etf"], Query()] = "stock", + budget_profile: Annotated[ + Literal["exploratory", "balanced", "strict"], Query() + ] = "balanced", + start: Annotated[date | None, Query()] = None, + end: Annotated[date | None, Query()] = None, +) -> dict[str, Any]: + try: + return mining_availability( + request.app.state.repo.store.data_dir, + asset_type=asset_type, + budget_profile=budget_profile, + start=start, + end=end, + ).to_dict() + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/runs") +def list_runs( + request: Request, + limit: Annotated[int, Query(ge=1, le=200)] = 50, + status: Annotated[list[str] | None, Query()] = None, +) -> dict[str, Any]: + manager = _manager(request) + statuses = None + if status: + unknown = sorted(set(status) - RUN_STATUSES) + if unknown: + raise HTTPException(status_code=400, detail=f"unsupported mining statuses: {unknown}") + statuses = status + try: + manifests = manager.store.list_runs(limit=limit, statuses=statuses) + return {"items": [_project_run(manager.store, item) for item in manifests]} + except MiningRunValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except MiningRunStoreError as exc: + raise HTTPException(status_code=500, detail="failed to read mining runs") from exc + + +@router.post("/runs") +def start_run(payload: MiningStartRequest, request: Request) -> dict[str, Any]: + manager = _manager(request) + worker_request = payload.model_dump(mode="json", exclude={"force"}) + try: + _validate_selected_strategies( + request.app.state.strategy_engine, + payload.strategy_ids, + payload.asset_type, + ) + require_mining_availability( + request.app.state.repo.store.data_dir, + asset_type=payload.asset_type, + budget_profile=payload.budget_profile, + start=payload.start, + end=payload.end, + ) + fingerprint = build_data_fingerprint( + request.app.state.repo, + request.app.state, + worker_request, + ) + existing = None + if not payload.force: + from app.services.mining_jobs import ( + ACTIVE_RUN_STATUSES, + SUCCESS_RUN_STATUSES, + compute_run_signature, + ) + + signature = compute_run_signature(worker_request, fingerprint) + existing = manager.store.find_by_signature( + signature, + statuses=ACTIVE_RUN_STATUSES | SUCCESS_RUN_STATUSES, + ) + manifest = manager.start( + worker_request, + fingerprint, + force=payload.force, + source="manual", + ) + projected = _project_run(manager.store, manifest) + projected["reused"] = existing is not None + return projected + except (MiningRunValidationError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except MiningRunStoreError as exc: + raise HTTPException(status_code=500, detail="failed to persist mining run") from exc + + +@router.get("/runs/{run_id}") +def get_run(run_id: str, request: Request) -> dict[str, Any]: + store = _manager(request).store + return _project_run(store, _required_manifest(store, run_id)) + + +@router.post("/runs/{run_id}/cancel") +def cancel_run(run_id: str, request: Request) -> dict[str, Any]: + manager = _manager(request) + try: + return _project_run(manager.store, manager.cancel(run_id)) + except KeyError as exc: + raise HTTPException(status_code=404, detail="mining run not found") from exc + except MiningRunValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/runs/{run_id}/result") +def get_result(run_id: str, request: Request) -> dict[str, Any]: + store = _manager(request).store + manifest = _required_manifest(store, run_id) + status = str(manifest["status"]) + if status not in SUCCESS_RUN_STATUSES: + status_code = 409 if status not in TERMINAL_RUN_STATUSES else 422 + raise HTTPException( + status_code=status_code, + detail=f"mining result is unavailable for status {status}", + ) + try: + summary = store.read_summary(run_id) + frames = { + name: _read_registered_artifact(store, manifest, name) + for name in ("factors", "correlation", "candidates", "folds") + } + return _project_result(manifest, summary, frames) + except ( + MiningRunStoreError, + OSError, + pl.exceptions.PolarsError, + ValueError, + ) as exc: + raise HTTPException( + status_code=500, + detail="mining result artifacts are unavailable", + ) from exc + + +@router.get("/runs/{run_id}/events") +def stream_events( + run_id: str, + request: Request, + last_event_id: str | None = Header(None, alias="Last-Event-ID"), +) -> EventSourceResponse: + store = _manager(request).store + _required_manifest(store, run_id) + cursor = _event_cursor(last_event_id) + + async def generate() -> AsyncIterator[dict[str, str]]: + nonlocal cursor + last_emit = asyncio.get_running_loop().time() + terminal_sent = False + first_batch = True + while not await request.is_disconnected(): + events = await asyncio.to_thread(store.read_events, run_id, after_id=cursor) + if first_batch and events and int(events[0]["id"]) > cursor + 1: + summary = await asyncio.to_thread(store.read_summary, run_id) + progress = summary.get("progress") + if isinstance(progress, Mapping): + yield { + "id": str(cursor), + "event": "progress", + "data": json.dumps(progress, ensure_ascii=False, allow_nan=False), + } + last_emit = asyncio.get_running_loop().time() + first_batch = False + for event in events: + cursor = int(event["id"]) + event_type = "failed" if event.get("type") == "error" else str(event["type"]) + payload = dict(event.get("payload") or {}) + if event_type in TERMINAL_RUN_STATUSES: + payload.setdefault("status", event_type) + terminal_sent = True + yield { + "id": str(cursor), + "event": event_type, + "data": json.dumps(payload, ensure_ascii=False, allow_nan=False), + } + last_emit = asyncio.get_running_loop().time() + manifest = await asyncio.to_thread(store.get, run_id) + if manifest is None: + return + status = str(manifest["status"]) + if status in TERMINAL_RUN_STATUSES: + if not terminal_sent: + event_type = "failed" if status == "failed" else status + yield { + "id": str(cursor), + "event": event_type, + "data": json.dumps( + {"status": status, "message": manifest.get("error")}, + ensure_ascii=False, + ), + } + return + now = asyncio.get_running_loop().time() + if now - last_emit >= _SSE_HEARTBEAT_SECONDS: + yield {"event": "heartbeat", "data": "{}"} + last_emit = now + await asyncio.sleep(_SSE_POLL_SECONDS) + + return EventSourceResponse(generate(), ping=_SSE_HEARTBEAT_SECONDS) + + +@router.post("/runs/{run_id}/candidates/{signature}/promote") +def promote_candidate(run_id: str, signature: str, request: Request) -> dict[str, Any]: + service = _candidate_service(request) + try: + return service.promote(run_id, signature) + except KeyError as exc: + raise HTTPException(status_code=404, detail="mining run or candidate not found") from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + +@router.post("/runs/{run_id}/candidates/{signature}/publish") +def publish_candidate(run_id: str, signature: str, request: Request) -> dict[str, Any]: + service = _candidate_service(request) + try: + return service.publish(run_id, signature) + except KeyError as exc: + raise HTTPException(status_code=404, detail="mining run or candidate not found") from exc + except FileExistsError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + +@router.get("/config") +def get_config() -> dict[str, Any]: + return preferences.get_mining_schedule() + + +@router.patch("/config") +def update_config(payload: MiningSchedulePatch) -> dict[str, Any]: + current = preferences.get_mining_schedule() + updates = payload.model_dump(exclude_none=True) + if not updates: + raise HTTPException(status_code=400, detail="at least one mining config field is required") + merged = {**current, **updates} + try: + return preferences.set_mining_schedule( + merged["mining_schedule_enabled"], + merged["mining_schedule_weekday"], + merged["mining_budget_profile"], + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +def _manager(request: Request): + manager = getattr(request.app.state, "mining_manager", None) + if manager is None: + raise HTTPException(status_code=503, detail="mining manager is unavailable") + return manager + + +def _candidate_service(request: Request): + service = getattr(request.app.state, "mining_candidate_service", None) + if service is not None: + return service + from app.backtest.candidates import CandidateStore + from app.services.mining_candidates import MiningCandidateService + + manager = _manager(request) + data_dir = request.app.state.repo.store.data_dir + monitor_engine = getattr(request.app.state, "monitor_engine", None) + service = MiningCandidateService( + data_dir, + manager.store, + CandidateStore(data_dir), + request.app.state.strategy_engine, + monitor_state_invalidator=( + monitor_engine.invalidate_strategy_state + if monitor_engine is not None + else None + ), + ) + request.app.state.mining_candidate_service = service + return service + + +def _required_manifest(store: MiningRunStore, run_id: str) -> dict[str, Any]: + try: + manifest = store.get(run_id) + except MiningRunValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except MiningRunStoreError as exc: + raise HTTPException(status_code=500, detail="failed to read mining run") from exc + if manifest is None: + raise HTTPException(status_code=404, detail="mining run not found") + return manifest + + +def _project_run(store: MiningRunStore, manifest: Mapping[str, Any]) -> dict[str, Any]: + run_id = str(manifest["run_id"]) + summary = store.read_summary(run_id) + events = store.read_events(run_id) + source = next( + ( + event.get("payload", {}).get("source") + for event in events + if event.get("type") == "queued" and event.get("payload", {}).get("source") + ), + None, + ) + if source is None and isinstance(manifest.get("data_fingerprint"), Mapping): + source = manifest["data_fingerprint"].get("source") + compact = _summary_projection(summary) if manifest["status"] in SUCCESS_RUN_STATUSES else None + return { + "run_id": run_id, + "signature": manifest["run_signature"], + "status": manifest["status"], + "request": manifest.get("request") or {}, + "source": source or "manual", + "created_at": manifest.get("created_at"), + "updated_at": manifest.get("updated_at"), + "started_at": manifest.get("started_at"), + "finished_at": manifest.get("finished_at"), + "data_as_of": summary.get("data_as_of"), + "progress": ( + summary.get("progress") + if isinstance(summary.get("progress"), Mapping) + else None + ), + "error": manifest.get("error"), + "summary": compact, + } + + +def _request_summary(manifest: Mapping[str, Any]) -> dict[str, Any]: + request = manifest.get("request") or {} + factor_names = request.get("factor_names") + strategy_ids = request.get("strategy_ids") + return { + "asset_type": request.get("asset_type") or "stock", + "budget_profile": request.get("budget_profile") or "balanced", + "start": request.get("start"), + "end": request.get("end"), + "factor_count": len(factor_names) if isinstance(factor_names, list) else 0, + "strategy_count": len(strategy_ids) if isinstance(strategy_ids, list) else 0, + "commission_pct": _finite(request.get("commission_pct")), + "stamp_tax_pct": _finite(request.get("stamp_tax_pct")), + "slippage_bps": _finite(request.get("slippage_bps")), + "correlation_threshold": _finite(request.get("correlation_threshold")), + } + + +def _summary_projection(summary: Mapping[str, Any]) -> dict[str, Any]: + worker = summary.get("worker") if isinstance(summary.get("worker"), Mapping) else {} + return { + "factor_count": int(summary.get("factor_count") or 0), + "selected_factor_count": int(summary.get("selected_factor_count") or 0), + "candidate_count": int(summary.get("candidate_count") or 0), + "valid_fold_count": int(summary.get("valid_fold_count") or 0), + "skipped_fold_count": int(summary.get("skipped_fold_count") or 0), + "confidence": summary.get("confidence") or "low", + "budget_exhausted": bool(summary.get("budget_exhausted", False)), + "elapsed_ms": _finite(summary.get("elapsed_ms")), + "peak_rss_bytes": _optional_int(worker.get("peak_rss_bytes")), + } + + +def _read_registered_artifact( + store: MiningRunStore, + manifest: Mapping[str, Any], + name: str, +) -> pl.DataFrame: + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, Mapping) or name not in artifacts: + raise ValueError(f"mining artifact is not registered: {name}") + raw_path = artifacts[name] + if not isinstance(raw_path, str): + raise ValueError(f"mining artifact registration is invalid: {name}") + run_dir = store.artifact_path(str(manifest["run_id"]), name).parent # type: ignore[arg-type] + registered = (run_dir / raw_path).resolve() + if not registered.is_relative_to(run_dir.resolve()): + raise ValueError(f"mining artifact escapes its run directory: {name}") + if registered.suffix.lower() != ".parquet" or not registered.is_file(): + raise ValueError(f"mining artifact is unavailable: {name}") + if registered.stat().st_size > _MAX_ARTIFACT_BYTES: + raise ValueError(f"mining artifact exceeds size limit: {name}") + return pl.read_parquet(registered) + + +def _project_result( + manifest: Mapping[str, Any], + summary: Mapping[str, Any], + frames: Mapping[str, pl.DataFrame], +) -> dict[str, Any]: + factors = [_clean_record(row) for row in frames["factors"].to_dicts()] + correlation = _project_correlation(frames["correlation"]) + fold_records = [_project_fold(row) for row in frames["folds"].to_dicts()] + candidates = _project_candidates(frames["candidates"], fold_records) + selected_signature = candidates[0]["signature"] if candidates else None + folds = [ + _public_fold(row) + for row in fold_records + if row["regime_state"] == "overall" + and (selected_signature is None or row["candidate_signature"] == selected_signature) + ] + regimes = _project_regimes(fold_records, selected_signature) + worker = summary.get("worker") if isinstance(summary.get("worker"), Mapping) else {} + threshold = _finite((manifest.get("request") or {}).get("correlation_threshold")) + correlation["threshold"] = threshold if threshold is not None else 0.75 + return { + "run_id": manifest["run_id"], + "methodology_version": summary.get("methodology_version") or "factor_v2", + "algorithm_version": summary.get("algorithm_version") or MINING_ALGORITHM_VERSION, + "data_as_of": summary.get("data_as_of"), + "request_summary": _request_summary(manifest), + "summary": _summary_projection(summary), + "factors": factors, + "correlation": correlation, + "regimes": regimes, + "candidates": candidates, + "folds": folds, + "telemetry": { + "elapsed_ms": _finite(summary.get("elapsed_ms")), + "peak_rss_bytes": _optional_int(worker.get("peak_rss_bytes")), + "panel_scans": _optional_int(summary.get("panel_scans")), + "matrix_bytes": _optional_int(summary.get("matrix_bytes")), + "serialized_result_bytes": _optional_int(worker.get("serialized_result_bytes")), + "phase_ms": _finite_mapping(summary.get("phase_ms")), + }, + } + + +def _project_correlation(frame: pl.DataFrame) -> dict[str, Any]: + required = {"factor_x", "factor_y", "rho", "pair_count"} + if not required.issubset(frame.columns): + raise ValueError("correlation artifact schema is invalid") + labels = sorted(set(frame["factor_x"].to_list()) | set(frame["factor_y"].to_list())) + positions = {str(label): index for index, label in enumerate(labels)} + matrix: list[list[float | None]] = [[None for _ in labels] for _ in labels] + counts: list[list[int | None]] = [[None for _ in labels] for _ in labels] + for row in frame.iter_rows(named=True): + left = positions[str(row["factor_x"])] + right = positions[str(row["factor_y"])] + matrix[left][right] = _finite(row["rho"]) + counts[left][right] = _optional_int(row["pair_count"]) + return {"labels": labels, "matrix": matrix, "pair_counts": counts} + + +def _project_fold(row: Mapping[str, Any]) -> dict[str, Any]: + projected = _clean_record(row) + projected["selected_factors"] = _json_string_list(row.get("selected_factors_json")) + projected["candidate_signature"] = row.get("candidate_signature") + projected["regime_state"] = str(row.get("regime_state") or "overall") + projected["n_dates"] = int(row.get("n_dates") or 0) + return projected + + +def _public_fold(row: Mapping[str, Any]) -> dict[str, Any]: + return { + key: row.get(key) + for key in ( + "fold", "label", "train_start", "train_end", "test_start", "test_end", + "selected_factors", "total_return", "sharpe", "max_drawdown", "n_trades", + "skipped", "reason", "evaluation_kind", + ) + } + + +def _project_candidates( + frame: pl.DataFrame, + folds: Sequence[Mapping[str, Any]], +) -> list[dict[str, Any]]: + required = {"signature", "name", "kind", "factor_names_json", "confidence"} + if not required.issubset(frame.columns): + raise ValueError("candidates artifact schema is invalid") + candidates = [] + for row in frame.to_dicts(): + candidate = _clean_record(row) + candidate.pop("definition_json", None) + candidate.pop("factor_names_json", None) + candidate["factor_names"] = _json_string_list(row.get("factor_names_json")) + signature = str(row["signature"]) + candidate["folds"] = [ + _public_fold(fold) + for fold in folds + if fold["regime_state"] == "overall" + and fold["candidate_signature"] == signature + ] + gate = evaluate_candidate_gate( + confidence=row.get("confidence"), + valid_folds=row.get("valid_folds"), + positive_fold_ratio=row.get("oos_positive_fold_ratio"), + sharpe=row.get("oos_sharpe"), + max_drawdown=row.get("oos_max_drawdown"), + n_trades=row.get("oos_n_trades"), + ) + candidate["gate"] = { + "qualified": gate.qualified, + "reasons": list(gate.reasons), + } + candidates.append(candidate) + candidates.sort( + key=lambda item: ( + -(item.get("oos_sharpe") if item.get("oos_sharpe") is not None else -math.inf), + str(item["signature"]), + ) + ) + return candidates + + +def _project_regimes( + folds: Sequence[Mapping[str, Any]], + signature: str | None, +) -> list[dict[str, Any]]: + labels = {"overall": "整体", "strong": "强势", "range": "震荡", "weak": "弱势"} + result = [] + for state in ("overall", "strong", "range", "weak"): + rows = [ + row + for row in folds + if row["regime_state"] == state + and (signature is None or row["candidate_signature"] == signature) + and not row.get("skipped") + ] + result.append({ + "state": state, + "label": labels[state], + "n_dates": sum(int(row.get("n_dates") or 0) for row in rows), + "total_return": _mean(row.get("total_return") for row in rows), + "sharpe": _mean(row.get("sharpe") for row in rows), + "max_drawdown": _minimum(row.get("max_drawdown") for row in rows), + }) + return result + + +def _validate_selected_strategies( + strategy_engine: Any, + strategy_ids: Sequence[str], + asset_type: str, +) -> None: + for strategy_id in strategy_ids: + strategy = strategy_engine.get(strategy_id) + if strategy.meta.get("research_only"): + raise ValueError(f"research template cannot be mined as existing: {strategy_id}") + if strategy.execution_backend != "matrix_native": + raise ValueError(f"mining strategy is not matrix-native: {strategy_id}") + if "1d" not in strategy.meta.get("timeframes", ["1d"]): + raise ValueError(f"mining strategy is not daily-compatible: {strategy_id}") + if asset_type not in strategy.meta.get("asset_types", ["stock"]): + raise ValueError(f"mining strategy does not support {asset_type}: {strategy_id}") + + +def _event_cursor(value: str | None) -> int: + if value in (None, ""): + return 0 + try: + cursor = int(value) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Last-Event-ID must be an integer") from exc + if cursor < 0: + raise HTTPException(status_code=400, detail="Last-Event-ID must be non-negative") + return cursor + + +def _json_string_list(value: Any) -> list[str]: + if not isinstance(value, str): + return [] + parsed = json.loads(value) + if not isinstance(parsed, list) or any(not isinstance(item, str) for item in parsed): + raise ValueError("artifact JSON list is invalid") + return parsed + + +def _clean_record(row: Mapping[str, Any]) -> dict[str, Any]: + return { + str(key): (_finite(value) if isinstance(value, float) else value) + for key, value in row.items() + } + + +def _finite(value: Any) -> float | None: + if value is None: + return None + try: + number = float(value) + except (TypeError, ValueError): + return None + return number if math.isfinite(number) else None + + +def _optional_int(value: Any) -> int | None: + number = _finite(value) + return int(number) if number is not None else None + + +def _finite_mapping(value: Any) -> dict[str, float] | None: + if not isinstance(value, Mapping): + return None + return { + str(key): number + for key, item in value.items() + if (number := _finite(item)) is not None + } + + +def _mean(values: Sequence[Any] | Any) -> float | None: + finite = [number for value in values if (number := _finite(value)) is not None] + return sum(finite) / len(finite) if finite else None + + +def _minimum(values: Sequence[Any] | Any) -> float | None: + finite = [number for value in values if (number := _finite(value)) is not None] + return min(finite) if finite else None diff --git a/backend/app/api/monitor_rules.py b/backend/app/api/monitor_rules.py index 16a08c2..0d9286d 100644 --- a/backend/app/api/monitor_rules.py +++ b/backend/app/api/monitor_rules.py @@ -77,10 +77,12 @@ class RuleModel(BaseModel): id: str name: str enabled: bool = True - type: str # strategy | signal | price | market | sector + type: str # strategy | signal | price | market | sector | abnormal asset_type: str = "stock" # stock | etf (etf: strategy 型走 ETF 历史加载器) - scope: str = "symbols" # symbols | all | sector + scope: str = "symbols" # symbols | all | sector | watchlist_group symbols: list[str] = [] + # watchlist_group 作用域: 绑定的自选分组 id (成员动态解析, 增删自选自动生效) + group_id: str | None = None sector: str | None = None sector_kind: str | None = None # index | concept | industry sector_targets: list[SectorTargetModel] = [] @@ -88,8 +90,10 @@ class RuleModel(BaseModel): threshold_pct: float = 1.0 window_minutes: int = 5 strategy_id: str | None = None - direction: str = "entry" # entry | exit | both + direction: str = "entry" # entry | exit | both | (sector/ladder/abnormal: up|down|both) notify_events: list[str] | None = None + score_min: float | None = None + score_max: float | None = None conditions: list[ConditionModel] = [] logic: str = "and" # and | or cooldown_seconds: int = 3600 @@ -98,6 +102,8 @@ class RuleModel(BaseModel): webhook_enabled: bool = False # 兼容老规则 (已由 webhook_channels 取代, 仅做向后兼容读) webhook_channels: list[str] = [] # 命中时推送的外部渠道 (合法值 'feishu' | 'wecom') message: str = "" + # abnormal 专属 (异动边缘监控): any | 3d | 10d | 30d + abnormal_window: str = "any" # ladder 专属 (连板梯队封单监控) metric: str = "sealed_vol" # sealed_vol=封单量(手) | sealed_amount=封单额(元) threshold: float = 0 # 封单 <= 此值时报警 (原始单位: 量=手, 额=元) @@ -152,10 +158,12 @@ def get_options(request: Request): {"key": "price", "label": "价格/涨跌"}, {"key": "market", "label": "市场异动"}, {"key": "strategy", "label": "策略监控"}, + {"key": "abnormal", "label": "异动监控"}, {"key": "sector", "label": "板块监控"}, ], "scopes": [ {"key": "symbols", "label": "指定标的"}, + {"key": "watchlist_group", "label": "自选分组"}, {"key": "all", "label": "全市场"}, {"key": "sector", "label": "板块"}, ], @@ -222,6 +230,18 @@ def list_rules(request: Request): rule["runtime_warning"] = "部分板块数据已不存在, 请重新选择监控对象" elif unavailable: rule["runtime_warning"] = "所选指数未加入实时指数池, 请先在实时监控设置中启用" + # 分组作用域规则: 绑定的分组被删除 → 标注运行时警告 (引擎侧已 fail-closed 跳过) + group_rules = [rule for rule in rules if rule.get("scope") == "watchlist_group"] + if group_rules: + from app.services import watchlist as watchlist_service + + try: + existing_ids = {g["id"] for g in watchlist_service.list_groups()} + for rule in group_rules: + if rule.get("group_id") not in existing_ids: + rule["runtime_warning"] = "绑定的自选分组已删除, 规则已暂停监控, 编辑可重新选择" + except Exception: # noqa: BLE001 + pass # 按 created_at 倒序 rules.sort(key=lambda r: r.get("created_at", ""), reverse=True) return {"rules": rules} @@ -268,6 +288,17 @@ def save_rule(req: RuleModel, request: Request): monitor_rules.validate(rule) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e + if rule.get("scope") == "watchlist_group": + # 绑定的分组必须存在 (strategy 层校验形状, 存在性在本层校验) + from app.services import watchlist as watchlist_service + + group_id = str(rule.get("group_id") or "") + try: + group_ids = {g["id"] for g in watchlist_service.list_groups()} + except Exception as e: # noqa: BLE001 + raise HTTPException(status_code=503, detail=f"自选分组读取失败: {e}") from e + if group_id not in group_ids: + raise HTTPException(status_code=400, detail="自选分组不存在或已被删除, 请重新选择") if rule.get("type") == "sector": sector_service = getattr(request.app.state, "sector_monitor_service", None) if sector_service is None: @@ -317,7 +348,6 @@ def delete_rule(rule_id: str, request: Request): # ── 演示数据生成 (仅 Dev 页用) ───────────────────────── import time as _time -from datetime import datetime, timezone def _demo_rule(rule_id: str, name: str, rtype: str, scope: str, symbols: list[str], @@ -614,7 +644,7 @@ def trigger_ladder(request: Request): # 1. 落盘到 alerts.jsonl try: alert_store.append_many(repo.store.data_dir, rule_events) - except Exception as e: # noqa: BLE001 + except Exception: # noqa: BLE001 pass # 落盘失败不阻断推送 # 2. SSE 推送 (入 pending_alerts 队列) diff --git a/backend/app/api/regime.py b/backend/app/api/regime.py index f484033..57b9bd7 100644 --- a/backend/app/api/regime.py +++ b/backend/app/api/regime.py @@ -7,8 +7,9 @@ from __future__ import annotations import threading import time from datetime import date -from typing import Any +from typing import Annotated, Any +import polars as pl from fastapi import APIRouter, Query, Request from app.services import regime_builder @@ -142,6 +143,8 @@ def regime_recompute(request: Request, start: date | None = None, end: date | No 与 daily_pipeline 的增量补差(compute_regime_incremental)不同 —— 此接口面向 人工「我要重新算一遍」的预期, 必须真正重算而非增量补缺口。 - 传 start: 仅重算 [start, end] 区间。 + - 重算后统一重标情绪周期阶段(refresh_phase_labels)并回填主线 + (概念+行业, 概念成分为当前快照回看历史, 早年有归属漂移)。 """ repo = request.app.state.repo data_dir = _data_dir(request) @@ -156,5 +159,201 @@ def regime_recompute(request: Request, start: date | None = None, end: date | No new_rows = regime_builder.run_regime_batch(repo, start=start, end=end) if not new_rows.is_empty(): regime_builder.upsert_regime_history(data_dir, new_rows) + phase_days = regime_builder.refresh_phase_labels(data_dir) + + from app.services import market_mainline + + mainline_rows = 0 + for kind in ("concept", "industry"): + rows = market_mainline.compute_mainline_range(repo, data_dir, start, end, kind=kind) + if not rows.is_empty(): + market_mainline.upsert_mainline_history(data_dir, rows) + mainline_rows += rows.height + invalidate_regime_cache() - return {"ok": True, "computed": new_rows.height if not new_rows.is_empty() else 0} + return { + "ok": True, + "computed": new_rows.height if not new_rows.is_empty() else 0, + "phase_days": phase_days, + "mainline_rows": mainline_rows, + } + + +@router.get("/phases") +def regime_phases( + request: Request, + start: date | None = None, + end: date | None = None, +): + """情绪周期阶段段列表: 连续同阶段合段, 附段内均值指标与主导主线。 + + 直接回答「什么阶段走什么主升」: 主升/高潮段的主导主线即该段行情主线。 + 主线按段内进入当日 top5 的天数与累计分排序, 取前 3。 + """ + from app.services.market_mainline import load_mainline_history + from app.services.market_phase import PHASE_LABELS + + data_dir = _data_dir(request) + df = regime_builder.load_regime_history(data_dir) + if df.is_empty() or "phase" not in df.columns: + return {"segments": [], "total": 0} + if start: + df = df.filter(pl_col_date(df, ">=", start)) + if end: + df = df.filter(pl_col_date(df, "<=", end)) + df = df.sort("date") + if df.is_empty(): + return {"segments": [], "total": 0} + + mainline = load_mainline_history(data_dir, "concept") + + segments: list[dict] = [] + cur: dict | None = None + for r in df.iter_rows(named=True): + phase = r.get("phase") + if cur is None or cur["phase"] != phase: + cur = { + "phase": phase, + "label": PHASE_LABELS.get(phase, phase), + "start": str(r["date"]), + "end": str(r["date"]), + "days": 0, + "_height": 0.0, + "_first_board": 0.0, + "_ge2": 0.0, + "_promo_sum": 0.0, + "_promo_n": 0, + "_seal": 0.0, + } + segments.append(cur) + cur["end"] = str(r["date"]) + cur["days"] += 1 + cur["_height"] += float(r.get("max_consecutive") or 0) + cur["_first_board"] += float(r.get("first_board") or 0) + cur["_ge2"] += float(r.get("ge2_count") or 0) + promo = r.get("promo_rate") + if promo is not None: + cur["_promo_sum"] += float(promo) + cur["_promo_n"] += 1 + cur["_seal"] += float(r.get("seal_rate") or 0) + + for seg in segments: + n = seg["days"] + seg["avg_height"] = round(seg.pop("_height") / n, 1) + seg["avg_first_board"] = round(seg.pop("_first_board") / n, 1) + seg["avg_ge2"] = round(seg.pop("_ge2") / n, 1) + seg["avg_promo"] = ( + round(seg.pop("_promo_sum") / seg["_promo_n"], 3) if seg["_promo_n"] else None + ) + seg.pop("_promo_n") + seg["avg_seal_rate"] = round(seg.pop("_seal") / n, 3) + seg["top_mainlines"] = _segment_mainlines( + mainline, date.fromisoformat(seg["start"]), date.fromisoformat(seg["end"]) + ) + + return {"segments": segments, "total": len(segments)} + + +def _segment_mainlines(mainline: pl.DataFrame, start: date, end: date, top: int = 3) -> list[dict]: + """段内主导主线: 按进入当日 top5 的天数与累计分排序。""" + if mainline.is_empty(): + return [] + seg = mainline.filter( + (pl.col("date") >= start) & (pl.col("date") <= end) & (pl.col("rank") <= 5) + ) + if seg.is_empty(): + return [] + ranked = ( + seg.group_by("member") + .agg( + pl.col("date").n_unique().alias("top5_days"), + pl.col("score").sum().alias("score_sum"), + pl.col("max_boards").max().alias("max_boards"), + pl.col("leader_symbol").first().alias("leader_symbol"), + ) + .sort(["top5_days", "score_sum"], descending=[True, True]) + .head(top) + ) + return [ + { + "member": r["member"], + "top5_days": r["top5_days"], + "score_sum": round(r["score_sum"], 1), + "max_boards": r["max_boards"], + "leader_symbol": r["leader_symbol"], + } + for r in ranked.to_dicts() + ] + + +@router.post("/mainline/recompute") +def mainline_recompute(request: Request): + """全量重算主线(概念+行业), 应用当前过滤配置。窄扫描, 秒级。 + + 修改过滤配置(preferences mainline-filter)后调用本接口生效, + 无需触发较重的 regime 全量重算。 + """ + from app.services import market_mainline + + repo = request.app.state.repo + data_dir = _data_dir(request) + earliest = regime_builder.earliest_enriched_date(repo) + if earliest is None: + return {"ok": True, "rows": 0} + rows = 0 + for kind in ("concept", "industry"): + computed = market_mainline.compute_mainline_range( + repo, data_dir, earliest, date.today(), kind=kind + ) + if not computed.is_empty(): + market_mainline.upsert_mainline_history(data_dir, computed) + rows += computed.height + return {"ok": True, "rows": rows} + + +@router.get("/mainline") +def regime_mainline( + request: Request, + start: date | None = None, + end: date | None = None, + top: Annotated[int, Query(ge=1, le=30)] = 10, + kind: Annotated[str, Query(pattern="^(concept|industry)$")] = "concept", +): + """每日主线排行(截 rank<=top) + 窗口内持续性汇总。 + + membership_note 说明概念成分口径(当前快照回看历史)。 + """ + from app.services.market_mainline import MEMBERSHIP_NOTE, load_mainline_history + + try: + from app.services import preferences + + filter_cfg = preferences.get_mainline_filter_config() + except Exception: + filter_cfg = {"min_members": 4, "max_members": 600, "blacklist": []} + df = load_mainline_history(_data_dir(request), kind) + if df.is_empty(): + return {"rows": [], "leaders": [], "membership_note": MEMBERSHIP_NOTE, "filter": filter_cfg} + if start: + df = df.filter(pl_col_date(df, ">=", start)) + if end: + df = df.filter(pl_col_date(df, "<=", end)) + df = df.sort(["date", "rank"]) + rows_df = df.filter(pl.col("rank") <= top) + leaders = ( + df.filter(pl.col("rank") == 1) + .group_by("member") + .agg( + pl.col("date").n_unique().alias("top1_days"), + pl.col("score").mean().round(1).alias("avg_score"), + pl.col("max_boards").max().alias("max_boards"), + ) + .sort(["top1_days", "avg_score"], descending=[True, True]) + .head(10) + ) + return { + "rows": _df_to_records(rows_df), + "leaders": leaders.to_dicts(), + "membership_note": MEMBERSHIP_NOTE, + "filter": filter_cfg, + } diff --git a/backend/app/api/screener.py b/backend/app/api/screener.py index b05a5f1..784d105 100644 --- a/backend/app/api/screener.py +++ b/backend/app/api/screener.py @@ -238,6 +238,8 @@ def strategies( raise HTTPException(status_code=503, detail="策略引擎未初始化") presets = [] for meta in engine.list_strategies(): + if meta.get("research_only"): + continue if asset_type not in meta.get("asset_types", ["stock"]): continue if timeframe not in meta.get("timeframes", ["1d"]): @@ -292,6 +294,8 @@ def run_preset(req: PresetRequest, request: Request): try: if not engine.has(req.strategy_id): raise ValueError(f"unknown strategy: {req.strategy_id}") + if engine.get(req.strategy_id).meta.get("research_only"): + raise ValueError(f"unknown strategy: {req.strategy_id}") params = dict(overrides.get("params") or {}) context = svc.build_strategy_context( engine, @@ -520,14 +524,19 @@ def run_all(request: Request, body: Optional[dict] = None): requested_ids = body.get("strategy_ids") if requested_ids and isinstance(requested_ids, list): all_ids = [str(sid) for sid in requested_ids] - unknown = [sid for sid in all_ids if not engine.has(sid)] + unknown = [ + sid + for sid in all_ids + if not engine.has(sid) or engine.get(sid).meta.get("research_only") + ] if unknown: raise HTTPException(status_code=404, detail=f"unknown strategies: {unknown}") else: all_ids = [ meta["id"] for meta in engine.list_strategies() - if asset_type in meta.get("asset_types", ["stock"]) + if not meta.get("research_only") + and asset_type in meta.get("asset_types", ["stock"]) and timeframe in meta.get("timeframes", ["1d"]) ] @@ -707,7 +716,9 @@ def limit_ladder( sealed_ready = False sealed_age: float | None = None if depth_svc: - sealed_map = depth_svc.get_sealed_map(as_of, is_down=is_down) + # 复用上方双方向计数已读取的 sealed map: 同一请求、同一 as_of、同一对象, + # 不再第三次读取 (内存路径含全量浅拷贝, parquet 路径含整文件读)。 + sealed_map = down_map if is_down else up_map sealed_ready = bool(sealed_map) and depth_svc.is_sealed_ready(as_of) sealed_age = depth_svc.get_sealed_age(as_of) if sealed_ready else None diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 6528466..a5747ed 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -6,9 +6,10 @@ from __future__ import annotations import logging import time +from typing import Literal from fastapi import APIRouter, HTTPException, Request -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from app import secrets_store from app.data_providers.custom.config import MAX_TIMEOUT @@ -58,7 +59,10 @@ def get_settings() -> dict: ai_configured, current_ai_model, current_codex_command, + current_codex_model, current_codex_reasoning_effort, + current_openai_model, + current_openai_reasoning_effort, ) key = secrets_store.get_tickflow_key() @@ -81,6 +85,9 @@ def get_settings() -> dict: "has_ai_key": bool(secrets_store.get_ai_key()), "ai_configured": ai_configured(ai_provider), "ai_model": current_ai_model(), + "ai_openai_model": current_openai_model(), + "ai_reasoning_effort": current_openai_reasoning_effort(), + "ai_codex_model": current_codex_model(), "ai_codex_command": current_codex_command(), "ai_codex_reasoning_effort": current_codex_reasoning_effort(), "ai_user_agent": secrets_store.get_ai_config("ai_user_agent", settings.ai_user_agent), @@ -240,6 +247,7 @@ class AiSettingsIn(BaseModel): base_url: str = "" api_key: str | None = None model: str = "" + reasoning_effort: str = Field(default="high", max_length=64) codex_command: str = "" codex_reasoning_effort: str = "" user_agent: str = "" @@ -250,12 +258,17 @@ def save_ai_settings(req: AiSettingsIn) -> dict: """保存 AI 配置(全部持久化到 secrets.json)""" from app.config import settings from app.services.ai_provider import ( + OPENAI_PROVIDER, ai_configured, current_ai_model, current_ai_provider, current_codex_command, + current_codex_model, current_codex_reasoning_effort, + current_openai_model, + current_openai_reasoning_effort, normalize_codex_command, + normalize_codex_model, normalize_codex_reasoning_effort, ) @@ -263,23 +276,8 @@ def save_ai_settings(req: AiSettingsIn) -> dict: if req.provider: updates["ai_provider"] = req.provider settings.ai_provider = req.provider - if req.base_url: - updates["ai_base_url"] = req.base_url - settings.ai_base_url = req.base_url - if req.api_key is not None: - if req.api_key: - updates["ai_api_key"] = req.api_key - settings.ai_api_key = req.api_key - else: - secrets_store.clear("ai_api_key") - settings.ai_api_key = "" - if req.provider == "codex_cli" and not req.model: - secrets_store.clear("ai_model") - settings.ai_model = "" - elif req.model: - updates["ai_model"] = req.model - settings.ai_model = req.model if req.provider == "codex_cli": + updates["ai_codex_model"] = normalize_codex_model(req.model) try: codex_command = normalize_codex_command(req.codex_command) except ValueError as exc: @@ -289,6 +287,22 @@ def save_ai_settings(req: AiSettingsIn) -> dict: updates["ai_codex_reasoning_effort"] = codex_reasoning_effort settings.ai_codex_command = codex_command settings.ai_codex_reasoning_effort = codex_reasoning_effort + else: + if req.base_url: + updates["ai_base_url"] = req.base_url + settings.ai_base_url = req.base_url + if req.api_key is not None: + if req.api_key: + updates["ai_api_key"] = req.api_key + settings.ai_api_key = req.api_key + else: + secrets_store.clear("ai_api_key") + settings.ai_api_key = "" + if req.model: + updates["ai_model"] = req.model + settings.ai_model = req.model + if req.provider == OPENAI_PROVIDER: + updates["ai_reasoning_effort"] = req.reasoning_effort.strip() # user_agent 允许清空(回到默认浏览器 UA),故无条件持久化 updates["ai_user_agent"] = req.user_agent settings.ai_user_agent = req.user_agent @@ -301,6 +315,9 @@ def save_ai_settings(req: AiSettingsIn) -> dict: "ok": True, "ai_provider": provider, "ai_model": current_ai_model(), + "ai_openai_model": current_openai_model(), + "ai_reasoning_effort": current_openai_reasoning_effort(), + "ai_codex_model": current_codex_model(), "ai_codex_command": current_codex_command(), "ai_codex_reasoning_effort": current_codex_reasoning_effort(), "ai_configured": ai_configured(provider), @@ -315,7 +332,16 @@ def clear_ai_settings() -> dict: """ from app.config import settings - secrets_store.clear("ai_provider", "ai_base_url", "ai_api_key", "ai_model", "ai_codex_command", "ai_codex_reasoning_effort") + secrets_store.clear( + "ai_provider", + "ai_base_url", + "ai_api_key", + "ai_model", + "ai_reasoning_effort", + "ai_codex_model", + "ai_codex_command", + "ai_codex_reasoning_effort", + ) # 同步重置运行时内存(provider 回默认值,其余置空) settings.ai_provider = "openai_compat" settings.ai_base_url = "" @@ -350,6 +376,11 @@ class DataProvidersIn(BaseModel): financial_data_provider: str | None = None +class DataSourceJobTimeoutPrefs(BaseModel): + data_source_job_timeout_s: int = Field(ge=60) + data_source_long_job_timeout_s: int = Field(ge=60) + + class DatasetFieldMapItem(BaseModel): source: str target: str @@ -397,6 +428,14 @@ class CustomSourceTestIn(BaseModel): config: CustomSourceIn | None = None +class MiningSchedulePrefs(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + + mining_schedule_enabled: bool + mining_schedule_weekday: int = Field(ge=0, le=4) + mining_budget_profile: Literal["balanced", "strict"] + + @router.get("/preferences") def get_preferences() -> dict: """返回用户偏好设置。""" @@ -405,6 +444,7 @@ def get_preferences() -> dict: "realtime_quotes_enabled": preferences.get_realtime_quotes_enabled(), "realtime_allowed": _realtime_allowed(), "indices_nav_pinned": preferences.get_indices_nav_pinned(), + "watchlist_groups_in_nav": preferences.get_watchlist_groups_in_nav(), "minute_sync_enabled": preferences.get_minute_sync_enabled(), "minute_sync_days": preferences.get_minute_sync_days(), "minute_sync_segment_days": preferences.get_minute_sync_segment_days(), @@ -413,6 +453,8 @@ def get_preferences() -> dict: "minute_data_provider": preferences.get_minute_data_provider(), "realtime_data_provider": preferences.get_realtime_data_provider(), "financial_data_provider": preferences.get_financial_provider(), + "data_source_job_timeout_s": preferences.get_data_source_job_timeout_s(), + "data_source_long_job_timeout_s": preferences.get_data_source_long_job_timeout_s(), "realtime_watchlist_symbols": preferences.get_realtime_watchlist_symbols(), **preferences.get_realtime_quote_scope(), "pipeline_pull_a_share": preferences.get_pipeline_pull_a_share(), @@ -452,6 +494,7 @@ def get_preferences() -> dict: "depth_finalize_time": preferences.get_depth_finalize_time(), "review_schedule": preferences.get_review_schedule(), "review_push_channels": preferences.get_review_push_channels(), + **preferences.get_mining_schedule(), } @@ -547,7 +590,7 @@ def save_data_source(req: CustomSourceIn) -> dict: @router.delete("/data-sources/{name}") -def delete_data_source(name: str) -> dict: +def delete_data_source(name: str, request: Request) -> dict: """删除一个自定义数据源 yaml, 保存后自动 reload。 若当前总开关选中的就是被删的源, 回退到 tickflow。 @@ -572,6 +615,8 @@ def delete_data_source(name: str) -> dict: updates["adj_factor_provider"] = "same_as_daily" if updates: preferences.save(updates) + # 删除源可能触发偏好回退 tickflow, 同步刷新能力快照 + request.app.state.capabilities = detect_capabilities() return list_data_sources() @@ -601,12 +646,14 @@ def test_data_source(req: CustomSourceTestIn) -> dict: @router.put("/preferences/data-providers") -def update_data_providers(req: DataProvidersIn) -> dict: +def update_data_providers(req: DataProvidersIn, request: Request) -> dict: """保存数据源选择。""" from app.services import preferences updates = req.model_dump(exclude_none=True) if updates: preferences.save(updates) + # 刷新能力快照: 当前 provider 变化会改变自定义源能力增广结果 (读缓存, 无网络请求) + request.app.state.capabilities = detect_capabilities() return { "daily_data_provider": preferences.get_daily_data_provider(), "adj_factor_provider": preferences.get_adj_factor_provider(), @@ -616,6 +663,26 @@ def update_data_providers(req: DataProvidersIn) -> dict: } +@router.put("/preferences/data-source-job-timeouts") +def update_data_source_job_timeouts(req: DataSourceJobTimeoutPrefs) -> dict: + """保存普通与长数据后台任务的卡死判定时间。""" + from app.services import preferences + preferences.save(req.model_dump()) + return req.model_dump() + + +@router.put("/preferences/mining-schedule") +def update_mining_schedule(req: MiningSchedulePrefs) -> dict: + """一次更新周度自动 mining 配置。""" + from app.services import preferences + + return preferences.set_mining_schedule( + req.mining_schedule_enabled, + req.mining_schedule_weekday, + req.mining_budget_profile, + ) + + @router.get("/preferences/watchlist-columns") def get_watchlist_columns() -> dict: """返回自选列表列配置。""" @@ -718,6 +785,19 @@ def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict: """ from app.services import preferences qs = getattr(request.app.state, "quote_service", None) + depth_svc = getattr(request.app.state, "depth_service", None) + + def _sync_depth_polling(realtime_on: bool) -> None: + """实时行情开关联动 depth 盘中轮询: 开→恢复(仍受监控开关/能力门控), 关→立即停。 + + 实时行情关闭时 enriched 停留在上一交易日, depth 轮询只会反复拉陈旧名单。 + """ + if not depth_svc: + return + if realtime_on: + depth_svc.start_polling() + else: + depth_svc.stop_polling() allowed = qs.is_realtime_allowed() if qs else True if req.realtime_quotes_enabled and not allowed: @@ -725,12 +805,14 @@ def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict: preferences.save({"realtime_quotes_enabled": False}) if qs: qs.disable() + _sync_depth_polling(False) return {"realtime_quotes_enabled": False, "realtime_allowed": False} if req.realtime_quotes_enabled and qs and qs.is_paused(): # 管道/数据修正运行期间禁止开启实时行情 — 防止写盘竞态 raise HTTPException(status_code=409, detail="数据同步运行中,实时行情已临时暂停,请稍后再开启") if req.realtime_quotes_enabled and qs and qs.realtime_mode() == "watchlist" and not preferences.get_realtime_watchlist_symbols(): preferences.save({"realtime_quotes_enabled": False}) + _sync_depth_polling(False) return {"realtime_quotes_enabled": False, "realtime_allowed": True, "mode": "watchlist", "error": "watchlist_empty"} preferences.save({"realtime_quotes_enabled": req.realtime_quotes_enabled}) @@ -739,6 +821,7 @@ def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict: qs.enable() else: qs.disable() + _sync_depth_polling(req.realtime_quotes_enabled) return {"realtime_quotes_enabled": req.realtime_quotes_enabled, "realtime_allowed": allowed} @@ -776,6 +859,18 @@ def update_indices_nav_pinned(req: IndicesNavPinnedPrefs) -> dict: return {"indices_nav_pinned": req.indices_nav_pinned} +class WatchlistGroupsInNavPrefs(BaseModel): + watchlist_groups_in_nav: bool + + +@router.put("/preferences/watchlist-groups-in-nav") +def update_watchlist_groups_in_nav(req: WatchlistGroupsInNavPrefs) -> dict: + """保存自选分组是否显示在侧边栏开关。""" + from app.services import preferences + preferences.save({"watchlist_groups_in_nav": req.watchlist_groups_in_nav}) + return {"watchlist_groups_in_nav": req.watchlist_groups_in_nav} + + class RealtimeMonitorConfigIn(BaseModel): sse_refresh_pages: dict[str, bool] | None = None strategy_monitor_enabled: bool | None = None @@ -874,6 +969,27 @@ class PipelineIndexSymbolsIn(BaseModel): symbols: str = "" +class MainlineFilterIn(BaseModel): + """市场主线过滤配置(宽基/风格标签按成员数过滤 + 名称黑名单 + ST 剔除开关)。""" + + min_members: int | None = None + max_members: int | None = None + blacklist: list[str] | str | None = None + exclude_st: bool | None = None + + +@router.put("/preferences/mainline-filter") +def update_mainline_filter(req: MainlineFilterIn) -> dict: + """更新市场主线过滤配置。部分更新; 修改后需重算主线(POST /api/regime/mainline/recompute)生效。 + + exclude_st 同步控制市场环境(regime)统计口径 — 切换后需全量重算 regime。 + """ + from app.services import preferences + + payload = req.model_dump() + return preferences.set_mainline_filter_config(payload) + + @router.put("/preferences/pipeline-index-symbols") def update_pipeline_index_symbols(req: PipelineIndexSymbolsIn) -> dict: """保存指数自定义拉取代码。""" diff --git a/backend/app/api/strategy.py b/backend/app/api/strategy.py index 6e40c1c..53803fc 100644 --- a/backend/app/api/strategy.py +++ b/backend/app/api/strategy.py @@ -24,6 +24,11 @@ from app.strategy.ai_generator import AIStrategyGenerator, find_meta_assignment from app.strategy.engine import StrategyDef, StrategyEngine from app.strategy.monitor import StrategyMonitorService from app.strategy.prompt_builder import build_step1, build_step2 +from app.strategy.scoring import ( + SCORING_DIRECTIONS, + effective_scoring, + effective_scoring_directions, +) router = APIRouter(prefix="/api/strategies", tags=["strategies"]) logger = logging.getLogger(__name__) @@ -38,6 +43,16 @@ def _get_engine(request: Request) -> StrategyEngine: return engine +def _get_public_strategy(engine: StrategyEngine, strategy_id: str) -> StrategyDef: + try: + strategy = engine.get(strategy_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + if strategy.meta.get("research_only"): + raise HTTPException(status_code=404, detail=f"unknown strategy: {strategy_id}") + return strategy + + def _get_monitor(request: Request) -> StrategyMonitorService: mon = getattr(request.app.state, "strategy_monitor", None) if not mon: @@ -136,14 +151,13 @@ def _strategy_detail( ) -> dict: """策略详情(含用户覆盖)""" bf = {**s.basic_filter} - scoring = dict(s.meta.get("scoring", {})) + scoring = effective_scoring(s.meta.get("scoring"), overrides) + scoring_directions = effective_scoring_directions(overrides) params_defaults = {p["id"]: p["default"] for p in s.meta.get("params", [])} if overrides: if overrides.get("basic_filter"): bf.update(overrides["basic_filter"]) - if overrides.get("scoring"): - scoring.update(overrides["scoring"]) # 用户保存的参数覆盖默认值: 合并进 params_defaults, 前端据此回显 if overrides.get("params"): params_defaults.update(overrides["params"]) @@ -166,6 +180,11 @@ def _strategy_detail( "params": s.meta.get("params", []), "params_defaults": params_defaults, "scoring": scoring, + "scoring_directions": { + name: direction + for name, direction in scoring_directions.items() + if name in scoring + }, "entry_signals": overrides.get("entry_signals", s.entry_signals) if overrides else s.entry_signals, "exit_signals": overrides.get("exit_signals", s.exit_signals) if overrides else s.exit_signals, "minute_exit_trigger_supported_signals": sorted(MINUTE_EXIT_TRIGGER_SIGNALS), @@ -175,7 +194,6 @@ def _strategy_detail( "trailing_take_profit_activate": getattr(s, "trailing_take_profit_activate", None), "trailing_take_profit_drawdown": getattr(s, "trailing_take_profit_drawdown", None), "max_hold_days": overrides.get("max_hold_days", s.max_hold_days) if overrides else s.max_hold_days, - "alerts": s.alerts, "order_by": s.meta.get("order_by", "score"), "descending": s.meta.get("descending", True), "limit": s.meta.get("limit", 30), @@ -280,6 +298,8 @@ def list_strategies( result = [] for meta in engine.list_strategies(): + if meta.get("research_only"): + continue if asset_type and asset_type not in meta.get("asset_types", ["stock"]): continue if timeframe and timeframe not in meta.get("timeframes", ["1d"]): @@ -294,10 +314,7 @@ def list_strategies( @router.get("/{strategy_id}") def get_strategy(strategy_id: str, request: Request): engine = _get_engine(request) - try: - s = engine.get(strategy_id) - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) from e + s = _get_public_strategy(engine, strategy_id) overrides = strategy_config.load_override(_data_dir(request), strategy_id) return _strategy_detail(s, overrides or None, engine) @@ -308,6 +325,7 @@ def get_strategy(strategy_id: str, request: Request): @router.post("/run") def run_strategy(req: RunRequest, request: Request): engine = _get_engine(request) + _get_public_strategy(engine, req.strategy_id) data_dir = _data_dir(request) # 读取用户覆盖配置 @@ -369,7 +387,8 @@ def run_all(req: RunAllRequest, request: Request): strategy_ids = [ meta["id"] for meta in engine.list_strategies() - if req.asset_type in meta.get("asset_types", ["stock"]) + if not meta.get("research_only") + and req.asset_type in meta.get("asset_types", ["stock"]) and req.timeframe in meta.get("timeframes", ["1d"]) ] from app.services.screener import ScreenerService @@ -404,9 +423,9 @@ def run_all(req: RunAllRequest, request: Request): @router.post("/config") def save_config(req: SaveConfigRequest, request: Request): engine = _get_engine(request) - if not engine.has(req.strategy_id): - raise HTTPException(status_code=404, detail=f"策略 {req.strategy_id} 不存在") + _get_public_strategy(engine, req.strategy_id) + _validate_scoring_config(req.overrides) # 剥离与策略默认值相同的字段,只保存用户真正修改过的值 overrides = _strip_defaults(req.strategy_id, req.overrides, engine) @@ -414,6 +433,43 @@ def save_config(req: SaveConfigRequest, request: Request): return {"ok": True} +@router.patch("/config") +def patch_config(req: SaveConfigRequest, request: Request): + engine = _get_engine(request) + _get_public_strategy(engine, req.strategy_id) + data_dir = _data_dir(request) + overrides = strategy_config.load_override(data_dir, req.strategy_id) + overrides.update(req.overrides) + _validate_scoring_config(overrides) + strategy_config.save_override( + data_dir, + req.strategy_id, + _strip_defaults(req.strategy_id, overrides, engine), + ) + return {"ok": True} + + +def _validate_scoring_config(overrides: dict) -> None: + scoring = overrides.get("scoring") + if scoring is not None: + if not isinstance(scoring, dict): + raise HTTPException(status_code=400, detail="评分权重必须是对象") + for name, weight in scoring.items(): + if not isinstance(name, str) or not name: + raise HTTPException(status_code=400, detail="评分因子名称无效") + if isinstance(weight, bool) or not isinstance(weight, (int, float)) or not math.isfinite(weight) or weight < 0: + raise HTTPException(status_code=400, detail=f"评分因子 {name} 的权重必须是非负数") + directions = overrides.get("scoring_directions") + if directions is not None: + if not isinstance(directions, dict): + raise HTTPException(status_code=400, detail="评分方向必须是对象") + invalid = [name for name, direction in directions.items() if direction not in SCORING_DIRECTIONS] + if invalid: + raise HTTPException(status_code=400, detail=f"评分因子 {invalid[0]} 的方向无效") + if "scoring_replace" in overrides and not isinstance(overrides["scoring_replace"], bool): + raise HTTPException(status_code=400, detail="scoring_replace 必须是布尔值") + + def _strip_defaults(strategy_id: str, overrides: dict, engine) -> dict: """剥离与策略默认值相同的字段,避免默认值被固化到 override 中。 @@ -444,6 +500,7 @@ def _strip_defaults(strategy_id: str, overrides: dict, engine) -> dict: @router.delete("/config/{strategy_id}") def reset_config(strategy_id: str, request: Request): + _get_public_strategy(_get_engine(request), strategy_id) strategy_config.delete_override(_data_dir(request), strategy_id) return {"ok": True} @@ -708,10 +765,7 @@ def get_strategy_source(strategy_id: str, request: Request): # 先查 StrategyEngine 获取文件路径 engine = _get_engine(request) - try: - s = engine.get(strategy_id) - except ValueError: - raise HTTPException(status_code=404, detail=f"策略 {strategy_id} 不存在") + s = _get_public_strategy(engine, strategy_id) path = s.file_path if not path or not path.exists(): @@ -917,14 +971,12 @@ def _save_composite_strategy(req: StrategyCompositeSaveRequest, request: Request children = [{"strategy_id": c.strategy_id, "weight": c.weight} for c in req.children] # 子策略存在性预检(给出清晰错误, 而非等到 reload 后孤儿移除的笼统报错)。 for c in children: - if not engine.has(c["strategy_id"]): - raise ValueError(f"子策略 {c['strategy_id']!r} 不存在") try: - child_def = engine.get(c["strategy_id"]) - if child_def.execution_backend == "composite": - raise ValueError(f"子策略 {c['strategy_id']!r} 也是叠加策略; 首版禁止嵌套叠加") - except ValueError: - raise + child_def = _get_public_strategy(engine, c["strategy_id"]) + except HTTPException as exc: + raise ValueError(f"子策略 {c['strategy_id']!r} 不存在") from exc + if child_def.execution_backend == "composite": + raise ValueError(f"子策略 {c['strategy_id']!r} 也是叠加策略; 首版禁止嵌套叠加") code = _render_composite_code( sid, req.name, req.description, children, req.merge_mode, req.min_confirm diff --git a/backend/app/api/watchlist.py b/backend/app/api/watchlist.py index 8780b16..afd5ed3 100644 --- a/backend/app/api/watchlist.py +++ b/backend/app/api/watchlist.py @@ -36,11 +36,26 @@ _OCR_LIMITER = anyio.CapacityLimiter(2) class AddRequest(BaseModel): symbol: str note: str = "" + group_id: str | None = None class BatchAddRequest(BaseModel): symbols: list[str] note: str = "" + group_id: str | None = None + + +class GroupNameRequest(BaseModel): + name: str + color: str | None = None + + +class GroupReorderRequest(BaseModel): + ordered_ids: list[str] + + +class GroupAssignRequest(BaseModel): + group_id: str | None = None def _with_names(rows: list[dict], request: Request) -> list[dict]: @@ -64,20 +79,74 @@ def list_all(request: Request): @router.post("") def add_one(req: AddRequest, request: Request): - rows = watchlist.add(req.symbol, req.note) + try: + rows = watchlist.add(req.symbol, req.note, req.group_id) + except ValueError as e: + raise HTTPException(400, str(e)) from e return {"symbols": _with_names(rows, request)} @router.post("/batch") def add_batch(req: BatchAddRequest, request: Request): - existing = {r["symbol"] for r in watchlist.list_symbols()} - added = 0 - for sym in req.symbols: - if sym not in existing: - added += 1 - existing.add(sym) - watchlist.add(sym, req.note) - return {"symbols": _with_names(watchlist.list_symbols(), request), "added": added} + try: + rows, added = watchlist.add_batch(req.symbols, req.note, req.group_id) + except ValueError as e: + raise HTTPException(400, str(e)) from e + return {"symbols": _with_names(rows, request), "added": added} + + +@router.get("/groups") +def list_groups(): + return {"groups": watchlist.list_groups()} + + +@router.post("/groups") +def create_group(req: GroupNameRequest): + try: + groups, group = watchlist.create_group(req.name, req.color) + except ValueError as e: + raise HTTPException(400, str(e)) from e + return {"groups": groups, "group": group} + + +@router.put("/groups/reorder") +def reorder_groups(req: GroupReorderRequest): + """重排分组前后顺序 (json 数组顺序即定义顺序, 侧边栏/标签栏/分组视图共用)。""" + try: + groups = watchlist.reorder_groups(req.ordered_ids) + except ValueError as e: + raise HTTPException(400, str(e)) from e + return {"groups": groups} + + +@router.put("/groups/{group_id}") +def rename_group(group_id: str, req: GroupNameRequest): + try: + groups = watchlist.rename_group(group_id, req.name, req.color) + except KeyError as e: + raise HTTPException(404, "自选分组不存在") from e + except ValueError as e: + raise HTTPException(400, str(e)) from e + return {"groups": groups} + + +@router.delete("/groups/{group_id}") +def delete_group(group_id: str, request: Request): + try: + groups, rows = watchlist.delete_group(group_id) + except KeyError as e: + raise HTTPException(404, "自选分组不存在") from e + return {"groups": groups, "symbols": _with_names(rows, request)} + + +@router.post("/groups/{group_id}/clear") +def clear_group(group_id: str, request: Request): + """清空分组成员:把该分组内所有股票转为未分组,保留分组定义。""" + try: + rows = watchlist.clear_group(group_id) + except KeyError as e: + raise HTTPException(404, "自选分组不存在") from e + return {"symbols": _with_names(rows, request)} @router.get("/ocr-status") @@ -131,6 +200,42 @@ def move_one_to_top(symbol: str, request: Request): return {"symbols": _with_names(rows, request)} +@router.put("/{symbol}/group") +def assign_group(symbol: str, req: GroupAssignRequest, request: Request): + """互斥设定分组(仅保留此组; None=移出全部分组)。多组操作用 members 端点。""" + try: + rows = watchlist.set_group(symbol, req.group_id) + except KeyError as e: + raise HTTPException(404, "自选标的不存在") from e + except ValueError as e: + raise HTTPException(400, str(e)) from e + return {"symbols": _with_names(rows, request)} + + +@router.post("/groups/{group_id}/members/{symbol}") +def add_member(group_id: str, symbol: str, request: Request): + """把标的加入分组(多组成员关系: 不影响其他分组)。""" + try: + rows = watchlist.add_to_group(symbol, group_id) + except KeyError as e: + raise HTTPException(404, "自选标的不存在") from e + except ValueError as e: + raise HTTPException(400, str(e)) from e + return {"symbols": _with_names(rows, request)} + + +@router.delete("/groups/{group_id}/members/{symbol}") +def remove_member(group_id: str, symbol: str, request: Request): + """把标的移出分组(仅摘本组标签; 标的仍在自选, 可能落入未分组)。""" + try: + rows = watchlist.remove_from_group(symbol, group_id) + except KeyError as e: + raise HTTPException(404, "自选标的不存在") from e + except ValueError as e: + raise HTTPException(400, str(e)) from e + return {"symbols": _with_names(rows, request)} + + @router.delete("/{symbol}") def remove_one(symbol: str, request: Request): rows = watchlist.remove(symbol) @@ -146,7 +251,7 @@ def clear_all(): # 自选页需要的列 _WATCHLIST_COLS = [ - "symbol", "close", "change_pct", "change_amount", "amount", + "symbol", "close", "open", "high", "low", "change_pct", "change_amount", "amount", "turnover_rate", "amplitude", "annual_vol_20d", "vol_ratio_5d", @@ -159,6 +264,7 @@ _WATCHLIST_COLS = [ "boll_upper", "boll_lower", "atr_14", "momentum_5d", "momentum_10d", "momentum_20d", "momentum_30d", "momentum_60d", + "deviate_3d", "deviate_10d", "deviate_30d", "consecutive_limit_ups", "consecutive_limit_downs", "signal_limit_up", "signal_limit_down", "signal_volume_surge", "signal_ma_golden_5_20", "signal_macd_golden", "signal_n_day_high", diff --git a/backend/app/backtest/candidates.py b/backend/app/backtest/candidates.py new file mode 100644 index 0000000..52b55f2 --- /dev/null +++ b/backend/app/backtest/candidates.py @@ -0,0 +1,376 @@ +"""量化研究候选方案的轻量本地存储。""" + +from __future__ import annotations + +import json +import os +import threading +import uuid +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Literal + +CandidateKind = Literal["factor", "strategy"] +CandidateStatus = Literal["pending", "validated", "rejected"] + +MAX_CANDIDATES = 200 +MAX_NAME_LENGTH = 80 +MAX_PAYLOAD_BYTES = 32 * 1024 +MAX_FILE_BYTES = 2 * 1024 * 1024 + +_MINING_SOURCE_CONFIG_FIELDS = frozenset( + { + "origin_run_id", + "candidate_signature", + "regime_state", + "algorithm_version", + "methodology_version", + } +) +_CONFIG_FIELDS: dict[str, frozenset[str]] = { + "factor": frozenset( + { + "factor_name", + "symbols", + "start", + "end", + "n_groups", + "rebalance", + "weight", + "fees_pct", + "slippage_bps", + "asset_type", + } + ) + | _MINING_SOURCE_CONFIG_FIELDS, + "strategy": frozenset( + { + "strategy_id", + "symbols", + "start", + "end", + "params", + "overrides", + "matching", + "entry_fill", + "exit_fill", + "fees_pct", + "commission_pct", + "stamp_tax_pct", + "slippage_bps", + "max_positions", + "max_exposure_pct", + "initial_capital", + "position_sizing", + "mode", + "holding_days", + "asset_type", + "minute_fill", + "regime_filter", + "factor_names", + "directions", + "weights", + } + ) + | _MINING_SOURCE_CONFIG_FIELDS, +} +_MINING_METRIC_FIELDS = frozenset( + { + "oos_sharpe", + "oos_return", + "oos_max_drawdown", + "oos_positive_fold_ratio", + "oos_n_trades", + "valid_folds", + "skipped_folds", + "confidence", + "coverage", + "turnover", + "long_short_sharpe", + } +) +_METRIC_FIELDS: dict[str, frozenset[str]] = { + "factor": frozenset( + { + "ic_mean", + "ic_std", + "ir", + "ic_win_rate", + "long_short_return", + "long_short_max_drawdown", + "n_symbols", + "n_dates", + "elapsed_ms", + } + ) + | _MINING_METRIC_FIELDS, + "strategy": frozenset( + { + "total_return", + "annual_return", + "max_drawdown", + "sharpe", + "sortino", + "win_rate", + "n_trades", + "profit_factor", + "avg_return", + "median_return", + "elapsed_ms", + } + ) + | _MINING_METRIC_FIELDS, +} +_lock = threading.RLock() + + +class CandidateStoreError(RuntimeError): + pass + + +class CandidateValidationError(CandidateStoreError): + pass + + +class CandidateStore: + def __init__(self, data_dir: Path) -> None: + self.path = Path(data_dir) / "user_data" / "research_candidates.json" + + def list(self) -> list[dict[str, Any]]: + with _lock: + return self._load() + + def create( + self, + *, + kind: CandidateKind, + name: str, + source_id: str, + config: dict[str, Any], + metrics: dict[str, Any], + data_as_of: str | None, + status: CandidateStatus = "pending", + ) -> dict[str, Any]: + clean_name = self._validate_name(name) + clean_source_id = source_id.strip() + if not clean_source_id or len(clean_source_id) > 120: + raise CandidateValidationError("候选来源标识不能为空且不能超过 120 个字符") + clean_config = self._validate_config(kind, config) + clean_metrics = self._validate_metrics(kind, metrics) + + with _lock: + items = self._load() + if len(items) >= MAX_CANDIDATES: + raise CandidateValidationError(f"候选方案最多保存 {MAX_CANDIDATES} 个") + now = datetime.now(UTC).isoformat() + item = { + "id": uuid.uuid4().hex, + "kind": kind, + "name": clean_name, + "source_id": clean_source_id, + "config": clean_config, + "metrics": clean_metrics, + "data_as_of": data_as_of, + "status": status, + "created_at": now, + "updated_at": now, + } + items.insert(0, item) + self._write(items) + return item + + def create_or_get_by_provenance( + self, + *, + origin_run_id: str, + candidate_signature: str, + kind: CandidateKind, + name: str, + source_id: str, + config: dict[str, Any], + metrics: dict[str, Any], + data_as_of: str | None, + status: CandidateStatus = "pending", + ) -> dict[str, Any]: + """Atomically return or create one item for a mining run candidate.""" + clean_name = self._validate_name(name) + clean_source_id = source_id.strip() + if not clean_source_id or len(clean_source_id) > 120: + raise CandidateValidationError("候选来源标识不能为空且不能超过 120 个字符") + clean_config = self._validate_config(kind, config) + clean_metrics = self._validate_metrics(kind, metrics) + if ( + clean_config.get("origin_run_id") != origin_run_id + or clean_config.get("candidate_signature") != candidate_signature + ): + raise CandidateValidationError("候选来源与配置中的挖掘溯源不一致") + + with _lock: + items = self._load() + for item in items: + item_config = item.get("config") or {} + if ( + item_config.get("origin_run_id") == origin_run_id + and item_config.get("candidate_signature") == candidate_signature + ): + if ( + item.get("kind") == kind + and item.get("source_id") == clean_source_id + and item_config == clean_config + and item.get("metrics") == clean_metrics + and item.get("data_as_of") == data_as_of + ): + return item + raise CandidateValidationError( + "相同挖掘溯源的候选内容冲突, 已停止覆盖" + ) + if len(items) >= MAX_CANDIDATES: + raise CandidateValidationError(f"候选方案最多保存 {MAX_CANDIDATES} 个") + now = datetime.now(UTC).isoformat() + item = { + "id": uuid.uuid4().hex, + "kind": kind, + "name": clean_name, + "source_id": clean_source_id, + "config": clean_config, + "metrics": clean_metrics, + "data_as_of": data_as_of, + "status": status, + "created_at": now, + "updated_at": now, + } + items.insert(0, item) + self._write(items) + return item + + def update( + self, + candidate_id: str, + *, + name: str | None = None, + status: CandidateStatus | None = None, + ) -> dict[str, Any]: + with _lock: + items = self._load() + for item in items: + if item["id"] != candidate_id: + continue + if name is not None: + item["name"] = self._validate_name(name) + if status is not None: + item["status"] = status + item["updated_at"] = datetime.now(UTC).isoformat() + self._write(items) + return item + raise KeyError(candidate_id) + + def delete(self, candidate_id: str) -> None: + with _lock: + items = self._load() + remaining = [item for item in items if item["id"] != candidate_id] + if len(remaining) == len(items): + raise KeyError(candidate_id) + self._write(remaining) + + def _load(self) -> list[dict[str, Any]]: + if not self.path.exists(): + return [] + try: + if self.path.stat().st_size > MAX_FILE_BYTES: + raise CandidateStoreError("候选方案文件过大, 已停止读取") + raw = json.loads(self.path.read_text(encoding="utf-8")) + except CandidateStoreError: + raise + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise CandidateStoreError("候选方案文件损坏或无法读取, 未执行覆盖写入") from exc + if not isinstance(raw, list): + raise CandidateStoreError("候选方案文件格式无效, 未执行覆盖写入") + return [item for value in raw if (item := self._normalize(value)) is not None] + + def _write(self, items: list[dict[str, Any]]) -> None: + payload = json.dumps(items, ensure_ascii=False, indent=2, allow_nan=False) + self.path.parent.mkdir(parents=True, exist_ok=True) + temporary = self.path.with_suffix(".json.tmp") + try: + with temporary.open("w", encoding="utf-8") as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, self.path) + except OSError as exc: + temporary.unlink(missing_ok=True) + raise CandidateStoreError("候选方案保存失败") from exc + + @staticmethod + def _normalize(value: Any) -> dict[str, Any] | None: + if not isinstance(value, dict): + return None + kind = value.get("kind") + if kind not in _CONFIG_FIELDS or not isinstance(value.get("id"), str): + return None + raw_config = value.get("config") if isinstance(value.get("config"), dict) else {} + config = {key: item for key, item in raw_config.items() if key in _CONFIG_FIELDS[kind]} + raw_metrics = value.get("metrics") if isinstance(value.get("metrics"), dict) else {} + metrics = { + key: item + for key, item in raw_metrics.items() + if key in _METRIC_FIELDS[kind] and not isinstance(item, (dict, list)) + } + source_id = value.get("source_id") or config.get(f"{kind}_name") or config.get(f"{kind}_id") + if not isinstance(source_id, str) or not source_id: + return None + status = value.get("status") + if status not in {"pending", "validated", "rejected"}: + status = "pending" + return { + "id": value["id"], + "kind": kind, + "name": str(value.get("name") or source_id)[:MAX_NAME_LENGTH], + "source_id": source_id, + "config": config, + "metrics": metrics, + "data_as_of": value.get("data_as_of"), + "status": status, + "created_at": str(value.get("created_at") or ""), + "updated_at": str(value.get("updated_at") or value.get("created_at") or ""), + } + + @staticmethod + def _validate_name(name: str) -> str: + clean = name.strip() + if not clean: + raise CandidateValidationError("候选名称不能为空") + if len(clean) > MAX_NAME_LENGTH: + raise CandidateValidationError(f"候选名称不能超过 {MAX_NAME_LENGTH} 个字符") + return clean + + @staticmethod + def _validate_config(kind: CandidateKind, config: dict[str, Any]) -> dict[str, Any]: + unknown = set(config) - _CONFIG_FIELDS[kind] + if unknown: + raise CandidateValidationError( + f"候选配置包含不允许的字段: {', '.join(sorted(unknown))}" + ) + CandidateStore._check_json_size(config) + return config + + @staticmethod + def _validate_metrics(kind: CandidateKind, metrics: dict[str, Any]) -> dict[str, Any]: + unknown = set(metrics) - _METRIC_FIELDS[kind] + if unknown: + raise CandidateValidationError( + f"候选指标包含不允许的字段: {', '.join(sorted(unknown))}" + ) + if any(isinstance(value, (dict, list)) for value in metrics.values()): + raise CandidateValidationError("候选指标只允许保存标量摘要") + CandidateStore._check_json_size(metrics) + return metrics + + @staticmethod + def _check_json_size(value: dict[str, Any]) -> None: + try: + payload = json.dumps(value, ensure_ascii=False, allow_nan=False) + except (TypeError, ValueError) as exc: + raise CandidateValidationError("候选内容无法序列化") from exc + if len(payload.encode("utf-8")) > MAX_PAYLOAD_BYTES: + raise CandidateValidationError("候选内容超过 32KB 限制") diff --git a/backend/app/backtest/engine.py b/backend/app/backtest/engine.py index 7a6cdb7..b464462 100644 --- a/backend/app/backtest/engine.py +++ b/backend/app/backtest/engine.py @@ -26,6 +26,7 @@ from app.backtest.matrix import ( load_market_data_matrix_from_parquet, ) from app.config import settings +from app.enriched_generation import EnrichedGenerationUnavailableError from app.parquet import scan_enriched_parquet from app.tickflow.repository import KlineRepository @@ -213,8 +214,11 @@ class PanelCache: columns: list[str] | None, compute_fn, asset_type: str = "stock", + generation: str | None = None, ) -> pl.DataFrame: - key = self._make_key(symbols, start, end, columns, asset_type) + key = self._make_key( + symbols, start, end, columns, asset_type, generation + ) now = time.monotonic() with self._lock: @@ -280,13 +284,20 @@ class PanelCache: self._cache.clear() @staticmethod - def _make_key(symbols: list[str] | None, start: date, end: date, columns: list[str] | None, asset_type: str = "stock") -> str: + def _make_key( + symbols: list[str] | None, + start: date, + end: date, + columns: list[str] | None, + asset_type: str = "stock", + generation: str | None = None, + ) -> str: if symbols is None: h = "all" else: h = hashlib.md5(",".join(sorted(symbols)).encode()).hexdigest()[:12] cols = "all" if columns is None else hashlib.md5(",".join(sorted(columns)).encode()).hexdigest()[:8] - return f"{asset_type}:{h}:{start}:{end}:{cols}" + return f"{asset_type}:{generation or 'unmanaged'}:{h}:{start}:{end}:{cols}" # ================================================================ @@ -302,6 +313,23 @@ class BacktestEngine: # ── 数据加载 ────────────────────────────────────── + def data_generation(self, asset_type: str = "stock") -> str | None: + loader = getattr(self.repo, "get_matrix_data_generation", None) + return loader(asset_type) if callable(loader) else None + + def assert_data_generation( + self, + asset_type: str, + expected: str | None, + ) -> None: + if expected is None: + return + current = self.data_generation(asset_type) + if current != expected: + raise EnrichedGenerationUnavailableError( + "enriched data changed while the snapshot was being read" + ) + def load_panel( self, symbols: list[str] | None, @@ -309,9 +337,36 @@ class BacktestEngine: end: date, columns: list[str] | None = None, asset_type: str = "stock", + *, + expected_generation: str | None = None, ) -> pl.DataFrame: """加载 enriched 数据面板,带缓存。asset_type='etf' 时读 ETF enriched。""" - return self._cache.get_or_compute(symbols, start, end, columns, self._load_panel_inner, asset_type=asset_type) + attempts = 1 if expected_generation is not None else 2 + for attempt in range(attempts): + generation = ( + expected_generation + if expected_generation is not None + else self.data_generation(asset_type) + ) + panel = self._cache.get_or_compute( + symbols, + start, + end, + columns, + self._load_panel_inner, + asset_type=asset_type, + generation=generation, + ) + try: + self.assert_data_generation(asset_type, generation) + except EnrichedGenerationUnavailableError: + if attempt + 1 >= attempts: + raise + continue + return panel + raise EnrichedGenerationUnavailableError( + "unable to read a stable enriched data snapshot" + ) def load_panel_for_backtest( self, @@ -338,6 +393,25 @@ class BacktestEngine: if df.is_empty(): return df + from app.backtest.fundamentals import ( + attach_fundamental_factors, + load_fundamental_snapshot, + ) + + fundamental_names = sorted( + getattr(feature_plan, "fundamental_columns", frozenset()) + or frozenset() + ) + if fundamental_names: + # 财务因子列不落 enriched 存储, 在加载口按公告日门控并入。 + df = attach_fundamental_factors( + df, + load_fundamental_snapshot( + self.repo.store.data_dir if self.repo is not None else None + ), + fundamental_names, + ) + instruments = ( self.repo.get_instruments_asset(asset_type) if self.repo is not None @@ -402,6 +476,8 @@ class BacktestEngine: cache_profile: MatrixCacheProfile | None = None, coverage_start: date | None = None, coverage_end: date | None = None, + expected_generation: str | None = None, + cancel_event: threading.Event | None = None, ) -> MarketDataMatrix: """Load a matrix-native backtest directly from projected parquet batches.""" if feature_plan.execution_backend != "matrix_native": @@ -432,35 +508,67 @@ class BacktestEngine: ) generation_loader = getattr(self.repo, "get_matrix_data_generation", None) source_generation = ( - generation_loader(asset_type) - if cache_root is not None and callable(generation_loader) - else None - ) - try: - return load_market_data_matrix_from_parquet( - parquet_root, - start, - end, - field_columns=field_columns, - symbols=symbols, - instruments=instruments, - cache_root=cache_root, - coverage_start=coverage_start, - coverage_end=coverage_end, - cache_field_columns=cache_fields, - cache_max_bytes=cache_max_bytes, - profile_generation=( - cache_profile.generation if cache_profile is not None else "request" - ), - source_generation=source_generation, + expected_generation + if expected_generation is not None + else ( + generation_loader(asset_type) + if callable(generation_loader) + else None ) - except pa.ArrowException as exc: - raise ValueError(f"direct market matrix parquet scan failed: {exc}") from exc + ) + attempts = 1 if expected_generation is not None else 2 + for attempt in range(attempts): + try: + market = load_market_data_matrix_from_parquet( + parquet_root, + start, + end, + field_columns=field_columns, + symbols=symbols, + instruments=instruments, + cache_root=cache_root, + coverage_start=coverage_start, + coverage_end=coverage_end, + cache_field_columns=cache_fields, + cache_max_bytes=cache_max_bytes, + profile_generation=( + cache_profile.generation if cache_profile is not None else "request" + ), + source_generation=source_generation, + cancel_event=cancel_event, + ) + self.assert_data_generation(asset_type, source_generation) + from app.backtest.fundamentals import attach_matrix_fundamental_fields + + fundamental_names = sorted( + getattr(feature_plan, "fundamental_columns", frozenset()) + or frozenset() + ) + if fundamental_names: + # 财务因子不落 enriched 存储: 矩阵加载后按公告日门控附加字段。 + market = attach_matrix_fundamental_fields( + market, + self.repo.store.data_dir if self.repo is not None else None, + fundamental_names, + ) + return market + except EnrichedGenerationUnavailableError: + if attempt + 1 >= attempts: + raise + source_generation = self.data_generation(asset_type) + except pa.ArrowException as exc: + raise ValueError(f"direct market matrix parquet scan failed: {exc}") from exc + raise EnrichedGenerationUnavailableError( + "unable to read a stable enriched matrix snapshot" + ) def cache_stats(self) -> dict: """暴露 PanelCache 遥测快照 (扫盘耗时/次数/命中/复用), 供上层量化 IO 占比。""" return self._cache.stats() + def clear_panel_cache(self) -> None: + self._cache.invalidate() + def _load_panel_inner( self, symbols: list[str] | None, diff --git a/backend/app/backtest/factor.py b/backend/app/backtest/factor.py index 2b02b76..bc0f915 100644 --- a/backend/app/backtest/factor.py +++ b/backend/app/backtest/factor.py @@ -7,38 +7,110 @@ from __future__ import annotations import logging import time import uuid +from collections.abc import Mapping from dataclasses import dataclass, field from datetime import date, timedelta -from typing import Literal +from itertools import pairwise +from pathlib import Path +from typing import Any, Literal import numpy as np import polars as pl from app.backtest.engine import BacktestEngine +from app.backtest.fundamentals import ( + FUNDAMENTAL_FACTOR_NAMES, + attach_fundamental_factors, + load_fundamental_snapshot, +) +from app.strategy.scoring import ( + VIRTUAL_SCORING_DEPENDENCIES as DERIVED_FACTOR_DEPENDENCIES, +) +from app.strategy.scoring import ( + materialize_scoring_columns, +) logger = logging.getLogger(__name__) -# 可用因子列 (从 ENRICHED_COLUMNS 过滤出数值型指标) +# 可研究因子目录。保留历史 ID 兼容已有候选方案; 价格尺度相关指标优先提供归一化版本。 FACTOR_COLUMNS: list[dict] = [ - {"id": "momentum_5d", "label": "5日动量", "group": "动量", "desc": "5日涨跌幅,正值表示上涨趋势"}, - {"id": "momentum_10d", "label": "10日动量", "group": "动量", "desc": "10日涨跌幅,中短期趋势指标"}, - {"id": "momentum_20d", "label": "20日动量", "group": "动量", "desc": "月度涨跌幅,常用因子"}, - {"id": "momentum_30d", "label": "30日动量", "group": "动量", "desc": "30日涨跌幅"}, - {"id": "momentum_60d", "label": "60日动量", "group": "动量", "desc": "季度涨跌幅,中期动量"}, - {"id": "rsi_6", "label": "RSI(6)", "group": "超买超卖", "desc": "6日相对强弱指标,敏感度高"}, - {"id": "rsi_14", "label": "RSI(14)", "group": "超买超卖", "desc": "14日相对强弱指标,经典周期"}, - {"id": "rsi_24", "label": "RSI(24)", "group": "超买超卖", "desc": "24日相对强弱指标"}, - {"id": "annual_vol_20d","label": "20日波动率", "group": "波动率", "desc": "20日年化波动率"}, - {"id": "atr_14", "label": "ATR(14)", "group": "波动率", "desc": "14日平均真实波幅"}, - {"id": "vol_ratio_5d", "label": "量比(5日)", "group": "量价", "desc": "当日成交量 / 5日均量"}, - {"id": "turnover_rate", "label": "换手率", "group": "量价", "desc": "当日换手率"}, - {"id": "macd_hist", "label": "MACD柱", "group": "趋势", "desc": "MACD柱状图值"}, - {"id": "kdj_k", "label": "KDJ-K", "group": "趋势", "desc": "KDJ指标K值"}, - {"id": "change_pct", "label": "日涨跌幅", "group": "基础", "desc": "当日涨跌幅"}, - {"id": "amplitude", "label": "日振幅", "group": "基础", "desc": "当日振幅 (最高-最低)/昨收"}, + {"id": "momentum_5d", "label": "5日动量", "group": "动量", "desc": "5个交易日累计收益率"}, + {"id": "momentum_10d", "label": "10日动量", "group": "动量", "desc": "10个交易日累计收益率"}, + {"id": "momentum_20d", "label": "20日动量", "group": "动量", "desc": "20个交易日累计收益率"}, + {"id": "momentum_30d", "label": "30日动量", "group": "动量", "desc": "30个交易日累计收益率"}, + {"id": "momentum_60d", "label": "60日动量", "group": "动量", "desc": "60个交易日累计收益率"}, + {"id": "change_pct", "label": "日涨跌幅", "group": "动量", "desc": "当日收盘相对前收盘的收益率"}, + + {"id": "ma5_bias", "label": "MA5乖离", "group": "均线偏离", "desc": "收盘价 / MA5 - 1"}, + {"id": "ma10_bias", "label": "MA10乖离", "group": "均线偏离", "desc": "收盘价 / MA10 - 1"}, + {"id": "ma20_bias", "label": "MA20乖离", "group": "均线偏离", "desc": "收盘价 / MA20 - 1"}, + {"id": "ma30_bias", "label": "MA30乖离", "group": "均线偏离", "desc": "收盘价 / MA30 - 1"}, + {"id": "ma60_bias", "label": "MA60乖离", "group": "均线偏离", "desc": "收盘价 / MA60 - 1"}, + {"id": "ema5_bias", "label": "EMA5乖离", "group": "均线偏离", "desc": "收盘价 / EMA5 - 1"}, + {"id": "ema10_bias", "label": "EMA10乖离", "group": "均线偏离", "desc": "收盘价 / EMA10 - 1"}, + {"id": "ema20_bias", "label": "EMA20乖离", "group": "均线偏离", "desc": "收盘价 / EMA20 - 1"}, + {"id": "ema30_bias", "label": "EMA30乖离", "group": "均线偏离", "desc": "收盘价 / EMA30 - 1"}, + {"id": "ema60_bias", "label": "EMA60乖离", "group": "均线偏离", "desc": "收盘价 / EMA60 - 1"}, + + {"id": "rsi_6", "label": "RSI(6)", "group": "超买超卖", "desc": "6日相对强弱指标"}, + {"id": "rsi_14", "label": "RSI(14)", "group": "超买超卖", "desc": "14日相对强弱指标"}, + {"id": "rsi_24", "label": "RSI(24)", "group": "超买超卖", "desc": "24日相对强弱指标"}, + + {"id": "macd_hist", "label": "MACD柱(原值)", "group": "趋势", "desc": "兼容历史研究; 跨股票比较建议优先使用MACD柱强度"}, + {"id": "macd_dif_pct", "label": "MACD DIF强度", "group": "趋势", "desc": "MACD DIF / 收盘价"}, + {"id": "macd_dea_pct", "label": "MACD DEA强度", "group": "趋势", "desc": "MACD DEA / 收盘价"}, + {"id": "macd_hist_pct", "label": "MACD柱强度", "group": "趋势", "desc": "MACD柱 / 收盘价, 消除股价尺度影响"}, + {"id": "kdj_k", "label": "KDJ-K", "group": "趋势", "desc": "KDJ指标K值"}, + {"id": "kdj_d", "label": "KDJ-D", "group": "趋势", "desc": "KDJ指标D值"}, + {"id": "kdj_j", "label": "KDJ-J", "group": "趋势", "desc": "KDJ指标J值"}, + {"id": "boll_position", "label": "布林位置", "group": "趋势", "desc": "收盘价在布林带下轨到上轨之间的位置"}, + + {"id": "annual_vol_20d", "label": "20日波动率", "group": "波动率", "desc": "20日收益率年化标准差"}, + {"id": "atr_14", "label": "ATR(14)原值", "group": "波动率", "desc": "兼容历史研究; 跨股票比较建议优先使用ATR相对波动"}, + {"id": "atr_pct", "label": "ATR相对波动", "group": "波动率", "desc": "ATR(14) / 收盘价"}, + {"id": "amplitude", "label": "日振幅", "group": "波动率", "desc": "当日高低价差 / 前收盘价"}, + {"id": "boll_width", "label": "布林带宽", "group": "波动率", "desc": "布林带上下轨宽度 / MA20"}, + + {"id": "vol_ratio_5d", "label": "5日量比", "group": "量价", "desc": "当日成交量 / 前5日平均成交量"}, + {"id": "vol_ratio_10d", "label": "10日量比", "group": "量价", "desc": "当日成交量 / 前10日平均成交量"}, + {"id": "vol_trend_5_10", "label": "成交量趋势", "group": "量价", "desc": "5日平均成交量 / 10日平均成交量 - 1"}, + {"id": "turnover_rate", "label": "换手率", "group": "量价", "desc": "使用历史时点流通股本计算的当日换手率"}, + {"id": "turnover_ratio_5d", "label": "换手率放大", "group": "量价", "desc": "当日换手率 / 前5日平均换手率 - 1"}, + {"id": "log_amount", "label": "成交额对数", "group": "量价", "desc": "ln(成交额 + 1), 降低极端规模影响"}, + {"id": "amount_ratio_5d", "label": "成交额放大", "group": "量价", "desc": "当日成交额 / 前5日平均成交额 - 1"}, + + {"id": "gap_return", "label": "开盘跳空", "group": "价格位置", "desc": "开盘价 / 前收盘价 - 1"}, + {"id": "intraday_return", "label": "日内收益", "group": "价格位置", "desc": "收盘价 / 开盘价 - 1"}, + {"id": "close_position", "label": "收盘位置", "group": "价格位置", "desc": "收盘价在当日最低价到最高价之间的位置"}, + {"id": "distance_to_high_60d", "label": "距60日高点", "group": "价格位置", "desc": "收盘价 / 60日最高收盘价 - 1"}, + {"id": "distance_from_low_60d", "label": "距60日低点", "group": "价格位置", "desc": "收盘价 / 60日最低收盘价 - 1"}, + {"id": "vwap_bias", "label": "VWAP乖离", "group": "价格位置", "desc": "收盘价 / 当日成交均价 - 1, 成交均价 = 成交额 / (成交量x100)"}, + + {"id": "max_ret_20d", "label": "20日最大单日涨幅", "group": "收益形态", "desc": "近20个交易日单日涨幅最大值(彩票效应, 高值代表博彩型特征强)"}, + {"id": "ret_skew_20d", "label": "20日收益偏度", "group": "收益形态", "desc": "近20个交易日日收益偏度, 高值代表右偏(偶发大涨)"}, + {"id": "up_days_20d", "label": "20日上涨天数", "group": "收益形态", "desc": "近20个交易日中上涨天数(0~20)"}, + + {"id": "amihud_20d", "label": "20日Amihud非流动性", "group": "流动性", "desc": "近20日平均 |日涨跌幅| / 成交额(亿元), 高值代表流动性差"}, + {"id": "turnover_z_60d", "label": "换手率60日z分", "group": "流动性", "desc": "(当日换手率 - 前60日均值) / 前60日标准差, 衡量换手异动"}, + + {"id": "vol_price_corr_20d", "label": "20日量价相关", "group": "量价", "desc": "近20个交易日日涨跌幅与成交量的相关系数, 高值代表量价同向"}, + {"id": "vol_trend_5_60", "label": "量能趋势(5/60)", "group": "量价", "desc": "5日平均成交量 / 60日平均成交量 - 1"}, + + {"id": "limit_up_count_20d", "label": "涨停基因(20日)", "group": "涨停基因", "desc": "近20个交易日涨停次数"}, + {"id": "limit_up_count_60d", "label": "涨停基因(60日)", "group": "涨停基因", "desc": "近60个交易日涨停次数"}, + + {"id": "pb_latest", "label": "市净率(最新公告)", "group": "财务", "desc": "收盘价 / 最新已公告每股净资产; 无财务数据或公告前为空"}, + {"id": "roe_latest", "label": "ROE(最新公告)", "group": "财务", "desc": "最新已公告净资产收益率(%); 无财务数据或公告前为空"}, + {"id": "gross_margin_latest", "label": "毛利率(最新公告)", "group": "财务", "desc": "最新已公告销售毛利率(%)"}, + {"id": "net_margin_latest", "label": "净利率(最新公告)", "group": "财务", "desc": "最新已公告销售净利率(%)"}, + {"id": "revenue_yoy_latest", "label": "营收增速(最新公告)", "group": "财务", "desc": "最新已公告营业收入同比(%)"}, + {"id": "net_income_yoy_latest", "label": "净利增速(最新公告)", "group": "财务", "desc": "最新已公告归母净利润同比(%)"}, + {"id": "debt_ratio_latest", "label": "资产负债率(最新公告)", "group": "财务", "desc": "最新已公告资产负债率(%)"}, ] FACTOR_WARMUP_DAYS = 120 +FACTOR_METHODOLOGY_VERSION = "factor_v2" +_DAILY_FORWARD_HORIZONS = (1, 3, 5) @dataclass @@ -53,6 +125,8 @@ class FactorConfig: fees_pct: float = 0.0002 slippage_bps: float = 5.0 asset_type: str = "stock" + commission_pct: float | None = None + stamp_tax_pct: float | None = None @dataclass @@ -87,97 +161,508 @@ class FactorResult: n_symbols: int = 0 n_dates: int = 0 error: str | None = None + # factor_v2 兼容扩展字段必须追加在旧字段之后, 保留位置参数语义。 + methodology_version: str = FACTOR_METHODOLOGY_VERSION + coverage: float | None = None + turnover: float | None = None + long_short_sharpe: float | None = None + yearly_ic: list[dict] = field(default_factory=list) + ic_decay: list[dict] = field(default_factory=list) + regime_stats: list[dict] = field(default_factory=list) + + +@dataclass +class FactorBatchConfig: + factor_names: list[str] + symbols: list[str] | None + start: date + end: date + n_groups: int = 5 + rebalance: Literal["daily", "weekly", "monthly"] = "monthly" + weight: Literal["equal", "factor_weight"] = "equal" + fees_pct: float = 0.0002 + slippage_bps: float = 5.0 + asset_type: str = "stock" + commission_pct: float | None = None + stamp_tax_pct: float | None = None + + +@dataclass +class FactorBatchItem: + factor_name: str + label: str + group: str + ic_mean: float | None = None + ir: float | None = None + ic_win_rate: float | None = None + long_short_return: float | None = None + long_short_max_drawdown: float | None = None + n_symbols: int = 0 + n_dates: int = 0 + elapsed_ms: float = 0.0 + error: str | None = None + methodology_version: str = FACTOR_METHODOLOGY_VERSION + coverage: float | None = None + turnover: float | None = None + long_short_sharpe: float | None = None + yearly_ic: list[dict] = field(default_factory=list) + ic_decay: list[dict] = field(default_factory=list) + regime_stats: list[dict] = field(default_factory=list) + + +@dataclass +class FactorBatchResult: + run_id: str + config: dict + results: list[FactorBatchItem] = field(default_factory=list) + elapsed_ms: float = 0.0 + n_symbols: int = 0 + n_dates: int = 0 + error: str | None = None class FactorBacktestService: def __init__(self, engine: BacktestEngine) -> None: self.engine = engine - def run(self, config: FactorConfig) -> FactorResult: + def run( + self, + config: FactorConfig, + *, + regime_by_date: Mapping[object, Any] | None = None, + ) -> FactorResult: t0 = time.perf_counter() run_id = uuid.uuid4().hex[:10] + generation = self._data_generation(config.asset_type) + panel = self._load_factor_panel( + config, + [config.factor_name], + expected_generation=generation, + ) + if panel.is_empty(): + return self._error_result(config, run_id, t0, "无数据, 请检查日期范围或先运行盘后管道") - def _err(msg: str) -> FactorResult: - return FactorResult( - run_id=run_id, - config=self._config_to_dict(config), - error=msg, - elapsed_ms=(time.perf_counter() - t0) * 1000, + if config.factor_name in FUNDAMENTAL_FACTOR_NAMES and self._fundamentals_missing(): + return self._error_result( + config, run_id, t0, + "本地没有财务数据: 请先在数据页同步财务数据后再使用财务因子", ) - # 加载基础面板: 当前 enriched parquet 只持久化基础列, 指标因子可能需要运行时计算。 - panel_columns = ["symbol", "date", "open", "high", "low", "close", "volume", "turnover_rate"] - if config.factor_name not in panel_columns: - panel_columns.append(config.factor_name) + trading_dates = self._global_trading_dates(config) + self._assert_data_generation(config.asset_type, generation) + panel = self._attach_shared_next_return( + panel, + config, + trading_dates=trading_dates, + ) + evaluate_kwargs = ( + {"regime_by_date": regime_by_date} + if regime_by_date is not None + else {} + ) + return self._evaluate_panel( + panel, + config, + run_id, + t0, + market_trading_dates=trading_dates, + **evaluate_kwargs, + ) + + def run_batch( + self, + config: FactorBatchConfig, + *, + regime_by_date: Mapping[object, Any] | None = None, + ) -> FactorBatchResult: + """在同一份 Panel 上依次评估多个因子, 避免重复读取和计算指标。""" + t0 = time.perf_counter() + run_id = uuid.uuid4().hex[:10] + factor_names = list(dict.fromkeys(config.factor_names)) + result_config = self._batch_config_to_dict(config, factor_names) + if not factor_names: + return FactorBatchResult( + run_id=run_id, + config=result_config, + error="至少选择一个因子", + ) + + generation = self._data_generation(config.asset_type) + panel = self._load_factor_panel( + config, + factor_names, + expected_generation=generation, + ) + if panel.is_empty(): + return FactorBatchResult( + run_id=run_id, + config=result_config, + error="无数据, 请检查日期范围或先运行盘后管道", + elapsed_ms=round((time.perf_counter() - t0) * 1000, 1), + ) + + # P1: 预计算共享下期收益 (仅依赖 close/date/symbol), 避免每个因子重复 shift/调仓日 JOIN。 + trading_dates = self._global_trading_dates(config) + self._assert_data_generation(config.asset_type, generation) + panel = self._attach_shared_next_return( + panel, + config, + trading_dates=trading_dates, + ) + + metadata = {item["id"]: item for item in FACTOR_COLUMNS} + fundamentals_missing = ( + any(name in FUNDAMENTAL_FACTOR_NAMES for name in factor_names) + and self._fundamentals_missing() + ) + items: list[FactorBatchItem] = [] + for factor_name in factor_names: + item_t0 = time.perf_counter() + factor_config = FactorConfig( + factor_name=factor_name, + symbols=config.symbols, + start=config.start, + end=config.end, + n_groups=config.n_groups, + rebalance=config.rebalance, + weight=config.weight, + fees_pct=config.fees_pct, + commission_pct=config.commission_pct, + stamp_tax_pct=config.stamp_tax_pct, + slippage_bps=config.slippage_bps, + asset_type=config.asset_type, + ) + meta = metadata.get(factor_name, {}) + if factor_name in FUNDAMENTAL_FACTOR_NAMES and fundamentals_missing: + items.append(FactorBatchItem( + factor_name=factor_name, + label=str(meta.get("label", factor_name)), + group=str(meta.get("group", "")), + elapsed_ms=round((time.perf_counter() - item_t0) * 1000, 1), + error="本地没有财务数据: 请先在数据页同步财务数据后再使用财务因子", + )) + continue + try: + evaluate_kwargs = ( + {"regime_by_date": regime_by_date} + if regime_by_date is not None + else {} + ) + result = self._evaluate_panel( + panel, + factor_config, + f"{run_id}-{len(items) + 1}", + item_t0, + market_trading_dates=trading_dates, + **evaluate_kwargs, + ) + long_short = result.long_short_stats + items.append(FactorBatchItem( + factor_name=factor_name, + label=str(meta.get("label", factor_name)), + group=str(meta.get("group", "")), + ic_mean=result.ic_mean, + ir=result.ir, + ic_win_rate=result.ic_win_rate, + long_short_return=long_short.get("total_return"), + long_short_max_drawdown=long_short.get("max_drawdown"), + methodology_version=result.methodology_version, + coverage=result.coverage, + turnover=result.turnover, + long_short_sharpe=result.long_short_sharpe, + yearly_ic=result.yearly_ic, + ic_decay=result.ic_decay, + regime_stats=result.regime_stats, + n_symbols=result.n_symbols, + n_dates=result.n_dates, + elapsed_ms=result.elapsed_ms, + error=result.error, + )) + except Exception as exc: # 单因子失败不能中止整个筛选批次 + logger.exception("factor batch item failed: %s", factor_name) + items.append(FactorBatchItem( + factor_name=factor_name, + label=str(meta.get("label", factor_name)), + group=str(meta.get("group", "")), + elapsed_ms=round((time.perf_counter() - item_t0) * 1000, 1), + error=str(exc), + )) + + n_symbols = max((item.n_symbols for item in items), default=0) + n_dates = max((item.n_dates for item in items), default=0) + return FactorBatchResult( + run_id=run_id, + config=result_config, + results=items, + elapsed_ms=round((time.perf_counter() - t0) * 1000, 1), + n_symbols=n_symbols, + n_dates=n_dates, + ) + + def _data_generation(self, asset_type: str) -> str | None: + loader = getattr(self.engine, "data_generation", None) + return loader(asset_type) if callable(loader) else None + + def _fundamentals_missing(self) -> bool: + return load_fundamental_snapshot(self._fundamentals_data_dir()) is None + + def _fundamentals_data_dir(self) -> Path | None: + repo = getattr(self.engine, "repo", None) + return getattr(getattr(repo, "store", None), "data_dir", None) + + def _assert_data_generation( + self, + asset_type: str, + expected: str | None, + ) -> None: + verifier = getattr(self.engine, "assert_data_generation", None) + if callable(verifier): + verifier(asset_type, expected) + + def _load_factor_panel( + self, + config: FactorConfig | FactorBatchConfig, + factor_names: list[str], + *, + expected_generation: str | None = None, + ) -> pl.DataFrame: + panel_columns = [ + "symbol", "date", "open", "high", "low", "close", "volume", "amount", + "turnover_rate", + ] + if any( + name in ("limit_up_count_20d", "limit_up_count_60d") + for name in factor_names + ): + panel_columns.append("consecutive_limit_ups") load_start = config.start - if config.factor_name not in {"turnover_rate"}: + if any(name != "turnover_rate" for name in factor_names): load_start = config.start - timedelta(days=FACTOR_WARMUP_DAYS) + load_kwargs = { + "columns": panel_columns, + "asset_type": config.asset_type, + } + if expected_generation is not None: + load_kwargs["expected_generation"] = expected_generation panel = self.engine.load_panel( config.symbols, load_start, config.end, - columns=panel_columns, - asset_type=config.asset_type, + **load_kwargs, ) if panel.is_empty(): - return _err("无数据,请检查日期范围或先运行盘后管道") + return panel + + missing = set(factor_names) - set(panel.columns) + if missing: + panel = self._compute_missing_factors(panel, missing) + fundamental_names = [name for name in factor_names if name in FUNDAMENTAL_FACTOR_NAMES] + if fundamental_names: + # 点时财务因子: 公告日门控, 无数据标的保持 null (不参与该日截面)。 + panel = attach_fundamental_factors( + panel, + load_fundamental_snapshot(self._fundamentals_data_dir()), + fundamental_names, + ) + return panel + + def _global_trading_dates( + self, + config: FactorConfig | FactorBatchConfig, + ) -> list[date] | None: + """Read the market date axis from enriched partitions, independent of symbols.""" + repo = getattr(self.engine, "repo", None) + data_dir = getattr(getattr(repo, "store", None), "data_dir", None) + if data_dir is None: + return None + from app.tickflow.repository import enriched_dirname + + values: list[date] = [] + root = data_dir / enriched_dirname(config.asset_type) + for partition in root.glob("date=*"): + try: + value = date.fromisoformat(partition.name.removeprefix("date=")) + except ValueError: + continue + if value <= config.end and (partition / "part.parquet").is_file(): + values.append(value) + ordered = sorted(set(values)) + formal = [value for value in ordered if value >= config.start] + predecessor = next( + (value for value in reversed(ordered) if value < config.start), + None, + ) + if predecessor is not None: + formal.insert(0, predecessor) + return formal or None + + @staticmethod + def _attach_shared_next_return( + panel: pl.DataFrame, + config: FactorConfig | FactorBatchConfig, + *, + trading_dates: list[date] | None = None, + ) -> pl.DataFrame: + """Prepare returns once on the complete price axis before factor filtering.""" + forward_columns = [f"_forward_return_{horizon}d" for horizon in _DAILY_FORWARD_HORIZONS] + prepared_columns = ["_next_return", *forward_columns] + if all(column in panel.columns for column in prepared_columns): + return panel + existing_columns = [column for column in prepared_columns if column in panel.columns] + if existing_columns: + panel = panel.drop(existing_columns) + + base = ( + panel.filter((pl.col("date") >= config.start) & (pl.col("date") <= config.end)) + .filter(pl.col("close").is_not_null() & (pl.col("close") > 0)) + .select(["symbol", "date", "close"]) + .unique(subset=["symbol", "date"], keep="last") + .sort(["symbol", "date"]) + ) + if base.is_empty(): + return panel.with_columns( + [pl.lit(None).cast(pl.Float64).alias(column) for column in forward_columns] + + [pl.lit(None).cast(pl.Float64).alias("_next_return")] + ) + + all_dates = sorted( + value + for value in ( + trading_dates if trading_dates is not None else base["date"].unique().to_list() + ) + if config.start <= value <= config.end + ) + date_dtype = base.schema["date"] + for horizon, return_column in zip( + _DAILY_FORWARD_HORIZONS, + forward_columns, + strict=True, + ): + if len(all_dates) <= horizon: + base = base.with_columns( + pl.lit(None).cast(pl.Float64).alias(return_column) + ) + continue + target_column = f"_target_date_{horizon}d" + target_close_column = f"_target_close_{horizon}d" + date_map = pl.DataFrame({ + "date": all_dates[:-horizon], + target_column: all_dates[horizon:], + }).with_columns( + pl.col("date").cast(date_dtype), + pl.col(target_column).cast(date_dtype), + ) + price_lookup = base.select( + "symbol", + pl.col("date").alias(target_column), + pl.col("close").alias(target_close_column), + ) + base = ( + base.join(date_map, on="date", how="left") + .join(price_lookup, on=["symbol", target_column], how="left") + .with_columns( + pl.when(pl.col(target_close_column).is_not_null()) + .then(pl.col(target_close_column) / pl.col("close") - 1.0) + .otherwise(None) + .cast(pl.Float64) + .alias(return_column) + ) + .drop([target_column, target_close_column]) + ) + + if config.rebalance == "daily": + base = base.with_columns( + pl.col("_forward_return_1d").alias("_next_return") + ) + else: + base = FactorBacktestService._calc_period_return(base, config.rebalance) + + return panel.join( + base.select(["symbol", "date", "_next_return", *forward_columns]), + on=["symbol", "date"], + how="left", + ) + + def _evaluate_panel( + self, + source_panel: pl.DataFrame, + config: FactorConfig, + run_id: str, + t0: float, + *, + regime_by_date: Mapping[object, Any] | None = None, + market_trading_dates: list[date] | None = None, + ) -> FactorResult: + def _err(msg: str) -> FactorResult: + return self._error_result(config, run_id, t0, msg) factor_col = config.factor_name - if factor_col not in panel.columns: - panel = self._compute_missing_factor(panel, factor_col) - if factor_col not in panel.columns: + if factor_col not in source_panel.columns: return _err(f"因子列 '{factor_col}' 不存在于 enriched 数据中, 且无法从基础行情计算") - if "close" not in panel.columns: + if "close" not in source_panel.columns: return _err("enriched 数据缺少收盘价 close") - panel = panel.select(["symbol", "date", "close", factor_col]) - panel = panel.filter((pl.col("date") >= config.start) & (pl.col("date") <= config.end)) + if "_next_return" not in source_panel.columns: + source_panel = self._attach_shared_next_return(source_panel, config) - # 过滤有效行 - panel = panel.filter( - pl.col(factor_col).is_not_null() - & pl.col("close").is_not_null() - & (pl.col("close") > 0) + return_columns = [ + column + for column in ( + "_next_return", + *(f"_forward_return_{horizon}d" for horizon in _DAILY_FORWARD_HORIZONS), + ) + if column in source_panel.columns + ] + price_panel = ( + source_panel.select(["symbol", "date", "close", factor_col, *return_columns]) + .filter((pl.col("date") >= config.start) & (pl.col("date") <= config.end)) + .filter(pl.col("close").is_not_null() & (pl.col("close") > 0)) + ) + total_price_rows = price_panel.height + panel = price_panel.filter( + pl.col(factor_col).is_not_null() & pl.col(factor_col).is_finite() ) if panel.is_empty(): return _err("过滤后无有效数据") panel = panel.sort(["symbol", "date"]) - + coverage = panel.height / total_price_rows if total_price_rows else None n_symbols = panel["symbol"].n_unique() n_dates = panel["date"].n_unique() - # 计算下期收益 - # 根据调仓频率计算不同周期的 forward return - if config.rebalance == "daily": - panel = panel.with_columns( - (pl.col("close").shift(-1).over("symbol") / pl.col("close") - 1) - .alias("_next_return") - ) - else: - # weekly/monthly: 计算到下个调仓日的收益 - panel = self._calc_period_return(panel, config.rebalance) - # ── 1. IC 分析 ── ic_df = self._calc_ic(panel, factor_col) + valid_ic_df = ic_df.filter(pl.col("ic").is_not_null() & pl.col("ic").is_finite()) + ic_rows = valid_ic_df.iter_rows(named=True) ic_series = [ {"date": str(row["date"]), "ic": round(float(row["ic"]), 4)} - for row in ic_df.iter_rows(named=True) - if row["ic"] is not None and not np.isnan(float(row["ic"])) + for row in ic_rows ] - ic_values = [r["ic"] for r in ic_series] - ic_mean = float(np.mean(ic_values)) if ic_values else None - ic_std = float(np.std(ic_values)) if ic_values else None + ic_values = valid_ic_df["ic"].to_numpy() if not valid_ic_df.is_empty() else np.array([]) + ic_mean = float(np.mean(ic_values)) if ic_values.size else None + ic_std = float(np.std(ic_values)) if ic_values.size else None ir = (ic_mean / ic_std) if (ic_mean is not None and ic_std and ic_std > 1e-8) else None - ic_win_rate = (sum(1 for v in ic_values if v > 0) / len(ic_values)) if ic_values else None + ic_win_rate = float(np.mean(ic_values > 0)) if ic_values.size else None + yearly_ic = self._calc_yearly_ic(valid_ic_df) + ic_decay = self._calc_ic_decay(panel, factor_col) + regime_stats = self._calc_regime_stats( + valid_ic_df, + price_panel, + regime_by_date, + config.start, + config.end, + market_trading_dates=market_trading_dates, + ) # ── 2. 分层回测 ── panel = self._add_groups(panel, factor_col, config.n_groups) group_nav = self._calc_group_nav(panel, config) group_stats = self._calc_group_stats(group_nav, config.start, config.end, config.rebalance) + turnover = self._calc_turnover(panel, config) - # ── 3. 多空组合 ── + # ── 3. 理论因子多空组合 ── long_short_nav, long_short_stats = self._calc_long_short(group_nav, config) + long_short_sharpe = long_short_stats.get("sharpe") elapsed = (time.perf_counter() - t0) * 1000 return FactorResult( @@ -189,29 +674,63 @@ class FactorBacktestService: ic_win_rate=round(ic_win_rate, 4) if ic_win_rate is not None else None, ic_series=ic_series, group_stats=group_stats, - group_nav=group_nav, + group_nav=self._round_nav(group_nav), long_short_stats=long_short_stats, long_short_nav=long_short_nav, + coverage=round(coverage, 4) if coverage is not None else None, + turnover=round(turnover, 4) if turnover is not None else None, + long_short_sharpe=long_short_sharpe, + yearly_ic=yearly_ic, + ic_decay=ic_decay, + regime_stats=regime_stats, elapsed_ms=round(elapsed, 1), n_symbols=n_symbols, n_dates=n_dates, ) @staticmethod - def _compute_missing_factor(panel: pl.DataFrame, factor_col: str) -> pl.DataFrame: + def _compute_missing_factors( + panel: pl.DataFrame, + factor_cols: set[str], + *, + assume_sorted: bool = False, + ) -> pl.DataFrame: required = {"symbol", "date", "open", "high", "low", "close", "volume"} if not required.issubset(panel.columns): missing = sorted(required - set(panel.columns)) - logger.warning("factor %s cannot be computed, missing columns: %s", factor_col, missing) + logger.warning("factors %s cannot be computed, missing columns: %s", factor_cols, missing) return panel from app.indicators.pipeline import compute_indicators - # 只需要单个因子列 → 用 needed 裁剪, 跳过无关的 EMA/KDJ/RSI 等计算 pass - computed = compute_indicators(panel, needed={factor_col}) - if factor_col not in computed.columns: - return panel - return computed.select(["symbol", "date", "close", factor_col]) + derived = factor_cols & set(DERIVED_FACTOR_DEPENDENCIES) + indicator_columns = factor_cols - derived + for factor_name in derived: + indicator_columns.update(DERIVED_FACTOR_DEPENDENCIES[factor_name]) + panel = compute_indicators( + panel, + needed=indicator_columns, + assume_sorted=assume_sorted, + ) + return FactorBacktestService._compute_derived_factors(panel, derived) + + @staticmethod + def _compute_derived_factors(panel: pl.DataFrame, factor_cols: set[str]) -> pl.DataFrame: + return materialize_scoring_columns(panel, factor_cols) + + @staticmethod + def _error_result( + config: FactorConfig, + run_id: str, + started_at: float, + message: str, + ) -> FactorResult: + return FactorResult( + run_id=run_id, + config=FactorBacktestService._config_to_dict(config), + error=message, + elapsed_ms=round((time.perf_counter() - started_at) * 1000, 1), + ) # ── IC 计算 ── @@ -230,6 +749,159 @@ class FactorBacktestService: .sort("date") ) + @staticmethod + def _calc_yearly_ic(ic_df: pl.DataFrame) -> list[dict]: + if ic_df.is_empty(): + return [] + yearly = ( + ic_df.with_columns(pl.col("date").dt.year().alias("_year")) + .group_by("_year") + .agg( + pl.col("ic").mean().alias("ic_mean"), + pl.col("ic").std(ddof=0).alias("ic_std"), + (pl.col("ic") > 0).mean().alias("win_rate"), + pl.len().alias("n_dates"), + ) + .sort("_year") + ) + result: list[dict] = [] + for row in yearly.iter_rows(named=True): + mean = float(row["ic_mean"]) + std = float(row["ic_std"] or 0.0) + result.append({ + "year": int(row["_year"]), + "ic_mean": round(mean, 4), + "ir": round(mean / std, 4) if std > 1e-8 else None, + "win_rate": round(float(row["win_rate"]), 4), + "n_dates": int(row["n_dates"]), + }) + return result + + @staticmethod + def _calc_ic_decay(panel: pl.DataFrame, factor_col: str) -> list[dict]: + columns = [ + (horizon, f"_forward_return_{horizon}d") + for horizon in _DAILY_FORWARD_HORIZONS + if f"_forward_return_{horizon}d" in panel.columns + ] + if not columns: + return [] + decay_df = ( + panel.group_by("date") + .agg([ + pl.corr( + pl.col(factor_col).rank(method="average"), + pl.col(return_column).rank(method="average"), + ).alias(f"ic_{horizon}d") + for horizon, return_column in columns + ]) + .sort("date") + ) + result: list[dict] = [] + for horizon, _ in columns: + column = f"ic_{horizon}d" + values = decay_df.filter( + pl.col(column).is_not_null() & pl.col(column).is_finite() + )[column] + result.append({ + "horizon": horizon, + "ic_mean": round(float(values.mean()), 4) if len(values) else None, + "n_dates": len(values), + }) + return result + + @staticmethod + def _calc_regime_stats( + ic_df: pl.DataFrame, + price_panel: pl.DataFrame, + regime_by_date: Mapping[object, Any] | None, + required_start: date, + required_end: date, + *, + market_trading_dates: list[date] | None = None, + ) -> list[dict]: + if not regime_by_date: + return [] + + from app.backtest.regime_alignment import ( + align_regime_t_minus_one, + three_level_regime, + ) + + formal_labels = tuple( + str(value)[:10] for value in sorted(price_panel["date"].unique().to_list()) + ) + if market_trading_dates: + labels = tuple( + str(value)[:10] + for value in market_trading_dates + if value <= required_end + ) + else: + predecessor = max( + ( + str(value)[:10] + for value in regime_by_date + if str(value)[:10] < str(required_start) + ), + default=None, + ) + labels = ( + (predecessor, *formal_labels) + if predecessor is not None + else formal_labels + ) + aligned = align_regime_t_minus_one( + labels, + regime_by_date, + required_start, + required_end, + # 统计场景: 数据边界即正式首日 (如「全部」/「1年」范围起点=本地数据首日) 时, + # 首日无 T-1 环境属正常, 跳过首日不参与环境分组即可, 不阻断回测; + # 与策略回测 clamp_formal_start_for_regime 的「首日让渡」同口径。 + # 内部缺口 (次日 T-1 缺环境) 仍 fail-closed 报错。 + first_day_boundary_ok=True, + ) + ic_by_date = { + str(row["date"])[:10]: float(row["ic"]) + for row in ic_df.iter_rows(named=True) + } + grouped: dict[str, dict[str, list[float]]] = {} + for label, point in zip(labels, aligned, strict=True): + if label not in formal_labels or point is None: + continue + state, score = point + bucket = grouped.setdefault( + three_level_regime(state), + {"scores": [], "ics": [], "dates": []}, + ) + bucket["scores"].append(score) + bucket["dates"].append(label) + if label in ic_by_date: + bucket["ics"].append(ic_by_date[label]) + + order = {"strong": 0, "range": 1, "weak": 2} + result: list[dict] = [] + for state in sorted(grouped, key=lambda value: (order.get(value, 99), value)): + values = np.asarray(grouped[state]["ics"], dtype=np.float64) + scores = np.asarray(grouped[state]["scores"], dtype=np.float64) + mean = float(np.mean(values)) if values.size else None + std = float(np.std(values)) if values.size else None + result.append({ + "state": state, + "ic_mean": round(mean, 4) if mean is not None else None, + "ir": ( + round(mean / std, 4) + if mean is not None and std is not None and std > 1e-8 + else None + ), + "win_rate": round(float(np.mean(values > 0)), 4) if values.size else None, + "mean_score": round(float(np.mean(scores)), 2), + "n_dates": len(grouped[state]["dates"]), + "n_ic_dates": int(values.size), + }) + return result + # ── 调仓期收益 ── @staticmethod @@ -240,20 +912,21 @@ class FactorBacktestService: monthly: 下个月调仓日 close / 今日 close - 1 只在调仓日标记行有效,其他行为 null。 """ - import datetime as _dt - all_dates = sorted(panel["date"].unique().to_list()) - if rebalance == "weekly": - # 调仓日 = 每周一 rebalance_dates = set() - for d in all_dates: - if hasattr(d, "weekday"): - wd = d.weekday() - else: - wd = _dt.date.fromisoformat(str(d)).weekday() - if wd == 0: # Monday - rebalance_dates.add(d) + seen_weeks: set[tuple[int, int]] = set() + for current_date in all_dates: + normalized_date = ( + current_date + if hasattr(current_date, "isocalendar") + else date.fromisoformat(str(current_date)[:10]) + ) + iso_year, iso_week, _ = normalized_date.isocalendar() + week = (iso_year, iso_week) + if week not in seen_weeks: + seen_weeks.add(week) + rebalance_dates.add(current_date) else: # monthly # 调仓日 = 每月首个交易日 seen_months: set[str] = set() @@ -327,18 +1000,21 @@ class FactorBacktestService: @staticmethod def _add_groups(panel: pl.DataFrame, factor_col: str, n_groups: int) -> pl.DataFrame: - """截面序号分桶,避免 qcut 在重复因子值截面上抛错。""" + """Tie-aware cross-sectional buckets; equal factor values never split.""" return ( - panel.sort(["date", factor_col, "symbol"]) - .with_columns( - (pl.cum_count("symbol").over("date") - 1).alias("_factor_ord"), + panel.with_columns( + pl.col(factor_col).rank(method="average").over("date").alias("_factor_rank"), pl.len().over("date").alias("_factor_count"), ) .with_columns( ( pl.lit("Q") + ( - ((pl.col("_factor_ord") * n_groups) / pl.col("_factor_count")) + ( + (pl.col("_factor_rank") - 1.0) + * n_groups + / pl.col("_factor_count") + ) .floor() .cast(pl.Int64) + 1 @@ -346,9 +1022,16 @@ class FactorBacktestService: .clip(1, n_groups) .cast(pl.Utf8) ) - .alias("_group") + .alias("_group"), + ( + pl.col("_factor_rank") + - (pl.col("_factor_count") + 1) / 2.0 + ) + .abs() + .add(0.5) + .alias("_factor_strength"), ) - .drop(["_factor_ord", "_factor_count"]) + .drop(["_factor_rank", "_factor_count"]) ) @staticmethod @@ -362,38 +1045,126 @@ class FactorBacktestService: # ── 分组净值 ── + @staticmethod + def _round_nav(group_nav: list[dict]) -> list[dict]: + return [ + { + key: value if key == "date" else round(float(value), 4) + for key, value in row.items() + } + for row in group_nav + ] + + @staticmethod + def _round_trip_cost(config: FactorConfig) -> float: + commission = ( + config.commission_pct + if config.commission_pct is not None + else config.fees_pct + ) + stamp_tax = config.stamp_tax_pct or 0.0 + slippage = config.slippage_bps / 10_000.0 + return 2.0 * commission + stamp_tax + 2.0 * slippage + @staticmethod def _calc_group_nav(panel: pl.DataFrame, config: FactorConfig) -> list[dict]: - """计算分组净值曲线 — 只在调仓日更新净值。""" - # 只保留有下期收益的行 (= 调仓日) - group_ret = ( - panel.filter(pl.col("_next_return").is_not_null() & pl.col("_group").is_not_null()) - .group_by(["date", "_group"]) - .agg(pl.col("_next_return").mean().alias("group_return")) + """Calculate group NAV with the configured cross-sectional weighting.""" + eligible = panel.filter( + pl.col("_next_return").is_not_null() & pl.col("_group").is_not_null() + ) + if config.weight == "factor_weight": + group_ret = ( + eligible.with_columns( + pl.when(pl.col("_factor_strength") > 0) + .then(pl.col("_factor_strength")) + .otherwise(1.0) + .alias("_weight") + ) + .group_by(["date", "_group"]) + .agg( + ( + (pl.col("_next_return") * pl.col("_weight")).sum() + / pl.col("_weight").sum() + ).alias("group_return") + ) + ) + else: + group_ret = eligible.group_by(["date", "_group"]).agg( + pl.col("_next_return").mean().alias("group_return") + ) + group_ret = group_ret.with_columns( + (pl.col("group_return") - FactorBacktestService._round_trip_cost(config)) + .alias("group_return") ) - # pivot: date × group - pivot = group_ret.pivot(index="date", columns="_group", values="group_return").sort("date") - + pivot = group_ret.pivot( + index="date", + on="_group", + values="group_return", + ).sort("date") if pivot.is_empty(): return [] - group_cols = sorted([c for c in pivot.columns if c != "date"], key=FactorBacktestService._group_sort_key) - - # 累乘净值曲线 + group_cols = sorted( + [column for column in pivot.columns if column != "date"], + key=FactorBacktestService._group_sort_key, + ) + nav_df = pivot.with_columns( + [(1.0 + pl.col(column).fill_null(0.0)).cum_prod().alias(column) for column in group_cols] + ) result: list[dict] = [] - nav_values: dict[str, float] = {c: 1.0 for c in group_cols} - - for row in pivot.iter_rows(named=True): + for row in nav_df.iter_rows(named=True): entry: dict = {"date": str(row["date"])[:10]} - for c in group_cols: - ret = float(row[c]) if row[c] is not None else 0.0 - nav_values[c] *= (1 + ret) - entry[c] = round(nav_values[c], 4) + for column in group_cols: + entry[column] = float(row[column]) result.append(entry) - return result + @staticmethod + def _calc_turnover(panel: pl.DataFrame, config: FactorConfig) -> float | None: + eligible = panel.filter( + pl.col("_next_return").is_not_null() & pl.col("_group").is_not_null() + ) + if eligible.is_empty(): + return None + groups = sorted( + eligible["_group"].unique().to_list(), + key=FactorBacktestService._group_sort_key, + ) + if not groups: + return None + top_group = groups[-1] + weights = eligible.filter(pl.col("_group") == top_group) + if config.weight == "factor_weight": + weights = weights.with_columns( + pl.when(pl.col("_factor_strength") > 0) + .then(pl.col("_factor_strength")) + .otherwise(1.0) + .alias("_raw_weight") + ) + else: + weights = weights.with_columns(pl.lit(1.0).alias("_raw_weight")) + weights = weights.with_columns( + (pl.col("_raw_weight") / pl.col("_raw_weight").sum().over("date")) + .alias("_weight") + ) + + by_date: dict[object, dict[str, float]] = {} + for row in weights.select(["date", "symbol", "_weight"]).iter_rows(named=True): + by_date.setdefault(row["date"], {})[str(row["symbol"])] = float(row["_weight"]) + ordered_dates = sorted(by_date) + if len(ordered_dates) < 2: + return 0.0 + turnovers: list[float] = [] + for previous_date, current_date in pairwise(ordered_dates): + previous = by_date[previous_date] + current = by_date[current_date] + symbols = previous.keys() | current.keys() + turnovers.append( + 0.5 * sum(abs(current.get(symbol, 0.0) - previous.get(symbol, 0.0)) for symbol in symbols) + ) + return float(np.mean(turnovers)) + # ── 分组统计 ── @staticmethod @@ -410,36 +1181,33 @@ class FactorBacktestService: ) n_days = max((end - start).days, 1) years = n_days / 365.25 + # 夏普 — 年化系数必须匹配 group_nav 的调仓频率 (每个净值点 = 一个调仓周期收益); + # 周/月频收益若乘 √252 会把 Sharpe 高估 √(252/期数) 倍 (月频 ≈4.6x, 周频 ≈2.2x)。 + _ann = {"daily": 252, "weekly": 52, "monthly": 12}.get(rebalance, 252) stats = [] for i, c in enumerate(group_cols): values = [r[c] for r in group_nav if r.get(c) is not None] if not values: continue - total_return = values[-1] - 1.0 - annual_return = (values[-1]) ** (1 / max(years, 0.01)) - 1 if values[-1] > 0 else 0.0 + arr = np.asarray(values, dtype=np.float64) + last = float(arr[-1]) + total_return = last - 1.0 + annual_return = last ** (1 / max(years, 0.01)) - 1 if last > 0 else 0.0 - # 最大回撤 - peak = 1.0 - max_dd = 0.0 - for v in values: - peak = max(peak, v) - dd = (v - peak) / peak - max_dd = min(max_dd, dd) + # 最大回撤 (向量化): 峰值 = max(1.0, 历史最高), 与原 peak 初值 1.0 的逐行 max 一致 + peak = np.maximum(np.maximum.accumulate(arr), 1.0) + max_dd = float(np.min((arr - peak) / peak)) - # 日收益序列 - daily_rets = [] - for j in range(1, len(values)): - if values[j - 1] > 0: - daily_rets.append(values[j] / values[j - 1] - 1) - - # 夏普 — 年化系数必须匹配 group_nav 的调仓频率 (每个净值点 = 一个调仓周期收益); - # 周/月频收益若乘 √252 会把 Sharpe 高估 √(252/期数) 倍 (月频 ≈4.6x, 周频 ≈2.2x)。 - if daily_rets: - arr = np.array(daily_rets) - _ann = {"daily": 252, "weekly": 52, "monthly": 12}.get(rebalance, 252) - sharpe = float(np.mean(arr) / np.std(arr)) * np.sqrt(_ann) if np.std(arr) > 0 else 0.0 - win_rate = float(np.mean(arr > 0)) + # 周期收益序列 (向量化): nav[t]/nav[t-1] - 1, 仅保留 nav[t-1] > 0 的样本 + prev = arr[:-1] + with np.errstate(divide="ignore", invalid="ignore"): + rets = arr[1:] / prev - 1.0 + rets = rets[prev > 0] + if rets.size: + std = float(np.std(rets)) + sharpe = float(np.mean(rets) / std) * np.sqrt(_ann) if std > 0 else 0.0 + win_rate = float(np.mean(rets > 0)) else: sharpe = 0.0 win_rate = 0.0 @@ -476,41 +1244,55 @@ class FactorBacktestService: top_col = group_cols[-1] # Q5 (最高) bottom_col = group_cols[0] # Q1 (最低) - # 独立计算 top 和 bottom 的日收益,然后合成 - ls_value = 1.0 - prev_top = 1.0 - prev_bot = 1.0 - peak = 1.0 - max_dd = 0.0 - ls_nav: list[dict] = [] + # 向量化: 各组净值 (null 视为 1.0), 前置 1.0 作为初值 prev_top/prev_bot, + # 等价于原逐行 prev_top/prev_bot 初始 1.0 的累乘逻辑。 + top = np.array( + [r[top_col] if r.get(top_col) is not None else 1.0 for r in group_nav], + dtype=np.float64, + ) + bot = np.array( + [r[bottom_col] if r.get(bottom_col) is not None else 1.0 for r in group_nav], + dtype=np.float64, + ) + prev_top = np.concatenate(([1.0], top[:-1])) + prev_bot = np.concatenate(([1.0], bot[:-1])) - for row in group_nav: - top_nav = float(row.get(top_col, 1.0)) if row.get(top_col) is not None else 1.0 - bot_nav = float(row.get(bottom_col, 1.0)) if row.get(bottom_col) is not None else 1.0 + # 分组收益; prev <= 0 时按原逻辑置 0 (做多 top, 做空 bottom = 取反) + with np.errstate(divide="ignore", invalid="ignore"): + top_ret = np.where(prev_top > 0, top / prev_top - 1.0, 0.0) + bot_ret = np.where(prev_bot > 0, bot / prev_bot - 1.0, 0.0) + ls_ret = ( + (top_ret - bot_ret) / 2.0 + - FactorBacktestService._round_trip_cost(config) + ) # 50/50 理论多空两腿 + ls_value = np.cumprod(1.0 + ls_ret) - # top 组收益 (做多) - top_ret = (top_nav / prev_top - 1) if prev_top > 0 else 0.0 - # bottom 组收益 (做空 = 取反) - bot_ret = -(bot_nav / prev_bot - 1) if prev_bot > 0 else 0.0 - # 多空组合收益 - ls_ret = (top_ret + bot_ret) / 2 # 各分配 50% 资金 - ls_value *= (1 + ls_ret) + # 最大回撤: 峰值 = max(1.0, 历史最高), 与原 peak 初值 1.0 一致 + peak = np.maximum(np.maximum.accumulate(ls_value), 1.0) + max_dd = float(np.min((ls_value - peak) / peak)) - prev_top = top_nav - prev_bot = bot_nav - - peak = max(peak, ls_value) - dd = (ls_value - peak) / peak if peak > 0 else 0.0 - max_dd = min(max_dd, dd) - - ls_nav.append({"date": row["date"], "value": round(ls_value, 4)}) - - total_ret = ls_value - 1.0 + ann_factor = {"daily": 252, "weekly": 52, "monthly": 12}.get( + config.rebalance, + 252, + ) + ls_std = float(np.std(ls_ret)) + sharpe = ( + float(np.mean(ls_ret) / ls_std) * np.sqrt(ann_factor) + if ls_std > 1e-8 + else 0.0 + ) + ls_nav = [ + {"date": group_nav[k]["date"], "value": round(float(ls_value[k]), 4)} + for k in range(len(group_nav)) + ] ls_stats = { - "total_return": round(total_ret, 4), + "total_return": round(float(ls_value[-1]) - 1.0, 4), "max_drawdown": round(max_dd, 4), + "sharpe": round(sharpe, 4), "top_group": top_col, "bottom_group": bottom_col, + "portfolio_type": "theoretical_factor_spread", + "executable_short": False, } return ls_nav, ls_stats @@ -526,5 +1308,25 @@ class FactorBacktestService: "rebalance": c.rebalance, "weight": c.weight, "fees_pct": c.fees_pct, + "commission_pct": c.commission_pct, + "stamp_tax_pct": c.stamp_tax_pct, "slippage_bps": c.slippage_bps, + "asset_type": c.asset_type, + } + + @staticmethod + def _batch_config_to_dict(c: FactorBatchConfig, factor_names: list[str]) -> dict: + return { + "factor_names": factor_names, + "symbols": c.symbols, + "start": str(c.start), + "end": str(c.end), + "n_groups": c.n_groups, + "rebalance": c.rebalance, + "weight": c.weight, + "fees_pct": c.fees_pct, + "commission_pct": c.commission_pct, + "stamp_tax_pct": c.stamp_tax_pct, + "slippage_bps": c.slippage_bps, + "asset_type": c.asset_type, } diff --git a/backend/app/backtest/fundamentals.py b/backend/app/backtest/fundamentals.py new file mode 100644 index 0000000..d69f52a --- /dev/null +++ b/backend/app/backtest/fundamentals.py @@ -0,0 +1,209 @@ +"""财务因子: 基于本地财务快照的点时 (point-in-time) 无未来函数接入。 + +数据契约: +- 输入为 data/financials/metrics/part.parquet, 每行一份报告期指标; +- ``announce_date`` 是公告日。因子只在 **严格晚于公告日的交易日** 才有值 + (公告多在盘后发布, 保守取 T+1 生效), 此前保持 null; +- 财报历史按 (symbol, period_end) 累积 (见 services/financial_sync.py), + 同一期以最新公告为准; +- 无财务数据的标的/日期一律为 null, 绝不填 0 (填 0 会污染截面排名, + 例如资产负债率 0 会被当成最优杠杆)。下游 IC/分层/评分对 null 自动剔除。 + +性能: +- 财务表约数千行, join_asof 按 symbol 分组回填, 对百万行面板的代价是 + 毫秒级; 矩阵路径每个因子只物化一张 float32 TxN 矩阵 (T~900, N~5500 + 约 20MB), 且仅在策略/挖掘请求该因子时才构建。 +""" +from __future__ import annotations + +import logging +from pathlib import Path +from types import MappingProxyType +from typing import Any + +import numpy as np +import polars as pl + +logger = logging.getLogger(__name__) + +# 财务因子名 -> (metrics 表列名, 是否需要除以收盘价) +# pb_latest 单列声明为 bps 倒数口径: 因子值 = close / bps。 +FUNDAMENTAL_FACTORS: dict[str, dict[str, Any]] = { + "pb_latest": {"column": "bps", "price_ratio": True}, + "roe_latest": {"column": "roe", "price_ratio": False}, + "gross_margin_latest": {"column": "gross_margin", "price_ratio": False}, + "net_margin_latest": {"column": "net_margin", "price_ratio": False}, + "revenue_yoy_latest": {"column": "revenue_yoy", "price_ratio": False}, + "net_income_yoy_latest": {"column": "net_income_yoy", "price_ratio": False}, + "debt_ratio_latest": {"column": "debt_to_asset_ratio", "price_ratio": False}, +} + +FUNDAMENTAL_FACTOR_NAMES = frozenset(FUNDAMENTAL_FACTORS) + + +def load_fundamental_snapshot(data_dir: Path | None) -> pl.DataFrame | None: + """读取财务指标快照; 文件缺失或无有效行时返回 None。 + + 返回列: symbol, _announce (Date), 以及各因子对应的 metrics 列。 + """ + if data_dir is None: + return None + path = data_dir / "financials" / "metrics" / "part.parquet" + if not path.exists(): + return None + try: + frame = pl.read_parquet(path) + except Exception as exc: + logger.warning("读取财务指标快照失败: %s", exc) + return None + needed = {"symbol", "announce_date"} | { + spec["column"] for spec in FUNDAMENTAL_FACTORS.values() + } + if not needed.issubset(frame.columns): + logger.warning("财务指标快照缺少列: %s", sorted(needed - set(frame.columns))) + return None + snapshot = ( + frame.select(sorted(needed)) + .filter( + pl.col("symbol").is_not_null() + & pl.col("announce_date").is_not_null() + ) + .with_columns( + pl.col("announce_date").cast(pl.Utf8).str.slice(0, 10).str.to_date().alias("_announce") + ) + .sort(["symbol", "_announce"]) + ) + if snapshot.is_empty(): + return None + return snapshot + + +def attach_fundamental_factors( + panel: pl.DataFrame, + snapshot: pl.DataFrame | None, + names: Any, +) -> pl.DataFrame: + """把财务因子列按公告日门控地并入日频面板。 + + - snapshot 为 None (本地无财务数据): 产出全 null 列, 保持面板形状, + 由上层决定是否报"无财务数据"错误; + - 面板必须已按 (symbol, date) 排序 (存储与挖掘路径均满足)。 + """ + requested = [str(name) for name in names if str(name) in FUNDAMENTAL_FACTOR_NAMES] + missing_columns = [name for name in requested if name not in panel.columns] + if not missing_columns: + return panel + + if snapshot is None: + return panel.with_columns([ + pl.lit(None, dtype=pl.Float64).alias(name) + for name in missing_columns + ]) + + columns = sorted( + {FUNDAMENTAL_FACTORS[name]["column"] for name in missing_columns} + ) + right = snapshot.select(["symbol", "_announce", *columns]).sort(["symbol", "_announce"]) + joined = panel.join_asof( + right, + left_on="date", + right_on="_announce", + by="symbol", + strategy="backward", + check_sortedness=False, # 双侧均已按 (symbol, key) 排序, 免除逐组检查开销 + ) + announced = pl.col("_announce").is_not_null() & (pl.col("date") > pl.col("_announce")) + expressions = [] + for name in missing_columns: + spec = FUNDAMENTAL_FACTORS[name] + source = pl.col(spec["column"]) + if spec["price_ratio"]: + value = ( + pl.when(source > 0) + .then(pl.col("close") / source) + .otherwise(None) + ) + else: + value = source + expressions.append( + pl.when(announced).then(value).otherwise(None).alias(name) + ) + return joined.with_columns(expressions) + + +def build_fundamental_matrices( + market: Any, + snapshot: pl.DataFrame | None, + names: Any, +) -> dict[str, np.ndarray]: + """为 MarketDataMatrix 构建财务因子 TxN float32 字段。 + + 与 attach_fundamental_factors 同一口径: 公告日次一交易日起前向填充, + 无数据为 NaN。pb 类因子在矩阵侧用 close / bps 现算。 + """ + requested = [str(name) for name in names if str(name) in FUNDAMENTAL_FACTOR_NAMES] + if not requested: + return {} + + shape = market.shape + result: dict[str, np.ndarray] = {} + if snapshot is None: + for name in requested: + result[name] = np.full(shape, np.nan, dtype=np.float32) + return result + + asset_index = {symbol: index for index, symbol in enumerate(market.symbols)} + labels = market.timestamp_labels + label_dates = np.array([label[:10] for label in labels], dtype="datetime64[D]") + + raw_columns = { + FUNDAMENTAL_FACTORS[name]["column"]: np.full(shape, np.nan, dtype=np.float32) + for name in requested + } + announce_text = snapshot["announce_date"].str.slice(0, 10) + for row_index, symbol in enumerate(snapshot["symbol"].to_list()): + column_index = asset_index.get(symbol) + if column_index is None: + continue + announce = announce_text[row_index] + if announce is None: + continue + # 公告日之后 (严格大于) 的首个时间行索引 + start = int(np.searchsorted(label_dates, np.datetime64(announce, "D"), side="right")) + if start >= shape[0]: + continue + for column, target in raw_columns.items(): + value = snapshot[column][row_index] + if value is None or not np.isfinite(float(value)): + continue + target[start:, column_index] = float(value) + + for name in requested: + spec = FUNDAMENTAL_FACTORS[name] + source = raw_columns[spec["column"]] + if spec["price_ratio"]: + with np.errstate(divide="ignore", invalid="ignore"): + matrix = (market.close / source).astype(np.float32) + matrix[~(source > 0)] = np.nan + matrix[np.isinf(matrix)] = np.nan + else: + matrix = source + result[name] = matrix + return result + + +def attach_matrix_fundamental_fields(market: Any, data_dir: Path | None, names: Any) -> Any: + """把财务因子作为 matrix fields 附加到 (frozen) MarketDataMatrix 副本。""" + import dataclasses + + requested = [str(name) for name in names if str(name) in FUNDAMENTAL_FACTOR_NAMES] + if not requested: + return market + snapshot = load_fundamental_snapshot(data_dir) + extra = build_fundamental_matrices(market, snapshot, requested) + if not extra: + return market + merged = {**dict(market.fields), **extra} + for array in extra.values(): + array.flags.writeable = False + return dataclasses.replace(market, fields=MappingProxyType(merged)) diff --git a/backend/app/backtest/matrix.py b/backend/app/backtest/matrix.py index d488e42..d9cfda8 100644 --- a/backend/app/backtest/matrix.py +++ b/backend/app/backtest/matrix.py @@ -34,6 +34,7 @@ from app.price_limits import ( numpy_limit_price, write_numpy_price_limit_matrix, ) +from app.strategy.scoring import SCORING_DIRECTION_LOW try: from numba import njit, prange @@ -58,6 +59,17 @@ _ROLLING_MATERIALIZED_WINDOW_BUDGET_BYTES = 32 * 1024 * 1024 _MATRIX_DISK_CACHE_DEFAULT_MAX_BYTES = 512 * 1024 * 1024 logger = logging.getLogger(__name__) + + +class MatrixPrewarmCancelledError(RuntimeError): + """A matrix cache prewarm was cancelled during application shutdown.""" + + +def _raise_if_matrix_cancelled(cancel_event: threading.Event | None) -> None: + if cancel_event is not None and cancel_event.is_set(): + raise MatrixPrewarmCancelledError("matrix cache prewarm cancelled") + + _MATRIX_DISK_CACHE_LOCK = threading.RLock() _MATRIX_DISK_CACHE_LEASES: dict[str, int] = {} _MATRIX_DISK_CACHE_PENDING_DELETE: set[str] = set() @@ -678,8 +690,10 @@ def load_market_data_matrix_from_parquet( cache_max_bytes: int = _MATRIX_DISK_CACHE_DEFAULT_MAX_BYTES, profile_generation: str = "default", source_generation: str | None = None, + cancel_event: threading.Event | None = None, ) -> MarketDataMatrix: """Load a daily market matrix, reusing a covering read-only mmap when possible.""" + _raise_if_matrix_cancelled(cancel_event) if start > end: raise ValueError("matrix parquet range start must not exceed end") root = Path(parquet_root) @@ -728,6 +742,7 @@ def load_market_data_matrix_from_parquet( instruments, batch_size=batch_size, cache_status="disabled", + cancel_event=cancel_event, ) cache_dir = Path(cache_root) @@ -806,7 +821,9 @@ def load_market_data_matrix_from_parquet( source_generation, batch_size=batch_size, axis_cache_root=cache_dir, + cancel_event=cancel_event, ) + _raise_if_matrix_cancelled(cancel_event) _prune_matrix_disk_cache( cache_dir, keep=cache_path, @@ -915,12 +932,15 @@ def _build_market_data_matrix_from_dataset( *, batch_size: int, cache_status: str, + cancel_event: threading.Event | None = None, ) -> MarketDataMatrix: + _raise_if_matrix_cancelled(cancel_event) filter_expr = _matrix_filter_expression(start, end, symbols) actual_dates, actual_symbols = _collect_parquet_axes( dataset, filter_expr, batch_size=batch_size, + cancel_event=cancel_event, ) if not actual_dates or not actual_symbols: raise ValueError("matrix parquet range contains no market data") @@ -952,7 +972,9 @@ def _build_market_data_matrix_from_dataset( parquet_fields, seen, batch_size=batch_size, + cancel_event=cancel_event, ) + _raise_if_matrix_cancelled(cancel_event) names, latest_limits = _populate_matrix_derived_arrays( actual_symbols, arrays, @@ -1036,7 +1058,9 @@ def _build_market_data_matrix_cache_from_dataset( *, batch_size: int, axis_cache_root: Path, + cancel_event: threading.Event | None = None, ) -> None: + _raise_if_matrix_cancelled(cancel_event) build_started = time.perf_counter() timing_ms: dict[str, float] = {} cache_path.parent.mkdir(parents=True, exist_ok=True) @@ -1056,7 +1080,9 @@ def _build_market_data_matrix_cache_from_dataset( filter_expr, batch_size=batch_size, cache_root=axis_cache_root, + cancel_event=cancel_event, ) + _raise_if_matrix_cancelled(cancel_event) if not actual_dates or not actual_symbols: raise ValueError("matrix parquet range contains no market data") timing_ms["axes"] = round((time.perf_counter() - stage_started) * 1000, 1) @@ -1100,7 +1126,9 @@ def _build_market_data_matrix_cache_from_dataset( parquet_fields, seen, batch_size=batch_size, + cancel_event=cancel_event, ) + _raise_if_matrix_cancelled(cancel_event) if not seen.any(): raise ValueError("matrix parquet range contains no requested market data") timing_ms["scan"] = round((time.perf_counter() - stage_started) * 1000, 1) @@ -1117,6 +1145,7 @@ def _build_market_data_matrix_cache_from_dataset( vector_fields=vector_fields, ) _mask_unseen_staging_fields(fields, seen) + _raise_if_matrix_cancelled(cancel_event) if "price_limit_pct" in fields: write_numpy_price_limit_matrix( fields["price_limit_pct"], @@ -1146,6 +1175,7 @@ def _build_market_data_matrix_cache_from_dataset( apply_latest_limits=actual_dates[-1] == _latest_partition_date(root), ) timing_ms["derived"] = round((time.perf_counter() - stage_started) * 1000, 1) + _raise_if_matrix_cancelled(cancel_event) stage_started = time.perf_counter() for values in mapped: values.flush() @@ -1180,6 +1210,7 @@ def _build_market_data_matrix_cache_from_dataset( json.dumps(manifest, ensure_ascii=False, separators=(",", ":")), encoding="utf-8", ) + _raise_if_matrix_cancelled(cancel_event) try: os.replace(temporary, cache_path) except OSError: @@ -1312,6 +1343,7 @@ def _scan_matrix_values( seen: np.ndarray, *, batch_size: int, + cancel_event: threading.Event | None = None, ) -> None: date_to_id = {value: index for index, value in enumerate(actual_dates)} symbol_to_id = {value: index for index, value in enumerate(actual_symbols)} @@ -1342,6 +1374,7 @@ def _scan_matrix_values( **{name: fields[name] for name in parquet_fields}, } for batch in scanner.to_batches(): + _raise_if_matrix_cancelled(cancel_event) time_ids = _arrow_axis_ids(_batch_column(batch, "date"), date_to_id) asset_ids = _arrow_axis_ids(_batch_column(batch, "symbol"), symbol_to_id) flat_ids = time_ids.astype(np.int64) * asset_count + asset_ids @@ -1897,7 +1930,9 @@ def _load_or_build_matrix_axes( *, batch_size: int, cache_root: Path, + cancel_event: threading.Event | None = None, ) -> tuple[list[date], list[str]]: + _raise_if_matrix_cancelled(cancel_event) path = _matrix_axis_cache_path(cache_root, parquet_root, start, end, symbols) previous: dict[str, Any] | None = None if path.exists(): @@ -1930,6 +1965,7 @@ def _load_or_build_matrix_axes( dataset, filter_expr, batch_size=batch_size, + cancel_event=cancel_event, ) changed_labels = set() retained_dates = {value.isoformat() for value in actual_dates} @@ -1955,6 +1991,7 @@ def _load_or_build_matrix_axes( ) symbols_set = set(actual_symbols) for batch in scanner.to_batches(): + _raise_if_matrix_cancelled(cancel_event) retained_dates.update( value.isoformat() for value in pc.unique(_batch_column(batch, "date")).to_pylist() @@ -1970,8 +2007,10 @@ def _load_or_build_matrix_axes( dataset, filter_expr, batch_size=batch_size, + cancel_event=cancel_event, ) + _raise_if_matrix_cancelled(cancel_event) payload = { "version": _MATRIX_AXIS_INDEX_VERSION, "source_partitions": dict(source_partitions), @@ -1992,6 +2031,7 @@ def _collect_parquet_axes( filter_expr, *, batch_size: int, + cancel_event: threading.Event | None = None, ) -> tuple[list[date], list[str]]: dates: set[date] = set() symbols: set[str] = set() @@ -2002,6 +2042,7 @@ def _collect_parquet_axes( use_threads=True, ) for batch in scanner.to_batches(): + _raise_if_matrix_cancelled(cancel_event) dates.update(pc.unique(_batch_column(batch, "date")).to_pylist()) symbols.update( str(value) @@ -2997,6 +3038,7 @@ _VALID_REDUCE_MIN = 0 _VALID_REDUCE_MAX = 1 _VALID_REDUCE_MEAN = 2 _VALID_REDUCE_STD = 3 +_VALID_REDUCE_SUM = 4 @njit(cache=True, nogil=True, parallel=True) @@ -3048,6 +3090,8 @@ def _valid_rolling_kernel( mean = total / window_value if operation == _VALID_REDUCE_MEAN: out[row, asset_id] = mean + elif operation == _VALID_REDUCE_SUM: + out[row, asset_id] = total else: squared = 0.0 for offset in range(window): @@ -3186,6 +3230,30 @@ def valid_rolling_std( ) +def valid_rolling_sum( + values: np.ndarray, + valid_mask: np.ndarray, + window: int, + *, + bar_index: ValidBarIndex | None = None, +) -> np.ndarray: + source = np.asarray(values, dtype=np.float32) + valid = np.asarray(valid_mask, dtype=bool) & np.isfinite(source) + index = _resolve_valid_bar_index(source, valid, bar_index) + return _cached_matrix_operation( + "valid_rolling_sum", + (source, valid, index.offsets, index.rows), + {"window": int(window)}, + lambda: _valid_rolling_reduce( + source, + valid, + window, + _VALID_REDUCE_SUM, + bar_index=index, + ), + ) + + def rolling_quantile(values: np.ndarray, window: int, quantile: float) -> np.ndarray: source = np.asarray(values, dtype=np.float32) q = float(quantile) @@ -3397,6 +3465,7 @@ class MatrixPipelineConfig: scoring: dict[str, float] order_by: str | None descending: bool + scoring_directions: dict[str, str] = field(default_factory=dict) asset_mask: np.ndarray | None = None protect_strategy_cache: bool = False @@ -3462,6 +3531,7 @@ class MatrixStrategyPipeline: config.order_by, config.descending, fallback=signals.score, + directions=config.scoring_directions, ) entry_codes = np.where(entry != 0, signals.entry_signal_code, -1).astype(np.int16) exit_codes = np.where(signals.exit != 0, signals.exit_signal_code, -1).astype(np.int16) @@ -3502,14 +3572,7 @@ def _estimate_pipeline_cache_bytes( for name in feature_names: if name in {"open", "high", "low", "close", "volume"} or name in market.fields: continue - if name == "vol_ratio_5d": - estimated += 2 * float_bytes - elif name == "ma20_bias": - estimated += 2 * float_bytes - elif name == "change_pct" or ( - name.startswith("momentum_") and name.endswith("d") - ): - estimated += float_bytes + estimated += 5 * float_bytes return estimated @@ -3588,6 +3651,7 @@ def build_matrix_score( descending: bool, *, fallback: np.ndarray, + directions: Mapping[str, str] | None = None, ) -> np.ndarray: weights = {name: float(weight) for name, weight in scoring.items() if float(weight) != 0.0} total_weight = sum(weights.values()) @@ -3639,6 +3703,8 @@ def build_matrix_score( np.divide(scratch, row_range[:, None], out=scratch, where=mask) np.logical_and(finite, ~varying_rows[:, None], out=mask) scratch[mask] = np.float32(0.5) + if (directions or {}).get(name) == SCORING_DIRECTION_LOW: + scratch[finite] = np.float32(1.0) - scratch[finite] scratch *= normalized_weight score[:, start:stop] += scratch score *= np.float32(100.0) @@ -3665,38 +3731,42 @@ def build_matrix_score( return result +_MATRIX_COMPUTED_FEATURES = frozenset({ + "prev_close", "change_pct", "change_amount", "amplitude", + "boll_upper", "boll_lower", "boll_position", "boll_width", + "high_60d", "low_60d", "annual_vol_20d", + "macd_dif", "macd_dea", "macd_hist", + "macd_dif_pct", "macd_dea_pct", "macd_hist_pct", + "kdj_k", "kdj_d", "kdj_j", "atr_14", "atr_pct", + "vol_ma5", "vol_ma10", "vol_ratio_5d", "vol_ratio_10d", "vol_trend_5_10", + "turnover_ratio_5d", "log_amount", "amount_ratio_5d", + "gap_return", "intraday_return", "close_position", + "distance_to_high_60d", "distance_from_low_60d", + "max_ret_20d", "ret_skew_20d", "up_days_20d", + "amihud_20d", "turnover_z_60d", "vol_price_corr_20d", + "vwap_bias", "vol_trend_5_60", + "limit_up_count_20d", "limit_up_count_60d", +}) + + def matrix_feature(market: MarketDataMatrix, name: str) -> np.ndarray: if name in {"open", "high", "low", "close", "volume"} or name in market.fields: return market.field(name) - close_feature = ( - name in { - "prev_close", - "change_pct", - "change_amount", - "amplitude", - "boll_upper", - "boll_lower", - "high_60d", - "low_60d", - "annual_vol_20d", - "ma20_bias", - } + supported = ( + name in _MATRIX_COMPUTED_FEATURES + or (name.startswith("ma") and name.endswith("_bias") and name[2:-5].isdigit()) + or (name.startswith("ema") and name.endswith("_bias") and name[3:-5].isdigit()) or (name.startswith("ma") and name[2:].isdigit()) + or (name.startswith("ema") and name[3:].isdigit()) or (name.startswith("rsi_") and name[4:].isdigit()) - or ( - name.startswith("momentum_") and name.endswith("d") - ) + or (name.startswith("momentum_") and name.endswith("d")) ) - if close_feature: - source = market.close - elif name == "vol_ratio_5d": - source = market.volume - else: + if not supported: raise ValueError(f"unsupported matrix feature: {name}") with _activate_valid_bar_index(market.valid_bars): return _cached_matrix_operation( "matrix_feature", - (source,), + (market.close,), {"name": name}, lambda: _compute_matrix_feature(market, name), ) @@ -3729,40 +3799,82 @@ def _compute_matrix_feature(market: MarketDataMatrix, name: str) -> np.ndarray: except ValueError as exc: raise ValueError(f"unsupported matrix feature: {name}") from exc return _valid_return_over_bars(market.close, close_valid, bars) - if name == "vol_ratio_5d": + if name.startswith("ma") and name.endswith("_bias"): + period = int(name.removeprefix("ma").removesuffix("_bias")) + return _matrix_relative(market.close, valid_rolling_mean(market.close, close_valid, period)) + if name.startswith("ema") and name.endswith("_bias"): + period = int(name.removeprefix("ema").removesuffix("_bias")) + return _matrix_relative(market.close, _matrix_ema(market.close, close_valid, period)) + if name.startswith("ma") and name[2:].isdigit(): + return valid_rolling_mean(market.close, close_valid, int(name[2:])) + if name.startswith("ema") and name[3:].isdigit(): + return _matrix_ema(market.close, close_valid, int(name[3:])) + if name in {"macd_dif", "macd_dea", "macd_hist"}: + dif, dea = _matrix_macd(market.close, close_valid) + if name == "macd_dif": + return dif + if name == "macd_dea": + return dea + return ((dif - dea) * np.float32(2.0)).astype(np.float32, copy=False) + if name in {"macd_dif_pct", "macd_dea_pct", "macd_hist_pct"}: + source = matrix_feature(market, name.removesuffix("_pct")) + return _matrix_ratio(source, market.close) + if name in {"vol_ratio_5d", "vol_ratio_10d"}: + window = 5 if name == "vol_ratio_5d" else 10 volume_valid = close_valid & np.isfinite(market.volume) previous_volume = valid_shift(market.volume, 1, volume_valid) previous_mean = valid_rolling_mean( previous_volume, np.isfinite(previous_volume), - 5, + window, ) + return _matrix_ratio(market.volume, previous_mean) + if name in {"vol_ma5", "vol_ma10"}: + window = 5 if name == "vol_ma5" else 10 + volume_valid = close_valid & np.isfinite(market.volume) + return valid_rolling_mean(market.volume, volume_valid, window) + if name == "vol_trend_5_10": + return _matrix_relative( + matrix_feature(market, "vol_ma5"), + matrix_feature(market, "vol_ma10"), + ) + if name == "turnover_ratio_5d": + turnover = market.field("turnover_rate") + valid = close_valid & np.isfinite(turnover) + previous = valid_shift(turnover, 1, valid) + return _matrix_relative( + turnover, + valid_rolling_mean(previous, np.isfinite(previous), 5), + ) + if name == "log_amount": + amount = market.field("amount") out = np.full(market.shape, np.nan, dtype=np.float32) - np.divide( - market.volume, - previous_mean, - out=out, - where=volume_valid & np.isfinite(previous_mean) & (previous_mean != 0), - ) + valid = close_valid & np.isfinite(amount) & (amount >= 0) + np.log(amount + np.float32(1.0), out=out, where=valid) return out - if name == "ma20_bias": - ma20 = valid_rolling_mean(market.close, close_valid, 20) - out = np.full(market.shape, np.nan, dtype=np.float32) - np.divide( - market.close, - ma20, - out=out, - where=close_valid & np.isfinite(ma20) & (ma20 != 0), + if name == "amount_ratio_5d": + amount = market.field("amount") + valid = close_valid & np.isfinite(amount) + previous = valid_shift(amount, 1, valid) + return _matrix_relative( + amount, + valid_rolling_mean(previous, np.isfinite(previous), 5), ) - out -= np.float32(1.0) - return out - if name.startswith("ma") and name[2:].isdigit(): - return valid_rolling_mean(market.close, close_valid, int(name[2:])) if name == "boll_upper" or name == "boll_lower": middle = valid_rolling_mean(market.close, close_valid, 20) deviation = valid_rolling_std(market.close, close_valid, 20, ddof=1) offset = np.float32(2.0) * deviation return middle + offset if name == "boll_upper" else middle - offset + if name == "boll_position": + return _matrix_ratio( + market.close - matrix_feature(market, "boll_lower"), + matrix_feature(market, "boll_upper") - matrix_feature(market, "boll_lower"), + ) + if name == "boll_width": + return _matrix_ratio( + matrix_feature(market, "boll_upper") - matrix_feature(market, "boll_lower"), + matrix_feature(market, "ma20"), + ) if name == "high_60d": return valid_rolling_max(market.close, close_valid, 60) if name == "low_60d": @@ -3775,6 +3887,29 @@ def _compute_matrix_feature(market: MarketDataMatrix, name: str) -> np.ndarray: 20, ddof=1, ) * np.float32(252 ** 0.5) + if name in {"kdj_k", "kdj_d", "kdj_j"}: + low_valid = close_valid & np.isfinite(market.low) + high_valid = close_valid & np.isfinite(market.high) + low_9 = valid_rolling_min(market.low, low_valid, 9) + high_9 = valid_rolling_max(market.high, high_valid, 9) + rsv = _matrix_ratio(market.close - low_9, high_9 - low_9) * np.float32(100.0) + k = valid_ewm_adjust_false(rsv, np.isfinite(rsv), alpha=1.0 / 3.0) + if name == "kdj_k": + return k + d = valid_ewm_adjust_false(k, np.isfinite(k), alpha=1.0 / 3.0) + if name == "kdj_d": + return d + return (np.float32(3.0) * k - np.float32(2.0) * d).astype(np.float32, copy=False) + if name in {"atr_14", "atr_pct"}: + previous = valid_shift(market.close, 1, close_valid) + true_range = np.fmax.reduce([ + market.high - market.low, + np.abs(market.high - previous), + np.abs(market.low - previous), + ]).astype(np.float32, copy=False) + true_range[~close_valid] = np.nan + atr = valid_ewm_adjust_false(true_range, np.isfinite(true_range), alpha=1.0 / 14.0) + return atr if name == "atr_14" else _matrix_ratio(atr, market.close) if name.startswith("rsi_") and name[4:].isdigit(): window = int(name[4:]) delta = market.close - valid_shift(market.close, 1, close_valid) @@ -3798,9 +3933,140 @@ def _compute_matrix_feature(market: MarketDataMatrix, name: str) -> np.ndarray: np.divide(average_gain, denominator, out=out, where=np.isfinite(denominator)) out = np.float32(100.0) - np.float32(100.0) / (np.float32(1.0) + out) return out + if name == "gap_return": + return _matrix_relative(market.open, valid_shift(market.close, 1, close_valid)) + if name == "intraday_return": + return _matrix_relative(market.close, market.open) + if name == "close_position": + return _matrix_ratio(market.close - market.low, market.high - market.low) + if name == "distance_to_high_60d": + return _matrix_relative(market.close, matrix_feature(market, "high_60d")) + if name == "distance_from_low_60d": + return _matrix_relative(market.close, matrix_feature(market, "low_60d")) + if name == "max_ret_20d": + daily = matrix_feature(market, "change_pct") + return valid_rolling_max(daily, np.isfinite(daily), 20) + if name == "ret_skew_20d": + return _matrix_rolling_skew(matrix_feature(market, "change_pct"), 20) + if name == "up_days_20d": + daily = matrix_feature(market, "change_pct") + up = np.where(daily > 0, np.float32(1.0), np.float32(0.0)).astype(np.float32) + up[~np.isfinite(daily)] = np.nan + return valid_rolling_sum(up, np.isfinite(up), 20) + if name == "amihud_20d": + daily = matrix_feature(market, "change_pct") + amount = market.field("amount") + amount_yi = amount / np.float32(1e8) + illiquidity = _matrix_ratio(np.abs(daily), amount_yi) + return valid_rolling_mean( + illiquidity, + close_valid & np.isfinite(illiquidity), + 20, + ) + if name == "turnover_z_60d": + turnover = market.field("turnover_rate") + valid = close_valid & np.isfinite(turnover) + previous = valid_shift(turnover, 1, valid) + baseline_valid = np.isfinite(previous) + mean = valid_rolling_mean(previous, baseline_valid, 60) + std = valid_rolling_std(previous, baseline_valid, 60, ddof=1) + deviation = _matrix_ratio(turnover - mean, std) + deviation[np.isfinite(std) & (std <= 0)] = np.nan + return deviation + if name == "vol_price_corr_20d": + daily = matrix_feature(market, "change_pct") + return _matrix_rolling_corr(daily, market.volume, close_valid, 20) + if name == "vwap_bias": + amount = market.field("amount") + shares = market.volume * np.float32(100.0) + valid = close_valid & np.isfinite(amount) & (market.volume > 0) & (amount > 0) + vwap = np.full(market.shape, np.nan, dtype=np.float32) + np.divide(amount, shares, out=vwap, where=valid) + return _matrix_relative(market.close, vwap) + if name == "vol_trend_5_60": + volume_valid = close_valid & np.isfinite(market.volume) + fast = valid_rolling_mean(market.volume, volume_valid, 5) + slow = valid_rolling_mean(market.volume, volume_valid, 60) + return _matrix_relative(fast, slow) + if name in {"limit_up_count_20d", "limit_up_count_60d"}: + window = 20 if name == "limit_up_count_20d" else 60 + consecutive = market.field("consecutive_limit_ups") + hits = np.where(np.isfinite(consecutive) & (consecutive > 0), np.float32(1.0), np.float32(0.0)) + hits = hits.astype(np.float32) + return valid_rolling_sum(hits, close_valid, window) raise ValueError(f"unsupported matrix feature: {name}") +def _matrix_rolling_skew(values: np.ndarray, window: int) -> np.ndarray: + valid = np.isfinite(values) + first = valid_rolling_mean(values, valid, window) + second = valid_rolling_mean(np.square(values, dtype=np.float32), valid, window) + third = valid_rolling_mean( + (values * values * values).astype(np.float32), valid, window + ) + variance = second - np.square(first, dtype=np.float32) + central_third = ( + third + - np.float32(3.0) * first * second + + np.float32(2.0) * np.power(first, 3) + ) + out = _matrix_ratio(central_third, np.sqrt(np.power(variance, 3))) + out[np.isfinite(variance) & (variance <= 0)] = np.nan + return out + + +def _matrix_rolling_corr( + left: np.ndarray, right: np.ndarray, valid_mask: np.ndarray, window: int +) -> np.ndarray: + valid = valid_mask & np.isfinite(left) & np.isfinite(right) + product = (left * right).astype(np.float32) + mean_left = valid_rolling_mean(left, valid, window) + mean_right = valid_rolling_mean(right, valid, window) + mean_product = valid_rolling_mean(product, valid, window) + mean_left_sq = valid_rolling_mean( + np.square(left, dtype=np.float32), valid, window + ) + mean_right_sq = valid_rolling_mean( + np.square(right, dtype=np.float32), valid, window + ) + covariance = mean_product - mean_left * mean_right + variance_left = mean_left_sq - np.square(mean_left, dtype=np.float32) + variance_right = mean_right_sq - np.square(mean_right, dtype=np.float32) + denominator = np.sqrt(variance_left * variance_right) + out = _matrix_ratio(covariance, denominator) + degenerate = ( + np.isfinite(variance_left) + & np.isfinite(variance_right) + & ((variance_left <= 0) | (variance_right <= 0)) + ) + out[degenerate] = np.nan + return out + + +def _matrix_ema(values: np.ndarray, valid: np.ndarray, period: int) -> np.ndarray: + return valid_ewm_adjust_false(values, valid, alpha=2.0 / (period + 1.0)) + + +def _matrix_macd(values: np.ndarray, valid: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + dif = _matrix_ema(values, valid, 12) - _matrix_ema(values, valid, 26) + dif = dif.astype(np.float32, copy=False) + dea = valid_ewm_adjust_false(dif, np.isfinite(dif), alpha=2.0 / 10.0) + return dif, dea + + +def _matrix_ratio(numerator: np.ndarray, denominator: np.ndarray) -> np.ndarray: + out = np.full(numerator.shape, np.nan, dtype=np.float32) + valid = np.isfinite(numerator) & np.isfinite(denominator) & (denominator != 0) + np.divide(numerator, denominator, out=out, where=valid) + return out + + +def _matrix_relative(numerator: np.ndarray, denominator: np.ndarray) -> np.ndarray: + out = _matrix_ratio(numerator, denominator) + out[np.isfinite(out)] -= np.float32(1.0) + return out + + def apply_time_masks( signals: SignalMatrix, entry_time_mask: np.ndarray, diff --git a/backend/app/backtest/mining.py b/backend/app/backtest/mining.py new file mode 100644 index 0000000..a4a23fb --- /dev/null +++ b/backend/app/backtest/mining.py @@ -0,0 +1,1660 @@ +"""Pure factor-mining algorithms and callback-driven nested validation.""" +from __future__ import annotations + +import json +import logging +import math +import time +from collections.abc import Callable, Mapping, Sequence +from dataclasses import asdict, dataclass, field, replace +from datetime import date +from typing import Any, Literal, Protocol + +import numpy as np +import polars as pl + +logger = logging.getLogger(__name__) + +MAX_MINING_FACTORS = 48 +MAX_EXISTING_STRATEGIES = 8 +MAX_COMBINATION_SIZE = 4 +MAX_BEAM_WIDTH = 32 +MAX_FINALISTS = 8 +MAX_REAL_TRIALS = 256 + +# Evidence-based promotion gate documented in docs/mining.md. A candidate must +# clear every threshold before it may be published as an independent strategy. +GATE_MIN_VALID_FOLDS = 2 +GATE_MIN_POSITIVE_FOLD_RATIO = 2.0 / 3.0 +GATE_MIN_OOS_SHARPE = 0.5 +GATE_MAX_DRAWDOWN = -0.25 +GATE_MIN_TRADES = 60 + +MiningProfile = Literal["exploratory", "balanced", "strict"] + + +class JsonDataclassMixin: + """Provide JSON output without coupling pure models to Pydantic.""" + + def to_json(self) -> str: + return json.dumps(asdict(self), ensure_ascii=False, allow_nan=False) + + +@dataclass(frozen=True) +class MiningBudget(JsonDataclassMixin): + max_factors: int = MAX_MINING_FACTORS + max_existing_strategies: int = MAX_EXISTING_STRATEGIES + max_combination_size: int = MAX_COMBINATION_SIZE + beam_width: int = 16 + max_proxy_trials: int = 256 + max_trials: int = 96 + + def __post_init__(self) -> None: + if not 1 <= self.max_factors <= MAX_MINING_FACTORS: + raise ValueError(f"max_factors must be between 1 and {MAX_MINING_FACTORS}") + if not 0 <= self.max_existing_strategies <= MAX_EXISTING_STRATEGIES: + raise ValueError( + "max_existing_strategies must be between 0 and " + f"{MAX_EXISTING_STRATEGIES}" + ) + if not 1 <= self.max_combination_size <= MAX_COMBINATION_SIZE: + raise ValueError( + "max_combination_size must be between 1 and " + f"{MAX_COMBINATION_SIZE}" + ) + if not 1 <= self.beam_width <= MAX_BEAM_WIDTH: + raise ValueError(f"beam_width must be between 1 and {MAX_BEAM_WIDTH}") + if self.max_proxy_trials <= 0: + raise ValueError("max_proxy_trials must be positive") + if not 1 <= self.max_trials <= MAX_REAL_TRIALS: + raise ValueError(f"max_trials must be between 1 and {MAX_REAL_TRIALS}") + + @classmethod + def exploratory(cls) -> MiningBudget: + return cls( + max_combination_size=3, + beam_width=8, + max_proxy_trials=96, + max_trials=32, + ) + + @classmethod + def balanced(cls) -> MiningBudget: + return cls() + + @classmethod + def strict(cls) -> MiningBudget: + return cls(beam_width=32, max_proxy_trials=512, max_trials=256) + + +@dataclass(frozen=True) +class NestedValidationConfig(JsonDataclassMixin): + outer_train_bars: int = 504 + outer_test_bars: int = 126 + outer_step_bars: int = 63 + inner_train_bars: int = 252 + inner_test_bars: int = 63 + inner_step_bars: int = 63 + purge_bars: int = 30 + embargo_bars: int = 5 + min_train_bars: int = 126 + + def __post_init__(self) -> None: + positive = { + "outer_train_bars": self.outer_train_bars, + "outer_test_bars": self.outer_test_bars, + "outer_step_bars": self.outer_step_bars, + "inner_train_bars": self.inner_train_bars, + "inner_test_bars": self.inner_test_bars, + "inner_step_bars": self.inner_step_bars, + "min_train_bars": self.min_train_bars, + } + invalid = [name for name, value in positive.items() if value <= 0] + if invalid: + raise ValueError(f"nested validation bars must be positive: {invalid}") + if self.purge_bars < 0 or self.embargo_bars < 0: + raise ValueError("purge_bars and embargo_bars must not be negative") + if self.outer_train_bars < self.min_train_bars: + raise ValueError("outer_train_bars is smaller than min_train_bars") + if self.inner_train_bars < self.min_train_bars: + raise ValueError("inner_train_bars is smaller than min_train_bars") + + @classmethod + def exploratory(cls) -> NestedValidationConfig: + return cls( + outer_train_bars=126, + outer_test_bars=63, + outer_step_bars=63, + inner_train_bars=63, + inner_test_bars=21, + inner_step_bars=21, + purge_bars=30, + embargo_bars=5, + min_train_bars=63, + ) + + @classmethod + def balanced(cls) -> NestedValidationConfig: + return cls() + + @classmethod + def strict(cls) -> NestedValidationConfig: + return cls( + outer_train_bars=756, + outer_test_bars=126, + outer_step_bars=126, + inner_train_bars=504, + inner_test_bars=63, + inner_step_bars=63, + purge_bars=30, + embargo_bars=5, + min_train_bars=126, + ) + + +def validation_config_for_profile(profile: str) -> NestedValidationConfig: + if profile not in {"exploratory", "balanced", "strict"}: + raise ValueError(f"unknown mining profile: {profile}") + return getattr(NestedValidationConfig, profile)() + + +def required_outer_folds(profile: str) -> int: + validation_config_for_profile(profile) + return 1 if profile == "exploratory" else 3 + + +def required_trading_bars( + config: NestedValidationConfig, + outer_folds: int, +) -> int: + if outer_folds <= 0: + raise ValueError("outer_folds must be positive") + return ( + config.outer_train_bars + + config.purge_bars + + config.outer_test_bars + + (outer_folds - 1) * config.outer_step_bars + ) + + +def nested_fold_count( + trading_bars: int, + config: NestedValidationConfig, +) -> int: + if trading_bars < 0: + raise ValueError("trading_bars must not be negative") + one_fold_bars = required_trading_bars(config, 1) + if trading_bars < one_fold_bars: + return 0 + return 1 + (trading_bars - one_fold_bars) // config.outer_step_bars + + +@dataclass(frozen=True) +class CandidateGateResult(JsonDataclassMixin): + qualified: bool + reasons: tuple[str, ...] + + +def evaluate_candidate_gate( + *, + confidence: str | None, + valid_folds: int | None, + positive_fold_ratio: float | None, + sharpe: float | None, + max_drawdown: float | None, + n_trades: int | None, +) -> CandidateGateResult: + """Check the documented promotion thresholds against real candidate evidence.""" + reasons: list[str] = [] + if confidence == "low": + reasons.append("exploratory results can only be saved as pending candidates") + if valid_folds is None or valid_folds < GATE_MIN_VALID_FOLDS: + reasons.append( + "requires at least " + f"{GATE_MIN_VALID_FOLDS} valid outer folds (got " + f"{'none' if valid_folds is None else valid_folds})" + ) + if positive_fold_ratio is None or positive_fold_ratio < GATE_MIN_POSITIVE_FOLD_RATIO: + reasons.append( + "requires a positive-return fold ratio of at least " + f"{GATE_MIN_POSITIVE_FOLD_RATIO:.2f}" + ) + if sharpe is None or sharpe < GATE_MIN_OOS_SHARPE: + reasons.append(f"requires an OOS Sharpe of at least {GATE_MIN_OOS_SHARPE}") + if max_drawdown is None or max_drawdown < GATE_MAX_DRAWDOWN: + reasons.append( + f"requires a max drawdown no worse than {abs(GATE_MAX_DRAWDOWN):.0%}" + ) + if n_trades is None or n_trades < GATE_MIN_TRADES: + reasons.append(f"requires at least {GATE_MIN_TRADES} OOS trades") + return CandidateGateResult(qualified=not reasons, reasons=tuple(reasons)) + + +@dataclass(frozen=True) +class MiningRequest(JsonDataclassMixin): + factor_names: tuple[str, ...] + existing_strategy_ids: tuple[str, ...] = () + correlation_threshold: float = 0.8 + date_column: str = "date" + target_column: str = "_next_return" + budget: MiningBudget = field(default_factory=MiningBudget.balanced) + validation: NestedValidationConfig = field( + default_factory=NestedValidationConfig.balanced + ) + profile: Literal["exploratory", "balanced", "strict"] = "balanced" + + def __post_init__(self) -> None: + if not self.factor_names: + raise ValueError("factor_names must not be empty") + if len(set(self.factor_names)) != len(self.factor_names): + raise ValueError("factor_names must not contain duplicates") + if len(self.factor_names) > self.budget.max_factors: + raise ValueError( + f"factor count {len(self.factor_names)} exceeds budget " + f"{self.budget.max_factors}" + ) + if len(set(self.existing_strategy_ids)) != len(self.existing_strategy_ids): + raise ValueError("existing_strategy_ids must not contain duplicates") + if len(self.existing_strategy_ids) > self.budget.max_existing_strategies: + raise ValueError( + f"existing strategy count {len(self.existing_strategy_ids)} exceeds " + f"budget {self.budget.max_existing_strategies}" + ) + if not 0.0 < self.correlation_threshold <= 1.0: + raise ValueError("correlation_threshold must be in (0, 1]") + if not self.date_column or not self.target_column: + raise ValueError("date_column and target_column must not be empty") + + @classmethod + def for_profile( + cls, + profile: Literal["exploratory", "balanced", "strict"], + factor_names: Sequence[str], + existing_strategy_ids: Sequence[str] = (), + **kwargs: Any, + ) -> MiningRequest: + if profile not in {"exploratory", "balanced", "strict"}: + raise ValueError(f"unknown mining profile: {profile}") + return cls( + factor_names=tuple(factor_names), + existing_strategy_ids=tuple(existing_strategy_ids), + budget=getattr(MiningBudget, profile)(), + validation=getattr(NestedValidationConfig, profile)(), + profile=profile, + **kwargs, + ) + + +@dataclass(frozen=True) +class FactorMetric(JsonDataclassMixin): + factor_id: str + composite_score: float + ir: float + coverage: float + turnover: float + rank_ic: float = 0.0 + + +@dataclass(frozen=True) +class CorrelationResult(JsonDataclassMixin): + factor_names: tuple[str, ...] + matrix: tuple[tuple[float, ...], ...] + pair_counts: tuple[tuple[int, ...], ...] + elapsed_ms: float + n_dates: int + n_rows: int + timing_ms: dict[str, float] = field(default_factory=dict) + + +@dataclass(frozen=True) +class FactorExclusion(JsonDataclassMixin): + factor_id: str + reason: str + representative: str + rho: float + + +@dataclass(frozen=True) +class PruneResult(JsonDataclassMixin): + selected: tuple[str, ...] + excluded: tuple[FactorExclusion, ...] + + +@dataclass(frozen=True) +class MiningCandidate(JsonDataclassMixin): + candidate_id: str + kind: Literal["factor_rank", "existing_strategy"] + factor_names: tuple[str, ...] = () + weights: tuple[float, ...] = () + directions: tuple[int, ...] = () + strategy_id: str | None = None + proxy_rank_ic: float = 0.0 + proxy_ir: float = 0.0 + observations: int = 0 + dates: int = 0 + + def definition(self) -> dict[str, Any]: + if self.kind == "existing_strategy": + return {"kind": self.kind, "strategy_id": self.strategy_id} + return { + "kind": self.kind, + "factor_names": list(self.factor_names), + "scoring": dict(zip(self.factor_names, self.weights, strict=True)), + "directions": { + factor_id: "high" if direction > 0 else "low" + for factor_id, direction in zip( + self.factor_names, + self.directions, + strict=True, + ) + }, + } + + +@dataclass(frozen=True) +class BeamSearchResult(JsonDataclassMixin): + candidates: tuple[MiningCandidate, ...] + trials_used: int + cancelled: bool + budget_exhausted: bool + elapsed_ms: float + + +@dataclass(frozen=True) +class ValidationFold(JsonDataclassMixin): + level: Literal["outer", "inner"] + outer_index: int + inner_index: int | None + train_labels: tuple[str, ...] + purge_labels: tuple[str, ...] + test_labels: tuple[str, ...] + embargo_labels: tuple[str, ...] + + @property + def train_start(self) -> str: + return self.train_labels[0] + + @property + def train_end(self) -> str: + return self.train_labels[-1] + + @property + def test_start(self) -> str: + return self.test_labels[0] + + @property + def test_end(self) -> str: + return self.test_labels[-1] + + +@dataclass(frozen=True) +class NestedFold(JsonDataclassMixin): + outer: ValidationFold + inner: tuple[ValidationFold, ...] + + +@dataclass(frozen=True) +class CandidateEvaluation(JsonDataclassMixin): + score: float | None + metrics: dict[str, Any] = field(default_factory=dict) + error: str | None = None + + +@dataclass(frozen=True) +class FoldMiningResult(JsonDataclassMixin): + outer_index: int + selected_factors: tuple[str, ...] + candidates: tuple[MiningCandidate, ...] + selected_candidate_id: str | None + inner_score: float | None + outer_evaluation: CandidateEvaluation | None + error: str | None = None + benchmark_evaluations: tuple[tuple[str, CandidateEvaluation], ...] = () + cross_evaluations: tuple[tuple[str, CandidateEvaluation], ...] = () + + +@dataclass(frozen=True) +class MiningResult(JsonDataclassMixin): + request: MiningRequest + folds: tuple[FoldMiningResult, ...] + proxy_trials_used: int + trials_used: int + cancelled: bool + elapsed_ms: float + + +class CandidateEvaluator(Protocol): + def evaluate_candidate( + self, + train: pl.DataFrame, + test: pl.DataFrame, + definition: Mapping[str, Any], + ) -> CandidateEvaluation | Mapping[str, Any] | float: ... + + +class FactorMetricProvider(Protocol): + def __call__( + self, + train: pl.DataFrame, + factor_names: Sequence[str], + ) -> Sequence[FactorMetric]: ... + + +CancelCheck = Callable[[], bool] | Any + + +def compute_rank_correlation( + panel: pl.DataFrame, + factor_names: Sequence[str], + start: Any | None = None, + end: Any | None = None, + *, + date_column: str = "date", +) -> CorrelationResult: + """Average pairwise daily cross-sectional rank correlations. + + One date partition is ranked and materialized at a time. Only the factor-by-factor + correlation sums and valid-day counts remain resident across dates. + """ + started = time.perf_counter() + names = _validate_factor_names(panel, factor_names) + if date_column not in panel.columns: + raise ValueError(f"panel is missing date column {date_column!r}") + + filter_started = time.perf_counter() + date_expr = pl.col(date_column).cast(pl.Utf8).str.slice(0, 10) + scoped = panel.select([ + pl.col(date_column), + *( + pl.when(pl.col(name).is_finite()) + .then(pl.col(name)) + .otherwise(None) + .alias(name) + for name in names + ), + ]) + if start is not None: + scoped = scoped.filter(date_expr >= str(start)[:10]) + if end is not None: + scoped = scoped.filter(date_expr <= str(end)[:10]) + if scoped.is_empty(): + raise ValueError("rank correlation date range contains no panel rows") + filter_ms = (time.perf_counter() - filter_started) * 1000.0 + + width = len(names) + pair_columns: dict[tuple[int, int], str] = {} + pair_expressions: list[pl.Expr] = [] + for left in range(width): + for right in range(left + 1, width): + column = f"_rho_{left}_{right}" + pair_columns[(left, right)] = column + pair_expressions.append( + pl.corr(names[left], names[right], method="spearman").alias(column) + ) + observation_columns = [f"_n_{index}" for index in range(width)] + + rank_started = time.perf_counter() + daily = scoped.group_by(date_column).agg([ + *pair_expressions, + *( + pl.col(name).count().alias(column) + for name, column in zip(names, observation_columns, strict=True) + ), + ]) + rank_ms = (time.perf_counter() - rank_started) * 1000.0 + + finish_started = time.perf_counter() + correlation = np.full((width, width), np.nan, dtype=np.float64) + counts = np.zeros((width, width), dtype=np.int32) + for (left, right), column in pair_columns.items(): + values = daily.get_column(column).to_numpy() + finite = np.isfinite(values) + count = int(np.count_nonzero(finite)) + if count: + value = float(np.mean(values[finite])) + correlation[left, right] = correlation[right, left] = value + counts[left, right] = counts[right, left] = count + for index, column in enumerate(observation_columns): + count = int(np.count_nonzero(daily.get_column(column).to_numpy() >= 2)) + if count: + correlation[index, index] = 1.0 + counts[index, index] = count + finish_ms = (time.perf_counter() - finish_started) * 1000.0 + n_dates = daily.height + elapsed_ms = (time.perf_counter() - started) * 1000.0 + timing = { + "filter": round(filter_ms, 3), + "rank_accumulate": round(rank_ms, 3), + "finalize": round(finish_ms, 3), + "total": round(elapsed_ms, 3), + } + logger.info( + "mining rank correlation factors=%d rows=%d dates=%d elapsed_ms=%.1f", + width, + scoped.height, + n_dates, + elapsed_ms, + ) + return CorrelationResult( + factor_names=names, + matrix=tuple(tuple(float(value) for value in row) for row in correlation), + pair_counts=tuple( + tuple(int(value) for value in row) + for row in counts + ), + elapsed_ms=round(elapsed_ms, 3), + n_dates=n_dates, + n_rows=scoped.height, + timing_ms=timing, + ) + + +def prune_correlated_factors( + metrics: Sequence[FactorMetric], + correlation: CorrelationResult, + threshold: float, +) -> PruneResult: + """Keep the strongest deterministic representative of correlated factors.""" + if not 0.0 < threshold <= 1.0: + raise ValueError("correlation threshold must be in (0, 1]") + by_name = {name: index for index, name in enumerate(correlation.factor_names)} + if len({metric.factor_id for metric in metrics}) != len(metrics): + raise ValueError("factor metrics must not contain duplicate factor_id values") + missing = sorted(metric.factor_id for metric in metrics if metric.factor_id not in by_name) + if missing: + raise ValueError(f"factor metrics missing from correlation result: {missing}") + + ordered = sorted( + metrics, + key=lambda metric: ( + -_finite_sort_value(metric.composite_score), + -_finite_sort_value(metric.ir), + -_finite_sort_value(metric.coverage), + _finite_sort_value(metric.turnover, worst=float("inf")), + metric.factor_id, + ), + ) + selected: list[str] = [] + excluded: list[FactorExclusion] = [] + for metric in ordered: + factor_index = by_name[metric.factor_id] + correlated = [ + (representative, correlation.matrix[factor_index][by_name[representative]]) + for representative in selected + if ( + correlation.pair_counts[factor_index][by_name[representative]] > 0 + and math.isfinite( + correlation.matrix[factor_index][by_name[representative]] + ) + and abs( + correlation.matrix[factor_index][by_name[representative]] + ) >= threshold + ) + ] + if not correlated: + selected.append(metric.factor_id) + continue + representative, rho = max( + correlated, + key=lambda item: (abs(item[1]), -selected.index(item[0])), + ) + excluded.append(FactorExclusion( + factor_id=metric.factor_id, + reason="correlation_threshold", + representative=representative, + rho=round(float(rho), 8), + )) + return PruneResult(selected=tuple(selected), excluded=tuple(excluded)) + + +def beam_search_factor_combinations( + panel: pl.DataFrame, + factor_names: Sequence[str], + *, + target_column: str = "_next_return", + date_column: str = "date", + train_start: Any | None = None, + train_end: Any | None = None, + max_combination_size: int = MAX_COMBINATION_SIZE, + beam_width: int = 16, + max_trials: int = 256, + cancel_check: CancelCheck | None = None, +) -> BeamSearchResult: + """Search equal and one-factor-double rank combinations on training data only.""" + started = time.perf_counter() + names = _validate_factor_names(panel, factor_names) + if target_column not in panel.columns: + raise ValueError(f"panel is missing target column {target_column!r}") + if date_column not in panel.columns: + raise ValueError(f"panel is missing date column {date_column!r}") + if not 1 <= max_combination_size <= MAX_COMBINATION_SIZE: + raise ValueError( + f"max_combination_size must be between 1 and {MAX_COMBINATION_SIZE}" + ) + if not 1 <= beam_width <= MAX_BEAM_WIDTH: + raise ValueError(f"beam_width must be between 1 and {MAX_BEAM_WIDTH}") + if max_trials <= 0: + raise ValueError("max_trials must be positive") + + date_expr = pl.col(date_column).cast(pl.Utf8).str.slice(0, 10) + scoped = panel.select([date_column, *names, target_column]) + if train_start is not None: + scoped = scoped.filter(date_expr >= str(train_start)[:10]) + if train_end is not None: + scoped = scoped.filter(date_expr <= str(train_end)[:10]) + if scoped.is_empty(): + raise ValueError("beam search training range contains no panel rows") + + ordered_names = tuple(sorted(names)) + name_to_column = {name: index for index, name in enumerate(ordered_names)} + if not scoped.get_column(date_column).is_sorted(): + scoped = scoped.sort(date_column) + blocks = tuple( + daily.select([*ordered_names, target_column]).to_numpy() + for daily in scoped.partition_by(date_column, maintain_order=True) + ) + trials_used = 0 + cancelled = False + + def evaluate( + factors: tuple[str, ...], + weights: tuple[float, ...], + directions: tuple[int, ...], + ) -> tuple[float, float, int, int] | None: + nonlocal trials_used, cancelled + if trials_used >= max_trials: + return None + if _cancelled(cancel_check): + cancelled = True + return None + trials_used += 1 + columns = tuple(name_to_column[name] for name in factors) + return _proxy_rank_ic(blocks, columns, weights, directions) + + directions_by_factor: dict[str, int] = {} + single_candidates: list[MiningCandidate] = [] + for factor_name in ordered_names: + proxy = evaluate((factor_name,), (1.0,), (1,)) + if proxy is None: + break + rank_ic, proxy_ir, observations, n_dates = proxy + if n_dates <= 0 or observations < 3: + continue + direction = 1 if rank_ic >= 0.0 else -1 + directions_by_factor[factor_name] = direction + single_candidates.append(_factor_candidate( + (factor_name,), + (1.0,), + (direction,), + abs(rank_ic), + abs(proxy_ir), + observations, + n_dates, + )) + + current_beam = _rank_candidates(single_candidates)[:beam_width] + retained = list(current_beam) + searchable_names = tuple(sorted(directions_by_factor)) + max_size = min(max_combination_size, len(searchable_names)) + for size in range(2, max_size + 1): + if cancelled or trials_used >= max_trials or not current_beam: + break + factor_sets: set[tuple[str, ...]] = set() + for candidate in current_beam: + for factor_name in searchable_names: + combined = tuple(sorted({*candidate.factor_names, factor_name})) + if len(combined) == size: + factor_sets.add(combined) + + expanded: list[MiningCandidate] = [] + stop = False + for factors in sorted(factor_sets): + directions = tuple(directions_by_factor[name] for name in factors) + patterns = [(1.0,) * size] + patterns.extend( + tuple(2.0 if index == doubled else 1.0 for index in range(size)) + for doubled in range(size) + ) + for weights in patterns: + proxy = evaluate(factors, weights, directions) + if proxy is None: + stop = True + break + rank_ic, proxy_ir, observations, n_dates = proxy + if n_dates <= 0 or observations < 3: + continue + expanded.append(_factor_candidate( + factors, + weights, + directions, + rank_ic, + proxy_ir, + observations, + n_dates, + )) + if stop: + break + current_beam = _rank_candidates(expanded)[:beam_width] + retained.extend(current_beam) + if stop: + break + + elapsed_ms = (time.perf_counter() - started) * 1000.0 + return BeamSearchResult( + candidates=tuple(_rank_candidates(retained)), + trials_used=trials_used, + cancelled=cancelled, + budget_exhausted=trials_used >= max_trials, + elapsed_ms=round(elapsed_ms, 3), + ) + + +def generate_nested_folds( + trading_labels: Sequence[Any], + config: NestedValidationConfig, +) -> tuple[NestedFold, ...]: + """Generate rolling nested folds from ordered trading labels, not calendar days.""" + labels = tuple(dict.fromkeys(str(label) for label in trading_labels)) + if not labels: + raise ValueError("trading labels are empty") + if tuple(sorted(labels)) != labels: + raise ValueError("trading labels must be sorted in ascending order") + outer_required = ( + config.outer_train_bars + config.purge_bars + config.outer_test_bars + ) + if len(labels) < outer_required: + raise ValueError( + "insufficient trading bars for outer validation: " + f"need at least {outer_required}, got {len(labels)}" + ) + inner_required = ( + config.inner_train_bars + config.purge_bars + config.inner_test_bars + ) + if config.outer_train_bars < inner_required: + raise ValueError( + "outer training window is too short for one inner fold: " + f"need at least {inner_required}, got {config.outer_train_bars}" + ) + + nested: list[NestedFold] = [] + outer_start = 0 + outer_index = 0 + while outer_start + outer_required <= len(labels): + outer = _make_validation_fold( + labels, + level="outer", + outer_index=outer_index, + inner_index=None, + train_start=outer_start, + train_bars=config.outer_train_bars, + test_bars=config.outer_test_bars, + purge_bars=config.purge_bars, + embargo_bars=config.embargo_bars, + ) + inner_folds: list[ValidationFold] = [] + inner_start = outer_start + inner_index = 0 + outer_train_stop = outer_start + config.outer_train_bars + while inner_start + inner_required <= outer_train_stop: + inner_folds.append(_make_validation_fold( + labels, + level="inner", + outer_index=outer_index, + inner_index=inner_index, + train_start=inner_start, + train_bars=config.inner_train_bars, + test_bars=config.inner_test_bars, + purge_bars=config.purge_bars, + embargo_bars=config.embargo_bars, + hard_stop=outer_train_stop, + )) + inner_index += 1 + inner_start += config.inner_step_bars + if not inner_folds: + raise ValueError(f"outer fold {outer_index} contains no valid inner fold") + nested.append(NestedFold(outer=outer, inner=tuple(inner_folds))) + outer_index += 1 + outer_start += config.outer_step_bars + return tuple(nested) + + +class MiningService: + """Run leakage-bounded mining and delegate real backtests to a callback.""" + + def run( + self, + panel: pl.DataFrame, + request: MiningRequest, + *, + factor_metrics: Sequence[FactorMetric] | None = None, + metric_provider: FactorMetricProvider | None = None, + evaluator: CandidateEvaluator | None = None, + cancel_check: CancelCheck | None = None, + ) -> MiningResult: + started = time.perf_counter() + _validate_factor_names(panel, request.factor_names) + required = {request.date_column, request.target_column} + missing = sorted(required - set(panel.columns)) + if missing: + raise ValueError(f"mining panel is missing required columns: {missing}") + if evaluator is not None: + if factor_metrics is not None: + raise ValueError("factor_metrics must not be supplied with evaluator") + if metric_provider is None: + raise ValueError("metric_provider is required when evaluator is supplied") + elif metric_provider is None and factor_metrics is None: + raise ValueError("factor_metrics or metric_provider is required") + + labels = panel.select( + pl.col(request.date_column).cast(pl.Utf8).str.slice(0, 10).unique().sort() + ).to_series().to_list() + nested_folds = generate_nested_folds(labels, request.validation) + if request.profile != "exploratory" and len(nested_folds) < 3: + raise ValueError( + f"{request.profile} mining requires at least 3 outer folds; " + f"got {len(nested_folds)}" + ) + fold_results: list[FoldMiningResult] = [] + proxy_trials_used = 0 + trials_used = 0 + cancelled = False + labels_evaluator = ( + getattr(evaluator, "evaluate_candidate_labels", None) + if evaluator is not None + else None + ) + + def run_benchmarks(nested_fold) -> tuple[tuple[str, CandidateEvaluation], ...]: + """Score every user-selected strategy on one outer test window. + + Benchmarks document how existing strategies would have done on the + same walk-forward windows, so they are evaluated even when the + factor track fails on this fold and never join the winner race. + """ + nonlocal trials_used, cancelled + if evaluator is None: + return () + benchmarks: list[tuple[str, CandidateEvaluation]] = [] + for strategy_id in request.existing_strategy_ids: + if _cancelled(cancel_check): + cancelled = True + break + if trials_used >= request.budget.max_trials: + benchmarks.append(( + _benchmark_signature(strategy_id), + CandidateEvaluation( + score=None, + error="real trial budget exhausted before benchmark evaluation", + ), + )) + continue + benchmark = benchmark_candidate(strategy_id) + if callable(labels_evaluator): + benchmark_evaluation = _evaluate_labels( + labels_evaluator, + nested_fold.outer.train_labels, + nested_fold.outer.test_labels, + benchmark, + ) + else: + benchmark_evaluation = _evaluate( + evaluator, + _train_panel_for_fold(panel, request.date_column, nested_fold.outer), + _panel_for_labels(panel, request.date_column, nested_fold.outer.test_labels), + benchmark, + ) + trials_used += 1 + benchmarks.append((benchmark.candidate_id, benchmark_evaluation)) + return tuple(benchmarks) + + if evaluator is None: + selection_phases_remaining = len(nested_folds) + evaluation_phases_remaining = 0 + else: + selection_phases_remaining = sum( + len(nested.inner) + 1 for nested in nested_folds + ) + evaluation_phases_remaining = selection_phases_remaining + + for nested in nested_folds: + if _cancelled(cancel_check): + cancelled = True + break + + if evaluator is None: + outer_train = _train_panel_for_fold( + panel, + request.date_column, + nested.outer, + ) + metrics = tuple( + metric_provider(outer_train, request.factor_names) + if metric_provider is not None + else factor_metrics or () + ) + correlation = compute_rank_correlation( + outer_train, + request.factor_names, + date_column=request.date_column, + ) + pruned = prune_correlated_factors( + metrics, + correlation, + request.correlation_threshold, + ) + proxy_remaining = request.budget.max_proxy_trials - proxy_trials_used + if proxy_remaining <= 0: + fold_results.append(FoldMiningResult( + outer_index=nested.outer.outer_index, + selected_factors=pruned.selected, + candidates=(), + selected_candidate_id=None, + inner_score=None, + outer_evaluation=None, + error="proxy trial budget exhausted", + )) + break + proxy_allowance = max( + 1, + proxy_remaining // selection_phases_remaining, + ) + beam = beam_search_factor_combinations( + outer_train, + _searchable_factors(pruned.selected, request.budget), + target_column=request.target_column, + date_column=request.date_column, + max_combination_size=request.budget.max_combination_size, + beam_width=request.budget.beam_width, + max_trials=proxy_allowance, + cancel_check=cancel_check, + ) + proxy_trials_used += beam.trials_used + selection_phases_remaining -= 1 + candidates = beam.candidates + fold_results.append(FoldMiningResult( + outer_index=nested.outer.outer_index, + selected_factors=pruned.selected, + candidates=tuple(candidates), + selected_candidate_id=None, + inner_score=None, + outer_evaluation=None, + )) + continue + + winners: list[tuple[float, MiningCandidate]] = [] + all_candidates: dict[str, MiningCandidate] = {} + selected_factor_union: set[str] = set() + for inner in nested.inner: + if _cancelled(cancel_check): + cancelled = True + break + inner_train = _train_panel_for_fold( + panel, + request.date_column, + inner, + ) + inner_test = _panel_for_labels( + panel, + request.date_column, + inner.test_labels, + ) + metrics = tuple( + metric_provider(inner_train, request.factor_names) + if metric_provider is not None + else factor_metrics or () + ) + correlation = compute_rank_correlation( + inner_train, + request.factor_names, + date_column=request.date_column, + ) + pruned = prune_correlated_factors( + metrics, + correlation, + request.correlation_threshold, + ) + selected_factor_union.update(pruned.selected) + + proxy_remaining = request.budget.max_proxy_trials - proxy_trials_used + if proxy_remaining <= 0: + break + proxy_allowance = max( + 1, + proxy_remaining // selection_phases_remaining, + ) + beam = beam_search_factor_combinations( + inner_train, + _searchable_factors(pruned.selected, request.budget), + target_column=request.target_column, + date_column=request.date_column, + max_combination_size=request.budget.max_combination_size, + beam_width=request.budget.beam_width, + max_trials=proxy_allowance, + cancel_check=cancel_check, + ) + proxy_trials_used += beam.trials_used + selection_phases_remaining -= 1 + candidates = beam.candidates + for candidate in candidates: + all_candidates[candidate.candidate_id] = candidate + + real_remaining = request.budget.max_trials - trials_used + if real_remaining <= 0: + break + real_allowance = min( + MAX_FINALISTS, + max(1, real_remaining // evaluation_phases_remaining), + ) + evaluated: list[tuple[float, MiningCandidate]] = [] + for candidate in candidates[:real_allowance]: + evaluation = ( + _evaluate_labels( + labels_evaluator, + inner.train_labels, + inner.test_labels, + candidate, + ) + if callable(labels_evaluator) + else _evaluate( + evaluator, + inner_train, + inner_test, + candidate, + ) + ) + trials_used += 1 + if evaluation.error is None and evaluation.score is not None: + evaluated.append((evaluation.score, candidate)) + evaluation_phases_remaining -= 1 + if evaluated: + winners.append(min( + evaluated, + key=lambda item: (-item[0], item[1].candidate_id), + )) + + if cancelled: + break + if not winners: + fold_results.append(FoldMiningResult( + outer_index=nested.outer.outer_index, + selected_factors=tuple(sorted(selected_factor_union)), + candidates=tuple(_rank_candidates(list(all_candidates.values()))), + selected_candidate_id=None, + inner_score=None, + outer_evaluation=None, + error="no candidate completed inner validation within budget", + benchmark_evaluations=run_benchmarks(nested), + )) + break + + by_candidate: dict[str, list[float]] = {} + for score, candidate in winners: + by_candidate.setdefault(candidate.candidate_id, []).append(score) + selected_id, selected_scores = min( + by_candidate.items(), + key=lambda item: (-len(item[1]), -float(np.mean(item[1])), item[0]), + ) + voted_candidate = all_candidates[selected_id] + inner_score = float(np.mean(selected_scores)) + if not callable(labels_evaluator): + del inner_train, inner_test + + outer_train = _train_panel_for_fold( + panel, + request.date_column, + nested.outer, + ) + outer_metrics = tuple( + metric_provider(outer_train, request.factor_names) + if metric_provider is not None + else factor_metrics or () + ) + outer_correlation = compute_rank_correlation( + outer_train, + request.factor_names, + date_column=request.date_column, + ) + outer_pruned = prune_correlated_factors( + outer_metrics, + outer_correlation, + request.correlation_threshold, + ) + proxy_remaining = request.budget.max_proxy_trials - proxy_trials_used + if proxy_remaining <= 0: + fold_results.append(FoldMiningResult( + outer_index=nested.outer.outer_index, + selected_factors=outer_pruned.selected, + candidates=tuple(_rank_candidates(list(all_candidates.values()))), + selected_candidate_id=None, + inner_score=round(inner_score, 8), + outer_evaluation=None, + error="proxy trial budget exhausted before outer retraining", + benchmark_evaluations=run_benchmarks(nested), + )) + break + proxy_allowance = max( + 1, + proxy_remaining // selection_phases_remaining, + ) + outer_beam = beam_search_factor_combinations( + outer_train, + _searchable_factors(outer_pruned.selected, request.budget), + target_column=request.target_column, + date_column=request.date_column, + max_combination_size=request.budget.max_combination_size, + beam_width=request.budget.beam_width, + max_trials=proxy_allowance, + cancel_check=cancel_check, + ) + proxy_trials_used += outer_beam.trials_used + selection_phases_remaining -= 1 + outer_candidates = outer_beam.candidates + by_refit_key = { + _candidate_refit_key(candidate): candidate + for candidate in outer_candidates + } + selected = by_refit_key.get(_candidate_refit_key(voted_candidate)) + if selected is None: + fold_results.append(FoldMiningResult( + outer_index=nested.outer.outer_index, + selected_factors=outer_pruned.selected, + candidates=outer_candidates, + selected_candidate_id=None, + inner_score=round(inner_score, 8), + outer_evaluation=None, + error=( + "outer retraining did not reproduce selected candidate structure" + if outer_candidates + else "outer training produced no candidate" + ), + benchmark_evaluations=run_benchmarks(nested), + )) + break + + if trials_used >= request.budget.max_trials: + fold_results.append(FoldMiningResult( + outer_index=nested.outer.outer_index, + selected_factors=outer_pruned.selected, + candidates=outer_candidates, + selected_candidate_id=selected.candidate_id, + inner_score=round(inner_score, 8), + outer_evaluation=None, + error="real trial budget exhausted before outer evaluation", + benchmark_evaluations=run_benchmarks(nested), + )) + break + outer_test = _panel_for_labels( + panel, + request.date_column, + nested.outer.test_labels, + ) + if callable(labels_evaluator): + del outer_train, outer_test + outer_evaluation = _evaluate_labels( + labels_evaluator, + nested.outer.train_labels, + nested.outer.test_labels, + selected, + ) + else: + outer_evaluation = _evaluate( + evaluator, + outer_train, + outer_test, + selected, + ) + trials_used += 1 + evaluation_phases_remaining -= 1 + fold_results.append(FoldMiningResult( + outer_index=nested.outer.outer_index, + selected_factors=outer_pruned.selected, + candidates=outer_candidates, + selected_candidate_id=selected.candidate_id, + inner_score=round(inner_score, 8), + outer_evaluation=outer_evaluation, + benchmark_evaluations=run_benchmarks(nested), + )) + if cancelled: + break + if trials_used >= request.budget.max_trials: + break + + if not cancelled and evaluator is not None: + winner_union: dict[str, MiningCandidate] = {} + for fold in fold_results: + if fold.selected_candidate_id is None: + continue + if fold.selected_candidate_id in winner_union: + continue + winner = next( + ( + candidate + for candidate in fold.candidates + if candidate.candidate_id == fold.selected_candidate_id + ), + None, + ) + if winner is not None: + winner_union[winner.candidate_id] = winner + if len(winner_union) > 1: + extended_folds: list[FoldMiningResult] = [] + for nested, fold in zip(nested_folds, fold_results, strict=False): + cross: list[tuple[str, CandidateEvaluation]] = [] + for candidate_id, candidate in winner_union.items(): + if candidate_id == fold.selected_candidate_id: + continue + if trials_used >= request.budget.max_trials: + cross.append(( + candidate_id, + CandidateEvaluation( + score=None, + error=( + "real trial budget exhausted before " + "cross-fold evaluation" + ), + ), + )) + continue + if callable(labels_evaluator): + evaluation = _evaluate_labels( + labels_evaluator, + nested.outer.train_labels, + nested.outer.test_labels, + candidate, + ) + else: + cross_train = _train_panel_for_fold( + panel, + request.date_column, + nested.outer, + ) + cross_test = _panel_for_labels( + panel, + request.date_column, + nested.outer.test_labels, + ) + evaluation = _evaluate( + evaluator, + cross_train, + cross_test, + candidate, + ) + trials_used += 1 + cross.append((candidate_id, evaluation)) + extended_folds.append(replace( + fold, + cross_evaluations=tuple(cross), + )) + fold_results = extended_folds + + return MiningResult( + request=request, + folds=tuple(fold_results), + proxy_trials_used=proxy_trials_used, + trials_used=trials_used, + cancelled=cancelled, + elapsed_ms=round((time.perf_counter() - started) * 1000.0, 3), + ) + + +def _validate_factor_names( + panel: pl.DataFrame, + factor_names: Sequence[str], +) -> tuple[str, ...]: + names = tuple(str(name) for name in factor_names) + if not names: + raise ValueError("factor_names must not be empty") + if len(names) > MAX_MINING_FACTORS: + raise ValueError(f"factor count exceeds hard limit {MAX_MINING_FACTORS}") + if len(set(names)) != len(names): + raise ValueError("factor_names must not contain duplicates") + missing = sorted(set(names) - set(panel.columns)) + if missing: + raise ValueError(f"panel is missing factor columns: {missing}") + return names + + +def _finite_sort_value(value: float, *, worst: float = float("-inf")) -> float: + try: + number = float(value) + except (TypeError, ValueError): + return worst + return number if math.isfinite(number) else worst + + +def _average_rank(values: np.ndarray) -> np.ndarray: + order = np.argsort(values, kind="stable") + ordered = values[order] + boundaries = np.concatenate(( + np.array([0]), + np.flatnonzero(ordered[1:] != ordered[:-1]) + 1, + np.array([len(ordered)]), + )) + counts = np.diff(boundaries) + average = (boundaries[:-1] + 1 + boundaries[1:]) / 2.0 + ranked = np.empty(len(values), dtype=np.float64) + ranked[order] = np.repeat(average, counts) + return ranked + + +def _proxy_rank_ic( + blocks: Sequence[np.ndarray], + columns: tuple[int, ...], + weights: tuple[float, ...], + directions: tuple[int, ...], +) -> tuple[float, float, int, int]: + daily_ics: list[float] = [] + observations = 0 + weight_values = np.asarray(weights, dtype=np.float64) + direction_values = np.asarray(directions, dtype=np.float64) + for block in blocks: + factors = block[:, columns].astype(np.float64, copy=False) + target = block[:, -1].astype(np.float64, copy=False) + valid = np.isfinite(target) & np.isfinite(factors).all(axis=1) + count = int(np.count_nonzero(valid)) + if count < 3: + continue + factor_values = factors[valid] + factor_ranks = np.column_stack([ + _average_rank(factor_values[:, index]) + for index in range(factor_values.shape[1]) + ]) + composite = (factor_ranks * direction_values) @ weight_values + composite_rank = _average_rank(composite) + target_rank = _average_rank(target[valid]) + if np.std(composite_rank) <= 0.0 or np.std(target_rank) <= 0.0: + continue + rho = float(np.corrcoef(composite_rank, target_rank)[0, 1]) + if math.isfinite(rho): + daily_ics.append(rho) + observations += count + if not daily_ics: + return 0.0, 0.0, observations, 0 + values = np.asarray(daily_ics, dtype=np.float64) + mean = float(np.mean(values)) + std = float(np.std(values)) + ir = mean / std if std > 1e-12 else 0.0 + return mean, ir, observations, len(daily_ics) + + +def compute_candidate_signature(definition: Mapping[str, Any]) -> str: + """Return the canonical artifact signature for a mining candidate definition.""" + kind = definition.get("kind") + if kind == "existing_strategy": + strategy_id = definition.get("strategy_id") + if not isinstance(strategy_id, str) or not strategy_id: + raise ValueError("existing strategy candidate requires strategy_id") + return f"strategy:{strategy_id}" + if kind != "factor_rank": + raise ValueError(f"unsupported mining candidate kind: {kind!r}") + + factor_names = definition.get("factor_names") + scoring = definition.get("scoring") + directions = definition.get("directions") + if not isinstance(factor_names, list) or not factor_names: + raise ValueError("factor candidate requires factor_names") + if not isinstance(scoring, Mapping) or set(scoring) != set(factor_names): + raise ValueError("factor candidate scoring keys must match factor_names") + if not isinstance(directions, Mapping) or set(directions) != set(factor_names): + raise ValueError("factor candidate direction keys must match factor_names") + + weights: list[str] = [] + direction_values: list[str] = [] + for factor_name in factor_names: + if not isinstance(factor_name, str) or not factor_name: + raise ValueError("factor names must be non-empty strings") + value = scoring[factor_name] + if isinstance(value, bool): + raise ValueError("factor weights must be numeric") + try: + weight = float(value) + except (TypeError, ValueError) as exc: + raise ValueError("factor weights must be numeric") from exc + if not math.isfinite(weight) or weight <= 0.0: + raise ValueError("factor weights must be finite and positive") + direction = directions[factor_name] + if direction not in {"high", "low"}: + raise ValueError("factor directions must be high or low") + weights.append(f"{weight:g}") + direction_values.append("1" if direction == "high" else "-1") + return ( + f"factor:{','.join(factor_names)}|w:{','.join(weights)}" + f"|d:{','.join(direction_values)}" + ) + + +def _factor_candidate( + factors: tuple[str, ...], + weights: tuple[float, ...], + directions: tuple[int, ...], + rank_ic: float, + proxy_ir: float, + observations: int, + dates: int, +) -> MiningCandidate: + candidate_id = compute_candidate_signature({ + "kind": "factor_rank", + "factor_names": list(factors), + "scoring": dict(zip(factors, weights, strict=True)), + "directions": { + factor_id: "high" if direction > 0 else "low" + for factor_id, direction in zip(factors, directions, strict=True) + }, + }) + return MiningCandidate( + candidate_id=candidate_id, + kind="factor_rank", + factor_names=factors, + weights=weights, + directions=directions, + proxy_rank_ic=round(float(rank_ic), 8), + proxy_ir=round(float(proxy_ir), 8), + observations=observations, + dates=dates, + ) + + +def benchmark_candidate(strategy_id: str) -> MiningCandidate: + """Build the fixed benchmark candidate for one user-selected strategy.""" + return MiningCandidate( + candidate_id=_benchmark_signature(strategy_id), + kind="existing_strategy", + strategy_id=strategy_id, + ) + + +def _benchmark_signature(strategy_id: str) -> str: + return compute_candidate_signature({ + "kind": "existing_strategy", + "strategy_id": strategy_id, + }) + + +def _searchable_factors( + selected: Sequence[str], + budget: MiningBudget, +) -> tuple[str, ...]: + """Cap beam-search inputs so singletons plus combinations fit the phase budget. + + ``selected`` arrives in training-fold metric order, so the cap keeps the + strongest factors and keeps the lexicographic singleton pass from consuming + the whole proxy allowance before any combination is scored. + """ + return tuple(selected[: max(1, budget.beam_width)]) + + +def _candidate_refit_key(candidate: MiningCandidate) -> tuple[Any, ...]: + if candidate.kind == "existing_strategy": + return (candidate.kind, candidate.strategy_id) + return (candidate.kind, candidate.factor_names, candidate.weights) + + +def _rank_candidates(candidates: Sequence[MiningCandidate]) -> list[MiningCandidate]: + return sorted( + candidates, + key=lambda candidate: ( + -candidate.proxy_rank_ic, + -candidate.proxy_ir, + candidate.candidate_id, + ), + ) + + +def _make_validation_fold( + labels: tuple[str, ...], + *, + level: Literal["outer", "inner"], + outer_index: int, + inner_index: int | None, + train_start: int, + train_bars: int, + test_bars: int, + purge_bars: int, + embargo_bars: int, + hard_stop: int | None = None, +) -> ValidationFold: + train_stop = train_start + train_bars + test_start = train_stop + purge_bars + test_stop = test_start + test_bars + embargo_stop = test_stop + embargo_bars + if hard_stop is not None: + embargo_stop = min(embargo_stop, hard_stop) + return ValidationFold( + level=level, + outer_index=outer_index, + inner_index=inner_index, + train_labels=labels[train_start:train_stop], + purge_labels=labels[train_stop:test_start], + test_labels=labels[test_start:test_stop], + embargo_labels=labels[test_stop:embargo_stop], + ) + + +def _train_panel_for_fold( + panel: pl.DataFrame, + date_column: str, + fold: ValidationFold, +) -> pl.DataFrame: + train = _panel_for_labels(panel, date_column, fold.train_labels) + if "_target_date" not in train.columns: + return train + target_date = pl.col("_target_date").cast(pl.Utf8).str.slice(0, 10) + daily = train.select(date_column, "_target_date").unique( + subset=[date_column], + maintain_order=True, + ) + allowed = daily.select( + (target_date.is_null() | (target_date <= fold.train_end)).alias("_allowed") + ).get_column("_allowed").to_list() + first_excluded = next( + (index for index, value in enumerate(allowed) if not value), + len(allowed), + ) + if not any(allowed[first_excluded:]): + return _panel_for_labels( + train, + date_column, + fold.train_labels[:first_excluded], + ) + return train.filter(target_date.is_null() | (target_date <= fold.train_end)) + + +def _panel_for_labels( + panel: pl.DataFrame, + date_column: str, + labels: Sequence[str], +) -> pl.DataFrame: + normalized = tuple(str(label)[:10] for label in labels) + if not normalized: + return panel.slice(0, 0) + values = panel.get_column(date_column) + if values.is_sorted(): + bounds: tuple[Any, Any] | None = None + if values.dtype == pl.Date: + bounds = ( + date.fromisoformat(normalized[0]), + date.fromisoformat(normalized[-1]), + ) + elif values.dtype == pl.Utf8: + bounds = (normalized[0], normalized[-1]) + if bounds is not None: + start = int(values.search_sorted(bounds[0], side="left")) + stop = int(values.search_sorted(bounds[1], side="right")) + sliced = panel.slice(start, stop - start) + actual = tuple( + str(value)[:10] + for value in sliced.get_column(date_column).unique( + maintain_order=True + ) + ) + if actual == normalized: + return sliced + date_expr = pl.col(date_column).cast(pl.Utf8).str.slice(0, 10) + return panel.filter(date_expr.is_in(normalized)) + + +def _cancelled(cancel_check: CancelCheck | None) -> bool: + if cancel_check is None: + return False + if callable(cancel_check): + return bool(cancel_check()) + is_set = getattr(cancel_check, "is_set", None) + return bool(is_set()) if callable(is_set) else False + + +def _evaluate_labels( + evaluator: Callable[ + [Sequence[str], Sequence[str], Mapping[str, Any]], + CandidateEvaluation | Mapping[str, Any] | float, + ], + train_labels: Sequence[str], + test_labels: Sequence[str], + candidate: MiningCandidate, +) -> CandidateEvaluation: + try: + raw = evaluator(train_labels, test_labels, candidate.definition()) + except Exception as exc: + return CandidateEvaluation(score=None, error=str(exc)) + return _normalize_evaluation(raw) + + +def _evaluate( + evaluator: CandidateEvaluator, + train: pl.DataFrame, + test: pl.DataFrame, + candidate: MiningCandidate, +) -> CandidateEvaluation: + try: + raw = evaluator.evaluate_candidate(train, test, candidate.definition()) + except Exception as exc: + return CandidateEvaluation(score=None, error=str(exc)) + return _normalize_evaluation(raw) + + +def _normalize_evaluation( + raw: CandidateEvaluation | Mapping[str, Any] | float, +) -> CandidateEvaluation: + if isinstance(raw, CandidateEvaluation): + return CandidateEvaluation( + score=_finite_score(raw.score), + metrics=raw.metrics, + error=raw.error, + ) + if isinstance(raw, Mapping): + score = raw.get("score") + error = raw.get("error") + metrics = raw.get("metrics", {}) + return CandidateEvaluation( + score=_finite_score(score), + metrics=dict(metrics) if isinstance(metrics, Mapping) else {}, + error=str(error) if error is not None else None, + ) + return CandidateEvaluation(score=_finite_score(raw)) + + +def _finite_score(value: Any) -> float | None: + try: + score = float(value) + except (TypeError, ValueError): + return None + return score if math.isfinite(score) else None diff --git a/backend/app/backtest/mining_runtime.py b/backend/app/backtest/mining_runtime.py new file mode 100644 index 0000000..0733d7c --- /dev/null +++ b/backend/app/backtest/mining_runtime.py @@ -0,0 +1,1489 @@ +"""Production mining runtime executed only inside a spawned worker.""" +from __future__ import annotations + +import json +import math +import os +import time +import uuid +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, replace +from datetime import date, timedelta +from itertools import pairwise +from pathlib import Path +from typing import Any, Literal + +import numpy as np +import polars as pl + +from app.backtest.factor import ( + FACTOR_COLUMNS, + FACTOR_METHODOLOGY_VERSION, + FACTOR_WARMUP_DAYS, + FactorBacktestService, + FactorBatchConfig, +) +from app.backtest.fundamentals import ( + FUNDAMENTAL_FACTOR_NAMES, + attach_fundamental_factors, + load_fundamental_snapshot, +) +from app.backtest.mining import ( + MAX_FINALISTS, + CandidateEvaluation, + FactorMetric, + MiningBudget, + MiningCandidate, + MiningRequest, + MiningResult, + MiningService, + benchmark_candidate, + compute_rank_correlation, + generate_nested_folds, + required_outer_folds, + required_trading_bars, + validation_config_for_profile, +) +from app.backtest.strategy import ( + BacktestResultPolicy, + ResolvedFeaturePlan, + StrategyBacktestConfig, + StrategyBacktestService, + StrategyDependencyResolver, + _merge_resolved_feature_plans, + build_matrix_cache_profile, +) +from app.services.mining_jobs import MiningRunStore +from app.services.mining_preflight import enriched_partition_dates +from app.services.mining_schedule import MINING_ALGORITHM_VERSION +from app.strategy import config as strategy_config +from app.strategy.engine import StrategyEngine + +ProgressCallback = Callable[[dict[str, Any]], None] +CancelCheck = Callable[[], bool] | Any +_PROFILE_NAMES = frozenset({"exploratory", "balanced", "strict"}) +_FACTOR_IDS = frozenset(str(item["id"]) for item in FACTOR_COLUMNS) +_MINING_MATRIX_CACHE_BYTES = 32 * 1024 * 1024 +_RESULT_POLICY = BacktestResultPolicy( + required_stats=frozenset({"total_return", "sharpe", "max_drawdown", "n_trades"}), + include_monte_carlo=False, + include_curves=False, + include_trades=False, + include_per_symbol_stats=False, + include_return_distribution=False, + include_benchmark=False, + include_strategy_info=False, +) +_REGIME_FILTERS: dict[str, dict[str, list[str]]] = { + "strong": {"states": ["strong", "lean_strong"]}, + "range": {"states": ["range"]}, + "weak": {"states": ["lean_weak", "weak"]}, +} + + +class MiningRuntimeCancelledError(RuntimeError): + pass + + +@dataclass(frozen=True) +class RuntimeRequest: + run_id: str + factor_names: tuple[str, ...] + strategy_ids: tuple[str, ...] + symbols: list[str] | None + asset_type: Literal["stock", "etf"] + start: date + end: date + profile: Literal["exploratory", "balanced", "strict"] + forward_horizon: int + commission_pct: float + stamp_tax_pct: float + slippage_bps: float + correlation_threshold: float + max_finalists: int + require_regime: bool + mining_request: MiningRequest + + +class TrainingMetricProvider: + """Compute fold-local metrics and retain only compact call telemetry.""" + + def __init__(self, target_column: str) -> None: + self.target_column = target_column + self.calls: list[dict[str, Any]] = [] + + def __call__( + self, + train: pl.DataFrame, + factor_names: Sequence[str], + ) -> tuple[FactorMetric, ...]: + metrics = tuple( + _factor_metric(train, factor_name, self.target_column) + for factor_name in factor_names + ) + labels = _date_labels(train) + self.calls.append({ + "start": labels[0] if labels else None, + "end": labels[-1] if labels else None, + "rows": train.height, + "metrics": metrics, + }) + return metrics + + +class MatcherCandidateEvaluator: + """Evaluate one fixed definition with the production matrix matcher.""" + + def __init__( + self, + service: StrategyBacktestService, + strategy_engine: StrategyEngine, + data_dir: Path, + request: RuntimeRequest, + base_market, + cancel_check: CancelCheck | None, + ) -> None: + self.service = service + self.strategy_engine = strategy_engine + self.data_dir = data_dir + self.request = request + self.base_market = base_market + self.cancel_check = cancel_check + self.backtest_count = 0 + self.peak_compute_cache_bytes = 0 + + def evaluate_candidate( + self, + train: pl.DataFrame, + test: pl.DataFrame, + definition: Mapping[str, Any], + ) -> CandidateEvaluation: + del train + return self.evaluate_test(test, definition) + + def evaluate_candidate_labels( + self, + train_labels: Sequence[str], + test_labels: Sequence[str], + definition: Mapping[str, Any], + ) -> CandidateEvaluation: + del train_labels + return self._evaluate_labels(test_labels, definition) + + def evaluate_test( + self, + test: pl.DataFrame, + definition: Mapping[str, Any], + *, + regime_state: str = "overall", + ) -> CandidateEvaluation: + return self._evaluate_labels( + _date_labels(test), + definition, + regime_state=regime_state, + ) + + def _evaluate_labels( + self, + labels: Sequence[str], + definition: Mapping[str, Any], + *, + regime_state: str = "overall", + ) -> CandidateEvaluation: + _raise_if_cancelled(self.cancel_check) + if not labels: + return CandidateEvaluation(score=None, error="test fold contains no dates") + try: + config = self._backtest_config( + definition, + date.fromisoformat(labels[0]), + date.fromisoformat(labels[-1]), + regime_state, + ) + prepared = self.service.prepare_matrix_optimization( + [config], + matrix_cache_max_bytes=_MINING_MATRIX_CACHE_BYTES, + market_data_override=self.base_market, + ) + try: + result = self.service.run( + config, + cancel_event=self.cancel_check, + prepared=prepared, + result_policy=_RESULT_POLICY, + ) + self.peak_compute_cache_bytes = max( + self.peak_compute_cache_bytes, + prepared.compute_cache.snapshot()["peak_bytes"], + ) + finally: + prepared.compute_cache.close() + self.backtest_count += 1 + if result.error: + return CandidateEvaluation(score=None, error=result.error) + metrics = { + key: _finite_or_none(result.stats.get(key)) + for key in ("total_return", "sharpe", "max_drawdown", "n_trades") + } + score = _finite_or_none(metrics.get("sharpe")) + if score is None: + return CandidateEvaluation( + score=None, + metrics=metrics, + error="backtest did not return a finite sharpe", + ) + return CandidateEvaluation(score=score, metrics=metrics) + except (OSError, ValueError, TypeError) as exc: + return CandidateEvaluation(score=None, error=str(exc)) + + def _backtest_config( + self, + definition: Mapping[str, Any], + start: date, + end: date, + regime_state: str, + ) -> StrategyBacktestConfig: + kind = str(definition.get("kind") or "") + if kind == "existing_strategy": + strategy_id = str(definition.get("strategy_id") or "") + strategy = self.strategy_engine.get(strategy_id) + overrides = strategy_config.load_override(self.data_dir, strategy_id) + params = self.strategy_engine.resolve_params(strategy, overrides=overrides) + elif kind == "factor_rank": + strategy_id = "factor_rank_research" + scoring = definition.get("scoring") + directions = definition.get("directions") + if not isinstance(scoring, Mapping) or not scoring: + raise ValueError("factor candidate has no scoring definition") + if not isinstance(directions, Mapping): + raise ValueError("factor candidate has no direction definition") + params = { + "scoring": {str(key): float(value) for key, value in scoring.items()}, + "directions": {str(key): str(value) for key, value in directions.items()}, + "entry_score": 70.0, + "exit_score": 40.0, + "top_rank": 20, + } + overrides = {} + else: + raise ValueError(f"unsupported mining candidate kind: {kind!r}") + + regime_filter = None + if regime_state != "overall": + regime_filter = _REGIME_FILTERS.get(regime_state) + if regime_filter is None: + raise ValueError(f"unsupported regime state: {regime_state}") + return StrategyBacktestConfig( + strategy_id=strategy_id, + symbols=self.request.symbols, + start=start, + end=end, + params=params, + overrides=overrides, + matching="open_t+1", + entry_fill="open_t+1", + exit_fill="open_t+1", + fees_pct=self.request.commission_pct, + commission_pct=self.request.commission_pct, + stamp_tax_pct=self.request.stamp_tax_pct, + slippage_bps=self.request.slippage_bps, + max_positions=10, + max_exposure_pct=1.0, + initial_capital=1_000_000.0, + position_sizing="equal", + mode="position", + asset_type=self.request.asset_type, + holding_days=self.request.forward_horizon, + minute_fill=False, + regime_filter=regime_filter, + ) + + +_SYMBOL_BATCH_SIZE = 512 + + +def _load_compact_factor_panel( + factor_service: FactorBacktestService, + config: FactorBatchConfig, + factor_names: Sequence[str], + *, + expected_generation: str, + cancel_check: CancelCheck | None, +) -> pl.DataFrame: + panel_columns = [ + "symbol", + "date", + "open", + "high", + "low", + "close", + "volume", + "amount", + "turnover_rate", + ] + if any( + name in ("limit_up_count_20d", "limit_up_count_60d") + for name in factor_names + ): + panel_columns.append("consecutive_limit_ups") + load_start = ( + config.start + if all(name == "turnover_rate" for name in factor_names) + else config.start - timedelta(days=FACTOR_WARMUP_DAYS) + ) + raw = factor_service.engine.load_panel( + config.symbols, + load_start, + config.end, + columns=panel_columns, + asset_type=config.asset_type, + expected_generation=expected_generation, + ) + if raw.is_empty(): + return raw + + fundamental_names = [ + str(name) + for name in factor_names + if str(name) in FUNDAMENTAL_FACTOR_NAMES + ] + if fundamental_names: + data_dir = getattr( + getattr(getattr(factor_service.engine, "repo", None), "store", None), + "data_dir", + None, + ) + raw = attach_fundamental_factors( + raw, + load_fundamental_snapshot(data_dir), + fundamental_names, + ) + + symbol = pl.col("symbol") + day = pl.col("date") + previous_symbol = symbol.shift(1) + previous_day = day.shift(1) + invalid_key = raw.select( + ( + symbol.is_null() + | day.is_null() + | (symbol < previous_symbol).fill_null(False) + | ( + (symbol == previous_symbol) + & (day <= previous_day) + ).fill_null(False) + ).any() + ).item() + if invalid_key: + raise ValueError( + "mining factor panel requires non-null, unique symbol/date keys " + "sorted by symbol and strictly increasing date" + ) + output_by_date: dict[date, list[pl.DataFrame]] = {} + group_sizes = ( + raw.group_by("symbol", maintain_order=True) + .len() + .get_column("len") + .to_list() + ) + row_offset = 0 + for offset in range(0, len(group_sizes), _SYMBOL_BATCH_SIZE): + _raise_if_cancelled(cancel_check) + row_count = sum(group_sizes[offset:offset + _SYMBOL_BATCH_SIZE]) + batch = raw.slice(row_offset, row_count) + row_offset += row_count + missing = set(factor_names) - set(batch.columns) + if missing: + batch = factor_service._compute_missing_factors( + batch, + missing, + assume_sorted=True, + ) + projected = batch.filter( + (pl.col("date") >= config.start) + & (pl.col("date") <= config.end) + & pl.col("close").is_not_null() + & (pl.col("close") > 0) + ).select([ + "symbol", + "date", + "close", + *( + pl.col(name).cast(pl.Float32, strict=False).alias(name) + for name in factor_names + ), + ]) + for daily in projected.partition_by("date", maintain_order=True): + output_by_date.setdefault(daily.item(0, "date"), []).append(daily) + del batch, projected + return pl.concat( + [ + pl.concat(output_by_date[label], how="vertical", rechunk=False) + for label in sorted(output_by_date) + ], + how="vertical", + rechunk=False, + ) + + +def run_mining_runtime( + payload: Mapping[str, Any], + *, + data_dir: Path, + service: StrategyBacktestService, + strategy_engine: StrategyEngine, + progress_cb: ProgressCallback | None = None, + cancel_check: CancelCheck | None = None, + rss_sampler: Any | None = None, +) -> dict[str, Any]: + """Run one persistent mining job and return only a compact IPC summary.""" + started = time.perf_counter() + emit = progress_cb or (lambda _message: None) + request = _decode_runtime_request(payload, data_dir, strategy_engine) + fingerprint = payload.get("data_fingerprint") + expected_generation = ( + fingerprint.get("generation") + if isinstance(fingerprint, Mapping) + else None + ) + if not isinstance(expected_generation, str) or not expected_generation: + raise ValueError("mining worker payload is missing its data generation") + store = MiningRunStore(data_dir) + phase_peak_rss_bytes: dict[str, int] = {} + + def start_phase() -> None: + if rss_sampler is not None: + rss_sampler.reset_phase() + + def finish_phase(name: str) -> None: + if rss_sampler is not None: + phase_peak_rss_bytes[name] = rss_sampler.phase_peak_rss_bytes() + + emit({"phase": "panel", "label": "加载因子面板", "done": 0, "total": 1}) + start_phase() + _raise_if_cancelled(cancel_check) + panel_started = time.perf_counter() + factor_service = FactorBacktestService(service.engine) + factor_config = FactorBatchConfig( + factor_names=list(request.factor_names), + symbols=request.symbols, + start=request.start, + end=request.end, + asset_type=request.asset_type, + commission_pct=request.commission_pct, + stamp_tax_pct=request.stamp_tax_pct, + slippage_bps=request.slippage_bps, + ) + generation = factor_service._data_generation(request.asset_type) + if generation != expected_generation: + raise ValueError( + "mining data generation changed after the run was queued" + ) + source_panel = _load_compact_factor_panel( + factor_service, + factor_config, + request.factor_names, + expected_generation=generation, + cancel_check=cancel_check, + ) + if source_panel.is_empty(): + raise ValueError("mining date range contains no enriched data") + service.engine.clear_panel_cache() + trading_dates = enriched_partition_dates( + data_dir, + request.asset_type, + request.start, + request.end, + ) + factor_service._assert_data_generation(request.asset_type, generation) + panel = attach_single_forward_return( + source_panel, + start=request.start, + end=request.end, + horizon=request.forward_horizon, + trading_dates=trading_dates, + factor_names=request.factor_names, + target_column=request.mining_request.target_column, + assume_unique_symbol_date=True, + ) + del source_panel + if panel.is_empty(): + raise ValueError("mining panel contains no valid price rows") + phase_ms: dict[str, float] = { + "panel": round((time.perf_counter() - panel_started) * 1000.0, 3) + } + finish_phase("panel") + emit({ + "phase": "panel", + "label": "因子面板已准备", + "done": 1, + "total": 1, + "rows": panel.height, + "factors": len(request.factor_names), + }) + + _raise_if_cancelled(cancel_check) + start_phase() + matrix_started = time.perf_counter() + emit({"phase": "matrix", "label": "准备共享撮合矩阵", "done": 0, "total": 1}) + base_market = _prepare_base_market( + service, + strategy_engine, + data_dir, + request, + expected_generation=generation, + cancel_check=cancel_check, + ) + factor_service._assert_data_generation(request.asset_type, generation) + phase_ms["matrix"] = round((time.perf_counter() - matrix_started) * 1000.0, 3) + finish_phase("matrix") + emit({ + "phase": "matrix", + "label": "共享撮合矩阵已准备", + "done": 1, + "total": 1, + "matrix_bytes": base_market.nbytes, + }) + + metric_provider = TrainingMetricProvider(request.mining_request.target_column) + evaluator = MatcherCandidateEvaluator( + service, + strategy_engine, + data_dir, + request, + base_market, + cancel_check, + ) + emit({"phase": "search", "label": "嵌套样本外搜索", "done": 0, "total": 1}) + start_phase() + search_started = time.perf_counter() + result = MiningService().run( + panel, + request.mining_request, + metric_provider=metric_provider, + evaluator=evaluator, + cancel_check=cancel_check, + ) + phase_ms["search"] = round((time.perf_counter() - search_started) * 1000.0, 3) + finish_phase("search") + if result.cancelled: + raise MiningRuntimeCancelledError("mining cancelled") + emit({ + "phase": "search", + "label": "候选搜索完成", + "done": 1, + "total": 1, + "proxy_trials": result.proxy_trials_used, + "real_trials": result.trials_used, + }) + + _raise_if_cancelled(cancel_check) + start_phase() + artifact_started = time.perf_counter() + emit({"phase": "artifacts", "label": "写入研究结果", "done": 0, "total": 4}) + artifact_frames = _build_artifacts( + panel, + request, + result, + metric_provider, + evaluator, + cancel_check, + ) + for done, (name, frame) in enumerate(artifact_frames.items(), start=1): + _raise_if_cancelled(cancel_check) + path = store.artifact_path(request.run_id, name) # type: ignore[arg-type] + _atomic_write_parquet(frame, path) + store.register_artifact(request.run_id, name) # type: ignore[arg-type] + emit({ + "phase": "artifacts", + "label": f"已写入 {name}", + "done": done, + "total": 4, + }) + phase_ms["artifacts"] = round((time.perf_counter() - artifact_started) * 1000.0, 3) + finish_phase("artifacts") + + folds = artifact_frames["folds"] + candidates = artifact_frames["candidates"] + factors = artifact_frames["factors"] + selected_overall = folds.filter( + (pl.col("regime_state") == "overall") + & ( + (pl.col("evaluation_kind") == "selected") + | pl.col("candidate_signature").is_null() + ) + ) + valid_folds = selected_overall.filter(~pl.col("skipped")).height + skipped_folds = selected_overall.filter(pl.col("skipped")).height + budget_exhausted = _budget_exhausted(result) + elapsed_ms = round((time.perf_counter() - started) * 1000.0, 3) + phase_ms["total"] = elapsed_ms + confidence = _confidence(request.profile) + return { + "status": ( + "succeeded_with_budget_exhausted" if budget_exhausted else "succeeded" + ), + "factor_count": len(request.factor_names), + "selected_factor_count": int(factors.filter(pl.col("selected")).height), + "candidate_count": candidates.height, + "valid_fold_count": valid_folds, + "skipped_fold_count": skipped_folds, + "confidence": confidence, + "budget_exhausted": budget_exhausted, + "elapsed_ms": elapsed_ms, + "data_as_of": request.end.isoformat(), + "algorithm_version": MINING_ALGORITHM_VERSION, + "methodology_version": FACTOR_METHODOLOGY_VERSION, + "proxy_trials_used": result.proxy_trials_used, + "trials_used": result.trials_used + max(0, evaluator.backtest_count - result.trials_used), + "panel_rows": panel.height, + "panel_scans": 1, + "matrix_bytes": base_market.nbytes, + "matrix_compute_cache_peak_bytes": evaluator.peak_compute_cache_bytes, + "phase_ms": phase_ms, + "phase_peak_rss_bytes": phase_peak_rss_bytes, + "artifacts": list(artifact_frames), + } + + +def attach_single_forward_return( + panel: pl.DataFrame, + *, + start: date, + end: date, + horizon: int, + trading_dates: Sequence[date], + factor_names: Sequence[str], + target_column: str = "_next_return", + assume_unique_symbol_date: bool = False, +) -> pl.DataFrame: + """Materialize exactly one global-axis forward label and its endpoint date.""" + if horizon <= 0: + raise ValueError("forward horizon must be positive") + names = tuple(str(name) for name in factor_names) + required = {"symbol", "date", "close", *names} + missing = sorted(required - set(panel.columns)) + if missing: + raise ValueError(f"factor panel is missing required columns: {missing}") + dates = tuple(sorted(dict.fromkeys( + value for value in trading_dates if start <= value <= end + ))) + if not dates: + raise ValueError("mining date range has no trading dates") + + valid_rows = ( + (pl.col("date") >= start) + & (pl.col("date") <= end) + & pl.col("close").is_not_null() + & (pl.col("close") > 0) + ) + if assume_unique_symbol_date: + invalid_count = panel.select((~valid_rows).sum()).item() + if invalid_count: + raise ValueError("prevalidated mining panel contains invalid price rows") + scoped = panel.select([ + "symbol", + "date", + "close", + *( + pl.col(name).cast(pl.Float32, strict=False).alias(name) + for name in names + ), + ]) + else: + scoped = ( + panel.filter(valid_rows) + .select([ + "symbol", + "date", + "close", + *( + pl.col(name).cast(pl.Float32, strict=False).alias(name) + for name in names + ), + ]) + .unique(subset=["symbol", "date"], keep="last") + ) + date_dtype = scoped.schema["date"] + target_date_column = "_target_date" + if len(dates) > horizon: + date_map = pl.DataFrame({ + "date": dates[:-horizon], + target_date_column: dates[horizon:], + }).with_columns( + pl.col("date").cast(date_dtype), + pl.col(target_date_column).cast(date_dtype), + ) + else: + date_map = pl.DataFrame( + schema={"date": date_dtype, target_date_column: date_dtype} + ) + prices = scoped.select("symbol", "date", "close") + lookup = prices.select( + "symbol", + pl.col("date").alias(target_date_column), + pl.col("close").alias("_target_close"), + ) + labels = ( + prices.join(date_map, on="date", how="left") + .join(lookup, on=["symbol", target_date_column], how="left") + .select( + "symbol", + "date", + pl.when(pl.col("_target_close").is_not_null()) + .then(pl.col("_target_close") / pl.col("close") - 1.0) + .otherwise(None) + .cast(pl.Float32) + .alias(target_column), + target_date_column, + ) + ) + if assume_unique_symbol_date: + labels = labels.sort(["date", "symbol"]) + if ( + not labels.get_column("symbol").equals(scoped.get_column("symbol")) + or not labels.get_column("date").equals(scoped.get_column("date")) + ): + raise ValueError("prevalidated mining panel is not sorted by date and symbol") + return scoped.select(["symbol", "date", *names]).hstack([ + labels.get_column(target_column), + labels.get_column(target_date_column), + ]) + return ( + scoped.select(["symbol", "date", *names]) + .join(labels, on=["symbol", "date"], how="left") + .sort(["date", "symbol"]) + ) + + +def _decode_runtime_request( + payload: Mapping[str, Any], + data_dir: Path, + strategy_engine: StrategyEngine, +) -> RuntimeRequest: + run_id = str(payload.get("run_id") or "") + request = payload.get("request") + if not run_id or not isinstance(request, Mapping): + raise ValueError("mining worker payload is missing run_id or request") + + factor_names = tuple(str(value) for value in request.get("factor_names") or ()) + if not factor_names or len(set(factor_names)) != len(factor_names): + raise ValueError("factor_names must be non-empty and unique") + unknown_factors = sorted(set(factor_names) - _FACTOR_IDS) + if unknown_factors: + raise ValueError(f"unknown mining factors: {unknown_factors}") + if len(factor_names) > 48: + raise ValueError("mining supports at most 48 factors") + + strategy_ids = tuple(str(value) for value in request.get("strategy_ids") or ()) + if len(set(strategy_ids)) != len(strategy_ids) or len(strategy_ids) > 8: + raise ValueError("strategy_ids must be unique and contain at most 8 strategies") + asset_type = str(request.get("asset_type") or "stock") + if asset_type not in {"stock", "etf"}: + raise ValueError("mining asset_type must be stock or etf") + for strategy_id in strategy_ids: + strategy = strategy_engine.get(strategy_id) + if strategy.meta.get("research_only"): + raise ValueError(f"research template cannot be selected as existing strategy: {strategy_id}") + if strategy.execution_backend != "matrix_native": + raise ValueError(f"mining strategy is not matrix-native: {strategy_id}") + if asset_type not in strategy.meta.get("asset_types", ["stock"]): + raise ValueError(f"mining strategy does not support {asset_type}: {strategy_id}") + + all_dates = enriched_partition_dates(data_dir, asset_type) + if not all_dates: + raise ValueError(f"no enriched {asset_type} trading dates are available") + requested_start = _optional_date(request.get("start")) + requested_end = _optional_date(request.get("end")) + if ( + requested_start is not None + and requested_end is not None + and requested_start > requested_end + ): + raise ValueError("mining start must not be after end") + start = max(requested_start or all_dates[0], all_dates[0]) + end = min(requested_end or all_dates[-1], all_dates[-1]) + if start > end: + raise ValueError("mining date range contains no enriched data") + + profile = str(request.get("budget_profile") or "balanced") + if profile not in _PROFILE_NAMES: + raise ValueError(f"unsupported mining profile: {profile}") + validation = validation_config_for_profile(profile) + forward_horizon = int(request.get("forward_horizon") or 5) + if forward_horizon not in {1, 3, 5}: + raise ValueError("forward_horizon must be 1, 3, or 5 trading days") + if validation.purge_bars < forward_horizon: + raise ValueError("validation purge must cover the forward horizon") + + budget = getattr(MiningBudget, profile)() + max_combination = int( + request.get("max_combination_factors") or budget.max_combination_size + ) + beam_width = int(request.get("beam_width") or budget.beam_width) + max_finalists = int(request.get("max_finalists") or MAX_FINALISTS) + if not 1 <= max_finalists <= MAX_FINALISTS: + raise ValueError(f"max_finalists must be between 1 and {MAX_FINALISTS}") + scoped_dates = [value for value in all_dates if start <= value <= end] + required_folds = required_outer_folds(profile) + required_bars = required_trading_bars(validation, required_folds) + if len(scoped_dates) < required_bars: + fold_label = "outer fold" if required_folds == 1 else "outer folds" + raise ValueError( + f"{profile} mining requires at least {required_bars} enriched trading " + f"bars for {required_folds} {fold_label}; effective range " + f"{start.isoformat()} to {end.isoformat()} has {len(scoped_dates)}" + ) + nested = generate_nested_folds( + [value.isoformat() for value in scoped_dates], + validation, + ) + reserved_regime_trials = 3 * len(nested) if request.get("require_regime", True) else 0 + real_trials = max(len(nested) + 1, budget.max_trials - reserved_regime_trials) + real_trials = min(real_trials, budget.max_trials) + budget = replace( + budget, + max_combination_size=max_combination, + beam_width=beam_width, + max_trials=real_trials, + ) + correlation_threshold = _bounded_float( + request.get("correlation_threshold", 0.75), + "correlation_threshold", + 0.0, + 1.0, + exclusive_min=True, + ) + commission_pct = _bounded_float( + request.get("commission_pct", 0.0002), "commission_pct", 0.0, 0.05 + ) + stamp_tax_pct = _bounded_float( + request.get("stamp_tax_pct", 0.0005), "stamp_tax_pct", 0.0, 0.05 + ) + slippage_bps = _bounded_float( + request.get("slippage_bps", 5.0), "slippage_bps", 0.0, 1000.0 + ) + symbols_value = request.get("symbols") + symbols = None + if symbols_value is not None: + if not isinstance(symbols_value, list): + raise ValueError("symbols must be a list or null") + symbols = list(dict.fromkeys(str(value) for value in symbols_value if value)) + if not symbols: + symbols = None + + mining_request = MiningRequest( + factor_names=factor_names, + existing_strategy_ids=strategy_ids, + correlation_threshold=correlation_threshold, + target_column="_next_return", + budget=budget, + validation=validation, + profile=profile, # type: ignore[arg-type] + ) + return RuntimeRequest( + run_id=run_id, + factor_names=factor_names, + strategy_ids=strategy_ids, + symbols=symbols, + asset_type=asset_type, # type: ignore[arg-type] + start=start, + end=end, + profile=profile, # type: ignore[arg-type] + forward_horizon=forward_horizon, + commission_pct=commission_pct, + stamp_tax_pct=stamp_tax_pct, + slippage_bps=slippage_bps, + correlation_threshold=correlation_threshold, + max_finalists=max_finalists, + require_regime=bool(request.get("require_regime", True)), + mining_request=mining_request, + ) + + +def _prepare_base_market( + service: StrategyBacktestService, + strategy_engine: StrategyEngine, + data_dir: Path, + request: RuntimeRequest, + *, + expected_generation: str | None = None, + cancel_check: CancelCheck | None = None, +): + resolver = StrategyDependencyResolver() + plans: list[ResolvedFeaturePlan] = [] + research = strategy_engine.get("factor_rank_research") + for offset in range(0, len(request.factor_names), 4): + factor_chunk = request.factor_names[offset:offset + 4] + research_params = { + "scoring": {factor_name: 1.0 for factor_name in factor_chunk}, + "directions": {factor_name: "high" for factor_name in factor_chunk}, + } + plans.append(resolver.resolve( + research, + params=research_params, + basic_filter=service._effective_basic_filter(research, {}), + entry_signals=research.entry_signals, + exit_signals=research.exit_signals, + overrides={}, + )) + for strategy_id in request.strategy_ids: + strategy = strategy_engine.get(strategy_id) + overrides = strategy_config.load_override(data_dir, strategy_id) + params = strategy_engine.resolve_params(strategy, overrides=overrides) + plans.append(resolver.resolve( + strategy, + params=params, + basic_filter=service._effective_basic_filter(strategy, overrides), + entry_signals=service._effective_signals( + overrides, "entry_signals", strategy.entry_signals + ), + exit_signals=service._effective_signals( + overrides, "exit_signals", strategy.exit_signals + ), + overrides=overrides, + )) + merged = _merge_resolved_feature_plans(plans) + profile = build_matrix_cache_profile( + strategy_engine, + request.asset_type, + requested_plan=merged, + requested_forward_bars=request.forward_horizon, + ) + warmup_days = max(120, int(max(merged.warmup_bars, 1) * 1.6)) + load_start = request.start - timedelta(days=warmup_days) + return service.engine.load_market_data_matrix_for_backtest( + request.symbols, + load_start, + request.end, + merged, + asset_type=request.asset_type, + cache_profile=profile, + coverage_start=load_start, + coverage_end=request.end, + expected_generation=expected_generation, + cancel_event=cancel_check, + ) + + +def _build_artifacts( + panel: pl.DataFrame, + request: RuntimeRequest, + result: MiningResult, + metric_provider: TrainingMetricProvider, + evaluator: MatcherCandidateEvaluator, + cancel_check: CancelCheck | None, +) -> dict[str, pl.DataFrame]: + nested = generate_nested_folds(_date_labels(panel), request.mining_request.validation) + last_train = _panel_for_dates(panel, nested[-1].outer.train_labels) + if "_target_date" in last_train.columns: + last_train = last_train.filter( + pl.col("_target_date").is_not_null() + & (pl.col("_target_date") <= date.fromisoformat(nested[-1].outer.train_end)) + ) + latest_metrics = { + metric.factor_id: metric + for metric in metric_provider(last_train, request.factor_names) + } + selected_factors = { + factor_name + for fold in result.folds + for factor_name in fold.selected_factors + } + direction_by_factor: dict[str, int] = {} + for fold in result.folds: + for candidate in fold.candidates: + for factor_name, direction in zip( + candidate.factor_names, candidate.directions, strict=True + ): + direction_by_factor.setdefault(factor_name, int(direction)) + metadata = {str(item["id"]): item for item in FACTOR_COLUMNS} + factor_rows = [] + for factor_name in request.factor_names: + metric = latest_metrics[factor_name] + factor_rows.append({ + "factor_name": factor_name, + "label": str(metadata.get(factor_name, {}).get("label", factor_name)), + "direction": direction_by_factor.get( + factor_name, 1 if metric.rank_ic >= 0 else -1 + ), + "score": _finite_or_none(metric.composite_score), + "ic_mean": _finite_or_none(metric.rank_ic), + "ir": _finite_or_none(metric.ir), + "coverage": _finite_or_none(metric.coverage), + "turnover": _finite_or_none(metric.turnover), + "spread_return": None, + "spread_sharpe": None, + "selected": factor_name in selected_factors, + "excluded_reason": None if factor_name in selected_factors else "not_selected", + }) + factors = pl.DataFrame(factor_rows) + + correlation = compute_rank_correlation(last_train, request.factor_names) + correlation_rows = [] + for row_id, left in enumerate(correlation.factor_names): + for column_id, right in enumerate(correlation.factor_names): + count = int(correlation.pair_counts[row_id][column_id]) + correlation_rows.append({ + "factor_x": left, + "factor_y": right, + "rho": ( + float(correlation.matrix[row_id][column_id]) if count > 0 else None + ), + "pair_count": count, + }) + correlation_frame = pl.DataFrame(correlation_rows) + + benchmark_by_id = { + benchmark_candidate(strategy_id).candidate_id: benchmark_candidate(strategy_id) + for strategy_id in request.strategy_ids + } + candidate_by_id: dict[str, MiningCandidate] = {} + for fold in result.folds: + for candidate in fold.candidates: + candidate_by_id.setdefault(candidate.candidate_id, candidate) + fold_rows: list[dict[str, Any]] = [] + for fold, nested_fold in zip(result.folds, nested, strict=False): + selected = ( + candidate_by_id.get(fold.selected_candidate_id) + if fold.selected_candidate_id is not None + else None + ) + evaluation = fold.outer_evaluation + fold_rows.append(_fold_row( + fold.outer_index, + selected, + nested_fold.outer, + evaluation, + regime_state="overall", + n_dates=len(nested_fold.outer.test_labels), + reason=fold.error, + )) + for candidate_id, cross_evaluation in fold.cross_evaluations: + fold_rows.append(_fold_row( + fold.outer_index, + candidate_by_id.get(candidate_id), + nested_fold.outer, + cross_evaluation, + regime_state="overall", + n_dates=len(nested_fold.outer.test_labels), + reason=None, + evaluation_kind="cross", + )) + for candidate_id, benchmark_evaluation in fold.benchmark_evaluations: + fold_rows.append(_fold_row( + fold.outer_index, + benchmark_by_id.get(candidate_id), + nested_fold.outer, + benchmark_evaluation, + regime_state="overall", + n_dates=len(nested_fold.outer.test_labels), + reason=None, + evaluation_kind="benchmark", + )) + if ( + selected is None + or evaluation is None + or evaluation.error is not None + or not request.require_regime + ): + continue + test = _panel_for_dates(panel, nested_fold.outer.test_labels) + for state in ("strong", "range", "weak"): + _raise_if_cancelled(cancel_check) + regime_evaluation = evaluator.evaluate_test( + test, + selected.definition(), + regime_state=state, + ) + n_dates = _regime_date_count( + panel, + nested_fold.outer, + state, + evaluator.data_dir, + ) + fold_rows.append(_fold_row( + fold.outer_index, + selected, + nested_fold.outer, + regime_evaluation, + regime_state=state, + n_dates=n_dates, + reason=None, + )) + folds = pl.DataFrame(fold_rows, schema_overrides={ + "total_return": pl.Float64, + "sharpe": pl.Float64, + "max_drawdown": pl.Float64, + "n_trades": pl.Int64, + }) + + candidate_rows = [] + overall_rows = [row for row in fold_rows if row["regime_state"] == "overall"] + winner_by_id = { + fold.selected_candidate_id: candidate_by_id[fold.selected_candidate_id] + for fold in result.folds + if fold.selected_candidate_id is not None + and fold.selected_candidate_id in candidate_by_id + } + ranked_candidates = _rank_artifact_candidates( + winner_by_id.values(), + overall_rows, + limit=request.max_finalists, + ) + [benchmark_by_id[cid] for cid in sorted(benchmark_by_id)] + for candidate in ranked_candidates: + rows = [row for row in overall_rows if row["candidate_signature"] == candidate.candidate_id] + successful = [row for row in rows if not row["skipped"]] + returns = [row["total_return"] for row in successful if row["total_return"] is not None] + sharpes = [row["sharpe"] for row in successful if row["sharpe"] is not None] + drawdowns = [row["max_drawdown"] for row in successful if row["max_drawdown"] is not None] + trades = [row["n_trades"] for row in successful if row["n_trades"] is not None] + definition = candidate.definition() + candidate_rows.append({ + "signature": candidate.candidate_id, + "name": _candidate_name(candidate), + "kind": ( + "existing_strategy" + if candidate.kind == "existing_strategy" + else "factor_combination" + ), + "factor_names_json": json.dumps( + list(candidate.factor_names), ensure_ascii=False, separators=(",", ":") + ), + "strategy_id": candidate.strategy_id, + "definition_json": json.dumps( + definition, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ), + "regime_state": "overall", + "score": _mean_or_none(sharpes), + "oos_return": _mean_or_none(returns), + "oos_sharpe": _mean_or_none(sharpes), + "oos_max_drawdown": min(drawdowns) if drawdowns else None, + "oos_positive_fold_ratio": ( + sum(value > 0 for value in returns) / len(returns) if returns else None + ), + "oos_n_trades": sum(int(value) for value in trades) if trades else None, + "confidence": _confidence(request.profile), + "valid_folds": len(successful), + "skipped_folds": len(rows) - len(successful), + "promoted_candidate_id": None, + "published_strategy_id": None, + }) + candidates = pl.DataFrame(candidate_rows, schema_overrides={ + "score": pl.Float64, + "oos_return": pl.Float64, + "oos_sharpe": pl.Float64, + "oos_max_drawdown": pl.Float64, + "oos_positive_fold_ratio": pl.Float64, + "oos_n_trades": pl.Int64, + "strategy_id": pl.Utf8, + "promoted_candidate_id": pl.Utf8, + "published_strategy_id": pl.Utf8, + }) if candidate_rows else _empty_candidates_frame() + return { + "factors": factors, + "correlation": correlation_frame, + "candidates": candidates, + "folds": folds, + } + + +def _factor_metric( + train: pl.DataFrame, + factor_name: str, + target_column: str, +) -> FactorMetric: + scoped = train.select("date", "symbol", factor_name, target_column) + finite_factor = pl.col(factor_name).is_not_null() & pl.col(factor_name).is_finite() + eligible = scoped.filter( + finite_factor + & pl.col(target_column).is_not_null() + & pl.col(target_column).is_finite() + ) + coverage = ( + scoped.select(finite_factor.mean()).item() if scoped.height else 0.0 + ) + daily = ( + eligible.group_by("date") + .agg( + pl.corr( + pl.col(factor_name).rank(method="average"), + pl.col(target_column).rank(method="average"), + ).alias("ic") + ) + .filter(pl.col("ic").is_not_null() & pl.col("ic").is_finite()) + .sort("date") + ) + values = daily["ic"].to_numpy() if not daily.is_empty() else np.array([]) + mean = float(np.mean(values)) if values.size else 0.0 + std = float(np.std(values)) if values.size else 0.0 + ir = mean / std if std > 1e-12 else 0.0 + turnover = _top_quintile_turnover(eligible, factor_name, direction=1 if mean >= 0 else -1) + score = abs(ir) * float(coverage or 0.0) / (1.0 + turnover) + return FactorMetric( + factor_id=factor_name, + composite_score=round(score, 8), + ir=round(abs(ir), 8), + coverage=round(float(coverage or 0.0), 8), + turnover=round(turnover, 8), + rank_ic=round(mean, 8), + ) + + +def _top_quintile_turnover( + panel: pl.DataFrame, + factor_name: str, + *, + direction: int, +) -> float: + if panel.is_empty(): + return 1.0 + ranked = ( + panel.select("date", "symbol", factor_name) + .sort(["date", factor_name, "symbol"], descending=[False, direction < 0, False]) + .with_columns( + pl.col(factor_name).rank(method="average").over("date").alias("_rank"), + pl.len().over("date").alias("_count"), + ) + .filter( + pl.col("_rank") + > pl.col("_count") * (0.8 if direction > 0 else 0.0) + ) + ) + if direction < 0: + ranked = ranked.filter(pl.col("_rank") <= pl.col("_count") * 0.2) + holdings = [ + set(str(value) for value in daily["symbol"].to_list()) + for daily in ranked.partition_by("date", maintain_order=True) + ] + if len(holdings) < 2: + return 0.0 + values = [] + for previous, current in pairwise(holdings): + denominator = max(len(previous), len(current), 1) + values.append(1.0 - len(previous & current) / denominator) + return float(np.mean(values)) if values else 0.0 + + +def _fold_row( + fold_index: int, + candidate: MiningCandidate | None, + validation_fold, + evaluation: CandidateEvaluation | None, + *, + regime_state: str, + n_dates: int, + reason: str | None, + evaluation_kind: str = "selected", +) -> dict[str, Any]: + error = reason or (evaluation.error if evaluation is not None else None) + metrics = evaluation.metrics if evaluation is not None else {} + return { + "candidate_signature": candidate.candidate_id if candidate is not None else None, + "evaluation_kind": evaluation_kind, + "fold": fold_index, + "label": f"OOS {fold_index + 1}", + "regime_state": regime_state, + "n_dates": n_dates, + "train_start": validation_fold.train_start, + "train_end": validation_fold.train_end, + "test_start": validation_fold.test_start, + "test_end": validation_fold.test_end, + "selected_factors_json": json.dumps( + list(candidate.factor_names) if candidate is not None else [], + ensure_ascii=False, + separators=(",", ":"), + ), + "total_return": _finite_or_none(metrics.get("total_return")), + "sharpe": _finite_or_none(metrics.get("sharpe")), + "max_drawdown": _finite_or_none(metrics.get("max_drawdown")), + "n_trades": _int_or_none(metrics.get("n_trades")), + "skipped": evaluation is None or error is not None, + "reason": error, + } + + +def _regime_date_count( + panel: pl.DataFrame, + validation_fold, + regime_state: str, + data_dir: Path, +) -> int: + labels = tuple( + label + for label in _date_labels(panel) + if label <= validation_fold.test_end + ) + mask = StrategyBacktestService._build_regime_mask( + labels, + _REGIME_FILTERS[regime_state], + data_dir, + required_start=date.fromisoformat(validation_fold.test_start), + required_end=date.fromisoformat(validation_fold.test_end), + ) + if mask is None: + return 0 + return sum( + bool(allowed) + for label, allowed in zip(labels, mask, strict=True) + if validation_fold.test_start <= label <= validation_fold.test_end + ) + + +def _rank_artifact_candidates( + candidates: Sequence[MiningCandidate], + overall_rows: Sequence[Mapping[str, Any]], + *, + limit: int, +) -> list[MiningCandidate]: + return sorted( + candidates, + key=lambda candidate: _candidate_artifact_rank(candidate, overall_rows), + )[:limit] + + +def _candidate_artifact_rank( + candidate: MiningCandidate, + overall_rows: Sequence[Mapping[str, Any]], +) -> tuple[float, str]: + sharpes = [ + row.get("sharpe") + for row in overall_rows + if row.get("candidate_signature") == candidate.candidate_id + and not row.get("skipped") + and row.get("sharpe") is not None + ] + mean_sharpe = _mean_or_none(sharpes) + return ( + -(mean_sharpe if mean_sharpe is not None else float("-inf")), + candidate.candidate_id, + ) + + +def _candidate_name(candidate: MiningCandidate) -> str: + if candidate.kind == "existing_strategy": + return f"已有策略 · {candidate.strategy_id}" + return "因子组合 · " + " + ".join(candidate.factor_names) + + +def _empty_candidates_frame() -> pl.DataFrame: + return pl.DataFrame(schema={ + "signature": pl.Utf8, + "name": pl.Utf8, + "kind": pl.Utf8, + "factor_names_json": pl.Utf8, + "strategy_id": pl.Utf8, + "definition_json": pl.Utf8, + "regime_state": pl.Utf8, + "score": pl.Float64, + "oos_return": pl.Float64, + "oos_sharpe": pl.Float64, + "oos_max_drawdown": pl.Float64, + "oos_positive_fold_ratio": pl.Float64, + "oos_n_trades": pl.Int64, + "confidence": pl.Utf8, + "valid_folds": pl.Int64, + "skipped_folds": pl.Int64, + "promoted_candidate_id": pl.Utf8, + "published_strategy_id": pl.Utf8, + }) + + +def _atomic_write_parquet(frame: pl.DataFrame, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + try: + frame.write_parquet(temporary) + with temporary.open("r+b") as stream: + os.fsync(stream.fileno()) + os.replace(temporary, path) + except OSError: + temporary.unlink(missing_ok=True) + raise + + +def _panel_for_dates(panel: pl.DataFrame, labels: Sequence[str]) -> pl.DataFrame: + return panel.filter( + pl.col("date").cast(pl.Utf8).str.slice(0, 10).is_in(list(labels)) + ) + + +def _date_labels(panel: pl.DataFrame) -> tuple[str, ...]: + if panel.is_empty(): + return () + return tuple( + panel.select(pl.col("date").cast(pl.Utf8).str.slice(0, 10).unique().sort()) + .to_series() + .to_list() + ) + + +def _raise_if_cancelled(cancel_check: CancelCheck | None) -> None: + if cancel_check is None: + return + cancelled = cancel_check() if callable(cancel_check) else cancel_check.is_set() + if cancelled: + raise MiningRuntimeCancelledError("mining cancelled") + + +def _optional_date(value: Any) -> date | None: + if value in (None, ""): + return None + if isinstance(value, date): + return value + try: + return date.fromisoformat(str(value)) + except ValueError as exc: + raise ValueError(f"invalid ISO date: {value!r}") from exc + + +def _bounded_float( + value: Any, + name: str, + minimum: float, + maximum: float, + *, + exclusive_min: bool = False, +) -> float: + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must be numeric") from exc + valid_min = number > minimum if exclusive_min else number >= minimum + if not math.isfinite(number) or not valid_min or number > maximum: + left = "(" if exclusive_min else "[" + raise ValueError(f"{name} must be in {left}{minimum}, {maximum}]") + return number + + +def _finite_or_none(value: Any) -> float | None: + try: + number = float(value) + except (TypeError, ValueError): + return None + return number if math.isfinite(number) else None + + +def _int_or_none(value: Any) -> int | None: + number = _finite_or_none(value) + return int(number) if number is not None else None + + +def _mean_or_none(values: Sequence[float]) -> float | None: + return float(np.mean(values)) if values else None + + +def _confidence(profile: str) -> str: + return {"exploratory": "low", "balanced": "standard", "strict": "high"}[profile] + + +def _budget_exhausted(result: MiningResult) -> bool: + if result.proxy_trials_used >= result.request.budget.max_proxy_trials: + return True + if result.trials_used >= result.request.budget.max_trials: + return True + return any("budget exhausted" in (fold.error or "") for fold in result.folds) diff --git a/backend/app/backtest/regime_alignment.py b/backend/app/backtest/regime_alignment.py new file mode 100644 index 0000000..d2ac62f --- /dev/null +++ b/backend/app/backtest/regime_alignment.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from datetime import date +from typing import Any + +import numpy as np + +RegimePoint = tuple[str, float] + +REGIME_THREE_LEVEL_MAP = { + "strong": "strong", + "lean_strong": "strong", + "range": "range", + "lean_weak": "weak", + "weak": "weak", +} + + +def three_level_regime(state: str) -> str: + return REGIME_THREE_LEVEL_MAP.get(state, state) + + +def _date_text(value: object) -> str: + return str(value)[:10] + + +def _normalize_regime_point(value: Any) -> RegimePoint: + if isinstance(value, Mapping): + state = str(value.get("state", "")) + score = float(value.get("score", 0) or 0) + return state, score + if isinstance(value, (tuple, list)) and len(value) >= 2: + return str(value[0]), float(value[1] or 0) + raise ValueError("市场环境数据格式无效") + + +def clamp_formal_start_for_regime( + labels: Sequence[str], + required_start: date | None, + regime_filter: Mapping[str, Any] | None, +) -> date | None: + """正式首日无前驱交易日时, 把首日让渡为预热, 返回顺延后的正式起点。 + + 触发条件: 环境过滤实际启用, 且面板首日 >= 正式起点 (数据边界即正式起点, + 典型如「全部」范围从本地数据第一天开始)。此时首日的 T-1 环境所需的 + 上一交易日不在面板内, fail-closed 校验会直接拒绝; 把首日降级为预热、 + 正式起点顺延到第二个交易日, 首日环境即成为次日的 T-1。 + 其余情况 (有预热日 / 过滤未启用 / 标签不足两天) 原样返回 required_start, + 无法顺延时由后续校验给出明确报错。 + """ + if not regime_filter or required_start is None: + return required_start + has_states = bool(regime_filter.get("states") or []) + has_score = regime_filter.get("min_score") is not None + if not (has_states or has_score): + return required_start + if len(labels) < 2: + return required_start + if _date_text(labels[0]) < str(required_start): + return required_start + try: + return date.fromisoformat(_date_text(labels[1])) + except ValueError: + return required_start + + +def align_regime_t_minus_one( + labels: Sequence[str], + regime_by_date: Mapping[object, Any], + required_start: date | None, + required_end: date | None, + *, + first_day_boundary_ok: bool = False, +) -> list[RegimePoint | None]: + """Align each label with the preceding label's regime without any I/O. + + first_day_boundary_ok: 统计类调用方 (因子环境分组) 允许首日无前驱环境 —— + 数据边界即正式首日 (本地数据从正式首日开始) 时首日没有 T-1 环境属正常, + 跳过首日不参与分组即可, 不应阻断整个回测。内部缺口仍 fail-closed。 + """ + regime_map = { + _date_text(key): _normalize_regime_point(value) + for key, value in regime_by_date.items() + } + if not regime_map: + raise ValueError("市场环境数据为空, 请先在数据页完成市场环境计算后再回测") + + aligned: list[RegimePoint | None] = [None] * len(labels) + required_start_text = str(required_start) if required_start is not None else None + required_end_text = str(required_end) if required_end is not None else None + missing_dates: list[str] = [] + if labels and required_start_text is not None and not first_day_boundary_ok: + first_label = _date_text(labels[0]) + if first_label >= required_start_text and ( + required_end_text is None or first_label <= required_end_text + ): + raise ValueError( + f"市场环境数据覆盖不完整: 正式首日 {first_label} 缺少前一交易日环境, " + "请把前一交易日行情包含在预热区间" + ) + for index in range(1, len(labels)): + current_label = _date_text(labels[index]) + previous_label = _date_text(labels[index - 1]) + point = regime_map.get(previous_label) + if point is not None: + aligned[index] = point + continue + required = ( + (required_start_text is None or current_label >= required_start_text) + and (required_end_text is None or current_label <= required_end_text) + ) + if required: + missing_dates.append(previous_label) + + if missing_dates: + first_missing = missing_dates[0] + suffix = f" 等 {len(missing_dates)} 天" if len(missing_dates) > 1 else "" + raise ValueError( + f"市场环境数据覆盖不完整: 缺少前一交易日环境 {first_missing}{suffix}, " + "请先补算对应区间" + ) + return aligned + + +def build_regime_filter_mask( + labels: Sequence[str], + regime_filter: Mapping[str, Any] | None, + regime_by_date: Mapping[object, Any], + *, + required_start: date | None = None, + required_end: date | None = None, +) -> np.ndarray | None: + """Build a T-1 regime filter mask from caller-supplied regime data. + + States are matched against the raw five-level labels, so each regime + level can be filtered on its own. Callers that want the aggregated + three-level view must list the raw states explicitly, e.g. + ``["strong", "lean_strong"]`` for the strong bucket. + """ + if not regime_filter: + return None + allowed_states = { + str(state) + for state in (regime_filter.get("states") or []) + } + min_score = regime_filter.get("min_score") + if not allowed_states and min_score is None: + return None + + aligned = align_regime_t_minus_one( + labels, + regime_by_date, + required_start, + required_end, + ) + mask = np.ones(len(labels), dtype=bool) + for index, point in enumerate(aligned): + if point is None: + continue + state, score = point + mask[index] = ( + (not allowed_states or state in allowed_states) + and (min_score is None or score >= float(min_score)) + ) + return mask diff --git a/backend/app/backtest/strategy.py b/backend/app/backtest/strategy.py index edb4c30..a8af4d4 100644 --- a/backend/app/backtest/strategy.py +++ b/backend/app/backtest/strategy.py @@ -11,7 +11,7 @@ import threading import time import uuid from collections.abc import Callable, Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from datetime import date, timedelta from pathlib import Path from typing import Literal @@ -20,11 +20,13 @@ import numpy as np import polars as pl from app.backtest.engine import BacktestEngine, MatcherConfig, SimResult, SimulationOptions +from app.backtest.fundamentals import FUNDAMENTAL_FACTOR_NAMES from app.backtest.matrix import ( MarketDataMatrix, MatrixCacheProfile, MatrixComputeCache, MatrixPipelineConfig, + MatrixPrewarmCancelledError, MatrixStrategyPipeline, apply_time_masks, build_market_matrix, @@ -42,7 +44,15 @@ from app.indicators.pipeline import ( get_signal_dependencies, ) from app.strategy.engine import StrategyDataContext, StrategyDef, StrategyEngine -from app.strategy.scoring import scoring_dependencies, scoring_value_expr +from app.strategy.scoring import ( + SCORING_DIRECTION_LOW, + effective_scoring, + effective_scoring_directions, + materialize_scoring_columns, + scoring_dependencies, + scoring_value_expr, + scoring_warmup_bars, +) logger = logging.getLogger(__name__) @@ -51,7 +61,7 @@ _EXECUTION_COLUMNS = frozenset({ "symbol", "date", "open", "high", "low", "close", "volume", "name", "score", "signal_limit_up", "signal_limit_down", }) -_LIMIT_BASE_COLUMNS = frozenset({"raw_close", "raw_high"}) +_LIMIT_BASE_COLUMNS = frozenset({"raw_close", "raw_high", "raw_low"}) _INSTRUMENT_COLUMNS = frozenset({"name", "total_shares", "float_shares"}) @@ -73,6 +83,8 @@ class ResolvedFeaturePlan: warmup_bars: int full_feature_fallback: bool = False execution_backend: str = "polars_expr" + # 财务因子列不落 enriched 存储, 由 engine 在加载口按公告日门控附加。 + fundamental_columns: frozenset[str] = frozenset() def _merge_resolved_feature_plans( @@ -100,6 +112,7 @@ def _merge_resolved_feature_plans( warmup_bars=max(plan.warmup_bars for plan in plans), full_feature_fallback=any(plan.full_feature_fallback for plan in plans), execution_backend="matrix_native", + fundamental_columns=_union("fundamental_columns"), ) @@ -134,8 +147,7 @@ class StrategyDependencyResolver: } required_signals.update({"signal_limit_up", "signal_limit_down"}) - scoring = dict(strategy.meta.get("scoring", {}) or {}) - scoring.update(overrides.get("scoring") or {}) + scoring = effective_scoring(strategy.meta.get("scoring"), overrides) required_features.update(scoring_dependencies(scoring)) order_by = strategy.meta.get("order_by") if order_by and order_by != "score": @@ -187,7 +199,7 @@ class StrategyDependencyResolver: plan = FeaturePlan( required_features=frozenset(required_features), required_signals=frozenset(required_signals), - warmup_bars=max(60, int(strategy.lookback_days or 1)), + warmup_bars=max(60, int(strategy.lookback_days or 1), scoring_warmup_bars(scoring)), ) return ResolvedFeaturePlan( base_columns=base_columns, @@ -199,6 +211,9 @@ class StrategyDependencyResolver: warmup_bars=plan.warmup_bars, full_feature_fallback=full_fallback, execution_backend=strategy.execution_backend, + fundamental_columns=frozenset( + required_features & FUNDAMENTAL_FACTOR_NAMES + ), ) @staticmethod @@ -217,9 +232,20 @@ class StrategyDependencyResolver: required_features = set(strategy.required_features) required_features.update(strategy.matrix_strategy.required_fields()) + parameter_fields = getattr( + strategy.matrix_strategy, + "required_fields_for_params", + None, + ) + parameter_scoring: dict[str, float] = {} + if callable(parameter_fields): + parameter_scoring = { + str(name): 1.0 + for name in parameter_fields(params) + } + required_features.update(scoring_dependencies(parameter_scoring)) required_features.update(_basic_filter_dependencies(basic_filter)) - scoring = dict(strategy.meta.get("scoring", {}) or {}) - scoring.update(overrides.get("scoring") or {}) + scoring = effective_scoring(strategy.meta.get("scoring"), overrides) required_features.update(scoring_dependencies(scoring)) order_by = strategy.meta.get("order_by") if order_by and order_by != "score": @@ -229,7 +255,12 @@ class StrategyDependencyResolver: base_columns = frozenset(set(base_columns) | set(_LIMIT_BASE_COLUMNS)) instrument_columns = frozenset(required_features & set(_INSTRUMENT_COLUMNS)) instrument_columns = frozenset(set(instrument_columns) | {"name"}) - warmup_bars = max(60, int(strategy.matrix_strategy.required_warmup_bars(params))) + warmup_bars = max( + 60, + int(strategy.matrix_strategy.required_warmup_bars(params)), + scoring_warmup_bars(scoring), + scoring_warmup_bars(parameter_scoring), + ) matrix_columns = set(base_columns) | set(instrument_columns) | { "signal_limit_up", "signal_limit_down", @@ -244,6 +275,9 @@ class StrategyDependencyResolver: warmup_bars=warmup_bars, full_feature_fallback=False, execution_backend="matrix_native", + fundamental_columns=frozenset( + required_features & FUNDAMENTAL_FACTOR_NAMES + ), ) @@ -336,10 +370,13 @@ def prewarm_matrix_cache( asset_type: str, latest_date: date, years: int = 5, + cancel_event: threading.Event | None = None, ) -> dict[str, object]: """Build the shared full-universe mmap outside a user backtest request.""" if years <= 0: raise ValueError("matrix cache prewarm years must be positive") + if cancel_event is not None and cancel_event.is_set(): + raise MatrixPrewarmCancelledError("matrix cache prewarm cancelled") profile = build_matrix_cache_profile( strategy_engine, asset_type, @@ -380,6 +417,7 @@ def prewarm_matrix_cache( cache_profile=profile, coverage_start=coverage_start, coverage_end=latest_date, + cancel_event=cancel_event, ) result = { "asset_type": asset_type, @@ -600,6 +638,7 @@ class StrategyBacktestService: config.holding_days, config.minute_fill, json.dumps(config.overrides or {}, sort_keys=True, ensure_ascii=False, default=str), + json.dumps(config.regime_filter or {}, sort_keys=True, ensure_ascii=False, default=str), ) def _resolve_composite_feature_plan( @@ -661,12 +700,11 @@ class StrategyBacktestService: plans.append(child_plan) # pipeline 用 composite 统一的 basic_filter; scoring 用子策略自己的 # (默认 + 用户 override), 因为子策略内部排序影响合并器的排名融合。 - child_scoring = dict(child_def.meta.get("scoring", {}) or {}) - if isinstance(child_override.get("scoring"), dict): - child_scoring.update(child_override["scoring"]) + child_scoring = effective_scoring(child_def.meta.get("scoring"), child_override) child_pipeline_cfg = MatrixPipelineConfig( basic_filter=basic_filter, scoring=child_scoring, + scoring_directions=effective_scoring_directions(child_override), order_by=child_def.meta.get("order_by"), descending=bool(child_def.meta.get("descending", True)), protect_strategy_cache=False, @@ -835,6 +873,8 @@ class StrategyBacktestService: timing_ms["load_panel"] = direct_load_ms timing_ms["market_data_matrix_build"] = 0.0 timing_ms["market_data_direct_load"] = direct_load_ms + # 环境过滤下正式起点=矩阵首日时顺延 (首日让渡为预热), 见 _clamp_regime_formal_start + first = self._clamp_regime_formal_start(first, market_data.timestamp_labels) formal_range = self._matrix_date_range_mask( market_data.timestamp_labels, first.start, @@ -854,6 +894,8 @@ class StrategyBacktestService: _rm = self._build_regime_mask( market_data.timestamp_labels, first.regime_filter, getattr(getattr(self.engine.repo, "store", None), "data_dir", None), + required_start=first.start, + required_end=first.end, ) if _rm is not None: entry_time_mask = entry_time_mask & _rm @@ -1028,6 +1070,11 @@ class StrategyBacktestService: matrix_data_cache_status = prepared.market_data.cache_status matrix_data_cache_hit = matrix_data_cache_status in {"exact", "covering"} matrix_data_cache_timing_ms = prepared.market_data.cache_timing_ms + # 环境过滤下正式起点=矩阵首日时顺延 (首日让渡为预热) + if config.regime_filter: + config = self._clamp_regime_formal_start( + config, prepared.market_data.timestamp_labels + ) elif s.execution_backend in ("matrix_native", "composite"): t_load = time.perf_counter() max_hold_for_profile = self._override_value( @@ -1073,6 +1120,11 @@ class StrategyBacktestService: matrix_data_cache_status = market_data.cache_status matrix_data_cache_hit = matrix_data_cache_status in {"exact", "covering"} matrix_data_cache_timing_ms = market_data.cache_timing_ms + # 环境过滤下正式起点=矩阵首日时顺延 (首日让渡为预热) + if config.regime_filter: + config = self._clamp_regime_formal_start( + config, market_data.timestamp_labels + ) formal_time_mask = self._matrix_date_range_mask( market_data.timestamp_labels, config.start, @@ -1096,6 +1148,13 @@ class StrategyBacktestService: timing_ms["load_panel"] = round((time.perf_counter() - t_load) * 1000, 1) if panel.is_empty(): return _err("无数据,请检查日期范围或先运行盘后管道") + # 环境过滤下正式起点=面板首日时顺延 (首日让渡为预热) + if config.regime_filter: + date_labels = tuple( + str(value)[:10] + for value in panel.get_column("date").unique().sort().to_list() + ) + config = self._clamp_regime_formal_start(config, date_labels) formal_range = self._date_range_mask(panel, config.start, config.end) if not formal_range.any(): return _err("正式回测区间内无数据") @@ -1139,10 +1198,15 @@ class StrategyBacktestService: config.end, ) # 市场环境过滤(强制 T-1): 只叠加 entry, 不影响 exit - _rm = self._build_regime_mask( - market_data.timestamp_labels, config.regime_filter, - getattr(getattr(self.engine.repo, "store", None), "data_dir", None), - ) + try: + _rm = self._build_regime_mask( + market_data.timestamp_labels, config.regime_filter, + getattr(getattr(self.engine.repo, "store", None), "data_dir", None), + required_start=config.start, + required_end=config.end, + ) + except ValueError as e: + return _err(str(e)) if _rm is not None: entry_time_mask = entry_time_mask & _rm exit_time_mask = self._matrix_date_range_mask( @@ -1238,10 +1302,15 @@ class StrategyBacktestService: config.start, config.end, ) - _rm = self._build_regime_mask( - market_data.timestamp_labels, config.regime_filter, - getattr(getattr(self.engine.repo, "store", None), "data_dir", None), - ) + try: + _rm = self._build_regime_mask( + market_data.timestamp_labels, config.regime_filter, + getattr(getattr(self.engine.repo, "store", None), "data_dir", None), + required_start=config.start, + required_end=config.end, + ) + except ValueError as e: + return _err(str(e)) if _rm is not None: entry_time_mask = entry_time_mask & _rm exit_time_mask = self._matrix_date_range_mask( @@ -1267,12 +1336,12 @@ class StrategyBacktestService: else None ) - scoring = dict(s.meta.get("scoring", {}) or {}) - scoring.update(overrides.get("scoring") or {}) + scoring = effective_scoring(s.meta.get("scoring"), overrides) try: pipeline_config = MatrixPipelineConfig( basic_filter=basic_filter, scoring=scoring, + scoring_directions=effective_scoring_directions(overrides), order_by=s.meta.get("order_by"), descending=bool(s.meta.get("descending", True)), protect_strategy_cache=prepared is not None, @@ -1348,6 +1417,27 @@ class StrategyBacktestService: formal_candidate_mask = candidate_mask & formal_range entry_mask = self._build_entry_mask_from_candidate(panel, candidate_mask, s, entry_signals) entry_mask = entry_mask & formal_range + if config.regime_filter: + date_values = panel.get_column("date").unique().sort().to_list() + date_labels = tuple(str(value)[:10] for value in date_values) + try: + regime_time_mask = self._build_regime_mask( + date_labels, + config.regime_filter, + getattr(getattr(self.engine.repo, "store", None), "data_dir", None), + required_start=config.start, + required_end=config.end, + ) + except ValueError as e: + return _err(str(e)) + if regime_time_mask is not None: + allowed_dates = [ + value for value, allowed in zip(date_values, regime_time_mask, strict=True) + if allowed + ] + regime_row_mask = panel.get_column("date").is_in(allowed_dates).fill_null(False) + formal_candidate_mask = formal_candidate_mask & regime_row_mask + entry_mask = entry_mask & regime_row_mask raw_exit_mask = self._build_signal_mask(panel, exit_signals, "_exit") exit_range = self._date_range_mask(panel, config.start, load_end) if config.mode == "full" else formal_range exit_mask = raw_exit_mask & exit_range @@ -1644,55 +1734,60 @@ class StrategyBacktestService: count=len(timestamp_labels), ) + @staticmethod + def _clamp_regime_formal_start( + config: StrategyBacktestConfig, labels: tuple[str, ...] | list[str] + ) -> StrategyBacktestConfig: + """环境过滤下正式起点=面板首日 (无前驱交易日) 时, 顺延到第二个交易日。 + + 数据边界即正式起点 (如「全部」范围) 时, T-1 环境校验会 fail-closed 拒绝; + 首日降级为预热后, 其环境即成为次日的 T-1, 仅损失 1 个正式交易日。 + """ + from app.backtest.regime_alignment import clamp_formal_start_for_regime + + shifted = clamp_formal_start_for_regime(labels, config.start, config.regime_filter) + if shifted is not None and shifted != config.start: + return replace(config, start=shifted) + return config + @staticmethod def _build_regime_mask( timestamp_labels: tuple[str, ...], regime_filter: dict | None, data_dir: Path | None, + *, + required_start: date | None = None, + required_end: date | None = None, ) -> np.ndarray | None: - """构造逐日 regime mask。强制 T-1 防未来函数: regime[T-1] 决定 entry[T]。 - - timestamp_labels[i] 的入场资格 = 它的"前一交易日"的 regime 是否满足条件。 - "前一交易日"用 timestamp_labels 自身的顺序确定(回测时间轴上的前一天)。 - 边界: 首日无前一日环境 → 默认允许(不阻断)。 - regime_filter 为 None 或无 regime 数据时返回 None(不过滤)。 - """ - if not regime_filter or data_dir is None: + """构造逐日 T-1 regime mask, 保留历史静态入口兼容调用方。""" + if not regime_filter: return None allowed_states = set(regime_filter.get("states") or []) min_score = regime_filter.get("min_score") if not allowed_states and min_score is None: return None + if data_dir is None: + raise ValueError("市场环境过滤不可用: 未找到环境数据目录") + from app.backtest.regime_alignment import build_regime_filter_mask from app.services import regime_builder + regime_df = regime_builder.load_regime_history(data_dir) - if regime_df.is_empty(): - return None - - # 构建 date(ISO) → (state, score) 映射 - regime_map: dict[str, tuple[str, int]] = {} - for r in regime_df.iter_rows(named=True): - d = r.get("date") - ds = str(d)[:10] if d is not None else None - if ds: - regime_map[ds] = (str(r.get("state", "")), int(r.get("score", 0) or 0)) - - # 对每个 label, 找它的前一交易日的 regime(timestamp_labels 顺序里的前一天) - n = len(timestamp_labels) - mask = np.ones(n, dtype=bool) # 默认允许 - for i in range(1, n): - prev_label = timestamp_labels[i - 1][:10] - entry = regime_map.get(prev_label) - if entry is None: - continue # 无前一日环境数据 → 允许(不阻断) - state, score = entry - ok = True - if allowed_states and state not in allowed_states: - ok = False - if min_score is not None and score < min_score: - ok = False - mask[i] = ok - return mask + regime_by_date = { + row["date"]: { + "state": row.get("state", ""), + "score": row.get("score", 0), + } + for row in regime_df.iter_rows(named=True) + if row.get("date") is not None + } + return build_regime_filter_mask( + timestamp_labels, + regime_filter, + regime_by_date, + required_start=required_start, + required_end=required_end, + ) def _build_candidate_filter_mask( self, @@ -1975,6 +2070,7 @@ class StrategyBacktestService: "mode": c.mode, "holding_days": c.holding_days, "minute_fill": c.minute_fill, + "regime_filter": c.regime_filter, } @staticmethod @@ -1984,12 +2080,11 @@ class StrategyBacktestService: overrides: dict | None, universe_mask: pl.Series | None = None, ) -> pl.DataFrame: - scoring = s.meta.get("scoring", {}) - scoring_overrides = (overrides or {}).get("scoring") - if scoring_overrides: - scoring = {**scoring, **scoring_overrides} + scoring = effective_scoring(s.meta.get("scoring"), overrides) + directions = effective_scoring_directions(overrides) - work = panel + work = materialize_scoring_columns(panel, scoring.keys()) + temporary_scoring_columns = [name for name in scoring if name not in panel.columns and name in work.columns] has_universe = universe_mask is not None and len(universe_mask) == len(panel) if has_universe: work = work.with_columns(universe_mask.rename("_score_universe")) @@ -2000,18 +2095,23 @@ class StrategyBacktestService: return value def _finish(df: pl.DataFrame) -> pl.DataFrame: - return df.drop("_score_universe") if "_score_universe" in df.columns else df + temporary = [ + name + for name in ["_score_universe", *temporary_scoring_columns] + if name in df.columns + ] + return df.drop(temporary) if temporary else df if scoring: executable = [ - (value, weight) + (str(col), value, weight) for col, weight in scoring.items() if weight and (value := scoring_value_expr(work.columns, str(col))) is not None ] - total_weight = sum(weight for _, weight in executable) + total_weight = sum(weight for _, _, weight in executable) if total_weight > 0: score_parts: list[pl.Expr] = [] - for score_value, weight in executable: + for name, score_value, weight in executable: w = weight / total_weight value = _value_in_universe(score_value) col_min = value.min().over("date") @@ -2020,6 +2120,8 @@ class StrategyBacktestService: normalized = pl.when(col_range > 0).then( (score_value - col_min) / col_range ).otherwise(pl.lit(0.5)) + if directions.get(name) == SCORING_DIRECTION_LOW: + normalized = 1.0 - normalized if has_universe: normalized = pl.when(pl.col("_score_universe")).then(normalized).otherwise(0.0) score_parts.append(normalized * w) diff --git a/backend/app/backtest/worker.py b/backend/app/backtest/worker.py index 1da69c6..f4fb102 100644 --- a/backend/app/backtest/worker.py +++ b/backend/app/backtest/worker.py @@ -22,6 +22,9 @@ class BacktestWorkerError(RuntimeError): """Raised when a spawned worker fails before returning a task result.""" +_CANCEL_GRACE_SECONDS = 5.0 + + class _PeakRssSampler: """Track whole-task and resettable phase RSS peaks with one sampling thread.""" @@ -136,6 +139,10 @@ def make_worker_task(kind: str, data_dir: Path, config) -> dict[str, Any]: encoded = asdict(config) encoded["start"] = config.start.isoformat() encoded["end"] = config.end.isoformat() + elif kind == "mining": + if not isinstance(config, dict): + raise TypeError("mining worker config must be a dict") + encoded = dict(config) else: raise ValueError(f"unsupported worker task kind: {kind}") return { @@ -165,8 +172,8 @@ def _worker_entry(task: dict[str, Any], event_queue, cancel_event) -> None: from app.backtest.engine import BacktestEngine from app.backtest.optimizer import StrategyOptimizer from app.backtest.strategy import StrategyBacktestService - from app.strategy.engine import StrategyEngine from app.strategy import config as strategy_config + from app.strategy.engine import StrategyEngine from app.tickflow.repository import DataStore, KlineRepository data_dir = Path(task["data_dir"]) @@ -201,6 +208,18 @@ def _worker_entry(task: dict[str, Any], event_queue, cancel_event) -> None: optimizer = StrategyOptimizer(service, strategy_engine) walkforward = WalkForwardService(optimizer, service, strategy_engine) result = walkforward.run(config, _progress, cancel_event) + elif kind == "mining": + from app.backtest.mining_runtime import run_mining_runtime + + result = run_mining_runtime( + task["config"], + data_dir=data_dir, + service=service, + strategy_engine=strategy_engine, + progress_cb=_progress, + cancel_check=cancel_event, + rss_sampler=sampler, + ) else: raise ValueError(f"unsupported worker task kind: {kind}") @@ -261,11 +280,20 @@ def run_worker_task( result: dict[str, Any] | None = None failure: dict[str, Any] | None = None ipc_started = time.perf_counter() + cancel_started: float | None = None try: while result is None and failure is None: if cancel_event is not None and cancel_event.is_set(): process_cancel.set() + if cancel_started is None: + cancel_started = time.monotonic() + elif time.monotonic() - cancel_started >= _CANCEL_GRACE_SECONDS: + process.terminate() + process.join(timeout=5.0) + raise BacktestWorkerError( + "backtest worker did not stop within 5 seconds after cancellation" + ) try: message = events.get(timeout=0.1) except queue.Empty: diff --git a/backend/app/config.py b/backend/app/config.py index b57f305..38be513 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,6 +1,7 @@ """全局配置 — 从环境变量 / .env 读取。""" from __future__ import annotations +import os import sys from pathlib import Path @@ -63,11 +64,17 @@ def _project_root() -> Path: _PROJECT_ROOT = _project_root() _RESOURCE_ROOT = _resource_root() +_ENV_FILE = Path( + os.environ.get( + "TICKFLOW_ENV_FILE", + str(_RESOURCE_ROOT / ".env") if not _IS_FROZEN else ".env", + ) +) class Settings(BaseSettings): model_config = SettingsConfigDict( - env_file=str(_RESOURCE_ROOT / ".env") if not _IS_FROZEN else ".env", + env_file=str(_ENV_FILE), env_file_encoding="utf-8", extra="ignore", ) diff --git a/backend/app/custom/README.md b/backend/app/custom/README.md new file mode 100644 index 0000000..6063157 --- /dev/null +++ b/backend/app/custom/README.md @@ -0,0 +1,5 @@ +# 后端二次开发目录 + +在本目录新增普通 `.py` 模块即可由应用自动发现,无需修改 `app/main.py`。以下划线开头的模块不会加载。 + +以 [`_template.py.example`](_template.py.example) 为起点,并遵循 [`docs/secondary-development.md`](../../../docs/secondary-development.md)。模板文件不会参与运行。 diff --git a/backend/app/custom/__init__.py b/backend/app/custom/__init__.py new file mode 100644 index 0000000..aa53925 --- /dev/null +++ b/backend/app/custom/__init__.py @@ -0,0 +1,4 @@ +"""In-repository backend secondary-development modules. + +Copy ``_template.py.example`` to a non-underscore ``.py`` module to enable it. +""" diff --git a/backend/app/custom/_template.py.example b/backend/app/custom/_template.py.example new file mode 100644 index 0000000..b11ad86 --- /dev/null +++ b/backend/app/custom/_template.py.example @@ -0,0 +1,38 @@ +from fastapi import APIRouter + +from app.extensions import ( + BACKEND_EXTENSION_API_VERSION, + BackendExtensionRegistrar, + ExtensionContext, + NotificationFormatContext, + NotificationFormatter, +) + + +class CompanyNotificationFormatter(NotificationFormatter): + def format_message(self, event: dict, context: NotificationFormatContext) -> str: + return f"[公司规则] {event.get('message', '')}".strip() + + +EXTENSION_ID = "company.example" +EXTENSION_API_VERSION = BACKEND_EXTENSION_API_VERSION + + +def setup(registrar: BackendExtensionRegistrar) -> None: + registrar.register_notification_formatter( + "company.notification", + CompanyNotificationFormatter(), + ) + + router = APIRouter(prefix="/api/custom/example", tags=["custom-example"]) + + @router.get("/status") + def status() -> dict: + return {"status": "ok"} + + registrar.include_router(router) + + +def startup(context: ExtensionContext) -> None: + # Core repository and data directory are available here. Keep this hook fast. + _ = context diff --git a/backend/app/enriched_generation.py b/backend/app/enriched_generation.py new file mode 100644 index 0000000..66b6b11 --- /dev/null +++ b/backend/app/enriched_generation.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +import json +import os +import threading +import time +import uuid +import weakref +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +from typing import Any, BinaryIO + +import polars as pl + + +class EnrichedGenerationUnavailableError(RuntimeError): + """The enriched dataset has no stable generation available for readers.""" + + +_WRITER_LOCKS_GUARD = threading.Lock() +_WRITER_LOCKS: dict[tuple[str, str], threading.RLock] = {} +_ACTIVE_PUBLICATIONS: weakref.WeakValueDictionary[str, EnrichedPublication] = ( + weakref.WeakValueDictionary() +) + + +def _marker_path(data_dir: Path, asset_type: str) -> Path: + return Path(data_dir) / f".matrix_generation_{asset_type}.json" + + +def _writer_lock(data_dir: Path, asset_type: str) -> threading.RLock: + key = (str(Path(data_dir).resolve()), asset_type) + with _WRITER_LOCKS_GUARD: + return _WRITER_LOCKS.setdefault(key, threading.RLock()) + + +def _read_marker(path: Path) -> dict[str, Any] | None: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return None + except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc: + raise EnrichedGenerationUnavailableError( + "enriched data generation marker is invalid" + ) from exc + if not isinstance(payload, dict): + raise EnrichedGenerationUnavailableError( + "enriched data generation marker is invalid" + ) + return payload + + +def _fsync_directory(path: Path) -> None: + if os.name == "nt": + return + descriptor = os.open(path, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def _write_marker(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + try: + with temporary.open("x", encoding="utf-8", newline="\n") as stream: + json.dump(payload, stream, separators=(",", ":")) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + _fsync_directory(path.parent) + finally: + temporary.unlink(missing_ok=True) + + +def _unlock_file(stream: BinaryIO) -> None: + if os.name == "nt": + import msvcrt + + stream.seek(0) + msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1) + return + import fcntl + + fcntl.flock(stream.fileno(), fcntl.LOCK_UN) + + +def _try_lock_file(stream: BinaryIO) -> None: + if os.name == "nt": + import msvcrt + + stream.seek(0) + try: + msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1) + except OSError as exc: + raise EnrichedGenerationUnavailableError( + "another enriched publication is active" + ) from exc + return + import fcntl + + try: + fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as exc: + raise EnrichedGenerationUnavailableError( + "another enriched publication is active" + ) from exc + + +@contextmanager +def _exclusive_generation_lock(data_dir: Path, asset_type: str) -> Iterator[None]: + lock_path = Path(data_dir) / f".matrix_generation_{asset_type}.lock" + lock_path.parent.mkdir(parents=True, exist_ok=True) + with ( + _writer_lock(data_dir, asset_type), + lock_path.open("a+b") as stream, + ): + stream.seek(0, os.SEEK_END) + if stream.tell() == 0: + stream.write(b"0") + stream.flush() + _try_lock_file(stream) + try: + yield + finally: + _unlock_file(stream) + + +def _process_is_alive(pid: Any) -> bool: + if not isinstance(pid, int) or pid <= 0: + return False + if pid == os.getpid(): + return True + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except (OSError, PermissionError) as exc: + # Windows 对不存在的 pid 返回 WinError 87 (ERROR_INVALID_PARAMETER), + # 不会映射为 ProcessLookupError; 按存活处理会让孤儿发布锁永远无法恢复。 + if getattr(exc, "winerror", None) == 87: + return False + return True + return True + + +def _ready_payload(generation: str) -> dict[str, Any]: + return { + "state": "ready", + "generation": generation, + "updated_at_ns": time.time_ns(), + } + + +def get_enriched_generation( + data_dir: Path, + asset_type: str = "stock", + *, + initialize: bool = True, +) -> str: + path = _marker_path(data_dir, asset_type) + payload = _read_marker(path) + if payload is None: + if not initialize: + raise EnrichedGenerationUnavailableError( + "enriched data generation marker is unavailable" + ) + with _exclusive_generation_lock(data_dir, asset_type): + payload = _read_marker(path) + if payload is None: + generation = uuid.uuid4().hex + _write_marker(path, _ready_payload(generation)) + return generation + state = payload.get("state", "ready") + generation = payload.get("generation") + if state != "ready" or not isinstance(generation, str) or not generation: + raise EnrichedGenerationUnavailableError( + "enriched data is being published; retry after the update finishes" + ) + return generation + + +def enriched_publication_incomplete( + data_dir: Path, + asset_type: str = "stock", +) -> bool: + try: + payload = _read_marker(_marker_path(data_dir, asset_type)) + except EnrichedGenerationUnavailableError: + return True + if payload is None: + return False + return ( + payload.get("state", "ready") != "ready" + or not isinstance(payload.get("generation"), str) + or not payload["generation"] + ) + + +def bump_enriched_generation(data_dir: Path, asset_type: str = "stock") -> str: + path = _marker_path(data_dir, asset_type) + with _exclusive_generation_lock(data_dir, asset_type): + current = _read_marker(path) + if current is not None and current.get("state", "ready") != "ready": + raise EnrichedGenerationUnavailableError( + "cannot bump an incomplete enriched publication" + ) + generation = uuid.uuid4().hex + _write_marker(path, _ready_payload(generation)) + return generation + + +class EnrichedPublication: + """Publish one logical enriched write batch under a stable generation token.""" + + def __init__( + self, + data_dir: Path, + asset_type: str = "stock", + *, + recover: bool = False, + ) -> None: + self.data_dir = Path(data_dir) + self.asset_type = asset_type + self.recover = recover + self._publishing = False + self._changed = False + self._base_generation: str | None = None + self._publication_id = uuid.uuid4().hex + + def begin(self) -> None: + with _exclusive_generation_lock(self.data_dir, self.asset_type): + self._claim_or_verify() + + def mark_changed(self) -> None: + if not self._publishing: + raise RuntimeError("enriched publication has not started") + self._changed = True + + def abandon(self) -> None: + if not self._publishing or self._changed: + return + path = _marker_path(self.data_dir, self.asset_type) + with _exclusive_generation_lock(self.data_dir, self.asset_type): + current = _read_marker(path) + if current is not None and current.get("publication_id") == self._publication_id: + _write_marker(path, _ready_payload(str(self._base_generation))) + self._publishing = False + + def write_parquet(self, df: pl.DataFrame, out: Path) -> None: + out.parent.mkdir(parents=True, exist_ok=True) + temporary = out.with_name(f".{out.name}.{uuid.uuid4().hex}.tmp") + try: + df.write_parquet(temporary) + with temporary.open("r+b") as stream: + stream.flush() + os.fsync(stream.fileno()) + with _exclusive_generation_lock(self.data_dir, self.asset_type): + self._claim_or_verify() + os.replace(temporary, out) + _fsync_directory(out.parent) + self._changed = True + finally: + temporary.unlink(missing_ok=True) + + def commit(self) -> str | None: + if not self._changed: + return None + path = _marker_path(self.data_dir, self.asset_type) + with _exclusive_generation_lock(self.data_dir, self.asset_type): + current = _read_marker(path) + if current is None or current.get("publication_id") != self._publication_id: + raise EnrichedGenerationUnavailableError( + "enriched publication ownership was lost" + ) + generation = uuid.uuid4().hex + _write_marker(path, _ready_payload(generation)) + self._publishing = False + return generation + + def _claim_or_verify(self) -> None: + path = _marker_path(self.data_dir, self.asset_type) + try: + current = _read_marker(path) + except EnrichedGenerationUnavailableError: + if not self.recover: + raise + current = None + if self._publishing: + if current is None or current.get("publication_id") != self._publication_id: + raise EnrichedGenerationUnavailableError( + "enriched publication ownership was lost" + ) + return + _ACTIVE_PUBLICATIONS[self._publication_id] = self + if current is not None and current.get("state", "ready") != "ready": + current_id = current.get("publication_id") + current_owner = _ACTIVE_PUBLICATIONS.get(str(current_id)) + owner_pid = current.get("owner_pid") + if current_owner is not None or ( + owner_pid != os.getpid() and _process_is_alive(owner_pid) + ): + raise EnrichedGenerationUnavailableError( + "another enriched publication is active" + ) + if not self.recover: + raise EnrichedGenerationUnavailableError( + "another enriched publication is incomplete" + ) + generation = None if current is None else current.get("generation") + if not isinstance(generation, str) or not generation: + generation = uuid.uuid4().hex + self._base_generation = generation + _write_marker(path, { + "state": "publishing", + "generation": generation, + "publication_id": self._publication_id, + "owner_pid": os.getpid(), + "updated_at_ns": time.time_ns(), + }) + self._publishing = True diff --git a/backend/app/extensions/__init__.py b/backend/app/extensions/__init__.py new file mode 100644 index 0000000..cef4187 --- /dev/null +++ b/backend/app/extensions/__init__.py @@ -0,0 +1,16 @@ +from app.extensions.contracts import ( + BACKEND_EXTENSION_API_VERSION, + ExtensionContext, + NotificationFormatContext, + NotificationFormatter, +) +from app.extensions.registry import BackendExtensionRegistrar, BackendExtensionRegistry + +__all__ = [ + "BACKEND_EXTENSION_API_VERSION", + "BackendExtensionRegistrar", + "BackendExtensionRegistry", + "ExtensionContext", + "NotificationFormatContext", + "NotificationFormatter", +] diff --git a/backend/app/extensions/contracts.py b/backend/app/extensions/contracts.py new file mode 100644 index 0000000..19f968c --- /dev/null +++ b/backend/app/extensions/contracts.py @@ -0,0 +1,52 @@ +"""Stable, small-grained contracts for in-repository secondary development.""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Protocol + +BACKEND_EXTENSION_API_VERSION = 1 + + +class RepositoryAccess(Protocol): + """Read-oriented repository surface exposed to backend extensions.""" + + def get_name_map(self, symbols: list[str] | None = None) -> dict[str, str]: ... + + +@dataclass(frozen=True) +class ExtensionContext: + api_version: int + data_dir: Path + repository: RepositoryAccess + + +@dataclass(frozen=True) +class NotificationFormatContext: + api_version: int + + +class NotificationFormatter(ABC): + """Customize notification copy without changing the event schema or semantics.""" + + api_version = BACKEND_EXTENSION_API_VERSION + + @abstractmethod + def format_message( + self, + event: dict[str, Any], + context: NotificationFormatContext, + ) -> str: + """Return notification copy. The input event must not be mutated.""" + raise NotImplementedError + + +class DefaultNotificationFormatter(NotificationFormatter): + def format_message( + self, + event: dict[str, Any], + context: NotificationFormatContext, + ) -> str: + del context + return str(event.get("message") or "") diff --git a/backend/app/extensions/loader.py b/backend/app/extensions/loader.py new file mode 100644 index 0000000..58a1e8b --- /dev/null +++ b/backend/app/extensions/loader.py @@ -0,0 +1,111 @@ +"""Discover in-repository backend customizations without touching user data.""" +from __future__ import annotations + +import importlib +import logging +import pkgutil +from dataclasses import dataclass + +from fastapi import FastAPI +from fastapi.routing import APIRoute + +from app.extensions.contracts import BACKEND_EXTENSION_API_VERSION, ExtensionContext +from app.extensions.registry import BackendExtensionRegistrar, BackendExtensionRegistry + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class BackendExtensionLoadError: + module: str + error: str + + +def _custom_module_names() -> list[str]: + try: + package = importlib.import_module("app.custom") + except ModuleNotFoundError: + return [] + return sorted( + item.name + for item in pkgutil.iter_modules(package.__path__, f"{package.__name__}.") + if not item.name.rsplit(".", 1)[-1].startswith("_") + ) + + +def configure_backend_extensions( + app: FastAPI, +) -> tuple[BackendExtensionRegistry, tuple[BackendExtensionLoadError, ...]]: + """Import custom modules and register validated routes and policies.""" + registry = BackendExtensionRegistry() + errors: list[BackendExtensionLoadError] = [] + + for module_name in _custom_module_names(): + try: + module = importlib.import_module(module_name) + extension_id = getattr(module, "EXTENSION_ID", None) + api_version = getattr(module, "EXTENSION_API_VERSION", None) + if not isinstance(extension_id, str): + raise ValueError("backend extension module must define EXTENSION_ID") + registrar = BackendExtensionRegistrar(extension_id, api_version=api_version) + setup = getattr(module, "setup", None) + if not callable(setup): + raise ValueError("backend extension module must define setup(registrar)") + setup(registrar) + _validate_router_conflicts(app, registrar) + registry.register(registrar) + for router in registrar.routers: + app.include_router(router) + except Exception as exc: + logger.warning("backend extension load failed %s: %s", module_name, exc) + errors.append(BackendExtensionLoadError(module_name, str(exc))) + + registry.freeze() + return registry, tuple(errors) + + +def _validate_router_conflicts(app: FastAPI, registrar: BackendExtensionRegistrar) -> None: + existing = { + (route.path, method) + for route in app.routes + if isinstance(route, APIRoute) + for method in route.methods + } + staged: set[tuple[str, str]] = set() + for router in registrar.routers: + for route in router.routes: + if not isinstance(route, APIRoute): + continue + for method in route.methods: + key = (route.path, method) + if key in existing or key in staged: + raise ValueError( + f"extension {registrar.extension_id!r} route conflicts: " + f"{method} {route.path}" + ) + staged.add(key) + + +def start_backend_extensions( + context: ExtensionContext, + registry: BackendExtensionRegistry, +) -> None: + """Run optional post-core startup hooks after the stable context is available.""" + for module_name in _custom_module_names(): + try: + module = importlib.import_module(module_name) + if getattr(module, "EXTENSION_ID", None) not in registry.extension_ids(): + continue + startup = getattr(module, "startup", None) + if callable(startup): + startup(context) + except Exception as exc: + logger.warning("backend extension startup failed %s: %s", module_name, exc) + + +def current_extension_context(*, data_dir, repository) -> ExtensionContext: + return ExtensionContext( + api_version=BACKEND_EXTENSION_API_VERSION, + data_dir=data_dir, + repository=repository, + ) diff --git a/backend/app/extensions/registry.py b/backend/app/extensions/registry.py new file mode 100644 index 0000000..13b0d60 --- /dev/null +++ b/backend/app/extensions/registry.py @@ -0,0 +1,134 @@ +"""Backend extension registry with version checks and deterministic freezing.""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Generic, TypeVar + +from fastapi import APIRouter + +from app.extensions.contracts import ( + BACKEND_EXTENSION_API_VERSION, + DefaultNotificationFormatter, + NotificationFormatter, +) + +_ID_RE = re.compile(r"^[a-z0-9]+(?:[._-][a-z0-9]+)*$") +T = TypeVar("T") + + +@dataclass(frozen=True) +class RegisteredImplementation(Generic[T]): + extension_id: str + implementation_id: str + implementation: T + order: int + + +class BackendExtensionRegistrar: + """Staging area: a failed setup is discarded without partial registration.""" + + def __init__(self, extension_id: str, *, api_version: int) -> None: + self.extension_id = extension_id + self.api_version = api_version + self.routers: list[APIRouter] = [] + self.notification_formatters: list[tuple[str, NotificationFormatter, int]] = [] + + def include_router(self, router: APIRouter) -> None: + if not isinstance(router, APIRouter): + raise TypeError("router must be fastapi.APIRouter") + self.routers.append(router) + + def register_notification_formatter( + self, + implementation_id: str, + formatter: NotificationFormatter, + *, + order: int = 100, + ) -> None: + self.notification_formatters.append((implementation_id, formatter, order)) + + +class BackendExtensionRegistry: + def __init__(self) -> None: + self._extension_ids: set[str] = set() + self._notification_formatters: list[RegisteredImplementation[NotificationFormatter]] = [] + self._frozen = False + + @property + def frozen(self) -> bool: + return self._frozen + + @property + def has_customizations(self) -> bool: + return bool(self._extension_ids) + + @property + def has_notification_formatters(self) -> bool: + return bool(self._notification_formatters) + + def extension_ids(self) -> frozenset[str]: + return frozenset(self._extension_ids) + + def register(self, registrar: BackendExtensionRegistrar) -> None: + """Validate a staged extension fully before mutating the registry.""" + self._ensure_mutable() + extension_id = registrar.extension_id + self._validate_id(extension_id, "extension_id") + if registrar.api_version != BACKEND_EXTENSION_API_VERSION: + raise ValueError( + f"extension {extension_id!r} requires backend API v{registrar.api_version}; " + f"current is v{BACKEND_EXTENSION_API_VERSION}" + ) + if extension_id in self._extension_ids: + raise ValueError(f"duplicate extension id: {extension_id}") + + known_ids = {item.implementation_id for item in self._notification_formatters} + staged_ids: set[str] = set() + staged: list[RegisteredImplementation[NotificationFormatter]] = [] + for implementation_id, formatter, order in registrar.notification_formatters: + self._validate_id(implementation_id, "implementation_id") + if not isinstance(formatter, NotificationFormatter): + raise TypeError("formatter must inherit NotificationFormatter") + if formatter.api_version != BACKEND_EXTENSION_API_VERSION: + raise ValueError( + f"formatter {implementation_id!r} requires API v{formatter.api_version}; " + f"current is v{BACKEND_EXTENSION_API_VERSION}" + ) + if implementation_id in known_ids or implementation_id in staged_ids: + raise ValueError(f"duplicate notification formatter id: {implementation_id}") + staged_ids.add(implementation_id) + staged.append( + RegisteredImplementation(extension_id, implementation_id, formatter, order) + ) + + self._extension_ids.add(extension_id) + self._notification_formatters.extend(staged) + + def freeze(self) -> None: + self._notification_formatters.sort( + key=lambda item: (item.order, item.implementation_id) + ) + self._frozen = True + + def notification_formatters( + self, + ) -> tuple[RegisteredImplementation[NotificationFormatter], ...]: + if not self._frozen: + raise RuntimeError("backend extension registry must be frozen before use") + if not self._notification_formatters: + return ( + RegisteredImplementation( + "core", "core.notification", DefaultNotificationFormatter(), 0, + ), + ) + return tuple(self._notification_formatters) + + def _ensure_mutable(self) -> None: + if self._frozen: + raise RuntimeError("backend extension registry is frozen") + + @staticmethod + def _validate_id(value: str, label: str) -> None: + if not isinstance(value, str) or not _ID_RE.fullmatch(value): + raise ValueError(f"invalid {label}: {value!r}") diff --git a/backend/app/indicators/pipeline.py b/backend/app/indicators/pipeline.py index 24ade27..3a80d00 100644 --- a/backend/app/indicators/pipeline.py +++ b/backend/app/indicators/pipeline.py @@ -22,6 +22,10 @@ from pathlib import Path import polars as pl from app.config import settings +from app.enriched_generation import ( + EnrichedPublication, + enriched_publication_incomplete, +) from app.market_time import cn_today from app.parquet import scan_daily_parquet, scan_enriched_parquet, scan_parquet_compat from app.price_limits import ( @@ -160,6 +164,10 @@ ENRICHED_COLUMNS: dict[str, dict[str, str]] = { "momentum_20d": "20日动量", "momentum_30d": "30日动量", "momentum_60d": "60日动量", + # ── 异动偏离 (运行时由 repository 附着, 不落盘) ──────── + "deviate_3d": "3日涨跌幅偏离值(vs对应指数, 小数)", + "deviate_10d": "10日涨跌幅偏离值", + "deviate_30d": "30日涨跌幅偏离值", # ── 波动率 ─────────────────────────────────────────── "annual_vol_20d": "20日年化波动率", # ── RSI ────────────────────────────────────────────── @@ -206,6 +214,7 @@ ENRICHED_COLUMNS_BY_CATEGORY: dict[str, list[str]] = { "volume": ["vol_ma5", "vol_ma10", "vol_ratio_5d"], "extremes": ["high_60d", "low_60d"], "momentum": ["momentum_5d", "momentum_10d", "momentum_20d", "momentum_30d", "momentum_60d"], + "deviation": ["deviate_3d", "deviate_10d", "deviate_30d"], "volatility": ["annual_vol_20d"], "rsi": ["rsi_6", "rsi_14", "rsi_24"], "signals": [k for k in ENRICHED_COLUMNS if k.startswith("signal_")], @@ -350,7 +359,12 @@ def _resolve_needed(needed: set[str] | None) -> set[str]: return want -def compute_indicators(df: pl.DataFrame, needed: set[str] | None = None) -> pl.DataFrame: +def compute_indicators( + df: pl.DataFrame, + needed: set[str] | None = None, + *, + assume_sorted: bool = False, +) -> pl.DataFrame: """从 OHLCV 数据计算全套技术指标。 输入必须包含: symbol, date, open, high, low, close, volume @@ -370,7 +384,7 @@ def compute_indicators(df: pl.DataFrame, needed: set[str] | None = None) -> pl.D want = _resolve_needed(needed) - df = df.sort(["symbol", "date"]) + df = df if assume_sorted else df.sort(["symbol", "date"]) # Pass 1: 均线 + EMA + MACD 基础 + BOLL 基础 + KDJ 基础 + ATR 基础 + 量价 + 极值 prev_close = pl.col("close").shift(1).over("symbol") @@ -670,7 +684,7 @@ def compute_limit_signals( signal_limit_down_recovery (跌停翘板) signal_broken_limit_up (炸板: 最高价触及涨停价但收盘未封住) - 输入必须包含: symbol, date, raw_close, raw_high, open, high, low, close, + 输入必须包含: symbol, date, raw_close, raw_high, raw_low, open, high, low, close, change_pct, vol_ratio_5d。 """ if df.is_empty(): @@ -879,9 +893,10 @@ def compute_limit_signals( pl.when( pl.col("_prev_raw_close").is_not_null() & (pl.col("_prev_raw_close") > 0) + & (pl.col("raw_low") > 0) ).then( (~pl.col("signal_limit_down").fill_null(False)) # 最终没跌停 - & (pl.col("low") <= pl.col("_effective_limit_down") + 0.005) # 曾触及跌停 + & (pl.col("raw_low") <= pl.col("_effective_limit_down") + 0.005) # 曾触及跌停(原始价口径, 跌停价为原始价基准) & (pl.col("close") > pl.col("open")) # 收阳 ).otherwise(None).cast(pl.Boolean) .alias("signal_limit_down_recovery") @@ -1019,6 +1034,249 @@ def _select_storage_cols(df: pl.DataFrame) -> pl.DataFrame: return df.select(cols) +# ================================================================ +# 异动偏离列 (deviate_3d/10d/30d) +# +# N 日涨跌幅偏离值 = 个股 N 日累计涨跌幅 - 对应指数同期涨跌幅, +# 是交易所「异常波动 / 严重异常波动」规则的量化口径 (如主板 3日±20%, +# 10日+100%, 30日+200%)。不属于 compute_indicators 的纯函数范围 +# (需要指数数据), 因此在 repository 读取路径上附着, 不随 parquet 落盘。 +# ================================================================ + +DEVIATION_WINDOWS: tuple[int, ...] = (3, 10, 30) + +# 各交易所基准指数 (偏离值规则的「对应指数」近似): 优先分类指数, 缺失时回退 +_BENCHMARK_PREFERENCE: dict[str, list[str]] = { + "SH": ["000002.SH", "000001.SH"], # 上证A指 → 上证指数 + "SZ": ["399107.SZ", "399001.SZ"], # 深证A指 → 深证成指 + "BJ": ["899050.BJ", "000001.SH"], # 北证50 → 上证指数 +} + +_benchmark_cache: dict[str, tuple[float, pl.DataFrame | None]] = {} +_BENCHMARK_CACHE_TTL = 600.0 + + +def load_benchmark_momentum(data_dir: Path) -> pl.DataFrame | None: + """读取指数日K, 计算各基准指数的滚动 N 日涨跌幅。 + + 返回长表: date, bench_exchange, bench_close, bench_mom3d, bench_mom10d, bench_mom30d。 + bench_close 供盘中路径外推今日基准动量 (benchmark_momentum_today)。 + 无可用指数数据时返回 None (偏离列置 null, 不阻塞主流程)。 + 进程内按 data_dir 缓存 (TTL 10 分钟)。 + """ + import time as _time + + now = _time.monotonic() + key = str(Path(data_dir).resolve()) + cached = _benchmark_cache.get(key) + if cached is not None and now - cached[0] < _BENCHMARK_CACHE_TTL: + return cached[1] + + frame: pl.DataFrame | None = None + try: + index_glob = str(Path(data_dir) / "kline_index_daily" / "**" / "*.parquet") + wanted: list[str] = [] + bench_of: dict[str, str] = {} + for exchange, candidates in _BENCHMARK_PREFERENCE.items(): + for sym in candidates: + if sym not in bench_of: + wanted.append(sym) + bench_of[sym] = exchange + lf = scan_daily_parquet( + index_glob, cast_options=pl.ScanCastOptions(integer_cast="allow-float") + ) + df_idx = ( + lf.filter(pl.col("symbol").is_in(wanted)) + .select(["symbol", "date", "close"]) + .sort(["symbol", "date"]) + .collect() + ) + if not df_idx.is_empty(): + available = set(df_idx["symbol"].to_list()) + picked = [s for s in wanted if s in available] + # 每个交易所取优先级最高的可用基准; 全缺时回退到任一可用基准。 + # 同一基准可服务多个交易所 (如北证50 缺失时北交所回退上证指数)。 + pairs: list[tuple[str, str]] = [] + for exchange, candidates in _BENCHMARK_PREFERENCE.items(): + hit = next((s for s in candidates if s in available), None) + if hit is None and picked: + hit = picked[0] + if hit is not None: + pairs.append((hit, exchange)) + df_bench = df_idx.filter(pl.col("symbol").is_in([p[0] for p in pairs])) + if not df_bench.is_empty(): + df_bench = df_bench.with_columns( + pl.col("close").cast(pl.Float64, strict=False) + ).with_columns([ + (pl.col("close") / pl.col("close").shift(n).over("symbol") - 1).alias(f"_bm{n}") + for n in DEVIATION_WINDOWS + ]).rename({f"_bm{n}": f"bench_mom{n}d" for n in DEVIATION_WINDOWS}) + exchange_map = pl.DataFrame({ + "symbol": [p[0] for p in pairs], + "bench_exchange": [p[1] for p in pairs], + }) + frame = ( + df_bench.join(exchange_map, on="symbol", how="inner") + .select(["date", "bench_exchange", "close", + *[f"bench_mom{n}d" for n in DEVIATION_WINDOWS]]) + .rename({"close": "bench_close"}) + .unique(subset=["date", "bench_exchange"]) + ) + except Exception as exc: # noqa: BLE001 + logger.warning("基准指数偏离数据加载失败: %s", exc) + frame = None + + _benchmark_cache[key] = (now, frame) + return frame + + +def _bench_exchange_expr() -> pl.Expr: + """symbol 后缀 → 交易所 (SH/SZ/BJ), 无法识别时 null。""" + return ( + pl.col("symbol").str.slice(-2).str.to_uppercase().replace( + {ex: ex for ex in _BENCHMARK_PREFERENCE}, + default=None, + return_dtype=pl.Utf8, + ) + ) + + +def attach_deviation_columns(df: pl.DataFrame, data_dir: Path) -> pl.DataFrame: + """为已含 momentum_Nd 的 enriched 帧附着 deviate_Nd 偏离列 (全量/冷路径)。 + + 缺失的动量列 (如 momentum_3d 不在指标全集里) 就地按 close 补算, + 与 compute_indicators 在同一帧上的 shift 语义一致。 + 基准按 symbol 后缀分交易所匹配, join 不上的行 (新上市/基准缺失) 置 null。 + """ + if df.is_empty(): + return df + bench = load_benchmark_momentum(data_dir) + dev_cols = [f"deviate_{n}d" for n in DEVIATION_WINDOWS] + if bench is None or bench.is_empty(): + return df.with_columns([pl.lit(None, dtype=pl.Float64).alias(c) for c in dev_cols]) + if "close" not in df.columns: + logger.warning("偏离列附着跳过: 缺少 close 列") + return df.with_columns([pl.lit(None, dtype=pl.Float64).alias(c) for c in dev_cols]) + missing = [n for n in DEVIATION_WINDOWS if f"momentum_{n}d" not in df.columns] + if missing: + df = df.sort(["symbol", "date"]).with_columns([ + (pl.col("close") / pl.col("close").shift(n).over("symbol") - 1).alias(f"momentum_{n}d") + for n in missing + ]) + out = ( + df.with_columns(_bench_exchange_expr().alias("_bench_ex")) + .join(bench, left_on=["_bench_ex", "date"], right_on=["bench_exchange", "date"], how="left") + .with_columns([ + (pl.col(f"momentum_{n}d") - pl.col(f"bench_mom{n}d")).alias(f"deviate_{n}d") + for n in DEVIATION_WINDOWS + ]) + .drop(["_bench_ex", "bench_close", *[f"bench_mom{n}d" for n in DEVIATION_WINDOWS]]) + ) + return out + + +def _bench_rt_pct_of(index_quotes: pl.DataFrame | None, candidates: list[str]) -> float: + """从实时指数行情取某交易所首选基准的今日涨跌, 缺数据时 0。""" + if index_quotes is None or index_quotes.is_empty(): + return 0.0 + df = index_quotes.filter(pl.col("symbol").is_in(candidates)) + if df.is_empty(): + return 0.0 + # 候选按优先级排序, 取第一个有有效涨跌的 + by_sym = {r["symbol"]: r for r in df.iter_rows(named=True)} + for sym in candidates: + row = by_sym.get(sym) + if row is None: + continue + for col in ("change_pct", "pct", "pct_change"): + v = row.get(col) + if v is not None: + return float(v) + if row.get("close") is not None and row.get("prev_close") is not None and row["prev_close"]: + return float(row["close"] / row["prev_close"] - 1) + return 0.0 + + +def benchmark_momentum_today( + data_dir: Path, + index_quotes: pl.DataFrame | None = None, +) -> pl.DataFrame | None: + """各交易所基准指数的「今日」N 日动量 (盘中实时外推)。 + + 基准日K parquet 盘中不含今日, 今日基准收盘 = 昨收 × (1 + 实时涨跌)。 + N 日动量 = 今日基准收盘 / N 个交易日前的收盘 - 1; 交易所与 + load_benchmark_momentum 的选基逻辑一致 (同一 TTL 缓存帧)。 + 返回小表: bench_exchange, bench_mom3d, bench_mom10d, bench_mom30d。 + 无基准数据时 None。 + """ + bench = load_benchmark_momentum(data_dir) + if bench is None or bench.is_empty(): + return None + # 指数监控 (mode=all) 盘中会向 kline_index_daily 写入今日行; + # 「昨收」必须排除今日, 否则实时涨跌被重复叠加 + today = cn_today() + bench = bench.filter(pl.col("date") < today) + if bench.is_empty(): + return None + rows: list[dict[str, float | str]] = [] + for ex in sorted(bench["bench_exchange"].unique().to_list()): + sub = bench.filter(pl.col("bench_exchange") == ex).sort("date") + closes = sub["bench_close"] + if closes.len() == 0: + continue + yesterday_close = closes[-1] + rt = _bench_rt_pct_of(index_quotes, _BENCHMARK_PREFERENCE.get(ex, [])) + row: dict[str, float | str] = { + "bench_exchange": ex, + } + for n in DEVIATION_WINDOWS: + base = closes[-n] if closes.len() >= n else None # N 个交易日前 (不含今日) + row[f"bench_mom{n}d"] = ( + (yesterday_close * (1.0 + rt)) / base - 1.0 + if base is not None and yesterday_close is not None and base > 0 + else None + ) + rows.append(row) + if not rows: + return None + schema = {"bench_exchange": pl.Utf8, **{f"bench_mom{n}d": pl.Float64 for n in DEVIATION_WINDOWS}} + return pl.DataFrame(rows, schema=schema) + + +def attach_deviation_columns_today( + df: pl.DataFrame, + data_dir: Path, + index_quotes: pl.DataFrame | None = None, +) -> pl.DataFrame: + """为盘中单日 enriched 帧附着 deviate_Nd 偏离列 (增量热路径)。 + + 与 attach_deviation_columns 的区别: 入参是「仅今日」的单日帧, 无法用 + shift 补算动量, 直接使用帧上已有的 momentum_Nd (compute_enriched_today + 产出); 基准动量用 benchmark_momentum_today 的实时外推值。 + 缺失动量的窗口 (如全量回退路径无 momentum_3d) 置 null, 不阻塞主流程。 + """ + dev_cols = [f"deviate_{n}d" for n in DEVIATION_WINDOWS] + if df.is_empty(): + return df + bench = benchmark_momentum_today(data_dir, index_quotes) + if bench is None or bench.is_empty(): + return df.with_columns([ + pl.lit(None, dtype=pl.Float64).alias(c) for c in dev_cols if c not in df.columns + ]) + exprs = [ + (pl.col(f"momentum_{n}d") - pl.col(f"bench_mom{n}d")).alias(f"deviate_{n}d") + if f"momentum_{n}d" in df.columns + else pl.lit(None, dtype=pl.Float64).alias(f"deviate_{n}d") + for n in DEVIATION_WINDOWS + ] + return ( + df.with_columns(_bench_exchange_expr().alias("_bench_ex")) + .join(bench, left_on="_bench_ex", right_on="bench_exchange", how="left") + .with_columns(exprs) + .drop(["_bench_ex", *[f"bench_mom{n}d" for n in DEVIATION_WINDOWS]]) + ) + + def run_pipeline(data_dir: Path | None = None, symbols: list[str] | None = None, new_dates_only: bool = False, @@ -1045,6 +1303,11 @@ def run_pipeline(data_dir: Path | None = None, t0 = _t.perf_counter() d = Path(data_dir or settings.data_dir) + if enriched_publication_incomplete(d, "stock"): + logger.warning("检测到未完成的 enriched 发布,改为全量重建") + symbols = None + new_dates_only = False + publication = EnrichedPublication(d, "stock", recover=True) daily_dir = d / "kline_daily" enriched_base = d / "kline_daily_enriched" factor_path = d / "adj_factor" / "all.parquet" @@ -1134,7 +1397,7 @@ def run_pipeline(data_dir: Path | None = None, out = enriched_base / f"date={ds}" / "part.parquet" out.parent.mkdir(parents=True, exist_ok=True) date_df = _select_storage_cols(date_df).sort(["symbol"]) - date_df.write_parquet(out) + publication.write_parquet(date_df, out) written += date_df.height t_write_new = _t.perf_counter() logger.info("增量写入: %.2fs, %d 行", t_write_new - t_new, written) @@ -1166,10 +1429,11 @@ def run_pipeline(data_dir: Path | None = None, existing = existing.filter(~pl.col("symbol").is_in(list(sym_set))) date_df_storage = pl.concat([existing, date_df_storage], how="diagonal_relaxed") date_df_storage = date_df_storage.sort(["symbol"]) - date_df_storage.write_parquet(out) + publication.write_parquet(date_df_storage, out) written += date_df.height logger.info("除权重算: %d 只, 共写入 %d 行", len(sym_set), written) + publication.commit() t_done = _t.perf_counter() logger.info("增量管道完成: %.2fs, %d 行", t_done - t0, written) return written @@ -1267,7 +1531,7 @@ def run_pipeline(data_dir: Path | None = None, existing = existing.filter(~pl.col("symbol").is_in(batch_syms)) date_df_storage = pl.concat([existing, date_df_storage], how="diagonal_relaxed") date_df_storage = date_df_storage.sort(["symbol"]) - date_df_storage.write_parquet(out) + publication.write_parquet(date_df_storage, out) written += date_df_storage.height else: # 全量模式: 缓冲到 date_buffers, 最后一次性写入 @@ -1308,11 +1572,12 @@ def run_pipeline(data_dir: Path | None = None, out = base / f"date={ds}" / "part.parquet" out.parent.mkdir(parents=True, exist_ok=True) merged = pl.concat(dfs, how="diagonal_relaxed").sort(["symbol"]) - merged.write_parquet(out) + publication.write_parquet(merged, out) date_buffers.clear() gc.collect() + publication.commit() t_done = _t.perf_counter() adj_label = "含复权" if not factors.is_empty() else "无复权" logger.info("enriched 完成 [%s]: %.2fs, 共 %d 行, %s", @@ -1339,9 +1604,13 @@ def _load_recent_history(enriched_base: Path, symbols: list[str], days: int) -> from datetime import date, timedelta cutoff = date.today() - timedelta(days=days + 30) # 多读 30 天余量 + cast_options = pl.ScanCastOptions(integer_cast="allow-float") try: lf = ( - scan_enriched_parquet(str(enriched_base / "**" / "*.parquet"), cast_options=_cast) + scan_enriched_parquet( + str(enriched_base / "**" / "*.parquet"), + cast_options=cast_options, + ) .filter( (pl.col("symbol").is_in(symbols)) & (pl.col("date") >= cutoff) @@ -1572,6 +1841,12 @@ def compute_enriched_today( (pl.col("close") / pl.col("_close_60d_ago") - 1).alias("momentum_60d"), ]) + # ---- 动量 3d (异动偏离 deviate_3d 用; 旧 live_agg 未带该状态时跳过, 偏离列自然置 null) ---- + if "_close_3d_ago" in df.columns: + df = df.with_columns( + (pl.col("close") / pl.col("_close_3d_ago") - 1).alias("momentum_3d") + ) + # ---- 年化波动率 20d (递推) ---- # 用 Welford 简化: sum + sum_sq of 19 historical returns + today's return today_ret = pl.col("close") / pl.col("prev_close") - 1 @@ -1814,10 +2089,10 @@ def _compute_limit_signals_today(df: pl.DataFrame, instruments: pl.DataFrame) -> # 跌停翘板 pl.when(no_price_limit) .then(False) - .when(valid_prev_raw | has_authoritative_down) + .when((valid_prev_raw | has_authoritative_down) & (pl.col("raw_low") > 0)) .then( (~is_limit_down.fill_null(True)) - & (pl.col("low") <= effective_limit_down + 0.005) + & (pl.col("raw_low") <= effective_limit_down + 0.005) & (pl.col("close") > pl.col("open")) ).otherwise(None).cast(pl.Boolean) .alias("signal_limit_down_recovery"), diff --git a/backend/app/jobs/daily_pipeline.py b/backend/app/jobs/daily_pipeline.py index 6a70541..54af0b0 100644 --- a/backend/app/jobs/daily_pipeline.py +++ b/backend/app/jobs/daily_pipeline.py @@ -537,11 +537,40 @@ def run_now( invalidate_regime_cache() logger.info("compute_regime: %d days", regime_days) emit("compute_regime", 92, f"市场环境 {regime_days} 天") + # 阶段切换推送监控通知 (软失败, 不影响管道): 末两日阶段不同 = 今日发生切换。 + # 切入退潮/冰点为风险信号, 用 warn 级别; 其余 info。 + if regime_days: + try: + _push_phase_change_alert(repo.store.data_dir) + except Exception as e: + logger.warning("phase change alert failed (soft): %s", e) except Exception as e: # noqa: BLE001 logger.warning("compute_regime failed (soft): %s", e) stage_errors.append(f"compute_regime: {e}") skipped.append("regime") + # Step 2.7: 市场主线(概念/行业涨停梯队聚合) 增量计算 — regime 同开关。 + # 只窄扫连板 >=1 的行, 增量通常 1 天, 开销可忽略。软失败: 不阻断主管道。 + mainline_rows = 0 + if not _prefs_regime.get_pipeline_regime_enabled(): + skipped.append("mainline") + else: + try: + emit("compute_mainline", 93, "计算市场主线…") + from app.services import market_mainline + for _kind in ("concept", "industry"): + rows = market_mainline.compute_mainline_incremental( + repo, repo.store.data_dir, kind=_kind + ) + mainline_rows += rows.height if not rows.is_empty() else 0 + if mainline_rows: + logger.info("compute_mainline: %d rows", mainline_rows) + emit("compute_mainline", 94, f"市场主线 {mainline_rows} 行") + except Exception as e: + logger.warning("compute_mainline failed (soft): %s", e) + stage_errors.append(f"compute_mainline: {e}") + skipped.append("mainline") + # Step 3: 刷新视图 emit("refresh_views", 95, "刷新 DuckDB 视图…") _refresh_views(repo) @@ -561,6 +590,7 @@ def run_now( "etf_adj_factor_symbols": etf_adj_symbols, "minute_rows": written_minute, "regime_days": regime_days, + "mainline_rows": mainline_rows, "lagging_symbols": len(lagging_symbols), "skipped_stages": skipped, "stage_errors": stage_errors, @@ -626,37 +656,81 @@ def _refresh_instruments_view(repo: KlineRepository) -> None: logger.warning("refresh instruments view failed: %s", e) -def _run_tracked(fn, job_label: str) -> None: +def _push_phase_change_alert(data_dir) -> None: + """情绪周期阶段切换 → 推送监控通知(SSE toast + 监控中心)。 + + 阶段切换(如 退潮→冰点)是重要的市场信号, 原先只有打开市场环境页才能看到。 + 复用 quote_service.push_alerts 广播通道; 未发生切换静默返回。 + """ + from app.services.market_phase import PHASE_LABELS + from app.services.regime_builder import latest_phase_transition + + tr = latest_phase_transition(data_dir) + if not tr: + return + prev, cur, d = tr + msg = f"情绪周期阶段切换: {PHASE_LABELS.get(prev, prev)} → {PHASE_LABELS.get(cur, cur)} ({d})" + severity = "warn" if cur in ("ebb", "ice") else "info" + app_state = _get_app_state() + qs = getattr(app_state, "quote_service", None) if app_state else None + if qs: + qs.push_alerts([{ + "source": "market", + "type": "phase_change", + "message": msg, + "severity": severity, + }]) + logger.info("phase change alert: %s (severity=%s)", msg, severity) + + +def _run_tracked(fn, job_label: str) -> bool: """调度触发时包装 JobStore 跟踪,确保同步历史有记录。 单飞: 若已有活跃(pending∨running)任务(手动同步中), 本次调度直接跳过, 不并发。 重任务执行槽: 再挡一层僵尸并发(reap 后线程仍活时不得并行写 parquet)。 + 返回 True 仅表示任务已成功并且执行槽已释放。 """ from app.services.pipeline_jobs import job_store, release_run_slot, try_acquire_run_slot job_id, is_new = job_store.create() if not is_new: logger.info("scheduled %s 跳过: 已有活跃任务在运行 (job_id=%s)", job_label, job_id) - return + return False if not try_acquire_run_slot(): logger.warning("scheduled %s 跳过: 重任务执行槽被占用(疑似上次任务卡死)", job_label) job_store.fail(job_id, f"scheduled {job_label} skipped: 已有数据任务在运行") - return + return False def progress(stage: str, pct: int, msg: str, stage_pct: int | None = None, skip_log: bool = False) -> None: job_store.progress(job_id, stage, pct, msg, stage_pct=stage_pct, skip_log=skip_log) + succeeded = False try: job_store.start(job_id) result = fn(on_progress=progress) job_store.succeed(job_id, result) + succeeded = True logger.info("scheduled %s completed: job_id=%s", job_label, job_id) except Exception: logger.exception("scheduled %s failed: job_id=%s", job_label, job_id) job_store.fail(job_id, f"scheduled {job_label} failed") finally: release_run_slot() + return succeeded + + +def _scheduled_pipeline_task(pipeline_fn) -> None: + """Run weekly mining only after the tracked daily pipeline has fully succeeded.""" + if not _run_tracked(pipeline_fn, "daily_pipeline"): + return + try: + from app.services.mining_schedule import run_weekly_mining + + result = run_weekly_mining(_get_app_state()) + logger.info("scheduled mining result: %s", result) + except Exception: + logger.exception("scheduled mining enqueue failed; daily pipeline remains succeeded") # ================================================================ @@ -916,7 +990,7 @@ def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOSche return result scheduler.add_job( - lambda: _run_tracked(_pipeline_then_refresh, "daily_pipeline"), + lambda: _scheduled_pipeline_task(_pipeline_then_refresh), trigger=CronTrigger(day_of_week="mon-fri", hour=sched["hour"], minute=sched["minute"], timezone="Asia/Shanghai"), diff --git a/backend/app/main.py b/backend/app/main.py index 61a8769..6fe86b0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -import threading +import sys from contextlib import asynccontextmanager from pathlib import Path @@ -12,10 +12,43 @@ from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from app import __version__ -from app.api import analysis, auth as auth_api, backtest, data, ext_data, financials, indices, intraday, kline, market_recap, monitor_rules, alerts, overview, pipeline, regime, rps, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist +from app.api import ( + abnormal, + alerts, + analysis, + backtest, + data, + ext_data, + financials, + indices, + intraday, + kline, + market_recap, + mining, + monitor_rules, + overview, + pipeline, + regime, + rps, + screener, + signals, + stock_analysis, + strategy, + watchlist, +) +from app.api import auth as auth_api +from app.api import settings as settings_api from app.api.routes import router as core_router from app.config import settings +from app.enriched_generation import EnrichedGenerationUnavailableError +from app.extensions.loader import ( + configure_backend_extensions, + current_extension_context, + start_backend_extensions, +) from app.jobs import daily_pipeline +from app.services.matrix_prewarm_owner import MatrixCachePrewarmOwner +from app.services.mining_process_lock import MiningProcessLock from app.services.quote_service import QuoteService from app.tickflow import client as tf_client from app.tickflow.policy import detect_capabilities @@ -27,11 +60,33 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) +# 追加文件日志: uvicorn (含 --reload 开发模式) 默认只有 StreamHandler, 同步/管道等 +# 运行时日志仅出现在 dev 终端, 关掉或滚屏后即丢失, 排查「同步后日志没落」时无处可查。 +# 落盘到 data/backend.log 与桌面版 (desktop.py:_setup_logging → desktop.log) 行为对齐, +# 事后可查。桌面版 (frozen) 已由 desktop.py 写 desktop.log, 此处跳过避免重复落盘。 +# RotatingFileHandler 防止长期运行/频繁 reload 导致文件无限增长。 +if not getattr(sys, "frozen", False): + try: + from logging.handlers import RotatingFileHandler + + _log_path = settings.data_dir / "backend.log" + _log_path.parent.mkdir(parents=True, exist_ok=True) + _file_handler = RotatingFileHandler( + _log_path, maxBytes=10 * 1024 * 1024, backupCount=3, + mode="a", encoding="utf-8", errors="replace", + ) + _file_handler.setFormatter( + logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s") + ) + logging.getLogger().addHandler(_file_handler) + except Exception as _e: # noqa: BLE001 + logger.warning("文件日志初始化失败, 仅输出到终端: %s", _e) + @asynccontextmanager -async def lifespan(app: FastAPI): +async def _application_lifespan(app: FastAPI): logger.info( - "TickFlow Stock Panel v%s starting (mode=%s)", + "Tick Stock Panel v%s starting (mode=%s)", __version__, tf_client.current_mode(), ) @@ -48,9 +103,19 @@ async def lifespan(app: FastAPI): repo = KlineRepository(store) app.state.datastore = store app.state.repo = repo + from app.services.mining_manager import MiningJobManager + + mining_manager = MiningJobManager(store.data_dir) + recovered_mining_runs = mining_manager.recover_interrupted() + app.state.mining_manager = mining_manager + if recovered_mining_runs: + logger.warning("recovered %d interrupted mining runs", recovered_mining_runs) # 在接受回测请求前固定 managed generation,避免首批并发 worker 各自创建版本。 if settings.backtest_matrix_disk_cache_enabled: - repo.get_matrix_data_generation("stock") + try: + repo.get_matrix_data_generation("stock") + except EnrichedGenerationUnavailableError as exc: + logger.warning("enriched generation requires a full rebuild: %s", exc) # 指标异步预热标志: enriched 缓存在后台线程构建, 完成后置 True app.state.indicators_ready = False repo._on_warmup_done = lambda: setattr(app.state, "indicators_ready", True) # noqa: SLF001 @@ -160,51 +225,50 @@ async def lifespan(app: FastAPI): app.state.strategy_engine = strategy_engine logger.info("strategy engine loaded: %d strategies", len(strategy_engine.list_strategies())) - matrix_prewarm_lock = threading.Lock() - matrix_prewarm_running = False + matrix_prewarm_owner = MatrixCachePrewarmOwner() def _schedule_matrix_cache_prewarm() -> None: - nonlocal matrix_prewarm_running if ( not settings.backtest_matrix_disk_cache_enabled or not settings.backtest_matrix_cache_prewarm ): return - with matrix_prewarm_lock: - if matrix_prewarm_running: - logger.info("matrix cache prewarm already in progress, skip") - return - matrix_prewarm_running = True def _prewarm() -> None: - nonlocal matrix_prewarm_running + from app.backtest.engine import BacktestEngine + from app.backtest.matrix import MatrixPrewarmCancelledError + from app.backtest.strategy import prewarm_matrix_cache + from app.services.heavy_job_limiter import ( + HeavyJobCancelledError, + shared_heavy_job_limiter, + ) + try: latest = repo.latest_enriched_date("stock") if latest is None: logger.info("matrix cache prewarm skipped: no stock enriched data") return - from app.backtest.engine import BacktestEngine - from app.backtest.strategy import prewarm_matrix_cache - result = prewarm_matrix_cache( - BacktestEngine(repo), - strategy_engine, - asset_type="stock", - latest_date=latest, - years=settings.backtest_matrix_cache_prewarm_years, - ) + with shared_heavy_job_limiter.slot( + "normal", + cancel_event=matrix_prewarm_owner.cancel_event, + ): + result = prewarm_matrix_cache( + BacktestEngine(repo), + strategy_engine, + asset_type="stock", + latest_date=latest, + years=settings.backtest_matrix_cache_prewarm_years, + cancel_event=matrix_prewarm_owner.cancel_event, + ) logger.info("matrix cache prewarm done: %s", result) + except (HeavyJobCancelledError, MatrixPrewarmCancelledError): + logger.info("matrix cache prewarm cancelled") except Exception: # noqa: BLE001 logger.exception("matrix cache prewarm failed") - finally: - with matrix_prewarm_lock: - matrix_prewarm_running = False - threading.Thread( - target=_prewarm, - name="matrix-cache-prewarm", - daemon=True, - ).start() + if not matrix_prewarm_owner.schedule(_prewarm): + logger.info("matrix cache prewarm already running or shutting down, skip") repo._on_refresh_done = _schedule_matrix_cache_prewarm # noqa: SLF001 if repo.enriched_ready: @@ -246,30 +310,55 @@ async def lifespan(app: FastAPI): app.state.monitor_engine = monitor_engine app.state.sector_monitor_service = sector_monitor_service - yield + # 源码内二次开发启动钩子: 仅暴露稳定只读上下文, 单个扩展失败不影响核心启动。 + extension_registry = app.state.extension_registry + start_backend_extensions( + current_extension_context(data_dir=store.data_dir, repository=repo), + extension_registry, + ) - if app.state.scheduler: - app.state.scheduler.shutdown(wait=False) - ps = getattr(app.state, "pull_scheduler", None) - if ps: - ps.stop() - fsc = getattr(app.state, "financial_scheduler", None) - if fsc: - fsc.stop() - qs = getattr(app.state, "quote_service", None) - if qs: - qs.stop() - dsvc = getattr(app.state, "depth_service", None) - if dsvc: - dsvc.stop_polling() - wbot = getattr(app.state, "wecom_bot_service", None) - if wbot: - wbot.stop() - logger.info("shutdown") + try: + yield + finally: + repo._on_refresh_done = None # noqa: SLF001 + if not matrix_prewarm_owner.shutdown(timeout=5.0): + logger.warning("matrix cache prewarm did not stop within 5 seconds") + mmanager = getattr(app.state, "mining_manager", None) + if mmanager: + mmanager.shutdown() + if app.state.scheduler: + app.state.scheduler.shutdown(wait=False) + ps = getattr(app.state, "pull_scheduler", None) + if ps: + ps.stop() + fsc = getattr(app.state, "financial_scheduler", None) + if fsc: + fsc.stop() + qs = getattr(app.state, "quote_service", None) + if qs: + qs.stop() + dsvc = getattr(app.state, "depth_service", None) + if dsvc: + dsvc.stop_polling() + wbot = getattr(app.state, "wecom_bot_service", None) + if wbot: + wbot.stop() + logger.info("shutdown") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + mining_process_lock = MiningProcessLock(settings.data_dir) + mining_process_lock.acquire() + try: + async with _application_lifespan(app): + yield + finally: + mining_process_lock.release() app = FastAPI( - title="TickFlow Stock Panel", + title="Tick Stock Panel", version=__version__, description="A 股选股 + 回测面板 — TickFlow 适配", lifespan=lifespan, @@ -339,9 +428,11 @@ app.include_router(kline.router) app.include_router(watchlist.router) app.include_router(screener.router) app.include_router(backtest.router) +app.include_router(mining.router) app.include_router(intraday.router) app.include_router(indices.router) app.include_router(overview.router) +app.include_router(abnormal.router) app.include_router(regime.router) app.include_router(analysis.router) app.include_router(pipeline.router) @@ -357,6 +448,11 @@ app.include_router(monitor_rules.router) app.include_router(alerts.router) app.include_router(rps.router) +# 二次开发路由与小粒度策略在所有核心路由后注册, 禁止覆盖核心路径。 +extension_registry, extension_load_errors = configure_backend_extensions(app) +app.state.extension_registry = extension_registry +app.state.extension_load_errors = extension_load_errors + # 能力门控异常 → 403(而非默认 500) # 业务代码用 capset.require(Cap.X) 断言能力,缺失时抛 CapabilityDenied; diff --git a/backend/app/plugins/stocksdk/plugin.yaml b/backend/app/plugins/stocksdk/plugin.yaml index 05308b9..c1a5c49 100644 --- a/backend/app/plugins/stocksdk/plugin.yaml +++ b/backend/app/plugins/stocksdk/plugin.yaml @@ -5,7 +5,7 @@ # 开发模式下需手动安装依赖: cd backend/app/plugins/stocksdk && npm install name: stocksdk -display_name: "stock-sdk(第三方行情·合规风险自负)" +display_name: "stock-sdk" runtime: node entry: app.plugins.stocksdk.provider:StockSDKProvider check: app.plugins.stocksdk.bridge:availability diff --git a/backend/app/services/abnormal_moves.py b/backend/app/services/abnormal_moves.py new file mode 100644 index 0000000..c324570 --- /dev/null +++ b/backend/app/services/abnormal_moves.py @@ -0,0 +1,225 @@ +"""异动边缘统计 — 按交易所异动规则口径实时计算个股接近度。 + +规则 (近似口径, 与交易所《交易规则》的异常波动/严重异常波动披露阈值对齐; +主板/科创板条款号指上交所《交易规则(2026年修订)》, 2026-07-06 施行): +- 主板: 连续3日收盘价涨跌幅偏离值累计 ±20% (5.4.2) +- 创业板/科创板: 3日 ±30% (科创板 6.10) +- 北交所: 3日 ±40% +- 严重异常波动 (5.4.3/6.11): 10日累计偏离 +100%(-50%), 30日 +200%(-70%) — + 负向阈值显著严于正向 (跌方向更早触发), 各板块相同。 + 「10日内4次同向异常波动」情形 (科创板3次) 需事件计数, 暂未实现。 +- 风险警示 (ST/*ST): 2026-07-06 起主板风险警示股票涨跌幅限制调整为 10%, + 异常波动特别规定 (原 3日±15% / 10日+50% / 30日+100%) 同步废止, + 与主板普通股票适用同一套标准 (见 price_limits.MAIN_BOARD_ST_LIMIT_CHANGE_DATE)。 + +偏离值 = 个股 N 日累计涨跌幅 - 对应指数同期涨跌幅 (enriched 运行时列 deviate_Nd)。 +「接近度」= |实时偏离| / 该方向阈值: ≥1 已触发, ≥0.7 边缘, ≥0.5 观察。 +盘中实时叠加: 历史偏离 (已完成交易日) + 今日实时涨跌 - 基准指数今日涨跌。 +""" + +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass +from datetime import date +from typing import Any + +import polars as pl + +from app.indicators.pipeline import DEVIATION_WINDOWS + +# ── 规则表 ──────────────────────────────────────────────── + +@dataclass(frozen=True) +class AbnormalRule: + board: str + st: bool + # 各窗口阈值 (小数): {窗口: (正向, 负向)} — 严重异动负向阈值更严 (见模块 docstring) + thresholds: dict[int, tuple[float, float]] + + +# 3日异常波动阈值各板块对称; 10/30日严重异动各板块一致且不对称 (+100%/-50%, +200%/-70%) +_MAIN = {3: (0.20, 0.20), 10: (1.00, 0.50), 30: (2.00, 0.70)} +_GEM_STAR = {3: (0.30, 0.30), 10: (1.00, 0.50), 30: (2.00, 0.70)} +_BSE = {3: (0.40, 0.40), 10: (1.00, 0.50), 30: (2.00, 0.70)} + +RULES_META: list[dict[str, Any]] = [ + {"board": "主板", "st": False, "thresholds": {f"{k}d": {"up": u, "down": d} for k, (u, d) in _MAIN.items()}, + "note": "3日±20% 异常波动; 严重异常波动 10日+100%(-50%) / 30日+200%(-70%), " + "负向更严; 2026-07-06 起风险警示(ST)股票同口径 (原±15%特别规定已废止)"}, + {"board": "创业板/科创板", "st": False, "thresholds": {f"{k}d": {"up": u, "down": d} for k, (u, d) in _GEM_STAR.items()}, + "note": "20%涨跌幅板块, 3日±30%"}, + {"board": "北交所", "st": False, "thresholds": {f"{k}d": {"up": u, "down": d} for k, (u, d) in _BSE.items()}, + "note": "30%涨跌幅板块, 3日±40%"}, +] + +_BENCH_RT_CANDIDATES = ["000002.SH", "000001.SH", "399107.SZ", "399001.SZ", "899050.BJ"] + + +def board_of(symbol: str) -> str: + """按代码前缀判定板块。""" + code = symbol.split(".")[0] + if symbol.endswith(".BJ") or code[:2] in {"43", "83", "87", "92"}: + return "北交所" + if code.startswith("68"): + return "科创板" + if code.startswith(("30", "301")): + return "创业板" + return "主板" + + +def is_st_name(name: str | None) -> bool: + return bool(name) and "ST" in str(name).upper() + + +def rule_for(symbol: str, name: str | None) -> AbnormalRule: + board = board_of(symbol) + st = is_st_name(name) + # 主板风险警示股票 2026-07-06 起与普通股票同标准 (涨跌幅 10%, + # 异常波动特别规定废止); st 仅为展示标记。创业板/科创板/北交所本就不区分。 + if board == "北交所": + return AbnormalRule(board, st, _BSE) + if board in ("创业板", "科创板"): + return AbnormalRule(board, st, _GEM_STAR) + return AbnormalRule(board, st, _MAIN) + + +# ── 快照计算 ────────────────────────────────────────────── + +_hist_cache_lock = threading.Lock() +_hist_cache: dict[str, Any] = {} +_HIST_CACHE_TTL = 60.0 + +_STATUS_TRIGGERED = "triggered" +_STATUS_EDGE = "edge" +_STATUS_WATCH = "watch" + + +def _status_of(closeness: float) -> str: + if closeness >= 1.0: + return _STATUS_TRIGGERED + if closeness >= 0.7: + return _STATUS_EDGE + return _STATUS_WATCH + + +def _hist_snapshot(repo: Any) -> dict[str, Any]: + """enriched 最新日的偏离列快照 (60s 进程内缓存)。""" + now = time.monotonic() + with _hist_cache_lock: + cached = _hist_cache.get("data") + if cached is not None and now - cached["_ts"] < _HIST_CACHE_TTL: + return cached + + df, cache_date = repo.get_enriched_latest() + rows: dict[str, dict[str, Any]] = {} + if not df.is_empty() and "symbol" in df.columns: + cols = ["symbol", *[c for c in ("name", "close", "change_pct", + "deviate_3d", "deviate_10d", "deviate_30d") if c in df.columns]] + df = df.select(cols) + for r in df.iter_rows(named=True): + rows[str(r["symbol"])] = { + "name": r.get("name"), + "close": r.get("close"), + "rt_pct": r.get("change_pct"), + "deviate_3d": r.get("deviate_3d"), + "deviate_10d": r.get("deviate_10d"), + "deviate_30d": r.get("deviate_30d"), + } + payload = {"_ts": now, "rows": rows, "cache_date": cache_date.isoformat() if cache_date else None} + with _hist_cache_lock: + _hist_cache["data"] = payload + return payload + + +def _bench_rt_pct(quote_service: Any) -> float: + """基准指数今日实时涨跌 (各候选均值, 缺数据时 0)。""" + try: + df = quote_service.get_index_quotes() + except Exception: + return 0.0 + if df is None or df.is_empty(): + return 0.0 + df = df.filter(pl.col("symbol").is_in(_BENCH_RT_CANDIDATES)) + if df.is_empty(): + return 0.0 + for col in ("change_pct", "pct", "pct_change"): + if col in df.columns: + vals = df[col].drop_nulls() + if vals.len() > 0: + return float(vals.mean()) + if {"close", "prev_close"} <= set(df.columns): + sub = df.select(["close", "prev_close"]).drop_nulls() + if sub.height > 0: + return float((sub["close"] / sub["prev_close"] - 1).mean()) + return 0.0 + + +def build_overview( + repo: Any, + quote_service: Any = None, + *, + min_closeness: float = 0.5, + limit: int = 200, +) -> dict[str, Any]: + """返回异动边缘总览: 规则表 + 按接近度排序的个股列表。""" + hist = _hist_snapshot(repo) + cache_date = hist.get("cache_date") + hist_rows: dict[str, dict[str, Any]] = hist["rows"] + + bench_rt = _bench_rt_pct(quote_service) if quote_service is not None else 0.0 + # enriched 已含今日收盘 (盘后已同步) 时, 今日涨跌已计入历史偏离, 不再叠加 + includes_today = cache_date is not None and cache_date >= date.today().isoformat() + + out_rows: list[dict[str, Any]] = [] + for symbol, base in hist_rows.items(): + rule = rule_for(symbol, base.get("name")) + rt_pct = base.get("rt_pct") + rt_delta = 0.0 if includes_today else ((rt_pct or 0.0) - bench_rt) + + windows: dict[str, dict[str, Any]] = {} + max_closeness = 0.0 + for n in DEVIATION_WINDOWS: + hist_dev = base.get(f"deviate_{n}d") + if hist_dev is None: + continue + live = hist_dev + rt_delta + up_t, down_t = rule.thresholds[n] + threshold = up_t if live >= 0 else down_t + closeness = abs(live) / threshold if threshold > 0 else 0.0 + windows[f"{n}d"] = { + "value": round(live, 4), + "threshold": threshold, + "closeness": round(closeness, 4), + } + max_closeness = max(max_closeness, closeness) + if not windows or max_closeness < min_closeness: + continue + out_rows.append({ + "symbol": symbol, + "name": base.get("name"), + "board": rule.board, + "st": rule.st, + "close": base.get("close"), + "rt_pct": rt_pct, + "windows": windows, + "max_closeness": round(max_closeness, 4), + "status": _status_of(max_closeness), + }) + + out_rows.sort(key=lambda r: r["max_closeness"], reverse=True) + counts = { + _STATUS_TRIGGERED: sum(1 for r in out_rows if r["status"] == _STATUS_TRIGGERED), + _STATUS_EDGE: sum(1 for r in out_rows if r["status"] == _STATUS_EDGE), + _STATUS_WATCH: sum(1 for r in out_rows if r["status"] == _STATUS_WATCH), + } + return { + "asof": time.time(), + "cache_date": cache_date, + "bench_rt_pct": round(bench_rt, 4), + "includes_today": includes_today, + "rules": RULES_META, + "counts": counts, + "rows": out_rows[:limit], + } diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py index 2886520..7b5f9ae 100644 --- a/backend/app/services/ai_provider.py +++ b/backend/app/services/ai_provider.py @@ -20,9 +20,11 @@ from app import secrets_store from app.config import settings OPENAI_COMPAT_PROVIDER = "openai_compat" +OPENAI_PROVIDER = "openai" CODEX_CLI_PROVIDER = "codex_cli" CODEX_DEFAULT_COMMAND = "codex" CODEX_SUPPORTED_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"} +OPENAI_DEFAULT_REASONING_EFFORT = "high" _CODEX_ENV_ALLOWLIST = ( "PATH", @@ -104,10 +106,31 @@ def current_ai_provider() -> str: return secrets_store.get_ai_config("ai_provider", settings.ai_provider) or OPENAI_COMPAT_PROVIDER +def current_openai_model() -> str: + return secrets_store.get_ai_config("ai_model", settings.ai_model) + + +def current_codex_model() -> str: + stored = secrets_store.load() + model = stored.get("ai_codex_model") + # 旧版本的两种 provider 共用 ai_model。仅在旧配置仍启用 Codex 时回退读取, + # 避免把正常的 OpenAI-compatible 模型误当作 Codex 模型。 + if model is None and current_ai_provider() == CODEX_CLI_PROVIDER: + model = stored.get("ai_model") + return normalize_codex_model(str(model or "")) + + def current_ai_model() -> str: if current_ai_provider() == CODEX_CLI_PROVIDER: - return normalize_codex_model(str(secrets_store.load().get("ai_model") or "")) - return secrets_store.get_ai_config("ai_model", settings.ai_model) + return current_codex_model() + return current_openai_model() + + +def current_openai_reasoning_effort() -> str: + stored = secrets_store.load() + if "ai_reasoning_effort" not in stored: + return OPENAI_DEFAULT_REASONING_EFFORT + return str(stored.get("ai_reasoning_effort") or "").strip() def current_codex_command() -> str: @@ -193,10 +216,15 @@ async def generate_ai_text( messages: Sequence[Message], *, temperature: float | None = 0.3, - max_tokens: int = 3000, + max_tokens: int | None = 3000, timeout: float = 180.0, ) -> str: - """Return a complete AI response from the currently configured provider.""" + """Return a complete AI response from the currently configured provider. + + max_tokens=None 表示不传该参数(输出上限交给服务端默认) — 推理型模型 + (如 deepseek reasoner 系)的思考 token 计入 max_tokens 预算, 显式限制 + 会挤占正文甚至全部吃光(正文 0 字 + finish=length), 长分析类调用应放开。 + """ if is_codex_cli_provider(): return await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0)) return await _run_openai_once( @@ -211,13 +239,15 @@ async def stream_ai_text( messages: Sequence[Message], *, temperature: float | None = 0.5, - max_tokens: int = 4000, + max_tokens: int | None = 4000, timeout: float = 180.0, ) -> AsyncIterator[str]: """Yield text deltas from the configured provider. Codex CLI only exposes the final assistant message for this use case, so it yields one complete chunk after the command exits. + + max_tokens=None 表示不限制输出(同 generate_ai_text 的说明)。 """ if is_codex_cli_provider(): yield await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0)) @@ -236,7 +266,7 @@ async def _run_openai_once( messages: Sequence[Message], *, temperature: float | None, - max_tokens: int, + max_tokens: int | None, timeout: float, ) -> str: ai_key = secrets_store.get_ai_key() @@ -246,23 +276,20 @@ async def _run_openai_once( client = _openai_client(ai_key, timeout) model = current_ai_model() req_messages = list(messages) - try: - resp = await client.chat.completions.create( - model=model, - messages=req_messages, - **_openai_kwargs(temperature=temperature, max_tokens=max_tokens), - ) - except Exception as exc: - # Reasoning 类模型 (如 kimi-k2.7-code, deepseek-r1, o 系列) 拒绝非约定 - # temperature (Moonshot 报 "only 1 is allowed for this model")。不再靠 - # 模型名猜测, 而是捕获该错误后去掉 temperature 重试一次 —— 对所有此类模型都稳。 - if temperature is not None and _is_temperature_rejected(exc): + kwargs = _openai_kwargs(temperature=temperature, max_tokens=max_tokens) + while True: + try: resp = await client.chat.completions.create( model=model, messages=req_messages, - **_openai_kwargs(temperature=None, max_tokens=max_tokens), + **kwargs, ) - else: + break + except Exception as exc: + retry_kwargs = _openai_retry_kwargs(exc, kwargs) + if retry_kwargs is not None: + kwargs = retry_kwargs + continue if _is_openai_transport_error(exc): raise RuntimeError(_format_openai_error(exc)) from exc raise @@ -275,7 +302,7 @@ async def _stream_openai( messages: Sequence[Message], *, temperature: float | None, - max_tokens: int, + max_tokens: int | None, timeout: float, ) -> AsyncIterator[str]: ai_key = secrets_store.get_ai_key() @@ -292,23 +319,22 @@ async def _stream_openai( if delta and delta.content: yield delta.content - try: - stream = await client.chat.completions.create( - model=model, - messages=req_messages, - **_openai_kwargs(temperature=temperature, max_tokens=max_tokens), - stream=True, - ) - except Exception as exc: - # 流尚未开始 yield, 可安全重建: 去掉 temperature 后重开 stream。 - if temperature is not None and _is_temperature_rejected(exc): + kwargs = _openai_kwargs(temperature=temperature, max_tokens=max_tokens) + while True: + try: stream = await client.chat.completions.create( model=model, messages=req_messages, - **_openai_kwargs(temperature=None, max_tokens=max_tokens), + **kwargs, stream=True, ) - else: + break + except Exception as exc: + # 流尚未开始 yield, 可安全移除被拒绝的可选参数后重建。 + retry_kwargs = _openai_retry_kwargs(exc, kwargs) + if retry_kwargs is not None: + kwargs = retry_kwargs + continue if _is_openai_transport_error(exc): raise RuntimeError(_format_openai_error(exc)) from exc raise @@ -335,11 +361,10 @@ def _openai_client(api_key: str, timeout: float): ) -# Reasoning / thinking 类模型 (kimi-k2.7-code, deepseek-r1, OpenAI o 系列等) 不接受 -# 任意 temperature, 上游会以 400 拒绝 (如 Moonshot: "only 1 is allowed for this model")。 -# 这里不靠模型名猜测, 而是在真正命中该错误后自动去掉 temperature 重试 (见 -# _run_openai_once / _stream_openai), 对任意 reasoning 模型都稳健。 -_TEMP_REJECT_HINTS = ("temperature", "only 1 is allowed", "unsupported parameter") +# 不同模型可能拒绝 temperature 或 reasoning_effort。这里不靠模型名猜测, +# 只在 400 明确指出对应参数时移除该参数并重试; 每个参数最多移除一次。 +_TEMP_REJECT_HINTS = ("temperature", "only 1 is allowed") +_REASONING_EFFORT_REJECT_HINTS = ("reasoning_effort", "reasoning effort") def _is_temperature_rejected(exc: Exception) -> bool: @@ -347,14 +372,58 @@ def _is_temperature_rejected(exc: Exception) -> bool: if getattr(exc, "status_code", None) != 400: return False text = _openai_error_detail(exc) or str(exc) - return any(h in text.lower() for h in _TEMP_REJECT_HINTS) + return _openai_error_param(exc) == "temperature" or any( + h in text.lower() for h in _TEMP_REJECT_HINTS + ) -def _openai_kwargs(*, temperature: float | None, max_tokens: int) -> dict: - """Build OpenAI create() kwargs; temperature omitted when None.""" - kwargs: dict = {"max_tokens": max_tokens} +def _is_reasoning_effort_rejected(exc: Exception) -> bool: + """True if the upstream 400 specifically rejects reasoning_effort.""" + if getattr(exc, "status_code", None) != 400: + return False + text = _openai_error_detail(exc) or str(exc) + return _openai_error_param(exc) == "reasoning_effort" or any( + h in text.lower() for h in _REASONING_EFFORT_REJECT_HINTS + ) + + +def _openai_error_param(exc: Exception) -> str: + body = getattr(exc, "body", None) + if not isinstance(body, dict): + return "" + error = body.get("error") + if isinstance(error, dict): + body = error + return str(body.get("param") or "").strip().lower() + + +def _openai_retry_kwargs(exc: Exception, kwargs: dict) -> dict | None: + """Remove one explicitly rejected optional argument for a bounded retry.""" + retry_kwargs = dict(kwargs) + if "temperature" in retry_kwargs and _is_temperature_rejected(exc): + retry_kwargs.pop("temperature") + return retry_kwargs + if "reasoning_effort" in retry_kwargs and _is_reasoning_effort_rejected(exc): + retry_kwargs.pop("reasoning_effort") + return retry_kwargs + return None + + +def _openai_kwargs(*, temperature: float | None, max_tokens: int | None) -> dict: + """Build OpenAI create() kwargs; optional parameters are omitted when empty. + + max_tokens=None 时不传 — 由服务端默认上限管理(推理模型的思考 token 也 + 计入该参数预算, 限制会挤占正文, 见 stream_ai_text 文档)。 + """ + kwargs: dict = {} + if max_tokens is not None: + kwargs["max_tokens"] = max_tokens if temperature is not None: kwargs["temperature"] = temperature + if current_ai_provider() == OPENAI_PROVIDER: + reasoning_effort = current_openai_reasoning_effort() + if reasoning_effort: + kwargs["reasoning_effort"] = reasoning_effort return kwargs @@ -451,7 +520,7 @@ def _compact_error_text(text: str) -> str: async def _run_codex_cli( messages: Sequence[Message], *, - max_tokens: int, + max_tokens: int | None, timeout: float, ) -> str: prompt = _codex_prompt(messages, max_tokens=max_tokens) @@ -584,14 +653,14 @@ def _make_writable_and_retry( raise exc_info[1] from None -def _codex_prompt(messages: Sequence[Message], *, max_tokens: int) -> str: +def _codex_prompt(messages: Sequence[Message], *, max_tokens: int | None) -> str: parts = [ - "You are TickFlow Stock Panel's local AI provider.", + "You are Tick Stock Panel's local AI provider.", "This is a text-generation task. The working directory is intentionally empty.", "Use only the user-provided prompt content below; do not inspect or modify local files.", "Return only the final requested content; do not include execution logs.", ] - if max_tokens > 0: + if max_tokens: parts.append(f"Keep the final answer within about {max_tokens} output tokens.") for message in messages: role = message.get("role", "user") @@ -700,10 +769,14 @@ def _codex_home() -> Path: def _write_compatible_codex_config(path: Path) -> None: config = _read_codex_config() lines: list[str] = [] - local_provider = _docker_codex_local_provider(config) + active_provider = _active_codex_provider(config) - if local_provider: - lines.append(_toml_string("model_provider", "codex_local_access")) + if active_provider: + lines.append(_toml_string("model_provider", active_provider[0])) + + openai_base_url = config.get("openai_base_url") + if isinstance(openai_base_url, str) and openai_base_url: + lines.append(_toml_string("openai_base_url", openai_base_url)) model = current_ai_model() or normalize_codex_model(str(config.get("model") or "")) if model: @@ -718,41 +791,43 @@ def _write_compatible_codex_config(path: Path) -> None: lines.append(_toml_string("approval_policy", "never")) lines.append(_toml_string("sandbox_mode", "read-only")) - if local_provider: + if active_provider: + provider_name, provider = active_provider lines.append("") - lines.append("[model_providers.codex_local_access]") + lines.append(f"[model_providers.{_toml_key(provider_name)}]") for key in ("name", "base_url", "wire_api", "experimental_bearer_token"): - value = local_provider.get(key) + value = provider.get(key) if isinstance(value, str) and value: lines.append(_toml_string(key, value)) for key in ("requires_openai_auth", "supports_websockets"): - value = local_provider.get(key) + value = provider.get(key) if isinstance(value, bool): lines.append(f"{key} = {'true' if value else 'false'}") path.write_text("\n".join(lines) + "\n", encoding="utf-8") -def _docker_codex_local_provider(config: dict) -> dict | None: - """Return the local-access provider adapted to Docker's host gateway.""" - docker_host = os.environ.get("CODEX_DOCKER_HOST", "").strip() - if not docker_host or config.get("model_provider") != "codex_local_access": +def _active_codex_provider(config: dict) -> tuple[str, dict] | None: + """Return the active custom provider, adapting loopback URLs for Docker.""" + provider_name = config.get("model_provider") + if not isinstance(provider_name, str) or not provider_name: return None providers = config.get("model_providers") if not isinstance(providers, dict): return None - source = providers.get("codex_local_access") + source = providers.get(provider_name) if not isinstance(source, dict): return None provider = dict(source) base_url = str(provider.get("base_url") or "").strip() parsed = urlsplit(base_url) - if parsed.hostname in {"localhost", "127.0.0.1", "::1"}: + docker_host = os.environ.get("CODEX_DOCKER_HOST", "").strip() + if docker_host and parsed.hostname in {"localhost", "127.0.0.1", "::1"}: port = f":{parsed.port}" if parsed.port else "" provider["base_url"] = urlunsplit(parsed._replace(netloc=f"{docker_host}{port}")) - return provider + return provider_name, provider def _read_codex_config() -> dict: @@ -786,6 +861,13 @@ def _toml_string(key: str, value: str) -> str: return f'{key} = "{escaped}"' +def _toml_key(value: str) -> str: + if re.fullmatch(r"[A-Za-z0-9_-]+", value): + return value + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + def _clean_process_text(raw: bytes) -> str: text = raw.decode("utf-8", errors="replace") return _ANSI_RE.sub("", text).strip() diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index dbfcbad..951956a 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -131,9 +131,17 @@ def bootstrap_from_env() -> bool: Returns: True 表示本次用环境变量初始化了密码; False 表示无需初始化。 """ - from app.config import settings + from app.config import _ENV_FILE, settings pwd = (settings.auth_password or "").strip() + # Compose 会对 env_file 中未加单引号的 $VAR 做插值。Docker 部署时同时 + # 只读挂载原始 .env,首次初始化密码直接按 dotenv 语义读取,避免特殊字符被截断。 + if _ENV_FILE.is_file(): + from dotenv import dotenv_values + + raw_pwd = dotenv_values(_ENV_FILE, encoding="utf-8", interpolate=False).get("AUTH_PASSWORD") + if isinstance(raw_pwd, str) and raw_pwd.strip(): + pwd = raw_pwd.strip() if not pwd: return False if is_configured(): diff --git a/backend/app/services/concept_rotation_analyzer.py b/backend/app/services/concept_rotation_analyzer.py index d01683e..9dbbdc5 100644 --- a/backend/app/services/concept_rotation_analyzer.py +++ b/backend/app/services/concept_rotation_analyzer.py @@ -365,18 +365,26 @@ async def analyze_rotation_stream( return user_prompt = _build_user_prompt(signals, overview, days, dates, focus, kind) + got_content = False async for delta in stream_ai_text( [ {"role": "system", "content": _build_system_prompt(kind)}, {"role": "user", "content": user_prompt}, ], temperature=0.5, - max_tokens=4000, + # 不限制输出(推理模型思考 token 计入预算, 见 ai_provider.stream_ai_text) + max_tokens=None, ): + got_content = True yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False) except Exception as e: # noqa: BLE001 logger.exception("AI %s rotation analyze failed: %s", kind, e) yield json.dumps({"type": "error", "message": f"AI 轮动分析失败: {e}"}, ensure_ascii=False) + return + if not got_content: + logger.warning("AI %s rotation analyze ended with empty content", kind) + yield json.dumps({"type": "error", "message": "AI 未返回正文(输出被截断), 请重试"}, ensure_ascii=False) + return yield json.dumps({"type": "done"}, ensure_ascii=False) diff --git a/backend/app/services/depth_service.py b/backend/app/services/depth_service.py index c239530..9e7abcf 100644 --- a/backend/app/services/depth_service.py +++ b/backend/app/services/depth_service.py @@ -146,12 +146,19 @@ class DepthService: logger.warning("depth sealed 从 parquet 恢复失败: %s", e) def start_polling(self) -> None: - """启动盘中轮询线程(连板梯队监控开启 + 有能力 + 交易时段)。""" + """启动盘中轮询线程(连板梯队监控开启 + 实时行情开启 + 有能力)。 + + 依赖实时行情开关: 实时行情关闭时 enriched 内存缓存停留在上一交易日, + 轮询会反复拉取陈旧的涨跌停名单(浪费 API 额度且数据无意义)。 + 实时行情开关切换时由 settings API 调 stop_polling/start_polling 同步启停。 + """ if not self._has_capability(): return from app.services import preferences if not preferences.get_limit_ladder_monitor_enabled(): return + if not preferences.get_realtime_quotes_enabled(): + return # check-then-act 加锁: 两个线程同时 start_polling 不会各起一个轮询线程 with self._lock: if self._running: diff --git a/backend/app/services/ext_data.py b/backend/app/services/ext_data.py index 729558c..8a6ff27 100644 --- a/backend/app/services/ext_data.py +++ b/backend/app/services/ext_data.py @@ -1,6 +1,7 @@ """扩展数据服务 — 配置管理 + 文件解析 + Parquet 存储。""" from __future__ import annotations +import copy import json import logging import re @@ -39,7 +40,7 @@ class PullConfig: "url", "method", "headers", "body", "response_path", "field_map", "schedule_minutes", "enabled", "last_run", "last_status", "last_message", "last_rows", - "next_run", + "next_run", "time_window_start", "time_window_end", ) def __init__( @@ -57,6 +58,8 @@ class PullConfig: last_message: str | None = None, last_rows: int | None = None, next_run: str | None = None, + time_window_start: str | None = None, + time_window_end: str | None = None, ) -> None: self.url = url self.method = method # GET | POST @@ -71,6 +74,8 @@ class PullConfig: self.last_message = last_message self.last_rows = last_rows self.next_run = next_run # 下次预计运行 (ISO, 调度器写入) + self.time_window_start = time_window_start # 每日拉取窗口起始 "HH:MM", None=不限 + self.time_window_end = time_window_end # 每日拉取窗口结束 "HH:MM", None=不限 def to_dict(self) -> dict: return { @@ -87,6 +92,8 @@ class PullConfig: "last_message": self.last_message, "last_rows": self.last_rows, "next_run": self.next_run, + "time_window_start": self.time_window_start, + "time_window_end": self.time_window_end, } @classmethod @@ -107,6 +114,8 @@ class PullConfig: last_message=d.get("last_message"), last_rows=d.get("last_rows"), next_run=d.get("next_run"), + time_window_start=d.get("time_window_start"), + time_window_end=d.get("time_window_end"), ) @@ -179,17 +188,48 @@ class ExtConfig: # 配置持久化 # --------------------------------------------------------------------------- +# load_all 进程内缓存: kline/screener/watchlist 等热路径每请求调用, 每次都 +# iterdir + 逐 config.json read_text+parse 纯重复; 以配置目录的 +# (目录名, mtime_ns, size) 签名失效 (新增/编辑/删除配置都会改变签名)。 +_load_all_cache: dict[str, tuple[tuple, list[ExtConfig]]] = {} + + +def _ext_config_dir_signature(base: Path) -> tuple | None: + """配置目录下所有 config.json 的 (目录名, mtime_ns, size) 签名; 出错返回 None (禁用缓存)。""" + try: + sig = [] + for d in sorted(base.iterdir()): + cp = d / "config.json" + if d.is_dir() and cp.exists(): + st = cp.stat() + sig.append((d.name, st.st_mtime_ns, st.st_size)) + return tuple(sig) + except Exception: # noqa: BLE001 + return None + + class ExtConfigStore: """扩展数据配置文件读写 — 每个表独立目录 data/ext/{config_id}/config.json。""" + # 与创建端点 CreateExtReq.id 的 pattern 一致; load_all 之外的 config_id + # 来自 URL path 参数, 必须先过白名单再拼路径, 防止 ../ 穿越删除。 + _VALID_ID = re.compile(r"^[a-zA-Z0-9_]+$") + def __init__(self, data_dir: Path) -> None: self._base = data_dir / "ext_data" def _config_path(self, config_id: str) -> Path: + if not self._VALID_ID.match(config_id): + raise ValueError(f"非法 config_id: {config_id!r}") return self._base / config_id / "config.json" def load_all(self) -> list[ExtConfig]: # 兼容旧版: 如果目录为空且旧配置文件存在则迁移 + sig = _ext_config_dir_signature(self._base) + if sig is not None: + cached = _load_all_cache.get(str(self._base)) + if cached is not None and cached[0] == sig: + return copy.deepcopy(cached[1]) if not self._base.exists() or not any(self._base.iterdir()): old = self._base.parent / "ext_configs.json" if not old.exists(): @@ -207,10 +247,16 @@ class ExtConfigStore: configs.append(ExtConfig.from_dict(raw)) except Exception as e: logger.warning("扩展表配置解析失败 %s: %s", cp, e) + if sig is not None and configs: + # 缓存存私有副本, 命中时返回深拷贝, 调用方改配置对象不会污染缓存。 + _load_all_cache[str(self._base)] = (sig, copy.deepcopy(configs)) return configs def get(self, config_id: str) -> ExtConfig | None: - cp = self._config_path(config_id) + try: + cp = self._config_path(config_id) + except ValueError: + return None if not cp.exists(): return None try: @@ -230,7 +276,10 @@ class ExtConfigStore: def delete(self, config_id: str) -> bool: import shutil - cp = self._config_path(config_id) + try: + cp = self._config_path(config_id) + except ValueError: + return False if not cp.exists(): return False shutil.rmtree(cp.parent, ignore_errors=True) diff --git a/backend/app/services/ext_pull.py b/backend/app/services/ext_pull.py index f9528b7..d1e777c 100644 --- a/backend/app/services/ext_pull.py +++ b/backend/app/services/ext_pull.py @@ -20,6 +20,21 @@ from app.services.ext_data import ( logger = logging.getLogger(__name__) +def _in_time_window(start: str | None, end: str | None) -> bool: + """检查当前本地时间是否在每日时间窗口内。 + + start/end 为 "HH:MM" 格式。两者都为 None 时不限制(返回 True)。 + 支持跨午夜窗口(如 22:00-02:00)。 + """ + if not start or not end: + return True + now = datetime.now().strftime("%H:%M") + if start <= end: + return start <= now < end + # 跨午夜: 如 22:00-02:00 + return now >= start or now < end + + # --------------------------------------------------------------------------- # 响应解析 # --------------------------------------------------------------------------- @@ -253,6 +268,17 @@ class PullScheduler: break pull = fresh.pull + # 时间窗口检查: 不在窗口内则跳过本次拉取 + if not _in_time_window(pull.time_window_start, pull.time_window_end): + fresh.pull.last_run = datetime.now(timezone.utc).isoformat() + fresh.pull.last_status = "skipped" + fresh.pull.last_message = "不在拉取时间窗口内" + store.upsert(fresh) + logger.info("PullScheduler: %s skipped (outside time window)", config.id) + interval = max(pull.schedule_minutes * 60, 60) + await asyncio.sleep(interval) + continue + # 先执行一次 (启用即拉取, 让用户立刻看到生效) try: n, d = await fetch_and_ingest(fresh, self._data_dir) diff --git a/backend/app/services/financial_analyzer.py b/backend/app/services/financial_analyzer.py index cd5c597..494f53f 100644 --- a/backend/app/services/financial_analyzer.py +++ b/backend/app/services/financial_analyzer.py @@ -178,14 +178,17 @@ async def analyze_financials_stream( from app.services.ai_provider import stream_ai_text user_prompt = _build_user_prompt(fins, symbol, focus) + got_content = False async for delta in stream_ai_text( [ {"role": "system", "content": _SYSTEM_PROMPT}, {"role": "user", "content": user_prompt}, ], temperature=0.4, - max_tokens=4000, + # 不限制输出(推理模型思考 token 计入预算, 见 ai_provider.stream_ai_text) + max_tokens=None, ): + got_content = True yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False) except Exception as e: # noqa: BLE001 @@ -193,4 +196,8 @@ async def analyze_financials_stream( yield json.dumps({"type": "error", "message": f"AI 分析失败: {e}"}, ensure_ascii=False) return + if not got_content: + logger.warning("AI financial analysis ended with empty content for %s", symbol) + yield json.dumps({"type": "error", "message": "AI 未返回正文(输出被截断), 请重试"}, ensure_ascii=False) + return yield json.dumps({"type": "done"}, ensure_ascii=False) diff --git a/backend/app/services/financial_sync.py b/backend/app/services/financial_sync.py index fe0b5a3..7b857eb 100644 --- a/backend/app/services/financial_sync.py +++ b/backend/app/services/financial_sync.py @@ -154,7 +154,7 @@ def _sync_table( ) -def _merge_share_history(*frames: pl.DataFrame) -> pl.DataFrame: +def _merge_report_history(*frames: pl.DataFrame) -> pl.DataFrame: valid = [ frame for frame in frames @@ -162,65 +162,74 @@ def _merge_share_history(*frames: pl.DataFrame) -> pl.DataFrame: ] if not valid: return pl.DataFrame() - return ( + merged = ( pl.concat(valid, how="diagonal_relaxed") .filter(pl.col("symbol").is_not_null() & pl.col("period_end").is_not_null()) - .unique(subset=["symbol", "period_end"], keep="last") - .sort(["symbol", "period_end"]) + ) + # 同一 (symbol, period_end) 多条时保留 announce_date 最新一条 (业绩修正以最新公告为准)。 + if "announce_date" in merged.columns: + merged = merged.sort(["symbol", "period_end", "announce_date"], nulls_last=True) + return merged.unique(subset=["symbol", "period_end"], keep="last").sort( + ["symbol", "period_end"] ) -def _sync_shares_for_symbols( +def _sync_history_table_for_symbols( + table: str, symbols: list[str], data_dir: Path, capset: CapabilitySet, ) -> int: - """首次拉全量股本历史,后续更新最新记录并补齐新增标的历史。""" - existing = get_financial_df(data_dir, "shares") + """历史累积同步: 保留已有各期记录, 仅拉最新期 + 为新标的补全量历史。 + + 与 shares 同一模式。若改为 latest_only 全量覆盖, 历史各期会在每次同步时 + 被冲掉, 财务因子将永远只有单期快照, 任何回测都是未来函数。 + """ + existing = get_financial_df(data_dir, table) if existing.is_empty() or not {"symbol", "period_end"} <= set(existing.columns): - return _sync_table("shares", symbols, data_dir, capset, latest_only=False) + return _sync_table(table, symbols, data_dir, capset, latest_only=False) existing_symbols = set(existing["symbol"].drop_nulls().to_list()) missing_symbols = [symbol for symbol in symbols if symbol not in existing_symbols] missing_history = ( - _fetch_table("shares", missing_symbols, capset, latest_only=False) + _fetch_table(table, missing_symbols, capset, latest_only=False) if missing_symbols else pl.DataFrame() ) current_symbols = [symbol for symbol in symbols if symbol in existing_symbols] - latest = _fetch_table("shares", current_symbols, capset, latest_only=True) - merged = _merge_share_history(existing, missing_history, latest) - return _write_table("shares", merged, data_dir) + latest = _fetch_table(table, current_symbols, capset, latest_only=True) + merged = _merge_report_history(existing, missing_history, latest) + return _write_table(table, merged, data_dir) def sync_metrics(data_dir: Path, capset: CapabilitySet) -> int: - """同步核心财务指标 (metrics)。""" + """同步核心财务指标 (metrics), 历史各期累积保留。""" symbols = _get_symbols(data_dir) - return _sync_table("metrics", symbols, data_dir, capset, latest_only=True) + return _sync_history_table_for_symbols("metrics", symbols, data_dir, capset) def sync_income(data_dir: Path, capset: CapabilitySet) -> int: - """同步利润表。""" + """同步利润表, 历史各期累积保留。""" symbols = _get_symbols(data_dir) - return _sync_table("income", symbols, data_dir, capset, latest_only=True) + return _sync_history_table_for_symbols("income", symbols, data_dir, capset) def sync_balance_sheet(data_dir: Path, capset: CapabilitySet) -> int: - """同步资产负债表。""" + """同步资产负债表, 历史各期累积保留。""" symbols = _get_symbols(data_dir) - return _sync_table("balance_sheet", symbols, data_dir, capset, latest_only=True) + return _sync_history_table_for_symbols("balance_sheet", symbols, data_dir, capset) def sync_cash_flow(data_dir: Path, capset: CapabilitySet) -> int: - """同步现金流量表。""" + """同步现金流量表, 历史各期累积保留。""" symbols = _get_symbols(data_dir) - return _sync_table("cash_flow", symbols, data_dir, capset, latest_only=True) + return _sync_history_table_for_symbols("cash_flow", symbols, data_dir, capset) def sync_shares(data_dir: Path, capset: CapabilitySet) -> int: """同步历史股本表。""" symbols = _get_symbols(data_dir) - return _sync_shares_for_symbols(symbols, data_dir, capset) + return _sync_history_table_for_symbols("shares", symbols, data_dir, capset) def sync_all(data_dir: Path, capset: CapabilitySet) -> dict[str, int]: @@ -232,10 +241,8 @@ def sync_all(data_dir: Path, capset: CapabilitySet) -> dict[str, int]: symbols = _get_symbols(data_dir) results: dict[str, int] = {} for table in FINANCIAL_TABLES: - results[table] = ( - _sync_shares_for_symbols(symbols, data_dir, capset) - if table == "shares" - else _sync_table(table, symbols, data_dir, capset, latest_only=True) + results[table] = _sync_history_table_for_symbols( + table, symbols, data_dir, capset ) # 同步完成后注册 DuckDB 视图 @@ -428,10 +435,8 @@ class FinancialScheduler: symbols = _get_symbols(self._data_dir) result: dict[str, int] = {} for t in FINANCIAL_TABLES: - result[t] = ( - _sync_shares_for_symbols(symbols, self._data_dir, self._capset) - if t == "shares" - else _sync_table(t, symbols, self._data_dir, self._capset, latest_only=True) + result[t] = _sync_history_table_for_symbols( + t, symbols, self._data_dir, self._capset ) self._record_sync(t) _refresh_financials_views(self._data_dir) diff --git a/backend/app/services/heavy_job_limiter.py b/backend/app/services/heavy_job_limiter.py new file mode 100644 index 0000000..65c7e88 --- /dev/null +++ b/backend/app/services/heavy_job_limiter.py @@ -0,0 +1,121 @@ +"""Weighted process-local limiter for memory-heavy jobs.""" + +from __future__ import annotations + +import threading +import time +from collections.abc import Iterator +from contextlib import contextmanager +from typing import ClassVar, Literal + +HeavyJobKind = Literal["normal", "mining"] + + +class HeavyJobLimitTimeoutError(TimeoutError): + """Raised when a heavy-job slot cannot be acquired before its deadline.""" + + +class HeavyJobCancelledError(RuntimeError): + """Raised when slot acquisition is cancelled while waiting.""" + + +class HeavyJobLimiter: + """A weighted limiter where normal jobs cost one slot and mining costs two.""" + + _WEIGHTS: ClassVar[dict[HeavyJobKind, int]] = {"normal": 1, "mining": 2} + + def __init__(self, capacity: int = 2, *, cancel_poll_interval: float = 0.05) -> None: + if capacity <= 0: + raise ValueError("capacity must be positive") + if cancel_poll_interval <= 0: + raise ValueError("cancel_poll_interval must be positive") + self.capacity = capacity + self._cancel_poll_interval = cancel_poll_interval + self._used = 0 + self._acquired = {"normal": 0, "mining": 0} + self._condition = threading.Condition() + + @property + def in_use(self) -> int: + with self._condition: + return self._used + + @property + def available(self) -> int: + with self._condition: + return self.capacity - self._used + + def acquire( + self, + kind: HeavyJobKind = "normal", + *, + timeout: float | None = None, + cancel_event: threading.Event | None = None, + ) -> bool: + """Wait for capacity and return ``False`` on cancellation or timeout.""" + weight = self._weight(kind) + if weight > self.capacity: + raise ValueError(f"{kind} requires {weight} slots, capacity is {self.capacity}") + if timeout is not None and timeout < 0: + raise ValueError("timeout must be non-negative") + + deadline = None if timeout is None else time.monotonic() + timeout + with self._condition: + while True: + if cancel_event is not None and cancel_event.is_set(): + return False + if self._used + weight <= self.capacity: + self._used += weight + self._acquired[kind] += 1 + return True + + remaining = None if deadline is None else deadline - time.monotonic() + if remaining is not None and remaining <= 0: + return False + wait_for = remaining + if cancel_event is not None: + wait_for = self._cancel_poll_interval + if remaining is not None: + wait_for = min(wait_for, remaining) + self._condition.wait(wait_for) + + def release(self, kind: HeavyJobKind = "normal") -> None: + """Return capacity previously acquired for ``kind``.""" + weight = self._weight(kind) + with self._condition: + if self._acquired[kind] == 0: + raise RuntimeError(f"cannot release unacquired {kind} capacity") + self._acquired[kind] -= 1 + self._used -= weight + self._condition.notify_all() + + @contextmanager + def slot( + self, + kind: HeavyJobKind = "normal", + *, + timeout: float | None = None, + cancel_event: threading.Event | None = None, + ) -> Iterator[HeavyJobLimiter]: + """Acquire weighted capacity for the duration of a ``with`` block.""" + acquired = self.acquire(kind, timeout=timeout, cancel_event=cancel_event) + if not acquired: + if cancel_event is not None and cancel_event.is_set(): + raise HeavyJobCancelledError(f"{kind} job was cancelled while waiting") + raise HeavyJobLimitTimeoutError(f"timed out waiting for {kind} job capacity") + try: + yield self + finally: + self.release(kind) + + @classmethod + def _weight(cls, kind: HeavyJobKind) -> int: + try: + return cls._WEIGHTS[kind] + except KeyError as exc: + raise ValueError(f"unsupported heavy job kind: {kind!r}") from exc + + +shared_heavy_job_limiter = HeavyJobLimiter(capacity=2) +# Short alias for entry points that prefer the existing module-singleton naming style. +heavy_job_limiter = shared_heavy_job_limiter diff --git a/backend/app/services/kline_sync.py b/backend/app/services/kline_sync.py index 8db0f11..46789c1 100644 --- a/backend/app/services/kline_sync.py +++ b/backend/app/services/kline_sync.py @@ -15,7 +15,7 @@ import polars as pl from app.data_providers.base import AssetType from app.indicators.pipeline import filter_halt_days -from app.market_time import cn_now +from app.market_time import CN_TZ, cn_now, cn_today from app.services import preferences from app.tickflow.capabilities import Cap, CapabilitySet from app.tickflow.client import get_client @@ -266,7 +266,9 @@ def sync_daily_by_quotes(repo: KlineRepository) -> int: if df.is_empty(): return 0 - today = _date.today() + # 分区日期用北京交易日 (与 quote_service._build_daily 的 cn_today 一致), + # 避免 UTC 服务器在盘中把日分区写成服务器本地日期。 + today = cn_today() daily_df = df.with_columns(pl.lit(today).cast(pl.Date).alias("date")) # 过滤停牌 (open/high 为 0; close 可能被填充为前收盘价, 不能用全零判断) @@ -833,8 +835,10 @@ def fetch_minute_single( ) -> pl.DataFrame: """实时拉取单股单日分钟 K(不写入本地)。优先自定义分钟源, 回退 TickFlow。""" from datetime import datetime - start_time = datetime(trade_date.year, trade_date.month, trade_date.day, 9, 25, 0) - end_time = datetime(trade_date.year, trade_date.month, trade_date.day, 15, 5, 0) + # 北京时间窗口必须带时区: naive datetime 会被 .timestamp() 按服务器本地时区解释, + # UTC 容器上窗口整体偏移 8 小时, 分时补拉必然为空。 + start_time = datetime(trade_date.year, trade_date.month, trade_date.day, 9, 25, 0, tzinfo=CN_TZ) + end_time = datetime(trade_date.year, trade_date.month, trade_date.day, 15, 5, 0, tzinfo=CN_TZ) # 自定义数据源分流: 与 sync_minute_batch 一致, 配了自定义分钟源时走 custom provider, # 避免无 TickFlow Pro+ 权限的用户分时图首次打开(本地无数据)时补拉失败返回空。 @@ -998,11 +1002,13 @@ def sync_and_persist_minute( days: int = 5, on_chunk_done: Callable[[int, int, str], None] | None = None, extend_backward: bool = False, + force_full_days: bool = False, ) -> int: """同步分钟 K 并存到 Parquet(前复权价格, SDK 端 adjust=qfq)。返回写入行数。 使用 start_time / end_time 区间拉取, 确保所有标的覆盖同一时间段。 on_chunk_done(current, total) 每个 chunk 完成后回调。 + force_full_days=True 时强制回溯 days 自然日 (不增量补, 用于个股补齐历史)。 """ minute_provider = preferences.get_minute_data_provider() # resolver 调用统一走 _resolve_minute_provider, 与 _try_custom_minute 共用异常边界。 @@ -1042,8 +1048,13 @@ def sync_and_persist_minute( end_time = now else: # 默认增量模式: 首次拉取回溯 N 天, 已有数据则从最新时间增量补到今天 + # force_full_days=True: 强制回溯 days 自然日 (个股补齐历史, 不增量) last_dt = _latest_minute_datetime(repo) - if last_dt: + if force_full_days: + # 按交易日换算自然日 (7/5 系数), 确保覆盖足够交易日 + calendar_days = int(days * 7 / 5) + 5 + start_time = now - timedelta(days=calendar_days) + elif last_dt: start_time = last_dt else: start_time = now - timedelta(days=days) @@ -1063,7 +1074,10 @@ def sync_and_persist_minute( written_box = [0] # list 闭包, 绕过 Python 闭包外层赋值 def _persist(seg_df: pl.DataFrame) -> None: - written_box[0] += _write_minute_partition(seg_df, minute_dir) + # 单股自动补齐可能与另一个补齐请求同时写同一日期分区。Windows 不允许 + # 替换仍被另一写入占用的临时文件,因此读-改-写必须复用仓库写锁。 + with repo._write_lock: + written_box[0] += _write_minute_partition(seg_df, minute_dir) segment_days = preferences.get_minute_sync_segment_days() sync_minute_batch( diff --git a/backend/app/services/market_mainline.py b/backend/app/services/market_mainline.py new file mode 100644 index 0000000..9ea0418 --- /dev/null +++ b/backend/app/services/market_mainline.py @@ -0,0 +1,294 @@ +"""市场主线(板块/概念)识别 — 基于涨停梯队的历史聚合。 + +用户判据的量化: 主升阶段的主线 = 同一概念内涨停家数多、最高连板高、 +梯队档位填得满(2 板到最高板之间不断层)。对每个交易日按概念聚合涨停梯队, +截面 rank 归一后加权成主线分, 持久化为日频时序, 供市场环境页展示 +"什么阶段走什么主升"。 + +口径限制(重要): 概念成分来自 ext_gn_ths 快照(本地自 2026-07 起留存, 无历史 +版本)。历史主线是把"今天的成分"回看历史 — 早年存在归属漂移(新概念不会 +出现在旧时段、成分调整会错归属)。MEMBERSHIP_NOTE 随 API 返回给前端展示。 + +性能: 全量回填只窄扫 enriched 的 4 列并先过滤连板 >=1(全历史 ~10 万行), +join 概念映射后 group_by, 峰值内存 <100MB。 +""" +from __future__ import annotations + +import logging +import time +from datetime import date +from pathlib import Path + +import polars as pl + +from app.services.rps_rotation import _load_concept_map_df + +logger = logging.getLogger(__name__) + +MEMBERSHIP_NOTE = ( + "概念成分为当前快照回看历史(本地自 2026-07 起留存, 无历史版本), " + "早年主线存在归属漂移, 越近越准" +) + +MAINLINE_DIR = "mainline_history" +_TOP_PER_DAY = 30 # 每日持久化的主线数(按分数截断) +_INDUSTRY_LEVEL = 2 # 行业主线取前两级(如 计算机-软件开发) +_MIN_LIMIT_UP = 3 # 单概念当日最少涨停家数(低于此不参与排名) + +# 主线分权重: 概念内涨停家数 / 最高连板 / 梯队档位数 / 二板以上家数 +_SCORE_WEIGHTS = { + "limit_up_count": 0.35, + "max_boards": 0.25, + "rungs_filled": 0.25, + "ge2_count": 0.15, +} + + +def _resolve_filter_config(filter_cfg: dict | None) -> dict: + """解析过滤配置; None 时读用户偏好(宽基/风格标签过滤, 见 preferences 文档)。""" + if filter_cfg is not None: + return { + "min_members": int(filter_cfg.get("min_members", 4)), + "max_members": int(filter_cfg.get("max_members", 600)), + "blacklist": {str(x) for x in filter_cfg.get("blacklist") or []}, + } + try: + from app.services import preferences + + cfg = preferences.get_mainline_filter_config() + return { + "min_members": int(cfg["min_members"]), + "max_members": int(cfg["max_members"]), + "blacklist": set(cfg["blacklist"]), + } + except Exception: + return {"min_members": 4, "max_members": 600, "blacklist": set()} + + +def mainline_path(data_dir: Path) -> Path: + return data_dir / MAINLINE_DIR / "part.parquet" + + +_ST_SYMBOLS_CACHE: tuple[float, frozenset[str]] | None = None + + +def load_risk_warning_symbols(data_dir: Path) -> frozenset[str]: + """当前维表快照中名称含 ST 标记的 symbol 集合(大写), 供主线/情绪统计剔除。 + + 判定与 indicators 涨跌停口径共用同一权威实现(price_limits.polars_is_risk_warning_name, + 即名称含 "ST", 覆盖 ST/*ST/S*ST)。维表是快照无历史版本, 与概念成分同样的 + 回看限制。600s 进程内缓存(维表 snapshot 进程内不变)。 + """ + global _ST_SYMBOLS_CACHE + now = time.time() + if _ST_SYMBOLS_CACHE is not None and now - _ST_SYMBOLS_CACHE[0] < 600: + return _ST_SYMBOLS_CACHE[1] + from app.price_limits import polars_is_risk_warning_name + + syms: frozenset[str] = frozenset() + inst_dir = data_dir / "instruments" + if inst_dir.exists(): + try: + df = pl.read_parquet(inst_dir / "**" / "*.parquet").select(["symbol", "name"]) + st = df.filter(polars_is_risk_warning_name(pl.col("name"))) + syms = frozenset(s.upper() for s in st["symbol"].to_list()) + except Exception as e: + logger.warning("load risk-warning symbols failed: %s", e) + _ST_SYMBOLS_CACHE = (now, syms) + return syms + + +def load_mainline_history(data_dir: Path, kind: str = "concept") -> pl.DataFrame: + """读取主线时序(全部 kind), 不存在返回空 DataFrame。""" + p = mainline_path(data_dir) + if not p.exists(): + return pl.DataFrame() + try: + df = pl.read_parquet(p) + except Exception as e: + logger.warning("load_mainline_history failed: %s", e) + return pl.DataFrame() + if df.is_empty() or "kind" not in df.columns: + return df + return df.filter(pl.col("kind") == kind) + + +def _industry_member(member: str, kind: str) -> str: + """行业维度取前 _INDUSTRY_LEVEL 级; 概念原样返回。""" + if kind != "industry": + return member + return "-".join(member.split("-")[:_INDUSTRY_LEVEL]) + + +def compute_mainline_range(repo, data_dir: Path, start: date, end: date, + kind: str = "concept", + filter_cfg: dict | None = None, + exclude_st: bool | None = None) -> pl.DataFrame: + """计算 [start, end] 每日主线排行(按 _SCORE_WEIGHTS 加权截面分)。 + + filter_cfg: {"min_members", "max_members", "blacklist"}; None 时读用户偏好。 + 宽基/风格标签(融资融券/沪深股通等数千成分)按成员数上限过滤, + 用户黑名单按名称过滤(不论大小)。修改配置后重算主线生效。 + exclude_st: 是否剔除风险警示(ST)股(按当前维表名称); None 时读用户偏好 + (默认剔除 — ST 是状态桶非题材, 主板 5% 便宜板时代曾系统性霸榜)。 + + 返回列: date, kind, member, limit_up_count, ge2_count, max_boards, + boards_sum, rungs_filled, leader_symbol, score, rank。空数据返回空表。 + """ + if start > end: + return pl.DataFrame() + enriched_dir = repo.store.data_dir / "kline_daily_enriched" + if not enriched_dir.exists(): + return pl.DataFrame() + + map_df, _ = _load_concept_map_df(repo, kind) + if map_df.is_empty(): + return pl.DataFrame() + + cfg = _resolve_filter_config(filter_cfg) + if cfg["min_members"] > 1 or cfg["max_members"] < 5000 or cfg["blacklist"]: + member_counts = map_df.group_by(kind).len().rename({"len": "_members"}) + member_counts = member_counts.filter( + pl.col("_members").ge(cfg["min_members"]) + & pl.col("_members").le(cfg["max_members"]) + & ~pl.col(kind).is_in(sorted(cfg["blacklist"])) + ) + allowed = member_counts.select(kind) + map_df = map_df.join(allowed, on=kind, how="semi") + if map_df.is_empty(): + return pl.DataFrame() + + limit_rows = ( + pl.scan_parquet(enriched_dir / "**" / "*.parquet") + .select(["date", "symbol", "consecutive_limit_ups", "amount"]) + .filter( + (pl.col("date") >= start) & (pl.col("date") <= end) + & (pl.col("consecutive_limit_ups") >= 1) + ) + .collect() + ) + if limit_rows.is_empty(): + return pl.DataFrame() + + limit_rows = limit_rows.with_columns(pl.col("symbol").str.to_uppercase().alias("_sym_up")) + + # 剔除风险警示股: ST 板块的涨停生态(主板曾 5% 便宜板)不代表题材主线。 + if exclude_st is None: + try: + from app.services import preferences + exclude_st = preferences.get_sentiment_exclude_st() + except Exception: + exclude_st = True + if exclude_st: + st_syms = load_risk_warning_symbols(repo.store.data_dir) + if st_syms: + limit_rows = limit_rows.filter(~pl.col("_sym_up").is_in(sorted(st_syms))) + + joined = limit_rows.join(map_df, on="_sym_up", how="inner") + if joined.is_empty(): + return pl.DataFrame() + joined = joined.with_columns( + pl.col(kind).map_elements( + lambda m: _industry_member(str(m), kind), + return_dtype=pl.Utf8, + ).alias("member") + ) + + agg = ( + joined.group_by(["date", "member"]) + .agg( + pl.len().alias("limit_up_count"), + (pl.col("consecutive_limit_ups") >= 2).sum().alias("ge2_count"), + pl.col("consecutive_limit_ups").max().alias("max_boards"), + pl.col("consecutive_limit_ups").sum().alias("boards_sum"), + pl.col("consecutive_limit_ups") + .filter(pl.col("consecutive_limit_ups") >= 2) + .n_unique() + .alias("rungs_filled"), + pl.col("symbol") + .sort_by( + pl.col("consecutive_limit_ups"), pl.col("amount"), + descending=[True, True], + ) + .first() + .alias("leader_symbol"), + ) + ) + + # 截面 rank 归一(0-1) → 加权主线分(0-100)。分母 max(n-1,1) 保证单概念日不除零。 + agg = agg.filter(pl.col("limit_up_count") >= _MIN_LIMIT_UP) + norm_exprs = [] + for col in _SCORE_WEIGHTS: + norm_exprs.append( + ((pl.col(col).rank(method="average") - 1.0) + / pl.max_horizontal(pl.len().over("date") - 1, 1)).over("date").alias(f"_{col}_r") + ) + agg = agg.with_columns(norm_exprs) + agg = agg.with_columns( + ( + 100.0 * sum( + _SCORE_WEIGHTS[col] * pl.col(f"_{col}_r") for col in _SCORE_WEIGHTS + ) + ).alias("score") + ) + agg = agg.with_columns( + pl.col("score").rank(method="ordinal", descending=True).over("date").alias("rank") + ) + result = ( + agg.filter(pl.col("rank") <= _TOP_PER_DAY) + .drop([f"_{col}_r" for col in _SCORE_WEIGHTS]) + .with_columns(pl.lit(kind).alias("kind")) + .select([ + "date", "kind", "member", "limit_up_count", "ge2_count", + "max_boards", "boards_sum", "rungs_filled", "leader_symbol", + "score", "rank", + ]) + .sort(["date", "rank"]) + ) + return result + + +def upsert_mainline_history(data_dir: Path, new_rows: pl.DataFrame) -> None: + """按 (date, kind) 整日覆盖 upsert; schema 以 new_rows 为权威(同 regime 模式)。""" + if new_rows.is_empty() or "date" not in new_rows.columns: + return + p = mainline_path(data_dir) + p.parent.mkdir(parents=True, exist_ok=True) + old = pl.read_parquet(p) if p.exists() else pl.DataFrame() + if old.is_empty(): + combined = new_rows + else: + # 按 (date, kind) 整日覆盖: anti-join 掉本次重算的 (日, 维度) 组合 + kept = old.join( + new_rows.select(["date", "kind"]).unique(), + on=["date", "kind"], + how="anti", + ) + target_cols = new_rows.columns + keep_exprs = [ + pl.col(c) if c in kept.columns else pl.lit(None).alias(c) + for c in target_cols + ] + kept = kept.select(keep_exprs) + combined = pl.concat([kept, new_rows.select(target_cols)], how="vertical_relaxed") + combined = combined.sort(["date", "kind", "rank"]) + combined.write_parquet(p) + + +def compute_mainline_incremental(repo, data_dir: Path, *, today: date | None = None, + kind: str = "concept") -> pl.DataFrame: + """增量补算主线(供 daily_pipeline / 手动触发): 补 enriched 已有而主线缺失的日。""" + today = today or date.today() + from app.services.regime_builder import enriched_date_set + + enriched_dates = enriched_date_set(repo) + existing = load_mainline_history(data_dir, kind) + existing_dates = set(existing["date"].to_list()) if not existing.is_empty() else set() + missing = sorted(d for d in enriched_dates if d not in existing_dates and d <= today) + if not missing: + return pl.DataFrame() + logger.info("mainline incremental(%s): compute %d days", kind, len(missing)) + new_rows = compute_mainline_range(repo, data_dir, missing[0], missing[-1], kind=kind) + if not new_rows.is_empty(): + upsert_mainline_history(data_dir, new_rows) + return new_rows diff --git a/backend/app/services/market_phase.py b/backend/app/services/market_phase.py new file mode 100644 index 0000000..3f10703 --- /dev/null +++ b/backend/app/services/market_phase.py @@ -0,0 +1,255 @@ +"""市场情绪周期阶段(冰点/启动/主升/高潮/退潮/修复) — 纯函数模块。 + +与 regime_builder 的 5 档 state(强势/偏强/震荡/偏弱/弱势)并存: +- state: 综合情绪分(赚钱/投机/抗跌/趋势 4 维加权), 回测环境过滤与挖掘在用, 不动。 +- phase: 基于"连板梯队"的阶段(用户判据: 高度、宽度、晋级率、梯队完整度), + 刻画情绪周期位置(冰点→启动→主升→高潮→退潮), 供市场环境页分析与主线识别。 + +驱动量(全部可从已存储的 consecutive_limit_ups 派生, 2020-08 起全历史可回算): +- height 高度: 当日最高连板数 +- first_board 首板宽度: 首板(1 连板)家数 +- ge2/ge3/ge5 宽度: N 板以上家数 +- promo 晋级率: 昨日连板池今日继续封板的比例 (池 <10 家记 null) +- seal_rate 封板率: regime 已有列 +- ladder_completeness 梯队完整度: 2..height 档位中非空占比 + +阈值标定: 2020-08~2026-08 全市场 1454 个交易日的 p10/p60/p90 分位数 +(标定脚本一次性运行, 不提交); 关键异常段抽查(2024-09/10 rally→climax→ebb, +2024-01/02 微盘退潮)人工核过归属。阈值集中在下方, 调整只需改这里。 +""" +from __future__ import annotations + +import logging + +import polars as pl + +logger = logging.getLogger(__name__) + +# ───────────────────────── 阶段词汇 ───────────────────────── +PHASE_ICE = "ice" +PHASE_IGNITE = "ignite" +PHASE_RALLY = "rally" +PHASE_CLIMAX = "climax" +PHASE_EBB = "ebb" +PHASE_REPAIR = "repair" + +PHASE_LABELS = { + PHASE_ICE: "冰点", + PHASE_IGNITE: "启动", + PHASE_RALLY: "主升", + PHASE_CLIMAX: "高潮", + PHASE_EBB: "退潮", + PHASE_REPAIR: "修复", +} + +# 规则判定优先级: 高潮 > 主升 > 退潮 > 启动 > 冰点 > 修复(兜底) +_PHASE_PRIORITY = (PHASE_CLIMAX, PHASE_RALLY, PHASE_EBB, PHASE_IGNITE, PHASE_ICE) + +# ───────────────────────── 阈值(标定自 2020-08~2026-08 分位数) ───────────────────────── +# 高潮: 情绪极端宣泄 — 二板以上宽度或首板数达到 p90 的 ~2 倍以上(历史 <2% 天数) +CLIMAX_GE2 = 50 # p90(25) 的 2 倍 +CLIMAX_FIRST_BOARD = 220 # p90(88) 的 2.5 倍 +# 主升: 高度/宽度/晋级率同时高于中位 (p60), 或晋级率极强 (p85+) +RALLY_HEIGHT = 7 # p60 +RALLY_GE2 = 15 # p60 +RALLY_PROMO = 0.23 # p60 +RALLY_PROMO_ALT = 0.30 # p85+ +RALLY_GE2_ALT = 12 +RALLY_HEIGHT_ALT = 5 +# 退潮: 晋级率崩至 p20 以下且宽度自近期高位回落; 或晋级率/封板率双弱 +EBB_PROMO = 0.15 # p20 +EBB_PROMO_STRICT = 0.13 +EBB_SEAL = 0.57 # ~p10-p15 +EBB_RECENT_GE2 = 12 # 5 日前 ge2 高于此才认定"自高位退潮" +EBB_RECENT_HEIGHT = 6 +# 启动: 宽度/高度自低位扩张且晋级率恢复 +IGNITE_GE2_DELTA = 3 # ge2 较 5 日前增加量 +IGNITE_GE2 = 8 # p20-p40 +IGNITE_PROMO = 0.20 # ~p55 +IGNITE_HEIGHT_DELTA = 1 # height 较 5 日前抬升 +IGNITE_HEIGHT = 5 # p40 +IGNITE_PROMO_SOFT = 0.19 +# 冰点: 高度/宽度/首板同时贴地 (p10) +ICE_HEIGHT = 4 # p10-p20 +ICE_GE2 = 6 # p10 +ICE_FIRST_BOARD = 24 # p10 + +# 晋级率最小池(家数), 低于此记 null(小样本噪声) +PROMO_MIN_POOL = 10 +# 平滑与持续性: EMA alpha≈1/3 (约 5 日), 阶段切换需连续 CONFIRM_DAYS 日同标签 +_EMA_ALPHA = 1.0 / 3.0 +_CONFIRM_DAYS = 2 +# 大盘弱档否决: 正面阶段(主升/高潮/启动)不允许出现在 5 档 state 为弱势/偏弱的日子。 +# 涨停梯队可能与大盘背离(如 2024-01 微盘崩期间中字头涨停生态走强), 该否决 +# 保证"主升"标签在大盘层面也成立; state 列缺失时(单元测试)不启用否决。 +_POSITIVE_PHASES = frozenset({PHASE_CLIMAX, PHASE_RALLY, PHASE_IGNITE}) +_VETO_STATES = frozenset({"weak", "lean_weak"}) + + +def with_prev_consecutive(df: pl.DataFrame) -> pl.DataFrame: + """按 symbol 追加昨日连板数列 _prev_consec (供晋级率)。 + + df 需含 symbol/date/consecutive_limit_ups; 输入应覆盖前一交易日 + (调用方保证 warmup 或直接传全量), 每个符号首行 _prev_consec 为 null。 + """ + if "_prev_consec" in df.columns: + return df + return ( + df.sort(["symbol", "date"]) + .with_columns( + pl.col("consecutive_limit_ups").shift(1).over("symbol").alias("_prev_consec") + ) + ) + + +def ladder_daily_aggs() -> list[pl.Expr]: + """group_by("date").agg(...) 可直接拼接的梯队聚合表达式。 + + 要求 df 含 consecutive_limit_ups; 含 _prev_consec 时附带晋级率分子/分母。 + """ + consec = pl.col("consecutive_limit_ups") + exprs = [ + consec.eq(1).sum().alias("first_board"), + consec.ge(2).sum().alias("ge2_count"), + consec.ge(3).sum().alias("ge3_count"), + consec.ge(5).sum().alias("ge5_count"), + consec.filter(consec.ge(2)).n_unique().alias("rungs_filled"), + ] + return exprs + + +def ladder_promo_aggs() -> list[pl.Expr]: + """晋级率聚合(分子/分母); 要求 df 已含 _prev_consec 列。""" + prev = pl.col("_prev_consec") + consec = pl.col("consecutive_limit_ups") + return [ + prev.ge(1).sum().alias("promo_pool"), + (prev.ge(1) & consec.eq(prev + 1)).sum().alias("promo_ok"), + ] + + +def finalize_ladder_row(r: dict) -> dict: + """把聚合行的梯队原始值整理为持久化字段(晋级率/ladder_completeness)。""" + height = int(r.get("max_consecutive") or 0) + rungs = int(r.get("rungs_filled") or 0) + completeness = (rungs / (height - 1)) if height >= 3 else 0.0 + pool = int(r.get("promo_pool") or 0) + ok = int(r.get("promo_ok") or 0) + promo = (ok / pool) if pool >= PROMO_MIN_POOL else None + return { + "first_board": int(r.get("first_board") or 0), + "ge2_count": int(r.get("ge2_count") or 0), + "ge3_count": int(r.get("ge3_count") or 0), + "ge5_count": int(r.get("ge5_count") or 0), + "ladder_completeness": round(completeness, 4), + "promo_pool": pool, + "promo_rate": round(promo, 4) if promo is not None else None, + } + + +def _ema(values: list[float], alpha: float = _EMA_ALPHA) -> list[float]: + out: list[float] = [] + cur = None + for v in values: + if v is None or v != v: # None 或 NaN + if cur is None: + out.append(None) + continue + out.append(cur) # ffill: 缺失沿用上一平滑值 + continue + cur = v if cur is None else cur + alpha * (v - cur) + out.append(cur) + # 前向回填: 序列开头缺失用首个有效值 + first_valid = next((i for i, x in enumerate(out) if x is not None), None) + if first_valid is not None: + for i in range(first_valid): + out[i] = out[first_valid] + else: + out = [0.0] * len(values) + return out + + +def classify_phase_series(daily: pl.DataFrame) -> pl.DataFrame: + """对完整日序打阶段标签, 追加 phase 列。 + + 输入列: date, max_consecutive, first_board, ge2_count, promo_rate, seal_rate + (promo_rate 允许 null)。处理: promo 前向填充 → 各驱动 EMA 平滑 → + 逐日规则判定(按优先级) → 连续 _CONFIRM_DAYS 日同标签才切换(持续性)。 + """ + required = {"date", "max_consecutive", "first_board", "ge2_count", "promo_rate", "seal_rate"} + missing = required - set(daily.columns) + if missing: + raise ValueError(f"classify_phase_series 缺少列: {sorted(missing)}") + + rows = daily.sort("date") + n = rows.height + states = rows["state"].to_list() if "state" in rows.columns else None + height_s = _ema([float(v) if v is not None else None for v in rows["max_consecutive"].to_list()]) + first_s = _ema([float(v) if v is not None else None for v in rows["first_board"].to_list()]) + ge2_s = _ema([float(v) if v is not None else None for v in rows["ge2_count"].to_list()]) + promo_s = _ema([float(v) if v is not None else None for v in rows["promo_rate"].to_list()]) + seal_s = _ema([float(v) if v is not None else None for v in rows["seal_rate"].to_list()]) + + def raw_label(i: int) -> str: + h, fb, g2, pr, sr = height_s[i], first_s[i], ge2_s[i], promo_s[i], seal_s[i] + g2_prev = ge2_s[max(0, i - 5)] + h_prev = height_s[max(0, i - 5)] + # 高潮 + if g2 >= CLIMAX_GE2 or fb >= CLIMAX_FIRST_BOARD: + return PHASE_CLIMAX + # 主升 + if h >= RALLY_HEIGHT and g2 >= RALLY_GE2 and pr >= RALLY_PROMO: + return PHASE_RALLY + if pr >= RALLY_PROMO_ALT and g2 >= RALLY_GE2_ALT and h >= RALLY_HEIGHT_ALT: + return PHASE_RALLY + # 冰点: 高度/宽度/首板同时贴地 — 优先于退潮(持续死寂的市场是"冰点" + # 而非"自高位退潮"; 退潮的规则 B 不带 from_high 条件, 顺序反了会把 + # 长期冰点误标成退潮) + if h <= ICE_HEIGHT and g2 <= ICE_GE2 and fb <= ICE_FIRST_BOARD: + return PHASE_ICE + # 退潮: 自高位回落 + 晋级率坍塌, 或晋级/封板双弱 + from_high = g2_prev >= EBB_RECENT_GE2 or h_prev >= EBB_RECENT_HEIGHT + if from_high and (pr <= EBB_PROMO and g2 < g2_prev): + return PHASE_EBB + if pr <= EBB_PROMO_STRICT and sr <= EBB_SEAL: + return PHASE_EBB + # 启动: 自低位扩张 + if g2 - g2_prev >= IGNITE_GE2_DELTA and g2 >= IGNITE_GE2 and pr >= IGNITE_PROMO: + return PHASE_IGNITE + if h - h_prev >= IGNITE_HEIGHT_DELTA and h >= IGNITE_HEIGHT and pr >= IGNITE_PROMO_SOFT: + return PHASE_IGNITE + return PHASE_REPAIR + + labels: list[str] = [] + current = None + pending: str | None = None + pending_run = 0 + for i in range(n): + raw = raw_label(i) + if ( + states is not None + and raw in _POSITIVE_PHASES + and states[i] in _VETO_STATES + ): + raw = PHASE_REPAIR + if current is None: + current = raw + labels.append(raw) + continue + if raw == current: + labels.append(current) + pending, pending_run = None, 0 + continue + if raw == pending: + pending_run += 1 + else: + pending, pending_run = raw, 1 + if pending_run >= _CONFIRM_DAYS: + current = raw + labels.append(current) + pending, pending_run = None, 0 + else: + labels.append(current) + return daily.with_columns( + pl.Series("phase", labels, dtype=pl.Utf8).alias("phase") + ).sort("date") diff --git a/backend/app/services/market_recap.py b/backend/app/services/market_recap.py index 333a3b1..5be1ff6 100644 --- a/backend/app/services/market_recap.py +++ b/backend/app/services/market_recap.py @@ -301,14 +301,17 @@ async def recap_market_stream( from app.services.ai_provider import stream_ai_text user_prompt = _build_user_prompt(overview, news or [], focus) + got_content = False async for delta in stream_ai_text( [ {"role": "system", "content": _SYSTEM_PROMPT}, {"role": "user", "content": user_prompt}, ], temperature=0.5, - max_tokens=4500, + # 不限制输出(推理模型思考 token 计入预算, 见 ai_provider.stream_ai_text) + max_tokens=None, ): + got_content = True yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False) except Exception as e: # noqa: BLE001 @@ -316,6 +319,10 @@ async def recap_market_stream( yield json.dumps({"type": "error", "message": f"AI 复盘失败: {e}"}, ensure_ascii=False) return + if not got_content: + logger.warning("AI market recap ended with empty content for %s", as_of_str) + yield json.dumps({"type": "error", "message": "AI 未返回正文(输出被截断), 请重试"}, ensure_ascii=False) + return yield json.dumps({"type": "done"}, ensure_ascii=False) diff --git a/backend/app/services/matrix_prewarm_owner.py b/backend/app/services/matrix_prewarm_owner.py new file mode 100644 index 0000000..9c01cb2 --- /dev/null +++ b/backend/app/services/matrix_prewarm_owner.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import threading +from collections.abc import Callable + + +class MatrixCachePrewarmOwner: + def __init__(self) -> None: + self._lock = threading.Lock() + self._cancel_event = threading.Event() + self._thread: threading.Thread | None = None + + @property + def cancel_event(self) -> threading.Event: + return self._cancel_event + + def schedule(self, target: Callable[[], None]) -> bool: + with self._lock: + if self._cancel_event.is_set(): + return False + if self._thread is not None and self._thread.is_alive(): + return False + thread = threading.Thread( + target=self._run, + args=(target,), + name="matrix-cache-prewarm", + daemon=True, + ) + self._thread = thread + thread.start() + return True + + def shutdown(self, timeout: float = 5.0) -> bool: + self._cancel_event.set() + with self._lock: + thread = self._thread + if thread is None: + return True + thread.join(timeout=max(0.0, timeout)) + return not thread.is_alive() + + def _run(self, target: Callable[[], None]) -> None: + try: + target() + finally: + with self._lock: + if self._thread is threading.current_thread(): + self._thread = None diff --git a/backend/app/services/mining_candidates.py b/backend/app/services/mining_candidates.py new file mode 100644 index 0000000..7625862 --- /dev/null +++ b/backend/app/services/mining_candidates.py @@ -0,0 +1,793 @@ +"""Trusted promotion and explicit publication for persisted mining candidates. + +Concurrency protection is process-local; V1 remains a single-process service. +""" +from __future__ import annotations + +import hashlib +import json +import math +import os +import re +import threading +import uuid +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import Any + +import polars as pl +import pyarrow.parquet as pq + +from app.backtest.candidates import CandidateStore +from app.backtest.factor import FACTOR_COLUMNS +from app.backtest.mining import compute_candidate_signature, evaluate_candidate_gate +from app.services.mining_jobs import SUCCESS_RUN_STATUSES, MiningRunStore +from app.strategy.ai_generator import AIStrategyGenerator +from app.strategy.engine import StrategyEngine + +_MAX_ARTIFACT_BYTES = 8 * 1024 * 1024 +_MAX_ARTIFACT_ROWS = 32 +_MAX_UNCOMPRESSED_BYTES = 32 * 1024 * 1024 +_MAX_DEFINITION_BYTES = 16 * 1024 +_FACTOR_IDS = frozenset(str(item["id"]) for item in FACTOR_COLUMNS) +_FACTOR_DEFINITION_FIELDS = frozenset({"kind", "factor_names", "scoring", "directions"}) +_EXISTING_DEFINITION_FIELDS = frozenset({"kind", "strategy_id"}) +_BACKLINK_FIELDS = frozenset({"promoted_candidate_id", "published_strategy_id"}) +_PUBLISHED_PREFIX = "mined_factor_" +_STRATEGY_ID_PATTERN = re.compile(r"^mined_factor_[A-Za-z0-9_-]{1,49}$") +_ARTIFACT_SCHEMA = { + "signature": pl.String, + "name": pl.String, + "kind": pl.String, + "factor_names_json": pl.String, + "strategy_id": pl.String, + "definition_json": pl.String, + "regime_state": pl.String, + "score": pl.Float64, + "oos_return": pl.Float64, + "oos_sharpe": pl.Float64, + "oos_max_drawdown": pl.Float64, + "oos_positive_fold_ratio": pl.Float64, + "oos_n_trades": pl.Int64, + "confidence": pl.String, + "valid_folds": pl.Int64, + "skipped_folds": pl.Int64, + "promoted_candidate_id": pl.String, + "published_strategy_id": pl.String, +} +_LOCK = threading.RLock() + + +class MiningCandidateService: + def __init__( + self, + data_dir: Path | str, + run_store: MiningRunStore, + candidate_store: CandidateStore, + strategy_engine: StrategyEngine, + *, + strategy_cache_invalidator: Callable[[Path], None] | None = None, + monitor_state_invalidator: Callable[[], None] | None = None, + ) -> None: + self.data_dir = Path(data_dir).resolve() + self.run_store = run_store + self.candidate_store = candidate_store + self.strategy_engine = strategy_engine + if strategy_cache_invalidator is None: + from app.services.strategy_cache import clear_cache + + strategy_cache_invalidator = clear_cache + self._strategy_cache_invalidator = strategy_cache_invalidator + self._monitor_state_invalidator = monitor_state_invalidator + + def promote(self, run_id: str, signature: str) -> dict[str, Any]: + with _LOCK: + manifest, summary, path, frame, row, definition = self._load_candidate( + run_id, signature + ) + kind, name, source_id, config = self._promotion_config( + manifest, summary, row, definition + ) + item = self.candidate_store.create_or_get_by_provenance( + origin_run_id=run_id, + candidate_signature=signature, + kind=kind, + name=name, + source_id=source_id, + config=config, + metrics=self._candidate_metrics(row), + data_as_of=_optional_string(summary.get("data_as_of")), + status="pending", + ) + if row.get("promoted_candidate_id") != item["id"]: + self._write_backlink( + path, + frame, + signature, + "promoted_candidate_id", + item["id"], + ) + return item + + def publish(self, run_id: str, signature: str) -> dict[str, Any]: + with _LOCK: + manifest, summary, path, frame, row, definition = self._load_candidate( + run_id, signature + ) + gate = evaluate_candidate_gate( + confidence=row.get("confidence"), + valid_folds=row.get("valid_folds"), + positive_fold_ratio=row.get("oos_positive_fold_ratio"), + sharpe=row.get("oos_sharpe"), + max_drawdown=row.get("oos_max_drawdown"), + n_trades=row.get("oos_n_trades"), + ) + if not gate.qualified: + raise ValueError( + "candidate does not meet the promotion gate: " + + "; ".join(gate.reasons) + ) + asset_type = self._asset_type(manifest) + if definition["kind"] == "existing_strategy": + published_id = str(definition["strategy_id"]) + self._validate_publication_backlink(row, published_id) + self._verify_public_strategy(published_id, asset_type) + if row.get("published_strategy_id") != published_id: + self._write_backlink( + path, + frame, + signature, + "published_strategy_id", + published_id, + ) + return {"ok": True, "strategy_id": published_id} + + published_id = self._validate_published_id( + _published_strategy_id(run_id, signature) + ) + self._validate_publication_backlink(row, published_id) + source = self._render_factor_strategy( + manifest, summary, row, definition, published_id + ) + + target = self._custom_strategy_path(published_id) + created = self._publish_or_verify_source( + target, + source, + published_id, + run_id, + signature, + asset_type, + ) + try: + self._strategy_cache_invalidator(self.data_dir) + if self._monitor_state_invalidator is not None: + self._monitor_state_invalidator() + except Exception as exc: + rollback_error = ( + self._rollback_created_source(target, source) if created else None + ) + message = f"strategy runtime invalidation failed: {exc}" + if rollback_error is not None: + message += f"; strategy rollback failed: {rollback_error}" + raise RuntimeError(message) from exc + if row.get("published_strategy_id") != published_id: + self._write_backlink( + path, + frame, + signature, + "published_strategy_id", + published_id, + ) + return {"ok": True, "strategy_id": published_id} + + def _load_candidate( + self, + run_id: str, + signature: str, + ) -> tuple[ + dict[str, Any], + dict[str, Any], + Path, + pl.DataFrame, + dict[str, Any], + dict[str, Any], + ]: + if not isinstance(signature, str) or not signature: + raise ValueError("candidate signature must not be empty") + manifest = self.run_store.get(run_id) + if manifest is None: + raise KeyError(run_id) + if manifest.get("status") not in SUCCESS_RUN_STATUSES: + raise ValueError("mining candidates require a successful run") + path = self._registered_candidates_path(manifest) + frame = self._read_artifact(path) + matches = frame.filter(pl.col("signature") == signature) + if matches.height == 0: + raise KeyError(signature) + if matches.height != 1: + raise ValueError("mining candidates artifact contains a duplicate signature") + row = matches.row(0, named=True) + self._asset_type(manifest) + definition = self._validate_definition(manifest, row, signature) + summary = self._validated_summary(self.run_store.read_summary(run_id)) + return manifest, summary, path, frame, row, definition + + def _registered_candidates_path(self, manifest: Mapping[str, Any]) -> Path: + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, Mapping): + raise ValueError("mining candidates artifact is not registered") + raw_path = artifacts.get("candidates") + if raw_path != "candidates.parquet": + raise ValueError( + "only the registered candidates.parquet artifact can be used" + ) + path = self.run_store.artifact_path( + str(manifest["run_id"]), "candidates" + ) + if path.is_symlink() or not path.is_file(): + raise ValueError("mining candidates artifact is unavailable") + return path + + @staticmethod + def _read_artifact(path: Path) -> pl.DataFrame: + try: + size = path.stat().st_size + except OSError as exc: + raise RuntimeError("failed to read mining candidates artifact") from exc + if size <= 0 or size > _MAX_ARTIFACT_BYTES: + raise ValueError("mining candidates artifact exceeds its size limit") + try: + parquet = pq.ParquetFile(path) + metadata = parquet.metadata + schema = pl.read_parquet_schema(path) + except Exception as exc: + raise RuntimeError("failed to read mining candidates artifact") from exc + if metadata is None or metadata.num_rows > _MAX_ARTIFACT_ROWS: + raise ValueError("mining candidates artifact exceeds its row limit") + uncompressed = sum( + metadata.row_group(index).total_byte_size + for index in range(metadata.num_row_groups) + ) + if uncompressed > _MAX_UNCOMPRESSED_BYTES: + raise ValueError( + "mining candidates artifact exceeds its uncompressed size limit" + ) + missing = sorted(set(_ARTIFACT_SCHEMA) - set(schema)) + if missing: + raise ValueError(f"mining candidates artifact schema is invalid: {missing}") + invalid_types = [ + name for name, dtype in _ARTIFACT_SCHEMA.items() + if schema[name] != dtype + ] + if invalid_types: + raise ValueError( + "mining candidates artifact column types are invalid: " + f"{invalid_types}" + ) + try: + return pl.read_parquet(path, columns=list(_ARTIFACT_SCHEMA)) + except Exception as exc: + raise RuntimeError("failed to read mining candidates artifact") from exc + + def _validate_definition( + self, + manifest: Mapping[str, Any], + row: Mapping[str, Any], + signature: str, + ) -> dict[str, Any]: + raw_definition = row.get("definition_json") + if ( + not isinstance(raw_definition, str) + or len(raw_definition.encode("utf-8")) > _MAX_DEFINITION_BYTES + ): + raise ValueError("mining candidate definition is unavailable") + try: + definition = json.loads(raw_definition) + except json.JSONDecodeError as exc: + raise ValueError("mining candidate definition is invalid") from exc + if not isinstance(definition, dict): + raise ValueError("mining candidate definition must be an object") + kind = definition.get("kind") + if kind == "factor_rank": + if set(definition) != _FACTOR_DEFINITION_FIELDS: + raise ValueError("factor candidate definition contains unsupported fields") + expected_kind = "factor_combination" + elif kind == "existing_strategy": + if set(definition) != _EXISTING_DEFINITION_FIELDS: + raise ValueError("existing candidate definition contains unsupported fields") + expected_kind = "existing_strategy" + else: + raise ValueError(f"unsupported mining candidate kind: {kind!r}") + if row.get("kind") != expected_kind: + raise ValueError("mining candidate kind does not match its definition") + if ( + not isinstance(row.get("name"), str) + or not row["name"] + or len(row["name"]) > 80 + or row.get("regime_state") != "overall" + ): + raise ValueError("mining candidate row metadata is invalid") + if kind == "factor_rank": + self._validate_factor_definition(manifest, row, definition) + else: + self._validate_existing_definition(manifest, row, definition) + computed = compute_candidate_signature(definition) + if row.get("signature") != signature or computed != signature: + raise ValueError("mining candidate signature does not match its definition") + score = row.get("score") + if ( + score is not None + and ( + isinstance(score, bool) + or not isinstance(score, (int, float)) + or not math.isfinite(float(score)) + ) + ): + raise ValueError("candidate score must be finite") + return definition + + @staticmethod + def _request(manifest: Mapping[str, Any]) -> Mapping[str, Any]: + request = manifest.get("request") + if not isinstance(request, Mapping): + raise ValueError("mining origin request is unavailable") + return request + + def _validate_factor_definition( + self, + manifest: Mapping[str, Any], + row: Mapping[str, Any], + definition: Mapping[str, Any], + ) -> None: + factor_names = definition.get("factor_names") + if ( + not isinstance(factor_names, list) + or not 1 <= len(factor_names) <= 4 + or len(set(factor_names)) != len(factor_names) + or any(not isinstance(name, str) or not name for name in factor_names) + ): + raise ValueError("factor candidate must contain 1 to 4 unique factors") + scoring = definition.get("scoring") + directions = definition.get("directions") + if not isinstance(scoring, Mapping) or set(scoring) != set(factor_names): + raise ValueError("factor scoring keys must exactly match factor names") + if not isinstance(directions, Mapping) or set(directions) != set(factor_names): + raise ValueError("factor direction keys must exactly match factor names") + for factor_name in factor_names: + weight = scoring[factor_name] + if ( + isinstance(weight, bool) + or not isinstance(weight, (int, float)) + or not math.isfinite(float(weight)) + or float(weight) <= 0.0 + ): + raise ValueError("factor weights must be finite and positive") + if directions[factor_name] not in {"high", "low"}: + raise ValueError("factor directions must be high or low") + unknown = sorted(set(factor_names) - _FACTOR_IDS) + if unknown: + raise ValueError(f"factor candidate contains unknown factors: {unknown}") + selected = self._request(manifest).get("factor_names") + if not isinstance(selected, list) or not set(factor_names) <= set(selected): + raise ValueError("factor candidate contains factors absent from its origin request") + try: + persisted_names = json.loads(str(row.get("factor_names_json"))) + except json.JSONDecodeError as exc: + raise ValueError("factor candidate factor list is invalid") from exc + if persisted_names != factor_names or row.get("strategy_id") is not None: + raise ValueError("factor candidate columns do not match its definition") + + def _validate_existing_definition( + self, + manifest: Mapping[str, Any], + row: Mapping[str, Any], + definition: Mapping[str, Any], + ) -> None: + strategy_id = definition.get("strategy_id") + if not isinstance(strategy_id, str) or not strategy_id: + raise ValueError("existing candidate strategy ID is invalid") + selected = self._request(manifest).get("strategy_ids") + if not isinstance(selected, list) or strategy_id not in selected: + raise ValueError("existing candidate was absent from its origin request") + if row.get("strategy_id") != strategy_id: + raise ValueError("existing candidate strategy ID does not match its definition") + asset_type = str(self._request(manifest).get("asset_type") or "stock") + self._verify_public_strategy(strategy_id, asset_type) + + def _promotion_config( + self, + manifest: Mapping[str, Any], + summary: Mapping[str, Any], + row: Mapping[str, Any], + definition: Mapping[str, Any], + ) -> tuple[str, str, str, dict[str, Any]]: + request = self._request(manifest) + signature = str(row["signature"]) + provenance = { + "origin_run_id": str(manifest["run_id"]), + "candidate_signature": signature, + "regime_state": str(row.get("regime_state") or "overall"), + "algorithm_version": str(summary.get("algorithm_version") or "mining-v1"), + "methodology_version": str(summary.get("methodology_version") or "factor_v2"), + } + common = { + "start": request.get("start"), + "end": request.get("end"), + "asset_type": request.get("asset_type") or "stock", + "matching": "open_t+1", + "entry_fill": "open_t+1", + "exit_fill": "open_t+1", + "commission_pct": request.get("commission_pct", 0.0002), + "stamp_tax_pct": request.get("stamp_tax_pct", 0.0005), + "slippage_bps": request.get("slippage_bps", 5.0), + "mode": "position", + "minute_fill": False, + **provenance, + } + name = str(row.get("name") or signature)[:80] + if definition["kind"] == "existing_strategy": + strategy_id = str(definition["strategy_id"]) + return "strategy", name, strategy_id, { + **common, + "strategy_id": strategy_id, + } + factor_names = list(definition["factor_names"]) + source_id = _published_strategy_id(str(manifest["run_id"]), signature) + return "strategy", name, source_id, { + **common, + "strategy_id": source_id, + "factor_names": factor_names, + "directions": [definition["directions"][name] for name in factor_names], + "weights": [float(definition["scoring"][name]) for name in factor_names], + } + + @staticmethod + def _candidate_metrics(row: Mapping[str, Any]) -> dict[str, Any]: + fields = ( + "oos_sharpe", + "oos_return", + "oos_max_drawdown", + "oos_positive_fold_ratio", + "oos_n_trades", + "valid_folds", + "skipped_folds", + "confidence", + ) + result: dict[str, Any] = {} + for field in fields: + value = row.get(field) + if value is None: + continue + if field == "confidence": + if not isinstance(value, str) or not value or len(value) > 32: + raise ValueError("candidate confidence is invalid") + elif ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(float(value)) + ): + raise ValueError(f"candidate metric {field} must be finite") + result[field] = value + return result + + @staticmethod + def _validated_summary(summary: Mapping[str, Any]) -> dict[str, str]: + result: dict[str, str] = {} + for field in ("data_as_of", "algorithm_version", "methodology_version"): + value = summary.get(field) + if not isinstance(value, str) or not value or len(value) > 120: + raise ValueError(f"mining summary {field} is invalid") + result[field] = value + return result + + @staticmethod + def _asset_type(manifest: Mapping[str, Any]) -> str: + asset_type = MiningCandidateService._request(manifest).get("asset_type") + if asset_type not in {"stock", "etf"}: + raise ValueError("mining origin asset_type is invalid") + return str(asset_type) + + def _verify_public_strategy( + self, + strategy_id: str, + asset_type: str, + *, + path: Path | None = None, + run_id: str | None = None, + signature: str | None = None, + ) -> None: + strategy = self.strategy_engine.get(strategy_id) + if strategy.meta.get("research_only"): + raise ValueError("research-only strategy cannot be published") + if strategy.execution_backend != "matrix_native": + raise ValueError("published strategy must be matrix-native") + if "1d" not in strategy.meta.get("timeframes", []): + raise ValueError("published strategy must support 1d") + if asset_type not in strategy.meta.get("asset_types", []): + raise ValueError("published strategy does not support the run asset type") + public_ids = { + str(meta.get("id")) for meta in self.strategy_engine.list_strategies() + } + if strategy_id not in public_ids: + raise ValueError("published strategy is not publicly discoverable") + if path is not None and ( + strategy.source != "custom" + or strategy.file_path is None + or strategy.file_path.resolve() != path.resolve() + or strategy.meta.get("origin_run_id") != run_id + or strategy.meta.get("candidate_signature") != signature + ): + raise ValueError("published strategy provenance is invalid") + + @staticmethod + def _validate_published_id(strategy_id: str) -> str: + if not _STRATEGY_ID_PATTERN.fullmatch(strategy_id): + raise ValueError( + f"strategy_id must use {_PUBLISHED_PREFIX!r} and safe characters" + ) + return strategy_id + + @staticmethod + def _validate_publication_backlink( + row: Mapping[str, Any], + strategy_id: str, + ) -> None: + existing = _optional_string(row.get("published_strategy_id")) + if existing is not None and existing != strategy_id: + raise ValueError("candidate publication backlink is inconsistent") + + def _custom_strategy_path(self, strategy_id: str) -> Path: + unresolved_root = self.data_dir / "strategies" / "custom" + unresolved_root.mkdir(parents=True, exist_ok=True) + if unresolved_root.is_symlink(): + raise ValueError("custom strategy directory must not be a symlink") + root = unresolved_root.resolve() + if not root.is_relative_to(self.data_dir): + raise ValueError("custom strategy directory escapes data_dir") + path = (root / f"{strategy_id}.py").resolve(strict=False) + if path.parent != root: + raise ValueError("strategy publication path escapes custom directory") + return path + + def _render_factor_strategy( + self, + manifest: Mapping[str, Any], + summary: Mapping[str, Any], + row: Mapping[str, Any], + definition: Mapping[str, Any], + strategy_id: str, + ) -> str: + asset_type = self._asset_type(manifest) + factor_names = list(definition["factor_names"]) + scoring = { + name: float(definition["scoring"][name]) for name in factor_names + } + directions = {name: definition["directions"][name] for name in factor_names} + meta = { + "id": strategy_id, + "name": row["name"], + "description": "Published mining factor-rank candidate", + "tags": ["mining", "factor-rank"], + "asset_types": [asset_type], + "timeframes": ["1d"], + "research_only": False, + "origin_run_id": manifest["run_id"], + "candidate_signature": row["signature"], + "mining_algorithm_version": summary["algorithm_version"], + "factor_methodology_version": summary["methodology_version"], + "params": [ + { + "id": "entry_score", + "label": "Entry minimum score", + "type": "float", + "default": 70.0, + "min": 0.0, + "max": 100.0, + "step": 5.0, + }, + { + "id": "exit_score", + "label": "Exit maximum score", + "type": "float", + "default": 40.0, + "min": 0.0, + "max": 100.0, + "step": 5.0, + }, + { + "id": "top_rank", + "label": "Daily selection limit", + "type": "int", + "default": 20, + "min": 1, + "max": 100, + "step": 1, + }, + ], + "scoring": {}, + "order_by": "score", + "descending": True, + "limit": 100, + } + return ( + '"""Trusted factor-rank strategy published from a mining run."""\n' + "from app.strategy.builtin.factor_rank_research import " + "FactorRankResearchMatrixStrategy\n\n" + f"META = {meta!r}\n\n" + 'EXECUTION_BACKEND = "matrix_native"\n' + 'ENTRY_SIGNALS = ["signal_factor_rank_entry"]\n' + 'EXIT_SIGNALS = ["signal_factor_rank_exit"]\n' + "STOP_LOSS = -0.08\n" + "MAX_HOLD_DAYS = 30\n\n" + f"SCORING = {scoring!r}\n" + f"DIRECTIONS = {directions!r}\n" + "MATRIX_STRATEGY = FactorRankResearchMatrixStrategy(SCORING, DIRECTIONS)\n" + ) + + def _publish_or_verify_source( + self, + path: Path, + source: str, + strategy_id: str, + run_id: str, + signature: str, + asset_type: str, + ) -> bool: + validation = AIStrategyGenerator().validate_code(source) + if not validation.get("valid"): + raise ValueError( + f"rendered strategy failed validation: {validation.get('error')}" + ) + if validation.get("meta", {}).get("id") != strategy_id: + raise ValueError("rendered strategy META id is invalid") + if path.exists() or path.is_symlink(): + self._verify_existing_source( + path, source, strategy_id, run_id, signature, asset_type + ) + return False + if self.strategy_engine.has(strategy_id): + raise ValueError(f"strategy ID already exists: {strategy_id}") + + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + linked = False + try: + with temporary.open("x", encoding="utf-8", newline="\n") as stream: + stream.write(source) + stream.flush() + os.fsync(stream.fileno()) + try: + os.link(temporary, path) + except FileExistsError as exc: + raise ValueError(f"strategy path already exists: {strategy_id}") from exc + except OSError as exc: + raise RuntimeError("failed to create strategy source") from exc + linked = True + self._fsync_directory(path.parent) + try: + self.strategy_engine.reload() + self._verify_public_strategy( + strategy_id, + asset_type, + path=path, + run_id=run_id, + signature=signature, + ) + except Exception as exc: + rollback_error = self._rollback_publication(path, temporary) + message = f"strategy publication failed: {exc}" + if rollback_error is not None: + message += f"; registry rollback failed: {rollback_error}" + raise RuntimeError(message) from exc + finally: + temporary.unlink(missing_ok=True) + if linked: + self._fsync_directory(path.parent) + return True + + def _verify_existing_source( + self, + path: Path, + source: str, + strategy_id: str, + run_id: str, + signature: str, + asset_type: str, + ) -> None: + if path.is_symlink() or not path.is_file(): + raise ValueError("strategy publication target is not a regular file") + try: + existing_source = path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + raise ValueError("existing strategy source is unreadable") from exc + if existing_source != source: + raise ValueError(f"strategy ID collision: {strategy_id}") + try: + self._verify_public_strategy( + strategy_id, + asset_type, + path=path, + run_id=run_id, + signature=signature, + ) + except ValueError: + self.strategy_engine.reload() + self._verify_public_strategy( + strategy_id, + asset_type, + path=path, + run_id=run_id, + signature=signature, + ) + + def _rollback_publication(self, path: Path, temporary: Path) -> Exception | None: + try: + if path.exists() and temporary.exists() and os.path.samefile(path, temporary): + path.unlink() + self._fsync_directory(path.parent) + self.strategy_engine.reload() + except Exception as exc: + return exc + return None + + def _rollback_created_source(self, path: Path, source: str) -> Exception | None: + try: + if path.is_file() and not path.is_symlink(): + if path.read_text(encoding="utf-8") != source: + return RuntimeError("published strategy source changed before rollback") + path.unlink() + self._fsync_directory(path.parent) + self.strategy_engine.reload() + except Exception as exc: + return exc + return None + + @staticmethod + def _fsync_directory(path: Path) -> None: + if os.name == "nt": + return + descriptor = os.open(path, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + @staticmethod + def _write_backlink( + path: Path, + frame: pl.DataFrame, + signature: str, + field: str, + value: str, + ) -> None: + if field not in _BACKLINK_FIELDS: + raise ValueError("unsupported mining candidate backlink") + matches = frame.filter(pl.col("signature") == signature) + if matches.height != 1: + raise ValueError("mining candidate backlink target is no longer unique") + updated = frame.with_columns( + pl.when(pl.col("signature") == signature) + .then(pl.lit(value)) + .otherwise(pl.col(field)) + .alias(field) + ) + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + try: + updated.write_parquet(temporary) + with temporary.open("r+b") as stream: + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + except Exception as exc: + temporary.unlink(missing_ok=True) + raise RuntimeError("failed to update mining candidate artifact") from exc + + +def _published_strategy_id(run_id: str, signature: str) -> str: + payload = f"{run_id}\0{signature}".encode() + digest = hashlib.blake2b(payload, digest_size=10).hexdigest() + return f"{_PUBLISHED_PREFIX}{digest}" + + +def _optional_string(value: Any) -> str | None: + return value if isinstance(value, str) and value else None diff --git a/backend/app/services/mining_jobs.py b/backend/app/services/mining_jobs.py new file mode 100644 index 0000000..343ab8a --- /dev/null +++ b/backend/app/services/mining_jobs.py @@ -0,0 +1,620 @@ +"""Persistent metadata and bounded event storage for mining runs.""" + +from __future__ import annotations + +import json +import math +import os +import re +import threading +import uuid +from collections.abc import Collection, Mapping +from datetime import UTC, date, datetime +from enum import Enum +from pathlib import Path +from typing import Any, Literal, cast + +MiningRunStatus = Literal[ + "queued", + "running", + "cancelling", + "succeeded", + "succeeded_with_budget_exhausted", + "failed", + "cancelled", + "interrupted", + "skipped_prerequisite", +] +ArtifactName = Literal["factors", "correlation", "candidates", "folds"] + +RUN_STATUSES: frozenset[str] = frozenset( + { + "queued", + "running", + "cancelling", + "succeeded", + "succeeded_with_budget_exhausted", + "failed", + "cancelled", + "interrupted", + "skipped_prerequisite", + } +) +ACTIVE_RUN_STATUSES: frozenset[str] = frozenset({"queued", "running", "cancelling"}) +SUCCESS_RUN_STATUSES: frozenset[str] = frozenset({"succeeded", "succeeded_with_budget_exhausted"}) +TERMINAL_RUN_STATUSES: frozenset[str] = RUN_STATUSES - ACTIVE_RUN_STATUSES +ARTIFACT_NAMES: frozenset[str] = frozenset({"factors", "correlation", "candidates", "folds"}) +MAX_EVENTS = 256 +MAX_EVENT_PAYLOAD_BYTES = 16 * 1024 +_SCHEMA_VERSION = 1 +_RUN_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$") +_STORE_LOCK = threading.RLock() + +_ALLOWED_TRANSITIONS: dict[str, frozenset[str]] = { + "queued": frozenset( + {"running", "cancelling", "cancelled", "failed", "interrupted", "skipped_prerequisite"} + ), + "running": frozenset( + { + "cancelling", + "succeeded", + "succeeded_with_budget_exhausted", + "failed", + "cancelled", + "interrupted", + "skipped_prerequisite", + } + ), + "cancelling": frozenset( + { + "succeeded", + "succeeded_with_budget_exhausted", + "failed", + "cancelled", + "interrupted", + } + ), + "succeeded": frozenset(), + "succeeded_with_budget_exhausted": frozenset(), + "failed": frozenset(), + "cancelled": frozenset(), + "interrupted": frozenset(), + "skipped_prerequisite": frozenset(), +} + + +class MiningRunStoreError(RuntimeError): + pass + + +class MiningRunValidationError(MiningRunStoreError, ValueError): + pass + + +class InvalidMiningStatusTransitionError(MiningRunStoreError): + pass + + +def canonicalize_request(request: Mapping[str, Any]) -> dict[str, Any]: + """Return a JSON-safe request whose mapping order cannot affect its signature.""" + if not isinstance(request, Mapping): + raise MiningRunValidationError("request must be a mapping") + return cast(dict[str, Any], _canonicalize_json_value(request)) + + +def compute_run_signature(request: Mapping[str, Any], data_fingerprint: Any) -> str: + """Hash every request dimension and the data fingerprint using BLAKE2b.""" + import hashlib + + signature_input = { + "request": canonicalize_request(request), + "data_fingerprint": _canonicalize_json_value(data_fingerprint), + } + payload = json.dumps( + signature_input, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.blake2b(payload, digest_size=32).hexdigest() + + +class MiningRunStore: + """Store one manifest, summary, artifact registry, and bounded event log per run.""" + + def __init__(self, data_dir: Path | str | None = None) -> None: + if data_dir is None: + from app.config import settings + + data_dir = settings.data_dir + self.runs_root = (Path(data_dir).resolve() / "research" / "mining" / "runs").resolve() + self.runs_root.mkdir(parents=True, exist_ok=True) + + def create( + self, + request: Mapping[str, Any], + data_fingerprint: Any, + *, + run_id: str | None = None, + ) -> dict[str, Any]: + """Create a queued run and its initial on-disk files.""" + safe_run_id = self._validate_run_id(uuid.uuid4().hex if run_id is None else run_id) + canonical_request = canonicalize_request(request) + canonical_fingerprint = _canonicalize_json_value(data_fingerprint) + now = _now_iso() + manifest = { + "schema_version": _SCHEMA_VERSION, + "run_id": safe_run_id, + "status": "queued", + "request": canonical_request, + "data_fingerprint": canonical_fingerprint, + "run_signature": compute_run_signature(canonical_request, canonical_fingerprint), + "artifacts": {}, + "created_at": now, + "updated_at": now, + "started_at": None, + "finished_at": None, + "cancellation_requested_at": None, + "error": None, + } + run_dir = self._run_dir(safe_run_id) + with _STORE_LOCK: + if run_dir.exists(): + raise MiningRunValidationError(f"run already exists: {safe_run_id}") + run_dir.mkdir(parents=False) + _atomic_write_json(run_dir / "summary.json", {}) + _atomic_write_text(run_dir / "events.jsonl", "") + _atomic_write_json(run_dir / "manifest.json", manifest) + return manifest + + def get(self, run_id: str) -> dict[str, Any] | None: + """Read a manifest, filling defaults for manifests written by older versions.""" + safe_run_id = self._validate_run_id(run_id) + with _STORE_LOCK: + return self._read_manifest_path( + self._run_dir(safe_run_id) / "manifest.json", safe_run_id + ) + + def transition_status( + self, + run_id: str, + status: MiningRunStatus, + *, + error: str | None = None, + ) -> dict[str, Any]: + """Apply a validated state transition and atomically replace the manifest.""" + if status not in RUN_STATUSES: + raise MiningRunValidationError(f"unsupported mining run status: {status!r}") + safe_run_id = self._validate_run_id(run_id) + with _STORE_LOCK: + manifest = self._required_manifest(safe_run_id) + return self._transition_locked(manifest, status, error=error) + + def write_summary(self, run_id: str, summary: Mapping[str, Any]) -> dict[str, Any]: + """Atomically replace a run's scalar or compact aggregate summary.""" + if not isinstance(summary, Mapping): + raise MiningRunValidationError("summary must be a mapping") + safe_run_id = self._validate_run_id(run_id) + clean_summary = cast(dict[str, Any], _canonicalize_json_value(summary)) + with _STORE_LOCK: + self._required_manifest(safe_run_id) + _atomic_write_json(self._run_dir(safe_run_id) / "summary.json", clean_summary) + return clean_summary + + def read_summary(self, run_id: str) -> dict[str, Any]: + safe_run_id = self._validate_run_id(run_id) + with _STORE_LOCK: + self._required_manifest(safe_run_id) + path = self._run_dir(safe_run_id) / "summary.json" + if not path.exists(): + return {} + value = _read_json(path) + if not isinstance(value, dict): + raise MiningRunStoreError(f"invalid summary for run {safe_run_id}") + return value + + def artifact_path(self, run_id: str, name: ArtifactName) -> Path: + """Return the safe default Parquet path for an artifact.""" + safe_run_id = self._validate_run_id(run_id) + self._validate_artifact_name(name) + return self._safe_artifact_path(safe_run_id, Path(f"{name}.parquet")) + + def register_artifact( + self, + run_id: str, + name: ArtifactName, + path: Path | str | None = None, + ) -> dict[str, Any]: + """Record a Parquet artifact path relative to its owning run directory.""" + safe_run_id = self._validate_run_id(run_id) + self._validate_artifact_name(name) + artifact_path = self._safe_artifact_path( + safe_run_id, + Path(path) if path is not None else Path(f"{name}.parquet"), + ) + if artifact_path.suffix.lower() != ".parquet": + raise MiningRunValidationError("mining artifacts must use the .parquet suffix") + run_dir = self._run_dir(safe_run_id) + relative_path = artifact_path.relative_to(run_dir).as_posix() + with _STORE_LOCK: + manifest = self._required_manifest(safe_run_id) + artifacts = dict(manifest.get("artifacts") or {}) + artifacts[name] = relative_path + manifest["artifacts"] = artifacts + manifest["updated_at"] = _now_iso() + _atomic_write_json(run_dir / "manifest.json", manifest) + return manifest + + def append_event( + self, + run_id: str, + event_type: str, + payload: Mapping[str, Any] | None = None, + ) -> dict[str, Any]: + """Append a compact event, retaining only the most recent ``MAX_EVENTS`` entries.""" + safe_run_id = self._validate_run_id(run_id) + if not isinstance(event_type, str): + raise MiningRunValidationError("event_type must be a string") + clean_event_type = event_type.strip() + if not clean_event_type or len(clean_event_type) > 64: + raise MiningRunValidationError("event_type must contain 1 to 64 characters") + raw_payload: Mapping[str, Any] | Any = {} if payload is None else payload + if not isinstance(raw_payload, Mapping): + raise MiningRunValidationError("event payload must be a mapping") + clean_payload = cast(dict[str, Any], _canonicalize_json_value(raw_payload)) + encoded_payload = json.dumps( + clean_payload, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + if len(encoded_payload) > MAX_EVENT_PAYLOAD_BYTES: + raise MiningRunValidationError( + f"event payload exceeds {MAX_EVENT_PAYLOAD_BYTES} byte limit" + ) + + with _STORE_LOCK: + self._required_manifest(safe_run_id) + path = self._run_dir(safe_run_id) / "events.jsonl" + events = self._read_events_path(path) + next_id = max((event["id"] for event in events), default=0) + 1 + event = { + "id": next_id, + "timestamp": _now_iso(), + "type": clean_event_type, + "payload": clean_payload, + } + events.append(event) + events = events[-MAX_EVENTS:] + text = "".join( + json.dumps(item, ensure_ascii=False, allow_nan=False, separators=(",", ":")) + "\n" + for item in events + ) + _atomic_write_text(path, text) + return event + + def read_events(self, run_id: str, *, after_id: int = 0) -> list[dict[str, Any]]: + """Read retained events whose monotonically increasing ID is greater than ``after_id``.""" + if isinstance(after_id, bool) or not isinstance(after_id, int) or after_id < 0: + raise MiningRunValidationError("after_id must be a non-negative integer") + safe_run_id = self._validate_run_id(run_id) + with _STORE_LOCK: + self._required_manifest(safe_run_id) + events = self._read_events_path(self._run_dir(safe_run_id) / "events.jsonl") + return [event for event in events if event["id"] > after_id] + + def list_runs( + self, + *, + limit: int = 50, + statuses: Collection[MiningRunStatus] | None = None, + ) -> list[dict[str, Any]]: + """Return recent valid manifests without exposing store paths to API callers.""" + if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 200: + raise MiningRunValidationError("limit must be between 1 and 200") + allowed_statuses = None if statuses is None else set(statuses) + if allowed_statuses is not None and not allowed_statuses <= RUN_STATUSES: + raise MiningRunValidationError("statuses contains an unsupported mining run status") + + try: + paths = list(self.runs_root.glob("*/manifest.json")) + except OSError as exc: + raise MiningRunStoreError("failed to scan mining run manifests") from exc + manifests: list[dict[str, Any]] = [] + for path in paths: + run_id = path.parent.name + if not _RUN_ID_PATTERN.fullmatch(run_id): + continue + try: + manifest = self._read_manifest_path( + self._run_dir(run_id) / "manifest.json", + run_id, + ) + except MiningRunStoreError: + continue + if manifest is None: + continue + if allowed_statuses is not None and manifest.get("status") not in allowed_statuses: + continue + manifests.append(manifest) + manifests.sort(key=_manifest_sort_key, reverse=True) + return manifests[:limit] + + def find_by_signature( + self, + run_signature: str, + *, + statuses: Collection[MiningRunStatus] | None = None, + ) -> dict[str, Any] | None: + """Find the newest run with a signature, optionally restricted to selected statuses.""" + if not isinstance(run_signature, str) or not run_signature: + raise MiningRunValidationError("run_signature must not be empty") + allowed_statuses = None if statuses is None else set(statuses) + if allowed_statuses is not None and not allowed_statuses <= RUN_STATUSES: + raise MiningRunValidationError("statuses contains an unsupported mining run status") + + # Directory enumeration and manifest reads stay outside the write lock. Atomic replacements + # make each individual read coherent while avoiding a lock around a potentially slow scan. + try: + paths = list(self.runs_root.glob("*/manifest.json")) + except OSError as exc: + raise MiningRunStoreError("failed to scan mining run manifests") from exc + matches: list[dict[str, Any]] = [] + for path in paths: + run_id = path.parent.name + if not _RUN_ID_PATTERN.fullmatch(run_id): + continue + try: + manifest_path = self._run_dir(run_id) / "manifest.json" + manifest = self._read_manifest_path(manifest_path, run_id) + except MiningRunStoreError: + continue + if manifest is None or manifest.get("run_signature") != run_signature: + continue + if allowed_statuses is not None and manifest.get("status") not in allowed_statuses: + continue + matches.append(manifest) + return max(matches, key=_manifest_sort_key, default=None) + + def recover_interrupted(self) -> int: + """Mark runs without a live in-process worker as interrupted at startup.""" + try: + paths = list(self.runs_root.glob("*/manifest.json")) + except OSError as exc: + raise MiningRunStoreError("failed to scan mining run manifests") from exc + + candidates: list[str] = [] + for path in paths: + run_id = path.parent.name + if not _RUN_ID_PATTERN.fullmatch(run_id): + continue + try: + manifest_path = self._run_dir(run_id) / "manifest.json" + manifest = self._read_manifest_path(manifest_path, run_id) + except MiningRunStoreError: + continue + if manifest is not None and manifest.get("status") in ACTIVE_RUN_STATUSES: + candidates.append(run_id) + + recovered = 0 + for run_id in candidates: + with _STORE_LOCK: + manifest = self._required_manifest(run_id) + if manifest["status"] not in ACTIVE_RUN_STATUSES: + continue + self._transition_locked(manifest, "interrupted", error=None) + recovered += 1 + return recovered + + def _transition_locked( + self, + manifest: dict[str, Any], + status: MiningRunStatus, + *, + error: str | None, + ) -> dict[str, Any]: + previous = manifest["status"] + if previous == status: + return manifest + if status not in _ALLOWED_TRANSITIONS[previous]: + raise InvalidMiningStatusTransitionError( + f"cannot transition from {previous} to {status}" + ) + + now = _now_iso() + manifest["status"] = status + manifest["updated_at"] = now + if status == "running" and not manifest.get("started_at"): + manifest["started_at"] = now + if status == "cancelling": + manifest["cancellation_requested_at"] = now + if status in TERMINAL_RUN_STATUSES: + manifest["finished_at"] = now + if error is not None: + manifest["error"] = str(error) + _atomic_write_json(self._run_dir(manifest["run_id"]) / "manifest.json", manifest) + return manifest + + def _required_manifest(self, run_id: str) -> dict[str, Any]: + manifest = self._read_manifest_path(self._run_dir(run_id) / "manifest.json", run_id) + if manifest is None: + raise KeyError(run_id) + return manifest + + def _read_manifest_path(self, path: Path, run_id: str) -> dict[str, Any] | None: + if not path.exists(): + return None + value = _read_json(path) + if not isinstance(value, dict): + raise MiningRunStoreError(f"invalid manifest for run {run_id}") + return self._normalize_manifest(value, run_id) + + def _normalize_manifest(self, value: dict[str, Any], run_id: str) -> dict[str, Any]: + status = value.get("status", "queued") + if status not in RUN_STATUSES: + raise MiningRunStoreError(f"invalid status in manifest for run {run_id}") + raw_request = value.get("request") if isinstance(value.get("request"), dict) else {} + data_fingerprint = value.get("data_fingerprint") + signature = value.get("run_signature") + if not isinstance(signature, str) or not signature: + signature = compute_run_signature(raw_request, data_fingerprint) + raw_artifacts = value.get("artifacts") if isinstance(value.get("artifacts"), dict) else {} + artifacts: dict[str, str] = {} + run_dir = self._run_dir(run_id) + for name, raw_path in raw_artifacts.items(): + if name not in ARTIFACT_NAMES or not isinstance(raw_path, str): + continue + try: + safe_path = self._safe_artifact_path(run_id, Path(raw_path)) + except MiningRunValidationError: + continue + if safe_path.suffix.lower() == ".parquet": + artifacts[name] = safe_path.relative_to(run_dir).as_posix() + normalized = dict(value) + normalized.update( + { + "schema_version": value.get("schema_version", 0), + "run_id": run_id, + "status": status, + "request": raw_request, + "data_fingerprint": data_fingerprint, + "run_signature": signature, + "artifacts": artifacts, + "created_at": value.get("created_at"), + "updated_at": value.get("updated_at") or value.get("created_at"), + "started_at": value.get("started_at"), + "finished_at": value.get("finished_at"), + "cancellation_requested_at": value.get("cancellation_requested_at"), + "error": value.get("error"), + } + ) + return normalized + + @staticmethod + def _read_events_path(path: Path) -> list[dict[str, Any]]: + if not path.exists(): + return [] + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeError) as exc: + raise MiningRunStoreError(f"failed to read events file: {path}") from exc + events: list[dict[str, Any]] = [] + for line in lines: + if not line.strip(): + continue + try: + event = json.loads(line) + except json.JSONDecodeError as exc: + raise MiningRunStoreError(f"invalid events file: {path}") from exc + if ( + not isinstance(event, dict) + or isinstance(event.get("id"), bool) + or not isinstance(event.get("id"), int) + or event["id"] <= 0 + ): + raise MiningRunStoreError(f"invalid event record: {path}") + if events and event["id"] <= events[-1]["id"]: + raise MiningRunStoreError(f"non-monotonic event IDs: {path}") + events.append(event) + return events[-MAX_EVENTS:] + + def _run_dir(self, run_id: str) -> Path: + safe_run_id = self._validate_run_id(run_id) + candidate = (self.runs_root / safe_run_id).resolve() + if not candidate.is_relative_to(self.runs_root): + raise MiningRunValidationError("run path escapes mining runs root") + return candidate + + def _safe_artifact_path(self, run_id: str, path: Path) -> Path: + run_dir = self._run_dir(run_id) + candidate = path if path.is_absolute() else run_dir / path + resolved = candidate.resolve() + if not resolved.is_relative_to(run_dir): + raise MiningRunValidationError("artifact path escapes its mining run directory") + return resolved + + @staticmethod + def _validate_run_id(run_id: str) -> str: + if not isinstance(run_id, str) or not _RUN_ID_PATTERN.fullmatch(run_id): + raise MiningRunValidationError("run_id contains unsafe characters") + return run_id + + @staticmethod + def _validate_artifact_name(name: str) -> None: + if name not in ARTIFACT_NAMES: + raise MiningRunValidationError(f"unsupported artifact name: {name!r}") + + +def _canonicalize_json_value(value: Any) -> Any: + if value is None or isinstance(value, (str, bool, int)): + return value + if isinstance(value, float): + if not math.isfinite(value): + raise MiningRunValidationError("non-finite numbers are not supported") + return value + if isinstance(value, Enum): + return _canonicalize_json_value(value.value) + if isinstance(value, (datetime, date)): + return value.isoformat() + if isinstance(value, Path): + return str(value) + if isinstance(value, Mapping): + result: dict[str, Any] = {} + for key, item in value.items(): + if not isinstance(key, str): + raise MiningRunValidationError("JSON mapping keys must be strings") + result[key] = _canonicalize_json_value(item) + return {key: result[key] for key in sorted(result)} + if isinstance(value, (list, tuple)): + return [_canonicalize_json_value(item) for item in value] + if isinstance(value, (set, frozenset)): + items = [_canonicalize_json_value(item) for item in value] + return sorted( + items, + key=lambda item: json.dumps( + item, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(",", ":") + ), + ) + raise MiningRunValidationError(f"value is not JSON serializable: {type(value).__name__}") + + +def _read_json(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise MiningRunStoreError(f"failed to read JSON file: {path}") from exc + + +def _atomic_write_json(path: Path, value: Any) -> None: + try: + text = json.dumps(value, ensure_ascii=False, allow_nan=False, indent=2) + "\n" + except (TypeError, ValueError) as exc: + raise MiningRunValidationError("value is not JSON serializable") from exc + _atomic_write_text(path, text) + + +def _atomic_write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + try: + with temporary.open("w", encoding="utf-8", newline="\n") as stream: + stream.write(text) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + except OSError as exc: + temporary.unlink(missing_ok=True) + raise MiningRunStoreError(f"failed to write file: {path}") from exc + + +def _now_iso() -> str: + return datetime.now(UTC).isoformat() + + +def _manifest_sort_key(manifest: dict[str, Any]) -> tuple[str, str]: + return ( + str(manifest.get("updated_at") or manifest.get("created_at") or ""), + str(manifest.get("run_id") or ""), + ) diff --git a/backend/app/services/mining_manager.py b/backend/app/services/mining_manager.py new file mode 100644 index 0000000..0422ac3 --- /dev/null +++ b/backend/app/services/mining_manager.py @@ -0,0 +1,263 @@ +"""Threaded orchestration for persistent mining jobs.""" + +from __future__ import annotations + +import threading +import time +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from app.backtest.worker import make_worker_task, run_worker_task +from app.services.heavy_job_limiter import ( + HeavyJobCancelledError, + shared_heavy_job_limiter, +) +from app.services.mining_jobs import ( + ACTIVE_RUN_STATUSES, + SUCCESS_RUN_STATUSES, + TERMINAL_RUN_STATUSES, + MiningRunStore, + MiningRunValidationError, + compute_run_signature, +) + +WorkerRunner = Callable[ + [dict[str, Any], Callable[[dict[str, Any]], None], threading.Event], + dict[str, Any], +] +TaskFactory = Callable[[str, Path, dict[str, Any]], dict[str, Any]] + +_SUCCESS_STATUSES = {"succeeded", "succeeded_with_budget_exhausted"} +_SHUTDOWN_JOIN_SECONDS = 1.0 + + +class MiningJobManager: + """Coordinate mining persistence, capacity, cancellation, and worker threads.""" + + def __init__( + self, + data_dir: Path | str, + worker_runner: WorkerRunner = run_worker_task, + task_factory: TaskFactory = make_worker_task, + ) -> None: + self._data_dir = Path(data_dir).resolve() + self._store = MiningRunStore(self._data_dir) + self._worker_runner = worker_runner + self._task_factory = task_factory + self._lock = threading.RLock() + self._threads: dict[str, threading.Thread] = {} + self._cancel_events: dict[str, threading.Event] = {} + self._shutdown = False + + @property + def store(self) -> MiningRunStore: + return self._store + + def start( + self, + request: dict[str, Any], + data_fingerprint: Any, + force: bool = False, + source: str = "manual", + run_id: str | None = None, + ) -> dict[str, Any]: + signature = compute_run_signature(request, data_fingerprint) + with self._lock: + if self._shutdown: + raise RuntimeError("mining job manager is shut down") + if not force: + active = self._store.find_by_signature( + signature, + statuses=ACTIVE_RUN_STATUSES, + ) + if active is not None: + return active + succeeded = self._store.find_by_signature( + signature, + statuses=SUCCESS_RUN_STATUSES, + ) + if succeeded is not None: + return succeeded + + try: + manifest = self._store.create( + request, + data_fingerprint, + run_id=run_id, + ) + except MiningRunValidationError: + if run_id is None: + raise + existing = self._store.get(run_id) + if existing is None: + raise + return existing + run_id = manifest["run_id"] + self._store.append_event( + run_id, + "queued", + {"status": "queued", "source": source}, + ) + self._start_thread_locked(run_id, source) + return manifest + + def cancel(self, run_id: str) -> dict[str, Any]: + with self._lock: + manifest = self._store.get(run_id) + if manifest is None: + raise KeyError(run_id) + if manifest["status"] in TERMINAL_RUN_STATUSES: + return manifest + + cancel_event = self._cancel_events.get(run_id) + if cancel_event is None: + cancelled = self._store.transition_status(run_id, "cancelled") + self._store.append_event(run_id, "cancelled", {"status": "cancelled"}) + return cancelled + + cancel_event.set() + if manifest["status"] != "cancelling": + manifest = self._store.transition_status(run_id, "cancelling") + self._store.append_event(run_id, "cancelling", {"status": "cancelling"}) + return manifest + + def shutdown(self) -> None: + with self._lock: + self._shutdown = True + run_ids = list(self._threads) + for run_id in run_ids: + self.cancel(run_id) + + deadline = time.monotonic() + _SHUTDOWN_JOIN_SECONDS + current = threading.current_thread() + for run_id in run_ids: + with self._lock: + thread = self._threads.get(run_id) + if thread is None or thread is current: + continue + remaining = deadline - time.monotonic() + if remaining <= 0: + break + thread.join(timeout=remaining) + + def recover_interrupted(self) -> int: + return self._store.recover_interrupted() + + def _start_thread_locked(self, run_id: str, source: str) -> None: + if run_id in self._threads: + return + cancel_event = threading.Event() + thread = threading.Thread( + target=self._run_job, + args=(run_id, source, cancel_event), + name=f"mining-{run_id}", + daemon=True, + ) + self._cancel_events[run_id] = cancel_event + self._threads[run_id] = thread + thread.start() + + def _run_job( + self, + run_id: str, + source: str, + cancel_event: threading.Event, + ) -> None: + try: + with shared_heavy_job_limiter.slot("mining", cancel_event=cancel_event): + if not self._mark_running(run_id, cancel_event): + return + manifest = self._store.get(run_id) + if manifest is None: + raise KeyError(run_id) + payload = { + "run_id": run_id, + "request": manifest["request"], + "data_fingerprint": manifest["data_fingerprint"], + "source": source, + } + task = self._task_factory("mining", self._data_dir, payload) + result = self._worker_runner( + task, + lambda progress: self._record_progress(run_id, progress, cancel_event), + cancel_event, + ) + if not isinstance(result, dict): + raise TypeError("mining worker result must be a compact dict") + self._finish_success(run_id, result, cancel_event) + except HeavyJobCancelledError: + self._finish_cancelled(run_id) + except Exception as exc: + if cancel_event.is_set(): + self._finish_cancelled(run_id) + else: + self._finish_failed(run_id, exc) + finally: + with self._lock: + self._threads.pop(run_id, None) + self._cancel_events.pop(run_id, None) + + def _mark_running(self, run_id: str, cancel_event: threading.Event) -> bool: + with self._lock: + if cancel_event.is_set(): + self._finish_cancelled_locked(run_id) + return False + self._store.transition_status(run_id, "running") + self._store.append_event(run_id, "running", {"status": "running"}) + return True + + def _record_progress( + self, + run_id: str, + progress: dict[str, Any], + cancel_event: threading.Event, + ) -> None: + if not isinstance(progress, dict): + raise TypeError("mining progress must be a compact dict") + with self._lock: + if cancel_event.is_set(): + return + self._store.append_event(run_id, "progress", progress) + self._store.write_summary(run_id, {"progress": progress}) + + def _finish_success( + self, + run_id: str, + result: dict[str, Any], + cancel_event: threading.Event, + ) -> None: + status = result.get("status", "succeeded") + if status not in _SUCCESS_STATUSES: + raise ValueError(f"unsupported mining worker status: {status!r}") + with self._lock: + if cancel_event.is_set(): + self._finish_cancelled_locked(run_id) + return + self._store.write_summary(run_id, result) + self._store.transition_status(run_id, status) + self._store.append_event(run_id, status, {"status": status}) + + def _finish_cancelled(self, run_id: str) -> None: + with self._lock: + self._finish_cancelled_locked(run_id) + + def _finish_cancelled_locked(self, run_id: str) -> None: + manifest = self._store.get(run_id) + if manifest is None or manifest["status"] in TERMINAL_RUN_STATUSES: + return + self._store.transition_status(run_id, "cancelled") + self._store.append_event(run_id, "cancelled", {"status": "cancelled"}) + + def _finish_failed(self, run_id: str, exc: Exception) -> None: + message = str(exc)[:2000] + with self._lock: + manifest = self._store.get(run_id) + if manifest is None or manifest["status"] in TERMINAL_RUN_STATUSES: + return + self._store.transition_status(run_id, "failed", error=message) + self._store.append_event( + run_id, + "error", + {"status": "failed", "message": message}, + ) diff --git a/backend/app/services/mining_preflight.py b/backend/app/services/mining_preflight.py new file mode 100644 index 0000000..6f234ee --- /dev/null +++ b/backend/app/services/mining_preflight.py @@ -0,0 +1,139 @@ +"""Lightweight mining date availability checks shared by API and workers.""" +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import date +from pathlib import Path +from typing import Any + +from app.backtest.mining import ( + nested_fold_count, + required_outer_folds, + required_trading_bars, + validation_config_for_profile, +) +from app.tickflow.repository import enriched_dirname + + +@dataclass(frozen=True) +class MiningAvailability: + asset_type: str + budget_profile: str + trading_bars: int + required_bars: int + outer_folds: int + required_outer_folds: int + eligible: bool + available_start: date | None + available_end: date | None + effective_start: date | None + effective_end: date | None + suggested_start: date | None + + def to_dict(self) -> dict[str, Any]: + return { + key: value.isoformat() if isinstance(value, date) else value + for key, value in asdict(self).items() + } + + +def enriched_partition_dates( + data_dir: Path, + asset_type: str, + start: date | None = None, + end: date | None = None, +) -> list[date]: + root = data_dir / enriched_dirname(asset_type) + values: set[date] = set() + for partition in root.glob("date=*"): + try: + value = date.fromisoformat(partition.name.removeprefix("date=")) + except ValueError: + continue + if start is not None and value < start: + continue + if end is not None and value > end: + continue + if (partition / "part.parquet").is_file(): + values.add(value) + return sorted(values) + + +def mining_availability( + data_dir: Path, + *, + asset_type: str, + budget_profile: str, + start: date | None = None, + end: date | None = None, +) -> MiningAvailability: + if asset_type not in {"stock", "etf"}: + raise ValueError(f"unsupported mining asset type: {asset_type}") + if start is not None and end is not None and start > end: + raise ValueError("mining start must not be after end") + + config = validation_config_for_profile(budget_profile) + required_folds = required_outer_folds(budget_profile) + required_bars = required_trading_bars(config, required_folds) + all_dates = enriched_partition_dates(data_dir, asset_type) + scoped = [ + value + for value in all_dates + if (start is None or value >= start) and (end is None or value <= end) + ] + dates_through_end = [ + value for value in all_dates if end is None or value <= end + ] + suggested_start = ( + dates_through_end[-required_bars] + if len(dates_through_end) >= required_bars + else None + ) + trading_bars = len(scoped) + return MiningAvailability( + asset_type=asset_type, + budget_profile=budget_profile, + trading_bars=trading_bars, + required_bars=required_bars, + outer_folds=nested_fold_count(trading_bars, config), + required_outer_folds=required_folds, + eligible=trading_bars >= required_bars, + available_start=all_dates[0] if all_dates else None, + available_end=all_dates[-1] if all_dates else None, + effective_start=scoped[0] if scoped else None, + effective_end=scoped[-1] if scoped else None, + suggested_start=suggested_start, + ) + + +def require_mining_availability( + data_dir: Path, + *, + asset_type: str, + budget_profile: str, + start: date | None = None, + end: date | None = None, +) -> MiningAvailability: + availability = mining_availability( + data_dir, + asset_type=asset_type, + budget_profile=budget_profile, + start=start, + end=end, + ) + if availability.eligible: + return availability + + if availability.effective_start is None: + effective_range = "contains no enriched data" + else: + effective_range = ( + f"{availability.effective_start.isoformat()} to " + f"{availability.effective_end.isoformat()}" + ) + fold_label = "outer fold" if availability.required_outer_folds == 1 else "outer folds" + raise ValueError( + f"{budget_profile} mining requires at least {availability.required_bars} " + f"enriched trading bars for {availability.required_outer_folds} {fold_label}; " + f"effective range {effective_range} has {availability.trading_bars}" + ) diff --git a/backend/app/services/mining_process_lock.py b/backend/app/services/mining_process_lock.py new file mode 100644 index 0000000..f0323b0 --- /dev/null +++ b/backend/app/services/mining_process_lock.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import BinaryIO + + +class MiningProcessLockError(RuntimeError): + """Another application process owns mining for this data directory.""" + + +class MiningProcessLock: + def __init__(self, data_dir: Path) -> None: + self._path = Path(data_dir) / ".mining_process.lock" + self._stream: BinaryIO | None = None + + def acquire(self) -> None: + if self._stream is not None: + return + self._path.parent.mkdir(parents=True, exist_ok=True) + stream = self._path.open("a+b") + try: + stream.seek(0, os.SEEK_END) + if stream.tell() == 0: + stream.write(b"0") + stream.flush() + os.set_inheritable(stream.fileno(), False) + _try_lock_file(stream) + except BaseException: + stream.close() + raise + self._stream = stream + + def release(self) -> None: + stream = self._stream + if stream is None: + return + self._stream = None + try: + _unlock_file(stream) + finally: + stream.close() + + +def _try_lock_file(stream: BinaryIO) -> None: + if os.name == "nt": + import msvcrt + + stream.seek(0) + try: + msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1) + except OSError as exc: + raise MiningProcessLockError( + "another application process already owns mining for this data directory" + ) from exc + return + + import fcntl + + try: + fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as exc: + raise MiningProcessLockError( + "another application process already owns mining for this data directory" + ) from exc + + +def _unlock_file(stream: BinaryIO) -> None: + if os.name == "nt": + import msvcrt + + stream.seek(0) + msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1) + return + + import fcntl + + fcntl.flock(stream.fileno(), fcntl.LOCK_UN) diff --git a/backend/app/services/mining_schedule.py b/backend/app/services/mining_schedule.py new file mode 100644 index 0000000..6a6475d --- /dev/null +++ b/backend/app/services/mining_schedule.py @@ -0,0 +1,391 @@ +"""Weekly scheduled mining orchestration and deterministic data claims.""" + +from __future__ import annotations + +import hashlib +import json +import logging +import threading +from datetime import date, datetime +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + +import polars as pl + +from app.backtest.factor import FACTOR_COLUMNS, FACTOR_METHODOLOGY_VERSION +from app.backtest.mining import ( + required_outer_folds, + required_trading_bars, + validation_config_for_profile, +) +from app.services import preferences +from app.services.mining_preflight import enriched_partition_dates +from app.services.regime_builder import load_regime_history, regime_path + +logger = logging.getLogger(__name__) + +BEIJING_TZ = ZoneInfo("Asia/Shanghai") +MINING_ALGORITHM_VERSION = "mining-v2" +FINGERPRINT_VERSION = "weekly-mining-data-v2" +_PROFILES = frozenset({"balanced", "strict"}) +_CLAIM_LOCK = threading.Lock() + + +def beijing_now(now: datetime | None = None) -> datetime: + """Return an aware Beijing datetime without depending on the server timezone.""" + if now is None: + return datetime.now(BEIJING_TZ) + if now.tzinfo is None: + return now.replace(tzinfo=BEIJING_TZ) + return now.astimezone(BEIJING_TZ) + + +def beijing_date(now: datetime | None = None) -> date: + return beijing_now(now).date() + + +def iso_week(value: date) -> tuple[int, int]: + iso_year, week, _ = value.isocalendar() + return iso_year, week + + +def build_default_request(repo: Any, profile: str) -> dict[str, Any]: + """Build the bounded V1 stock/full-market request used by the scheduler.""" + if profile not in _PROFILES: + raise ValueError(f"unsupported mining profile: {profile}") + latest = repo.latest_enriched_date("stock") + end = latest.isoformat() if latest is not None else None + return { + "factor_names": [item["id"] for item in FACTOR_COLUMNS[:48]], + "strategy_ids": [], + "symbols": None, + "asset_type": "stock", + "start": None, + "end": end, + "budget_profile": profile, + "require_regime": True, + } + + +def build_data_fingerprint( + repo: Any, + app_state: Any, + request: dict[str, Any], +) -> dict[str, Any]: + """Hash one stable managed generation plus source metadata.""" + for _attempt in range(2): + fingerprint = _build_data_fingerprint_once(repo, app_state, request) + if repo.get_matrix_data_generation(fingerprint["asset_type"]) == fingerprint["generation"]: + return fingerprint + raise ValueError("enriched data changed while building the mining fingerprint") + + +def _build_data_fingerprint_once( + repo: Any, + app_state: Any, + request: dict[str, Any], +) -> dict[str, Any]: + data_dir = Path(repo.store.data_dir) + asset_type = str(request.get("asset_type") or "stock") + enriched_root = ( + data_dir / "kline_daily_enriched" + if asset_type == "stock" + else data_dir / f"kline_{asset_type}_enriched" + ) + module_root = Path(__file__).resolve().parents[1] + components = { + "version": FINGERPRINT_VERSION, + "asset_type": asset_type, + "generation": repo.get_matrix_data_generation(asset_type), + "latest_enriched_date": _iso_or_none(repo.latest_enriched_date(asset_type)), + "enriched": _enriched_metadata(enriched_root), + "instruments": _instrument_metadata(repo, asset_type), + "regime": _path_metadata(regime_path(data_dir), root=data_dir), + "algorithm_version": MINING_ALGORITHM_VERSION, + "methodology_version": FACTOR_METHODOLOGY_VERSION, + "implementation": _implementation_metadata(module_root), + "strategies": _selected_strategy_metadata( + app_state, + request.get("strategy_ids") or [], + data_dir, + ), + } + payload = _canonical_json(components) + return { + **components, + "digest": hashlib.sha256(payload.encode("utf-8")).hexdigest(), + } + + +def schedule_claim(day: date) -> str: + iso_year, week = iso_week(day) + return f"weekly-{iso_year}-W{week:02d}" + + +def run_weekly_mining(app_state: Any, *, now: datetime | None = None) -> dict[str, Any]: + """Check the weekly gate and enqueue mining; never perform mining synchronously.""" + config = preferences.get_mining_schedule() + day = beijing_date(now) + if not config["mining_schedule_enabled"]: + return {"status": "disabled"} + weekday = day.weekday() + if weekday > 4 or weekday < config["mining_schedule_weekday"]: + return {"status": "weekday_mismatch"} + + manager = getattr(app_state, "mining_manager", None) + repo = getattr(app_state, "repo", None) + if manager is None or repo is None: + raise RuntimeError("scheduled mining dependencies are not initialized") + store = getattr(manager, "store", None) + if store is None: + raise RuntimeError("scheduled mining manager has no run store") + + request = build_default_request(repo, config["mining_budget_profile"]) + fingerprint = build_data_fingerprint(repo, app_state, request) + claim = schedule_claim(day) + fingerprint = {**fingerprint, "source": "scheduled", "source_claim": claim} + + with _CLAIM_LOCK: + existing = store.get(claim) + if existing is not None: + return {"status": "already_claimed", "run_id": claim} + + prerequisite_error = _prerequisite_error(repo, request) + if prerequisite_error is not None: + _record_skipped_prerequisite(store, claim, request, fingerprint, prerequisite_error) + return { + "status": "skipped_prerequisite", + "run_id": claim, + "error": prerequisite_error, + } + + run = manager.start( + request, + fingerprint, + force=False, + source="scheduled", + run_id=claim, + ) + run_id = run.get("run_id") if isinstance(run, dict) else getattr(run, "run_id", None) + return {"status": "enqueued", "run_id": run_id or claim} + + +def _prerequisite_error(repo: Any, request: dict[str, Any]) -> str | None: + data_dir = Path(repo.store.data_dir) + end = request.get("end") + if end is None: + return "stock enriched data is unavailable" + regime = regime_path(data_dir) + try: + if not regime.is_file() or regime.stat().st_size <= 0: + return "regime data is unavailable" + except OSError: + return "regime data is unavailable" + + start = request.get("start") + start_date = date.fromisoformat(start) if start is not None else None + end_date = date.fromisoformat(end) + partitions = enriched_partition_dates( + data_dir, + "stock", + start_date, + end_date, + ) + covered = [value.isoformat() for value in partitions] + profile = request["budget_profile"] + validation = validation_config_for_profile(profile) + required = required_trading_bars( + validation, + required_outer_folds(profile), + ) + if len(covered) < required: + return ( + "insufficient enriched trading dates: " + f"need {required}, got {len(covered)}" + ) + + regime_history = load_regime_history(data_dir) + if regime_history.is_empty() or "date" not in regime_history.columns: + return "regime data is unavailable" + regime_dates = set( + regime_history.select( + pl.col("date").cast(pl.Utf8).str.slice(0, 10) + ).to_series().to_list() + ) + required_predecessors = set(covered[:-1]) + missing_regime = required_predecessors - regime_dates + if missing_regime: + return ( + "regime coverage is incomplete for T-1 alignment: " + f"missing {len(missing_regime)} trading dates" + ) + return None + + +def _record_skipped_prerequisite( + store: Any, + claim: str, + request: dict[str, Any], + fingerprint: dict[str, Any], + error: str, +) -> None: + try: + store.create(request, fingerprint, run_id=claim) + except Exception: + if store.get(claim) is not None: + return + raise + store.append_event( + claim, + "skipped_prerequisite", + {"source": "scheduled", "reason": error}, + ) + store.transition_status(claim, "skipped_prerequisite", error=error) + + +def _instrument_metadata(repo: Any, asset_type: str) -> dict[str, Any]: + instruments = repo.get_instruments_asset(asset_type) + if instruments is None or instruments.is_empty() or "symbol" not in instruments.columns: + return {"rows": 0, "digest": "no-instruments"} + columns = [ + name + for name in ( + "symbol", + "name", + "total_shares", + "float_shares", + "limit_up", + "limit_down", + ) + if name in instruments.columns + ] + payload = instruments.select(columns).sort("symbol").to_dicts() + digest = hashlib.blake2b( + json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8"), + digest_size=20, + ).hexdigest() + return {"rows": instruments.height, "columns": columns, "digest": digest} + + +def _enriched_metadata(root: Path) -> dict[str, Any]: + records: list[dict[str, Any]] = [] + for partition in sorted(root.glob("date=*"), key=lambda item: item.name): + try: + date.fromisoformat(partition.name.removeprefix("date=")) + except ValueError: + continue + records.append( + { + "partition": partition.name, + "file": _path_metadata(partition / "part.parquet", root=root), + } + ) + return { + "partition_count": len(records), + "first_partition": records[0]["partition"] if records else None, + "last_partition": records[-1]["partition"] if records else None, + "metadata_digest": hashlib.sha256(_canonical_json(records).encode("utf-8")).hexdigest(), + } + + +def _selected_strategy_metadata( + app_state: Any, + strategy_ids: list[str], + data_dir: Path, +) -> list[dict[str, Any]]: + if not strategy_ids: + return [] + engine = getattr(app_state, "strategy_engine", None) + if engine is None: + raise RuntimeError("strategy engine is unavailable for scheduled mining fingerprint") + metadata: list[dict[str, Any]] = [] + for strategy_id in sorted(strategy_ids): + strategy = engine.get(strategy_id) + if strategy.execution_backend != "matrix_native": + raise ValueError(f"scheduled mining strategy is not matrix-native: {strategy_id}") + source_path = Path(strategy.file_path) if strategy.file_path is not None else None + override_path = data_dir / "user_data" / "strategy_overrides" / f"{strategy_id}.json" + metadata.append( + { + "strategy_id": strategy_id, + "source": _content_metadata( + source_path, root=source_path.parent if source_path else data_dir + ), + "source_tree": ( + _implementation_metadata(source_path.parent) + if source_path is not None + else None + ), + "override": _content_metadata(override_path, root=data_dir), + } + ) + return metadata + + +def _path_metadata(path: Path | None, *, root: Path) -> dict[str, Any] | None: + if path is None: + return None + try: + stat = path.stat() + except OSError: + return {"path": _relative_path(path, root), "exists": False} + return { + "path": _relative_path(path, root), + "exists": True, + "size": stat.st_size, + "mtime_ns": stat.st_mtime_ns, + } + + +def _content_metadata(path: Path | None, *, root: Path) -> dict[str, Any] | None: + if path is None: + return None + try: + content = path.read_bytes() + except OSError: + return {"path": _relative_path(path, root), "exists": False} + return { + "path": _relative_path(path, root), + "exists": True, + "size": len(content), + "sha256": hashlib.sha256(content).hexdigest(), + } + + +def _implementation_metadata(module_root: Path) -> dict[str, Any]: + records = [] + for path in sorted(module_root.rglob("*.py"), key=lambda item: item.as_posix()): + try: + content = path.read_bytes() + except OSError: + continue + records.append({ + "path": path.relative_to(module_root).as_posix(), + "sha256": hashlib.sha256(content).hexdigest(), + }) + return { + "file_count": len(records), + "digest": hashlib.sha256(_canonical_json(records).encode("utf-8")).hexdigest(), + } + + +def _relative_path(path: Path, root: Path) -> str: + try: + return path.resolve().relative_to(root.resolve()).as_posix() + except ValueError: + return path.name + + +def _iso_or_none(value: date | None) -> str | None: + return value.isoformat() if value is not None else None + + +def _canonical_json(value: Any) -> str: + return json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ) diff --git a/backend/app/services/pipeline_jobs.py b/backend/app/services/pipeline_jobs.py index 53f3397..c4b99eb 100644 --- a/backend/app/services/pipeline_jobs.py +++ b/backend/app/services/pipeline_jobs.py @@ -27,7 +27,7 @@ JobStatus = Literal["pending", "running", "succeeded", "failed"] # 由 reap_stale() 在 /run 和 /jobs/{id} 轮询端点检查 — 保证卡死后能自愈, # 无需用户再次点击「同步」。 # -# 超时阈值按任务类型区分: +# 默认超时阈值按任务类型区分,可在 Web 数据源设置中调整: # - 普通任务(日K管道/扩展/修正/重算): 1200s (20 分钟) # - 长任务(分钟K全市场同步,数据量是日K的 ~240 倍): 1800s (30 分钟) # 分钟K即使流式落盘后仍可能跑十几到数十分钟(限速 sleep 是主因), @@ -105,7 +105,12 @@ class JobStore: # ===== lifecycle ===== - def create(self, timeout_s: int = DEFAULT_JOB_TIMEOUT_S) -> tuple[str, bool]: + def create( + self, + timeout_s: int | None = None, + *, + long_running: bool = False, + ) -> tuple[str, bool]: """单飞创建任务。返回 (job_id, is_new)。 去重条件为 **pending ∨ running**(而非仅 running):`/run` 先 create() 再在 @@ -115,9 +120,17 @@ class JobStore: is_new=False 表示复用了已有活跃任务,调用方**不得**再调度新的后台任务。 - timeout_s: reap_stale 判定卡死的阈值。普通任务默认 1200s; - 分钟K全市场同步等长任务传 LONG_JOB_TIMEOUT_S (1800s)。 + timeout_s: reap_stale 判定卡死的阈值。None 时读取用户配置。 + long_running: timeout_s 为 None 时,是否读取长任务配置;普通任务默认 + 1200s,分钟K全市场同步等长任务默认 1800s。 """ + if timeout_s is None: + from app.services import preferences + if long_running: + timeout_s = preferences.get_data_source_long_job_timeout_s() + else: + timeout_s = preferences.get_data_source_job_timeout_s() + with self._lock: if self._active_id: active = self._active_jobs.get(self._active_id) diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index 5671bf8..f5470ff 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -5,12 +5,19 @@ """ from __future__ import annotations +import copy import json import logging +import re from pathlib import Path logger = logging.getLogger(__name__) +# 进程内缓存: 行情轮询线程一轮会调用 8~12 次 getter, 每次读盘+parse 是纯重复; +# 文件仅在用户改设置时变化, 以 (mtime_ns, size) 签名判断是否重读。 +_cache: dict | None = None +_cache_sig: tuple[int, int] | None = None + def _path() -> Path: from app.config import settings @@ -19,14 +26,32 @@ def _path() -> Path: return p +def _invalidate_cache() -> None: + global _cache, _cache_sig + _cache = None + _cache_sig = None + + def load() -> dict: + """读取 preferences.json (带 mtime 签名缓存)。返回深拷贝, 调用方可自由修改。""" + global _cache, _cache_sig p = _path() - if p.exists(): - try: - return json.loads(p.read_text(encoding="utf-8")) - except Exception as e: # noqa: BLE001 - logger.warning("preferences.json malformed: %s", e) - return {} + try: + sig = (p.stat().st_mtime_ns, p.stat().st_size) + except OSError: + return {} + if _cache is not None and sig == _cache_sig: + return copy.deepcopy(_cache) + try: + data = json.loads(p.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + except Exception as e: + logger.warning("preferences.json malformed: %s", e) + return {} + _cache = data + _cache_sig = sig + return copy.deepcopy(_cache) def save(updates: dict) -> dict: @@ -36,6 +61,7 @@ def save(updates: dict) -> dict: _path().write_text( json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8", ) + _invalidate_cache() return current @@ -49,6 +75,11 @@ def get_indices_nav_pinned() -> bool: return load().get("indices_nav_pinned", True) +def get_watchlist_groups_in_nav() -> bool: + """自选分组是否显示在侧边栏(可展开二级子菜单)。默认 False。""" + return load().get("watchlist_groups_in_nav", False) + + def get_realtime_quote_interval() -> float: return load().get("realtime_quote_interval", 6.0) @@ -83,6 +114,7 @@ def set_realtime_quote_interval(interval: float) -> float: _path().write_text( json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8", ) + _invalidate_cache() return interval @@ -186,6 +218,32 @@ def get_minute_sync_segment_days() -> int: # ===== 数据源选择 (默认 TickFlow;第一阶段仅日K切换入口) ===== _ALLOWED_DATA_PROVIDERS = {"tickflow"} +DATA_SOURCE_JOB_TIMEOUT_MIN_S = 60 + + +def get_data_source_job_timeout_s() -> int: + """返回普通数据后台任务的卡死判定时间(秒)。""" + from app.services.pipeline_jobs import DEFAULT_JOB_TIMEOUT_S + raw = load().get("data_source_job_timeout_s", DEFAULT_JOB_TIMEOUT_S) + try: + timeout_s = int(raw) + except (TypeError, ValueError): + timeout_s = DEFAULT_JOB_TIMEOUT_S + return max(DATA_SOURCE_JOB_TIMEOUT_MIN_S, timeout_s) + + +def get_data_source_long_job_timeout_s() -> int: + """返回分钟 K 全市场等长任务的卡死判定时间(秒)。""" + from app.services.pipeline_jobs import LONG_JOB_TIMEOUT_S + raw = load().get( + "data_source_long_job_timeout_s", + LONG_JOB_TIMEOUT_S, + ) + try: + timeout_s = int(raw) + except (TypeError, ValueError): + timeout_s = LONG_JOB_TIMEOUT_S + return max(DATA_SOURCE_JOB_TIMEOUT_MIN_S, timeout_s) def _allowed_data_providers() -> set[str]: @@ -285,6 +343,92 @@ def get_regime_warmup_days() -> int: return 40 +# ── 市场主线(概念/行业涨停梯队)过滤 ── +# 宽基/风格标签(融资融券 ~7700 成分、深股通/沪股通 ~3300-3700、国企改革 ~2900) +# 会按"家数"霸占主线榜首, 但它们不是可操作的题材主线。默认按成分股数上限过滤。 +# 标定(2026-08 THS 概念): 成员 >600 的 55 个概念几乎全是此类风格标签, +# 真实题材(华为概念 2006/人工智能 2166/固态电池等)均在 600 以下或可自行调整。 +_MAINLINE_MAX_MEMBERS_MIN = 50 +_MAINLINE_MAX_MEMBERS_MAX = 5000 +_MAINLINE_MIN_MEMBERS_MIN = 1 +_MAINLINE_MIN_MEMBERS_MAX = 200 + + +def get_mainline_max_members() -> int: + """主线维度成员数上限, 超过视为宽基/风格标签被过滤。默认 600。""" + v = load().get("mainline_max_members", 600) + try: + return max(_MAINLINE_MAX_MEMBERS_MIN, min(_MAINLINE_MAX_MEMBERS_MAX, int(v))) + except (TypeError, ValueError): + return 600 + + +def get_mainline_min_members() -> int: + """主线维度成员数下限, 过滤微型标签。默认 4。""" + v = load().get("mainline_min_members", 4) + try: + return max(_MAINLINE_MIN_MEMBERS_MIN, min(_MAINLINE_MIN_MEMBERS_MAX, int(v))) + except (TypeError, ValueError): + return 4 + + +def get_mainline_blacklist() -> list[str]: + """用户自定义屏蔽的维度成员名(不论成员数大小)。默认空。 + + 保存时接受 list 或逗号/顿号/分号/空白分隔的字符串。 + """ + v = load().get("mainline_blacklist", []) + if isinstance(v, str): + v = [part for part in re.split(r"[,,、;;\s]+", v) if part] # noqa: RUF001 + if not isinstance(v, list): + return [] + return [str(x).strip() for x in v if str(x).strip()] + + +def get_sentiment_exclude_st() -> bool: + """市场环境/主线统计是否剔除风险警示(ST)股。默认 True。 + + 口径: 主板 ST 在 2026-07 前享 5% 涨跌幅(封板成本减半), 且 ST 是跨行业的 + 状态桶而非投资题材, 混入会系统性抬高涨停宽度/高度(弱市尤甚)。剔除后 + 涨跌家数等宽度占比几乎不受影响。修改后需重算 regime 与主线生效。 + """ + return bool(load().get("sentiment_exclude_st", True)) + + +def set_sentiment_exclude_st(v: bool) -> bool: + save({"sentiment_exclude_st": bool(v)}) + return get_sentiment_exclude_st() + + +def get_mainline_filter_config() -> dict: + """主线过滤配置汇总(供 API 返回与计算读取)。""" + return { + "min_members": get_mainline_min_members(), + "max_members": get_mainline_max_members(), + "blacklist": get_mainline_blacklist(), + "exclude_st": get_sentiment_exclude_st(), + } + + +def set_mainline_filter_config(cfg: dict) -> dict: + """保存主线过滤配置(白名单字段, 部分更新)。修改后需重算主线生效。""" + updates: dict = {} + if "min_members" in cfg and cfg["min_members"] is not None: + updates["mainline_min_members"] = cfg["min_members"] + if "max_members" in cfg and cfg["max_members"] is not None: + updates["mainline_max_members"] = cfg["max_members"] + if "exclude_st" in cfg and cfg["exclude_st"] is not None: + updates["sentiment_exclude_st"] = bool(cfg["exclude_st"]) + if "blacklist" in cfg and cfg["blacklist"] is not None: + raw = cfg["blacklist"] + if isinstance(raw, str): + raw = [part for part in re.split(r"[,,、;;\s]+", raw) if part] # noqa: RUF001 + updates["mainline_blacklist"] = [str(x).strip() for x in (raw or []) if str(x).strip()] + if updates: + save(updates) + return get_mainline_filter_config() + + _PIPELINE_PULL_KEYS = ("pipeline_pull_etf", "pipeline_pull_index") @@ -444,6 +588,43 @@ def set_review_schedule(enabled: bool, hour: int, minute: int) -> dict: return {"enabled": bool(enabled), "hour": h, "minute": m} +MINING_BUDGET_PROFILES = frozenset({"balanced", "strict"}) + + +def get_mining_schedule() -> dict: + """返回周度自动 mining 配置。历史配置缺字段时默认关闭。""" + data = load() + weekday = data.get("mining_schedule_weekday", 4) + if isinstance(weekday, bool) or not isinstance(weekday, int) or not 0 <= weekday <= 4: + weekday = 4 + profile = data.get("mining_budget_profile", "balanced") + if not isinstance(profile, str) or profile not in MINING_BUDGET_PROFILES: + profile = "balanced" + enabled = data.get("mining_schedule_enabled", False) + if not isinstance(enabled, bool): + enabled = False + return { + "mining_schedule_enabled": enabled, + "mining_schedule_weekday": weekday, + "mining_budget_profile": profile, + } + + +def set_mining_schedule(enabled: bool, weekday: int, profile: str) -> dict: + """校验并一次写入周度自动 mining 的整组配置。""" + if isinstance(weekday, bool) or not isinstance(weekday, int) or not 0 <= weekday <= 4: + raise ValueError("mining schedule weekday must be between 0 and 4") + if profile not in MINING_BUDGET_PROFILES: + raise ValueError("mining budget profile must be balanced or strict") + result = { + "mining_schedule_enabled": bool(enabled), + "mining_schedule_weekday": weekday, + "mining_budget_profile": profile, + } + save(result) + return result + + def get_review_push_channels() -> list[str]: """复盘推送渠道(多选) — 选定的外部工具列表, 复盘归档后逐个推送。 diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index cac3c68..9844a5c 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -130,18 +130,38 @@ class QuoteSubscriber: self._event.set() +# 落盘节流间隔: last_fetch_ms 仅在进程重启后用于显示"最后获取时间"(运行中读内存值), +# 每 30s 持久化一次足够, 避免 expert 档每秒一轮的全量 preferences 重写磁盘。 +_LAST_FETCH_WRITE_INTERVAL_MS = 30_000.0 +_last_fetch_written_at_ms: float = 0.0 + + def _persist_last_fetch(fetched_at_ms: float) -> None: """把"最后获取"时间戳持久化到 preferences, 使进程重启后仍可显示。 放在锁外调用 (IO); 失败不影响主流程 (内存值已更新, 下次 fetch 再写)。 + 距上次成功落盘不足 30s 时跳过 (节流只影响落盘频率, 内存值不受影响)。 """ + global _last_fetch_written_at_ms + if (fetched_at_ms - _last_fetch_written_at_ms) < _LAST_FETCH_WRITE_INTERVAL_MS: + return try: from app.services import preferences preferences.save({"last_fetch_ms": round(fetched_at_ms, 0)}) + _last_fetch_written_at_ms = fetched_at_ms except Exception as e: # noqa: BLE001 logger.debug("last_fetch_ms 持久化失败 (不影响行情): %s", e) +def _monitor_name_map(repo) -> dict[str, str]: + """监控回填用的 symbol → name 映射 (股票 + ETF + 指数, 股票优先)。 + + 走 repo.get_name_map() 的进程内 memo (三份 instruments 维表刷新时失效), + 避免每轮监控对 ~7000 行维表 iter_rows 重建。过滤空名称与旧行为一致。 + """ + return {s: n for s, n in repo.get_name_map().items() if n} + + class QuoteService: """全局实时行情服务 — 单例。""" @@ -175,6 +195,9 @@ class QuoteService: self._subscribers: set[QuoteSubscriber] = set() self._strategy_monitor = None # 延迟注入 self._app_state = None # 延迟注入 (FastAPI app.state) + # 异动边缘规则上次评估时间戳 (秒)。异动快照历史部分有 60s 缓存, + # 但每次构建仍有全市场循环, 轮询线程里限频到 30s 一次。 + self._abnormal_last_eval = 0.0 # 拉取元信息 (给 SSE / status 用) self._fetch_time: float = 0.0 # perf_counter (用于计算 quote_age_ms) @@ -1081,28 +1104,10 @@ class QuoteService: engine = getattr(self._app_state, "monitor_engine", None) if engine and engine.rule_count > 0: # 预构建 symbol → name 映射 (enriched 已 drop name 列, 引擎触发时回填用)。 - # 含股票 + ETF 维表, 保证 ETF 监控告警也能回填名称。 + # 股票 + ETF + 指数三表合并走 _monitor_name_map -> repo.get_name_map() + # 的进程内 memo, 避免每轮监控对 ~7000 行维表 iter_rows 重建。 try: - name_map: dict[str, str] = {} - inst_df = self._app_state.repo.get_instruments() - if not inst_df.is_empty() and "symbol" in inst_df.columns and "name" in inst_df.columns: - for row in inst_df.select(["symbol", "name"]).iter_rows(named=True): - if row.get("name"): - name_map[row["symbol"]] = row["name"] - # 仅当存在 ETF 规则时补 ETF 维表 (股票名优先, setdefault 不覆盖股票) - if engine.has_asset_rules("etf"): - etf_inst = self._app_state.repo.get_etf_instruments() - if not etf_inst.is_empty() and "symbol" in etf_inst.columns and "name" in etf_inst.columns: - for row in etf_inst.select(["symbol", "name"]).iter_rows(named=True): - if row.get("name"): - name_map.setdefault(row["symbol"], row["name"]) - # 仅当存在指数规则时补指数维表 (setdefault 不覆盖股票/ETF) - if engine.has_asset_rules("index"): - idx_inst = self._app_state.repo.get_instruments_asset("index") - if not idx_inst.is_empty() and "symbol" in idx_inst.columns and "name" in idx_inst.columns: - for row in idx_inst.select(["symbol", "name"]).iter_rows(named=True): - if row.get("name"): - name_map.setdefault(row["symbol"], row["name"]) + name_map = _monitor_name_map(self._app_state.repo) if name_map: engine.set_name_map(name_map) except Exception as e: # noqa: BLE001 @@ -1121,6 +1126,23 @@ class QuoteService: enriched_today if stock_ready else pl.DataFrame(), self.get_index_quotes(), ) + # 异动边缘规则轮: 快照 (enriched 偏离列 + 实时叠加) 由 + # abnormal_moves.build_overview 统一构建, 引擎只做边缘触发判定。 + # 30s 限频 —— 快照历史部分 60s 缓存, 无需跟行情轮询同频重算。 + if engine.has_rule_type("abnormal") and self._repo is not None: + _now_ts = time.time() + if _now_ts - self._abnormal_last_eval >= 30.0: + self._abnormal_last_eval = _now_ts + try: + from app.services import abnormal_moves + _overview = abnormal_moves.build_overview( + self._repo, self, + min_closeness=engine.min_abnormal_closeness(), + limit=1000, + ) + rule_events += engine.evaluate_abnormal(_overview.get("rows") or []) + except Exception as e: # noqa: BLE001 + logger.warning("异动监控规则评估失败 (不影响其他告警): %s", e) # ETF 规则轮: 股票快照不含 ETF, 用 ETF enriched 快照单独评估。 # 独立 try —— ETF 轮任何异常都不得丢弃本轮已算出的股票告警。 # refresh=False —— 不在轮询线程上触发 ETF 冷缓存的同步重算 (缓存由 ETF 实时 @@ -1149,6 +1171,7 @@ class QuoteService: except Exception as e: # noqa: BLE001 logger.warning("指数监控评估失败 (不影响股票/ETF 告警): %s", e) if rule_events: + rule_events = self._format_extension_notifications(rule_events) # 落盘到 alerts.jsonl try: from app.services import alert_store @@ -1179,6 +1202,8 @@ class QuoteService: "sector_source_field", "sector_value", "sector_level", "window_change_pct", "coverage_ratio", "valid_count", "total_count", "up_count", "down_count", "leader", + "abnormal_window", "abnormal_value", "abnormal_threshold", + "abnormal_closeness", ): if key in ev: alert[key] = ev[key] @@ -1207,6 +1232,42 @@ class QuoteService: except Exception as e: # noqa: BLE001 logger.warning("监控评估失败: %s", e) + def _format_extension_notifications(self, events: list[dict]) -> list[dict]: + """Apply optional copy formatters after evaluation and before every output channel.""" + registry = ( + getattr(self._app_state, "extension_registry", None) + if self._app_state is not None + else None + ) + if registry is None or not registry.has_notification_formatters: + return events + + from app.extensions.contracts import ( + BACKEND_EXTENSION_API_VERSION, + NotificationFormatContext, + ) + + formatted_events: list[dict] = [] + for event in events: + formatted = dict(event) + context = NotificationFormatContext( + api_version=BACKEND_EXTENSION_API_VERSION, + ) + for registered in registry.notification_formatters(): + try: + message = registered.implementation.format_message(dict(formatted), context) + if not isinstance(message, str): + raise TypeError("notification formatter must return str") + formatted["message"] = message + except Exception as exc: + logger.warning( + "notification formatter failed %s: %s", + registered.implementation_id, + exc, + ) + formatted_events.append(formatted) + return formatted_events + def _enrich_alerts_ext(self, alerts: list[dict]) -> None: """就地给告警事件按 symbol 追加行业/概念 ext 字段。 @@ -1532,11 +1593,27 @@ class QuoteService: else None ), ) + # momentum_3d 不在指标全集里, 但 deviate_3d 需要; 多日帧上 shift 补算 + enriched_full = enriched_full.sort(["symbol", "date"]).with_columns( + (pl.col("close") / pl.col("close").shift(3).over("symbol") - 1).alias("momentum_3d") + ) enriched_today = enriched_full.filter(pl.col("date") == today) if enriched_today.is_empty(): return + # 异动偏离列: 盘中路径不经过 _refresh_enriched 冷刷新, + # 需在此附着 (基准 = 历史帧 + 指数实时外推), 否则盘中异动列表为空 + if asset_type == "stock": + from app.indicators.pipeline import attach_deviation_columns_today + try: + index_quotes = self.get_index_quotes() + except Exception: + index_quotes = None + enriched_today = attach_deviation_columns_today( + enriched_today, self._repo.store.data_dir, index_quotes + ) + # ---- 写盘 + 更新缓存 ---- if merge: self._repo.merge_live_enriched_asset(asset_type, enriched_today) diff --git a/backend/app/services/regime_builder.py b/backend/app/services/regime_builder.py index 3143779..17605ed 100644 --- a/backend/app/services/regime_builder.py +++ b/backend/app/services/regime_builder.py @@ -150,7 +150,16 @@ def _aggregate_daily(df: pl.DataFrame, index_pct_map: dict | None = None) -> pl. 纯 polars 聚合, 不重算指标(假设 df 已含 signal_*/change_pct/ma20 等列)。 index_pct_map: {date: 指数涨幅} 可选, 由调用方从指数数据预先算好。 + 梯队指标(首板/N板宽度/晋级率)由 market_phase 提供; phase 列不在此算 + (需要完整日序做平滑), 由 refresh_phase_labels 在 upsert 后统一重标。 """ + from app.services.market_phase import ( + finalize_ladder_row, + ladder_daily_aggs, + ladder_promo_aggs, + with_prev_consecutive, + ) + needed = ["date", "change_pct", "amount", "signal_limit_up", "signal_limit_down", "signal_broken_limit_up", "consecutive_limit_ups", "close", "ma20"] @@ -158,6 +167,9 @@ def _aggregate_daily(df: pl.DataFrame, index_pct_map: dict | None = None) -> pl. if "date" not in avail or "change_pct" not in avail: return pl.DataFrame() + if "consecutive_limit_ups" in avail and "symbol" in df.columns: + df = with_prev_consecutive(df) + # 基础聚合 — 全部用 group_by 一次性向量化算出, 避免逐日 filter 扫全表(OOM/超时元凶)。 has_ma20 = "close" in avail and "ma20" in avail grouped = df.group_by("date").agg( @@ -216,6 +228,15 @@ def _aggregate_daily(df: pl.DataFrame, index_pct_map: dict | None = None) -> pl. ] if has_ma20 else [] ), + # 梯队指标(阶段判定所需): 首板/N板宽度/非空档位数; 晋级率需 _prev_consec + *( + ladder_daily_aggs() + if "consecutive_limit_ups" in avail else [] + ), + *( + ladder_promo_aggs() + if "consecutive_limit_ups" in avail and "_prev_consec" in df.columns else [] + ), ).sort("date") # 转成 dict 列表做分类(规则引擎需逐日算, 但只扫 grouped 行数=天数, 不再回扫全表) @@ -289,6 +310,8 @@ def _aggregate_daily(df: pl.DataFrame, index_pct_map: dict | None = None) -> pl. "speculation_score": round(sub["speculation"]), "resilience_score": round(sub["resilience"]), "trend_score": round(sub["trend"]), + # 梯队指标(阶段判定所需); phase 由 refresh_phase_labels 统一重标 + **finalize_ladder_row(r), }) return pl.DataFrame(rows) if rows else pl.DataFrame() @@ -325,6 +348,10 @@ def _compute_batch(repo, enriched_dir, instruments, historical_shares, needed={"signal_limit_up", "signal_limit_down", "signal_broken_limit_up"}, historical_shares=historical_shares, ) + # 晋级率需要昨日连板数: 在裁掉 warmup 之前先按 symbol 平移, + # 保证每批首日的 _prev_consec 来自 warmup 的最后一个交易日而非 null。 + from app.services.market_phase import with_prev_consecutive + df = with_prev_consecutive(df) # 丢弃 warmup 行, 只留目标区间 return df.filter((pl.col("date") >= batch_start) & (pl.col("date") <= batch_end)) @@ -422,6 +449,23 @@ def run_regime_batch(repo, start: date, end: date) -> pl.DataFrame: logger.info("regime batch: no enriched data for [%s~%s]", start, end) return pl.DataFrame() + # 口径: 默认剔除风险警示(ST)股(与主线统计同一开关) — 主板 ST 在 2026-07 前 + # 享 5% 涨跌幅且是跨行业状态桶, 混入会系统性抬高涨停宽度/高度(弱市炒 ST 尤甚)。 + # 涨跌家数/MA20 占比等宽度指标几乎不受影响。切换口径需全量重算 regime。 + try: + from app.services import preferences as _prefs_st + exclude_st = _prefs_st.get_sentiment_exclude_st() + except Exception: + exclude_st = True + if exclude_st: + from app.services.market_mainline import load_risk_warning_symbols + + st_syms = load_risk_warning_symbols(repo.store.data_dir) + if st_syms and "symbol" in df.columns: + df = df.filter( + ~pl.col("symbol").str.to_uppercase().is_in(sorted(st_syms)) + ) + return _aggregate_daily(df, index_pct_map) @@ -446,6 +490,28 @@ def load_regime_history(data_dir: Path) -> pl.DataFrame: return pl.DataFrame() +def refresh_phase_labels(data_dir: Path) -> int: + """对全量 regime 时序重标情绪周期阶段(冰点/启动/主升/高潮/退潮/修复)。 + + 阶段判定需要完整日序(EMA 平滑 + 持续性确认), 不能在单批内完成, + 因此每次 upsert 后调用本函数整体重标并写回。行数为天数(千级), 开销可忽略。 + 返回标注的天数; 阶段列缺失所需指标(旧 schema 未重算)时返回 0。 + """ + from app.services.market_phase import classify_phase_series + + df = load_regime_history(data_dir) + required = {"date", "max_consecutive", "first_board", "ge2_count", "promo_rate", "seal_rate"} + if df.is_empty() or not required.issubset(df.columns): + return 0 + try: + labeled = classify_phase_series(df) + except Exception as e: + logger.warning("refresh_phase_labels failed: %s", e) + return 0 + labeled.write_parquet(regime_path(data_dir)) + return labeled.height + + def upsert_regime_history(data_dir: Path, new_rows: pl.DataFrame) -> None: """按 date 覆盖(upsert): 重算的天覆盖旧行, 新天追加。 @@ -526,6 +592,23 @@ def detect_stale_dates(data_dir: Path, repo) -> list[date]: return sorted(stale) +def latest_phase_transition(data_dir: Path) -> tuple[str, str, str] | None: + """读取 regime 时序末两日, 返回最近一次阶段切换 (prev, new, 日期str)。 + + 末两日阶段相同(或数据不足/无阶段列)返回 None。供盘后管道推送阶段切换通知。 + """ + hist = load_regime_history(data_dir) + if hist.is_empty() or "phase" not in hist.columns: + return None + tail = hist.select(["date", "phase"]).sort("date").tail(2) + if tail.height < 2: + return None + prev_phase, cur_phase = tail["phase"].to_list() + if not prev_phase or not cur_phase or prev_phase == cur_phase: + return None + return prev_phase, cur_phase, str(tail["date"][-1]) + + def compute_regime_incremental(repo, data_dir: Path, *, today: date | None = None) -> pl.DataFrame: """增量计算 regime(供 daily_pipeline / 启动补算调用)。 @@ -553,6 +636,7 @@ def compute_regime_incremental(repo, data_dir: Path, *, today: date | None = Non new_rows = run_regime_batch(repo, start=to_compute[0], end=to_compute[-1]) if not new_rows.is_empty(): upsert_regime_history(data_dir, new_rows) + refresh_phase_labels(data_dir) return new_rows diff --git a/backend/app/services/stock_analyzer.py b/backend/app/services/stock_analyzer.py index 0c2af8e..949054e 100644 --- a/backend/app/services/stock_analyzer.py +++ b/backend/app/services/stock_analyzer.py @@ -315,14 +315,18 @@ async def analyze_stock_stream( kline_tail = _clean_rows(df, _KLINE_KEEP_COLS) user_prompt = _build_user_prompt(kline_tail, fins, levels, close, symbol, focus, asset_type=repo.resolve_asset_type(symbol)) + got_content = False async for delta in stream_ai_text( [ {"role": "system", "content": _SYSTEM_PROMPT}, {"role": "user", "content": user_prompt}, ], temperature=0.5, - max_tokens=4500, + # 不限制输出: 推理模型(deepseek reasoner 系)思考 token 计入 max_tokens + # 预算, 固定上限会把正文挤光(实测 4500 全被推理吃掉 → 正文 0 字)。 + max_tokens=None, ): + got_content = True yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False) except Exception as e: # noqa: BLE001 @@ -330,4 +334,9 @@ async def analyze_stock_stream( yield json.dumps({"type": "error", "message": f"AI 分析失败: {e}"}, ensure_ascii=False) return + if not got_content: + # 流正常结束但一个正文块都没有(典型: 输出上限被思考吃光后静默截断) + logger.warning("AI stock analysis ended with empty content for %s", symbol) + yield json.dumps({"type": "error", "message": "AI 未返回正文(输出被截断), 请重试"}, ensure_ascii=False) + return yield json.dumps({"type": "done"}, ensure_ascii=False) diff --git a/backend/app/services/watchlist.py b/backend/app/services/watchlist.py index 476a56c..9ad81e7 100644 --- a/backend/app/services/watchlist.py +++ b/backend/app/services/watchlist.py @@ -1,10 +1,23 @@ -"""自选股服务(§6.1)。 +"""自选股与分组服务。 -存储:`data/user_data/watchlist.parquet`,字段 symbol + added_at + note。 +自选存储于 ``data/user_data/watchlist.parquet``,分组定义存储于同目录的 +``watchlist_groups.json``。 + +成员关系为多值 (M:N): 每条自选带 ``group_ids: list[str]``, 同一标的可同时 +属于多个分组; 移出分组只摘标签(标的仍在自选), 移出自选才删除实体。 +旧 schema (单值 ``group_id`` 列) 读取时自动迁移为 ``[group_id]``, 首次写回 +新 schema 前留一份 ``watchlist.parquet.bak`` 备份。 """ from __future__ import annotations +import json import logging +import os +import shutil +import threading +import uuid +from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import TimeoutError as FuturesTimeout from datetime import datetime from pathlib import Path @@ -17,6 +30,38 @@ from app.tickflow.rate_limits import chunked, resolve_limit logger = logging.getLogger(__name__) +_LOCK = threading.RLock() +# 数据版本号: 每次写盘 +1 (在 _LOCK 内递增, 读取免锁)。供监控引擎等进程内 +# 消费方做缓存失效判断 —— 版本没变就不必重读文件, 版本一变立即拿到新成员。 +_REVISION = 0 + + +def revision() -> int: + """自选/分组数据版本号, 每次写操作递增。""" + return _REVISION +_MAX_GROUP_NAME_LENGTH = 24 +DEFAULT_GROUP_COLOR = "sky" +GROUP_COLORS = frozenset({ + "sky", + "blue", + "indigo", + "violet", + "fuchsia", + "rose", + "orange", + "amber", + "lime", + "emerald", + "teal", + "cyan", +}) +_ENTRY_SCHEMA = { + "symbol": pl.Utf8, + "added_at": pl.Utf8, + "note": pl.Utf8, + "group_ids": pl.List(pl.Utf8), +} + def _path() -> Path: p = settings.data_dir / "user_data" / "watchlist.parquet" @@ -24,70 +69,319 @@ def _path() -> Path: return p -def list_symbols() -> list[dict]: +def _groups_path() -> Path: + p = settings.data_dir / "user_data" / "watchlist_groups.json" + p.parent.mkdir(parents=True, exist_ok=True) + return p + + +def _empty_entries() -> pl.DataFrame: + return pl.DataFrame(schema=_ENTRY_SCHEMA) + + +def _read_entries() -> pl.DataFrame: p = _path() if not p.exists(): - return [] + return _empty_entries() df = pl.read_parquet(p) - if df.is_empty(): - return [] - return df.to_dicts() + # 旧 schema 兼容: 单值 group_id → group_ids=[gid]; 两列都缺 → 空列表 + if "group_ids" not in df.columns: + old = df["group_id"].to_list() if "group_id" in df.columns else [None] * df.height + df = df.with_columns( + pl.Series("group_ids", [[g] if g else [] for g in old], dtype=pl.List(pl.Utf8)) + ).drop("group_id", strict=False) + if "symbol" not in df.columns: + df = df.with_columns(pl.lit("", dtype=pl.Utf8).alias("symbol")) + if "added_at" not in df.columns: + df = df.with_columns(pl.lit("", dtype=pl.Utf8).alias("added_at")) + if "note" not in df.columns: + df = df.with_columns(pl.lit(None, dtype=pl.Utf8).alias("note")) + return df.select(list(_ENTRY_SCHEMA)) -def add(symbol: str, note: str = "") -> list[dict]: +def _write_entries(df: pl.DataFrame) -> None: + global _REVISION p = _path() + # 首次从旧 schema 迁移到 group_ids 前, 备份原文件(一次性) if p.exists(): - df = pl.read_parquet(p) - # 已存在则先移除,后面重新插入到最前面 - if symbol in df["symbol"].to_list(): - df = df.filter(pl.col("symbol") != symbol) - else: - df = pl.DataFrame(schema={"symbol": pl.Utf8, "added_at": pl.Utf8, "note": pl.Utf8}) + try: + if "group_ids" not in pl.read_parquet_schema(p).names(): + shutil.copy(p, p.with_suffix(p.suffix + ".bak")) + except OSError as e: + logger.warning("watchlist backup before migration failed: %s", e) + tmp = p.with_suffix(p.suffix + ".tmp") + df.select(list(_ENTRY_SCHEMA)).write_parquet(tmp) + os.replace(tmp, p) + _REVISION += 1 - new_row = pl.DataFrame({ - "symbol": [symbol], - "added_at": [datetime.utcnow().isoformat(timespec="seconds")], - "note": [note], - }) - out = pl.concat([new_row, df], how="diagonal_relaxed") - out.write_parquet(p) - return out.to_dicts() + +def _read_groups() -> list[dict]: + p = _groups_path() + if not p.exists(): + return [] + try: + raw = json.loads(p.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError("自选分组配置损坏,请检查 watchlist_groups.json") from exc + if not isinstance(raw, list): + raise ValueError("自选分组配置格式不正确") + groups = [] + for item in raw: + if not isinstance(item, dict) or not item.get("id") or not item.get("name"): + continue + color = str(item.get("color", DEFAULT_GROUP_COLOR)) + groups.append({ + "id": str(item["id"]), + "name": str(item["name"]), + "color": color if color in GROUP_COLORS else DEFAULT_GROUP_COLOR, + }) + return groups + + +def _write_groups(groups: list[dict]) -> None: + global _REVISION + p = _groups_path() + tmp = p.with_suffix(p.suffix + ".tmp") + tmp.write_text(json.dumps(groups, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, p) + _REVISION += 1 + + +def _normalize_group_name(name: str) -> str: + normalized = name.strip() + if not normalized: + raise ValueError("分组名称不能为空") + if len(normalized) > _MAX_GROUP_NAME_LENGTH: + raise ValueError(f"分组名称不能超过 {_MAX_GROUP_NAME_LENGTH} 个字符") + return normalized + + +def _normalize_group_color(color: str | None) -> str: + normalized = (color or DEFAULT_GROUP_COLOR).strip().lower() + if normalized not in GROUP_COLORS: + raise ValueError("不支持的分组颜色") + return normalized + + +def _validate_group_id(group_id: str | None, groups: list[dict]) -> None: + if group_id is not None and not any(group["id"] == group_id for group in groups): + raise ValueError("自选分组不存在") + + +def list_symbols() -> list[dict]: + with _LOCK: + df = _read_entries() + return [] if df.is_empty() else df.to_dicts() + + +def add(symbol: str, note: str = "", group_id: str | None = None) -> list[dict]: + rows, _ = add_batch([symbol], note=note, group_id=group_id) + return rows + + +def add_batch( + symbols: list[str], + note: str = "", + group_id: str | None = None, +) -> tuple[list[dict], int]: + """批量添加并保持既有语义:每个新处理的标的移动到列表最前面。 + + group_id 为可选的初始分组(如从某分组页添加时); 重复添加的标的保留 + 既有全部分组, 仅在显式传入 group_id 且尚未属于该组时并入。 + """ + with _LOCK: + groups = _read_groups() + _validate_group_id(group_id, groups) + rows = _read_entries().to_dicts() + added = 0 + for symbol in symbols: + existing = next((row for row in rows if row["symbol"] == symbol), None) + if existing is None: + added += 1 + rows = [row for row in rows if row["symbol"] != symbol] + gids = list((existing or {}).get("group_ids") or []) + if group_id is not None and group_id not in gids: + gids.append(group_id) + rows.insert(0, { + "symbol": symbol, + "added_at": datetime.utcnow().isoformat(timespec="seconds"), + "note": note, + "group_ids": gids, + }) + out = pl.DataFrame(rows, schema=_ENTRY_SCHEMA) if rows else _empty_entries() + _write_entries(out) + return out.to_dicts(), added def remove(symbol: str) -> list[dict]: - p = _path() - if not p.exists(): - return [] - df = pl.read_parquet(p) - df = df.filter(pl.col("symbol") != symbol) - df.write_parquet(p) - return df.to_dicts() + with _LOCK: + df = _read_entries().filter(pl.col("symbol") != symbol) + _write_entries(df) + return df.to_dicts() def move_to_top(symbol: str) -> list[dict]: - p = _path() - if not p.exists(): - return [] - df = pl.read_parquet(p) - if df.is_empty() or symbol not in df["symbol"].to_list(): - return df.to_dicts() - target = df.filter(pl.col("symbol") == symbol) - rest = df.filter(pl.col("symbol") != symbol) - out = pl.concat([target, rest], how="diagonal_relaxed") - out.write_parquet(p) - return out.to_dicts() + with _LOCK: + df = _read_entries() + if df.is_empty() or symbol not in df["symbol"].to_list(): + return df.to_dicts() + target = df.filter(pl.col("symbol") == symbol) + rest = df.filter(pl.col("symbol") != symbol) + out = pl.concat([target, rest], how="diagonal_relaxed") + _write_entries(out) + return out.to_dicts() def clear() -> int: """清空自选列表。返回移除的数量。""" - p = _path() - if not p.exists(): - return 0 - df = pl.read_parquet(p) - count = df.height - if count > 0: - pl.DataFrame(schema={"symbol": pl.Utf8, "added_at": pl.Utf8, "note": pl.Utf8}).write_parquet(p) - return count + with _LOCK: + df = _read_entries() + count = df.height + if count > 0: + _write_entries(_empty_entries()) + return count + + +def list_groups() -> list[dict]: + with _LOCK: + return _read_groups() + + +def create_group(name: str, color: str | None = None) -> tuple[list[dict], dict]: + with _LOCK: + normalized = _normalize_group_name(name) + normalized_color = _normalize_group_color(color) + groups = _read_groups() + if any(group["name"].casefold() == normalized.casefold() for group in groups): + raise ValueError("分组名称已存在") + group = { + "id": uuid.uuid4().hex, + "name": normalized, + "color": normalized_color, + } + groups.append(group) + _write_groups(groups) + return groups, group + + +def rename_group(group_id: str, name: str, color: str | None = None) -> list[dict]: + with _LOCK: + normalized = _normalize_group_name(name) + groups = _read_groups() + target = next((group for group in groups if group["id"] == group_id), None) + if target is None: + raise KeyError(group_id) + if any( + group["id"] != group_id and group["name"].casefold() == normalized.casefold() + for group in groups + ): + raise ValueError("分组名称已存在") + target["name"] = normalized + if color is not None: + target["color"] = _normalize_group_color(color) + _write_groups(groups) + return groups + + +def reorder_groups(ordered_ids: list[str]) -> list[dict]: + """按给定 id 顺序重排分组 (json 数组顺序即定义顺序)。""" + with _LOCK: + groups = _read_groups() + by_id = {group["id"]: group for group in groups} + if len(ordered_ids) != len(groups) or set(ordered_ids) != set(by_id): + raise ValueError("分组顺序与现有分组不一致") + reordered = [by_id[group_id] for group_id in ordered_ids] + _write_groups(reordered) + return reordered + + +def delete_group(group_id: str) -> tuple[list[dict], list[dict]]: + """删除分组定义,原分组内的自选保留并转为未分组(仅摘掉该组标签)。""" + with _LOCK: + groups = _read_groups() + if not any(group["id"] == group_id for group in groups): + raise KeyError(group_id) + df = _strip_group(_read_entries(), group_id) + remaining = [group for group in groups if group["id"] != group_id] + _write_entries(df) + _write_groups(remaining) + return remaining, df.to_dicts() + + +def set_group(symbol: str, group_id: str | None) -> list[dict]: + """互斥设定: 该标的只保留这一个分组(group_id=None 即全部移出, 变未分组)。 + + 多组模型的日常操作走 add_to_group / remove_from_group; 本函数服务于 + 「仅保留此组」的显式场景。 + """ + with _LOCK: + groups = _read_groups() + _validate_group_id(group_id, groups) + rows = _read_entries().to_dicts() + if not any(row["symbol"] == symbol for row in rows): + raise KeyError(symbol) + for row in rows: + if row["symbol"] == symbol: + row["group_ids"] = [group_id] if group_id is not None else [] + out = pl.DataFrame(rows, schema=_ENTRY_SCHEMA) + _write_entries(out) + return out.to_dicts() + + +def add_to_group(symbol: str, group_id: str) -> list[dict]: + """把标的加入一个分组(多组成员关系: 不影响已属于的其他分组)。""" + with _LOCK: + groups = _read_groups() + _validate_group_id(group_id, groups) + rows = _read_entries().to_dicts() + if not any(row["symbol"] == symbol for row in rows): + raise KeyError(symbol) + for row in rows: + if row["symbol"] == symbol: + gids = row["group_ids"] or [] + if group_id not in gids: + gids.append(group_id) + row["group_ids"] = gids + out = pl.DataFrame(rows, schema=_ENTRY_SCHEMA) + _write_entries(out) + return out.to_dicts() + + +def remove_from_group(symbol: str, group_id: str) -> list[dict]: + """把标的移出一个分组(仅摘本组标签; 标的仍在自选, 可能落入未分组)。""" + with _LOCK: + groups = _read_groups() + _validate_group_id(group_id, groups) + rows = _read_entries().to_dicts() + if not any(row["symbol"] == symbol for row in rows): + raise KeyError(symbol) + for row in rows: + if row["symbol"] == symbol: + row["group_ids"] = [g for g in (row["group_ids"] or []) if g != group_id] + out = pl.DataFrame(rows, schema=_ENTRY_SCHEMA) + _write_entries(out) + return out.to_dicts() + + +def _strip_group(df: pl.DataFrame, group_id: str) -> pl.DataFrame: + """从所有条目的 group_ids 中摘掉指定分组(删除分组/清空分组共用)。""" + rows = df.to_dicts() + for row in rows: + gids = row.get("group_ids") or [] + if group_id in gids: + row["group_ids"] = [g for g in gids if g != group_id] + return pl.DataFrame(rows, schema=_ENTRY_SCHEMA) if rows else _empty_entries() + + +def clear_group(group_id: str) -> list[dict]: + """清空分组成员:把该分组标签从所有条目摘掉(变未分组),保留分组定义。""" + with _LOCK: + groups = _read_groups() + if not any(group["id"] == group_id for group in groups): + raise KeyError(group_id) + df = _strip_group(_read_entries(), group_id) + _write_entries(df) + return df.to_dicts() def fetch_quotes(symbols: list[str], capset: CapabilitySet, timeout_s: float = 8.0) -> list[dict]: @@ -96,8 +390,6 @@ def fetch_quotes(symbols: list[str], capset: CapabilitySet, timeout_s: float = 8 优先用 quote.batch;否则降级为 quote.by_symbol 单股请求。 timeout_s: 单批次请求超时(秒),防止 API 卡死阻塞整个请求。 """ - from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout - if not symbols: return [] diff --git a/backend/app/strategy/ai_generator.py b/backend/app/strategy/ai_generator.py index 648cb69..0e3dba9 100644 --- a/backend/app/strategy/ai_generator.py +++ b/backend/app/strategy/ai_generator.py @@ -343,6 +343,7 @@ META = {{...}},{entrypoint_requirement}。只输出完整 Python 代码。 "polars", "numpy", "app.backtest.matrix", + "app.strategy.builtin.factor_rank_research", "datetime", "__future__", }) diff --git a/backend/app/strategy/builtin/boll_breakout.py b/backend/app/strategy/builtin/boll_breakout.py index 87b809a..94c5336 100644 --- a/backend/app/strategy/builtin/boll_breakout.py +++ b/backend/app/strategy/builtin/boll_breakout.py @@ -40,7 +40,6 @@ ENTRY_SIGNALS = ["signal_boll_breakout_upper"] EXIT_SIGNALS = ["signal_boll_breakdown_lower"] STOP_LOSS = -0.06 MAX_HOLD_DAYS = 15 -ALERTS = [] class BollBreakoutMatrixStrategy: diff --git a/backend/app/strategy/builtin/broken_board_recovery.py b/backend/app/strategy/builtin/broken_board_recovery.py index d45f191..d71170c 100644 --- a/backend/app/strategy/builtin/broken_board_recovery.py +++ b/backend/app/strategy/builtin/broken_board_recovery.py @@ -53,7 +53,6 @@ ENTRY_SIGNALS = ["signal_limit_up"] EXIT_SIGNALS = ["signal_ma20_breakdown"] STOP_LOSS = -0.06 MAX_HOLD_DAYS = 10 -ALERTS = [] class BrokenBoardRecoveryMatrixStrategy: diff --git a/backend/app/strategy/builtin/bullish_alignment.py b/backend/app/strategy/builtin/bullish_alignment.py index fb05474..2bf70b5 100644 --- a/backend/app/strategy/builtin/bullish_alignment.py +++ b/backend/app/strategy/builtin/bullish_alignment.py @@ -44,7 +44,6 @@ ENTRY_SIGNALS = ["signal_ma_golden_5_20", "signal_ma_golden_20_60"] EXIT_SIGNALS = ["signal_ma_dead_5_20", "signal_ma20_breakdown"] STOP_LOSS = -0.06 MAX_HOLD_DAYS = 20 -ALERTS = [] class BullishAlignmentMatrixStrategy: diff --git a/backend/app/strategy/builtin/consecutive_limit_ups.py b/backend/app/strategy/builtin/consecutive_limit_ups.py index 7bc234c..c8c9bfa 100644 --- a/backend/app/strategy/builtin/consecutive_limit_ups.py +++ b/backend/app/strategy/builtin/consecutive_limit_ups.py @@ -35,7 +35,6 @@ ENTRY_SIGNALS = ["signal_limit_up"] EXIT_SIGNALS = [] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 5 -ALERTS = [] class ConsecutiveLimitUpsMatrixStrategy: diff --git a/backend/app/strategy/builtin/factor_rank_research.py b/backend/app/strategy/builtin/factor_rank_research.py new file mode 100644 index 0000000..bfe1ae3 --- /dev/null +++ b/backend/app/strategy/builtin/factor_rank_research.py @@ -0,0 +1,209 @@ +"""Fixed matrix-native strategy for controlled factor-rank research.""" +from __future__ import annotations + +import numpy as np + +from app.backtest.matrix import ( + MarketDataMatrix, + SignalMatrix, + build_matrix_score, + make_signal_matrix, +) + +META = { + "id": "factor_rank_research", + "name": "因子排名研究", + "description": "受控多因子截面评分、阈值与排名选股策略", + "tags": ["因子", "研究", "截面排名"], + "asset_types": ["stock", "etf"], + "timeframes": ["1d"], + "research_only": True, + "params": [ + { + "id": "entry_score", + "label": "入场最低分", + "type": "float", + "default": 70.0, + "min": 0.0, + "max": 100.0, + "step": 5.0, + }, + { + "id": "exit_score", + "label": "离场最高分", + "type": "float", + "default": 40.0, + "min": 0.0, + "max": 100.0, + "step": 5.0, + }, + { + "id": "top_rank", + "label": "每日最多入选", + "type": "int", + "default": 20, + "min": 1, + "max": 100, + "step": 1, + }, + ], + # Research-generated scoring is supplied in params. Keeping META scoring + # empty prevents the framework pipeline from replacing the strategy score. + "scoring": {}, + "order_by": "score", + "descending": True, + "limit": 100, +} + +EXECUTION_BACKEND = "matrix_native" +ENTRY_SIGNALS = ["signal_factor_rank_entry"] +EXIT_SIGNALS = ["signal_factor_rank_exit"] +STOP_LOSS = -0.08 +MAX_HOLD_DAYS = 30 + +_MAX_FACTORS = 4 +_VALID_DIRECTIONS = {"high", "low"} + + +class FactorRankResearchMatrixStrategy: + def __init__( + self, + scoring: dict[str, float] | None = None, + directions: dict[str, str] | None = None, + ) -> None: + self._scoring = _validated_scoring(scoring) if scoring is not None else None + self._directions = ( + _validated_directions(directions, self._scoring) + if directions is not None and self._scoring is not None + else None + ) + + def required_fields(self) -> frozenset[str]: + return frozenset({ + "open", + "high", + "low", + "close", + "volume", + "amount", + "turnover_rate", + }) + + def required_warmup_bars(self, params: dict) -> int: + del params + return 60 + + def required_fields_for_params(self, params: dict) -> frozenset[str]: + if self._scoring is not None: + return frozenset(self._scoring) + raw = params.get("scoring") + if raw is None: + return frozenset() + return frozenset(_validated_scoring(raw)) + + def compute_signals(self, market: MarketDataMatrix, params: dict) -> SignalMatrix: + scoring = self._scoring or _validated_scoring(params.get("scoring")) + directions = self._directions or _validated_directions( + params.get("directions"), scoring + ) + entry_score = _bounded_float(params.get("entry_score", 70.0), "entry_score") + exit_score = _bounded_float(params.get("exit_score", 40.0), "exit_score") + top_rank = int(params.get("top_rank", 20)) + if not 1 <= top_rank <= 100: + raise ValueError("top_rank must be between 1 and 100") + if exit_score > entry_score: + raise ValueError("exit_score must not exceed entry_score") + + universe = np.isfinite(market.close) + score = build_matrix_score( + market, + universe, + scoring, + "score", + True, + fallback=np.zeros(market.shape, dtype=np.float32), + directions=directions, + ) + entry = universe & (score >= np.float32(entry_score)) + entry = _limit_top_rank(entry, score, top_rank) + exit_ = universe & (score <= np.float32(exit_score)) + return make_signal_matrix( + market.shape, + entry=entry.astype(np.uint8), + exit=exit_.astype(np.uint8), + score=score, + entry_signal_code=np.where(entry, 0, -1).astype(np.int16), + exit_signal_code=np.where(exit_, 0, -1).astype(np.int16), + entry_signal_ids=("signal_factor_rank_entry",), + exit_signal_ids=("signal_factor_rank_exit",), + ) + + +def _validated_scoring(raw: object) -> dict[str, float]: + if not isinstance(raw, dict) or not raw: + raise ValueError("factor-rank research requires a non-empty scoring mapping") + if len(raw) > _MAX_FACTORS: + raise ValueError(f"factor-rank research supports at most {_MAX_FACTORS} factors") + scoring: dict[str, float] = {} + for name, value in raw.items(): + if not isinstance(name, str) or not name: + raise ValueError("scoring factor names must be non-empty strings") + try: + weight = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"scoring weight for {name!r} must be numeric") from exc + if not np.isfinite(weight) or weight <= 0.0: + raise ValueError(f"scoring weight for {name!r} must be finite and positive") + scoring[name] = weight + return scoring + + +def _validated_directions( + raw: object, + scoring: dict[str, float], +) -> dict[str, str]: + if raw is None: + return {} + if not isinstance(raw, dict): + raise ValueError("directions must be a mapping") + unknown = sorted(set(raw) - set(scoring)) + if unknown: + raise ValueError(f"directions contain factors absent from scoring: {unknown}") + directions: dict[str, str] = {} + for name, value in raw.items(): + if value not in _VALID_DIRECTIONS: + raise ValueError( + f"direction for {name!r} must be one of {sorted(_VALID_DIRECTIONS)}" + ) + directions[str(name)] = str(value) + return directions + + +def _bounded_float(value: object, name: str) -> float: + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must be numeric") from exc + if not np.isfinite(number) or not 0.0 <= number <= 100.0: + raise ValueError(f"{name} must be between 0 and 100") + return number + + +def _limit_top_rank( + eligible: np.ndarray, + score: np.ndarray, + top_rank: int, +) -> np.ndarray: + result = np.zeros(eligible.shape, dtype=bool) + for time_id in range(eligible.shape[0]): + asset_ids = np.flatnonzero(eligible[time_id]) + if asset_ids.size <= top_rank: + result[time_id, asset_ids] = True + continue + # mergesort preserves asset-axis order for equal scores. + order = np.argsort(-score[time_id, asset_ids], kind="stable")[:top_rank] + result[time_id, asset_ids[order]] = True + return result + + +MATRIX_STRATEGY = FactorRankResearchMatrixStrategy() diff --git a/backend/app/strategy/builtin/high_turnover_surge.py b/backend/app/strategy/builtin/high_turnover_surge.py index e4eaee2..9b42369 100644 --- a/backend/app/strategy/builtin/high_turnover_surge.py +++ b/backend/app/strategy/builtin/high_turnover_surge.py @@ -52,7 +52,6 @@ ENTRY_SIGNALS = ["signal_volume_surge"] EXIT_SIGNALS = ["signal_ma20_breakdown"] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 10 -ALERTS = [] class HighTurnoverSurgeMatrixStrategy: diff --git a/backend/app/strategy/builtin/limit_up_momentum.py b/backend/app/strategy/builtin/limit_up_momentum.py index 7f4b52a..1cb7de2 100644 --- a/backend/app/strategy/builtin/limit_up_momentum.py +++ b/backend/app/strategy/builtin/limit_up_momentum.py @@ -44,7 +44,6 @@ ENTRY_SIGNALS = ["signal_limit_up"] EXIT_SIGNALS = [] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 5 -ALERTS = [] class LimitUpMomentumMatrixStrategy: diff --git a/backend/app/strategy/builtin/low_volatility_leader.py b/backend/app/strategy/builtin/low_volatility_leader.py index f750f49..167dd49 100644 --- a/backend/app/strategy/builtin/low_volatility_leader.py +++ b/backend/app/strategy/builtin/low_volatility_leader.py @@ -54,7 +54,6 @@ ENTRY_SIGNALS = ["signal_ma20_breakout"] EXIT_SIGNALS = ["signal_ma20_breakdown"] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 30 -ALERTS = [] class LowVolatilityLeaderMatrixStrategy: diff --git a/backend/app/strategy/builtin/ma_golden_cross.py b/backend/app/strategy/builtin/ma_golden_cross.py index fe4f57b..82500ac 100644 --- a/backend/app/strategy/builtin/ma_golden_cross.py +++ b/backend/app/strategy/builtin/ma_golden_cross.py @@ -49,7 +49,6 @@ ENTRY_SIGNALS = ["signal_ma_golden_5_20"] EXIT_SIGNALS = ["signal_ma_dead_5_20"] STOP_LOSS = -0.06 MAX_HOLD_DAYS = 15 -ALERTS = [] class MAGoldenCrossMatrixStrategy: diff --git a/backend/app/strategy/builtin/macd_golden.py b/backend/app/strategy/builtin/macd_golden.py index 0785ef7..a599a4a 100644 --- a/backend/app/strategy/builtin/macd_golden.py +++ b/backend/app/strategy/builtin/macd_golden.py @@ -46,7 +46,6 @@ EXIT_SIGNALS = ["signal_macd_dead"] EXECUTION_BACKEND = "matrix_native" STOP_LOSS = -0.07 MAX_HOLD_DAYS = 20 -ALERTS = [] class MACDGoldenMatrixStrategy: diff --git a/backend/app/strategy/builtin/n_day_low_reversal.py b/backend/app/strategy/builtin/n_day_low_reversal.py index f263c22..eb68c7d 100644 --- a/backend/app/strategy/builtin/n_day_low_reversal.py +++ b/backend/app/strategy/builtin/n_day_low_reversal.py @@ -44,7 +44,6 @@ ENTRY_SIGNALS = ["signal_n_day_low"] EXIT_SIGNALS = ["signal_ma20_breakdown"] STOP_LOSS = -0.06 MAX_HOLD_DAYS = 15 -ALERTS = [] class NDayLowReversalMatrixStrategy: diff --git a/backend/app/strategy/builtin/near_limit_up.py b/backend/app/strategy/builtin/near_limit_up.py index 467ab91..7e5efdc 100644 --- a/backend/app/strategy/builtin/near_limit_up.py +++ b/backend/app/strategy/builtin/near_limit_up.py @@ -57,7 +57,6 @@ ENTRY_SIGNALS = [] EXIT_SIGNALS = ["signal_ma20_breakdown"] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 5 -ALERTS = [] class NearLimitUpMatrixStrategy: diff --git a/backend/app/strategy/builtin/oversold_bounce.py b/backend/app/strategy/builtin/oversold_bounce.py index c187996..5365533 100644 --- a/backend/app/strategy/builtin/oversold_bounce.py +++ b/backend/app/strategy/builtin/oversold_bounce.py @@ -53,9 +53,6 @@ ENTRY_SIGNALS = [] EXIT_SIGNALS = ["signal_ma20_breakdown"] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 15 -ALERTS = [ - {"field": "rsi_14", "op": "<", "value": 25, "message": "RSI极度超卖"}, -] class OversoldBounceMatrixStrategy: diff --git a/backend/app/strategy/builtin/oversold_reversal.py b/backend/app/strategy/builtin/oversold_reversal.py index 4334e63..afca00d 100644 --- a/backend/app/strategy/builtin/oversold_reversal.py +++ b/backend/app/strategy/builtin/oversold_reversal.py @@ -58,9 +58,6 @@ ENTRY_SIGNALS = [] EXIT_SIGNALS = ["signal_ma20_breakdown"] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 15 -ALERTS = [ - {"field": "rsi_14", "op": "<", "value": 25, "message": "RSI极度超卖"}, -] class OversoldReversalMatrixStrategy: diff --git a/backend/app/strategy/builtin/pullback_ma20_bounce.py b/backend/app/strategy/builtin/pullback_ma20_bounce.py index e7491c9..3412ede 100644 --- a/backend/app/strategy/builtin/pullback_ma20_bounce.py +++ b/backend/app/strategy/builtin/pullback_ma20_bounce.py @@ -49,7 +49,6 @@ ENTRY_SIGNALS = ["signal_ma_golden_5_20"] EXIT_SIGNALS = ["signal_ma20_breakdown", "signal_ma_dead_5_20"] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 15 -ALERTS = [] class PullbackMA20BounceMatrixStrategy: diff --git a/backend/app/strategy/builtin/pullback_to_support.py b/backend/app/strategy/builtin/pullback_to_support.py index 07fe8b1..792aae5 100644 --- a/backend/app/strategy/builtin/pullback_to_support.py +++ b/backend/app/strategy/builtin/pullback_to_support.py @@ -64,7 +64,6 @@ ENTRY_SIGNALS = ["signal_ma_golden_5_20"] EXIT_SIGNALS = ["signal_ma20_breakdown"] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 20 -ALERTS = [] class PullbackToSupportMatrixStrategy: diff --git a/backend/app/strategy/builtin/strong_open.py b/backend/app/strategy/builtin/strong_open.py index 537a25e..e00b663 100644 --- a/backend/app/strategy/builtin/strong_open.py +++ b/backend/app/strategy/builtin/strong_open.py @@ -58,7 +58,6 @@ ENTRY_SIGNALS = [] EXIT_SIGNALS = ["signal_ma20_breakdown"] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 10 -ALERTS = [] class StrongOpenMatrixStrategy: diff --git a/backend/app/strategy/builtin/trend_breakout.py b/backend/app/strategy/builtin/trend_breakout.py index a2824ff..6048adf 100644 --- a/backend/app/strategy/builtin/trend_breakout.py +++ b/backend/app/strategy/builtin/trend_breakout.py @@ -57,9 +57,6 @@ ENTRY_SIGNALS = ["signal_n_day_high"] EXIT_SIGNALS = ["signal_ma20_breakdown"] STOP_LOSS = -0.08 MAX_HOLD_DAYS = 20 -ALERTS = [ - {"field": "signal_volume_surge", "message": "放量异动"}, -] class TrendBreakoutMatrixStrategy: diff --git a/backend/app/strategy/builtin/volume_price_surge.py b/backend/app/strategy/builtin/volume_price_surge.py index ec70ac0..02c08ef 100644 --- a/backend/app/strategy/builtin/volume_price_surge.py +++ b/backend/app/strategy/builtin/volume_price_surge.py @@ -44,7 +44,6 @@ ENTRY_SIGNALS = ["signal_ma20_breakout"] EXIT_SIGNALS = ["signal_ma20_breakdown"] STOP_LOSS = -0.06 MAX_HOLD_DAYS = 15 -ALERTS = [] class VolumePriceSurgeMatrixStrategy: diff --git a/backend/app/strategy/config.py b/backend/app/strategy/config.py index 3c594f7..a22e2c5 100644 --- a/backend/app/strategy/config.py +++ b/backend/app/strategy/config.py @@ -6,12 +6,25 @@ """ from __future__ import annotations +import copy import json import logging from pathlib import Path logger = logging.getLogger(__name__) +# 进程内缓存: 监控引擎每轮对每条策略规则调用 load_override, 每次读盘+parse 纯重复; +# override 仅在用户编辑时变化, 以 (mtime_ns, size) 签名判断是否重读。 +# 键为 override 文件路径 (进程内可能有多个 data_dir, 如测试)。 +_override_cache: dict[str, dict] = {} +_override_cache_sig: dict[str, tuple[int, int]] = {} + + +def _invalidate_override_cache(path: Path) -> None: + key = str(path) + _override_cache.pop(key, None) + _override_cache_sig.pop(key, None) + def _overrides_dir(data_dir: Path) -> Path: d = data_dir / "user_data" / "strategy_overrides" @@ -19,15 +32,29 @@ def _overrides_dir(data_dir: Path) -> Path: return d -def _path(data_dir: Path, strategy_id: str) -> Path: - return _overrides_dir(data_dir) / f"{strategy_id}.json" +def _path(data_dir: Path, strategy_id: str, *, ensure_dir: bool = True) -> Path: + # ensure_dir=False 供热路径读取: mkdir 系统调用在 Windows 上 ~0.07ms, + # 读缓存命中时跳过它 (目录由写路径保证存在)。 + if ensure_dir: + d = _overrides_dir(data_dir) + else: + d = data_dir / "user_data" / "strategy_overrides" + return d / f"{strategy_id}.json" def load_override(data_dir: Path, strategy_id: str) -> dict: - """读取策略的用户覆盖配置,不存在返回空 dict""" - p = _path(data_dir, strategy_id) - if not p.exists(): + """读取策略的用户覆盖配置,不存在返回空 dict (带 mtime 签名缓存, 返回深拷贝)""" + p = _path(data_dir, strategy_id, ensure_dir=False) + key = str(p) + try: + st = p.stat() + sig = (st.st_mtime_ns, st.st_size) + except OSError: + _invalidate_override_cache(p) return {} + cached = _override_cache.get(key) + if cached is not None and sig == _override_cache_sig.get(key): + return copy.deepcopy(cached) try: data = json.loads(p.read_text(encoding="utf-8")) # 清理 basic_filter 中值为 None/空的键(避免固化无意义的空值) @@ -38,7 +65,9 @@ def load_override(data_dir: Path, strategy_id: str) -> dict: data["basic_filter"] = cleaned else: del data["basic_filter"] - return data + _override_cache[key] = data + _override_cache_sig[key] = sig + return copy.deepcopy(data) except Exception as e: logger.warning("load override %s failed: %s", strategy_id, e) return {} @@ -49,11 +78,13 @@ def save_override(data_dir: Path, strategy_id: str, overrides: dict) -> None: p = _path(data_dir, strategy_id) p.parent.mkdir(parents=True, exist_ok=True) p.write_text(json.dumps(overrides, ensure_ascii=False, indent=2), encoding="utf-8") + _invalidate_override_cache(p) def delete_override(data_dir: Path, strategy_id: str) -> None: """删除策略的用户覆盖配置(重置为默认值)""" p = _path(data_dir, strategy_id) + _invalidate_override_cache(p) if p.exists(): p.unlink() diff --git a/backend/app/strategy/custom_signals.py b/backend/app/strategy/custom_signals.py index eb957c4..8852b0e 100644 --- a/backend/app/strategy/custom_signals.py +++ b/backend/app/strategy/custom_signals.py @@ -51,6 +51,8 @@ ALLOWED_FIELDS: frozenset[str] = frozenset({ "momentum_5d", "momentum_10d", "momentum_20d", "momentum_30d", "momentum_60d", "annual_vol_20d", "rsi_6", "rsi_14", "rsi_24", + # 异动偏离 (交易所异动规则口径, 运行时列) + "deviate_3d", "deviate_10d", "deviate_30d", }) # 运算符 → Polars 表达式构造器(输入 col_expr, value) diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 1d3d4c2..5ee42d0 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -11,15 +11,24 @@ import logging import sys import threading import time +from collections.abc import Callable, Mapping from dataclasses import dataclass, field, replace from datetime import date from pathlib import Path -from typing import Any, Callable +from typing import Any import numpy as np import polars as pl -from app.strategy.scoring import scoring_dependencies, scoring_value_expr +from app.strategy.scoring import ( + SCORING_DIRECTION_LOW, + effective_scoring, + effective_scoring_directions, + materialize_scoring_columns, + scoring_dependencies, + scoring_value_expr, + scoring_warmup_bars, +) logger = logging.getLogger(__name__) @@ -181,7 +190,6 @@ class StrategyDef: trailing_take_profit_activate: float | None trailing_take_profit_drawdown: float | None max_hold_days: int | None - alerts: list[dict] filter_fn: Callable[[pl.DataFrame, dict], pl.Expr] | None filter_history_fn: Callable[[pl.DataFrame, dict], pl.DataFrame] | None lookback_days: int @@ -520,7 +528,6 @@ class StrategyEngine: trailing_take_profit_activate=getattr(mod, "TRAILING_TAKE_PROFIT_ACTIVATE", None), trailing_take_profit_drawdown=getattr(mod, "TRAILING_TAKE_PROFIT_DRAWDOWN", None), max_hold_days=getattr(mod, "MAX_HOLD_DAYS", None), - alerts=getattr(mod, "ALERTS", []), filter_fn=filter_fn, filter_history_fn=filter_history_fn, required_features=frozenset(meta.get("required_features", []) or []) @@ -547,10 +554,12 @@ class StrategyEngine: # 查询 # ================================================================ - def list_strategies(self) -> list[dict]: - """返回所有策略的元信息""" + def list_strategies(self, *, include_research: bool = False) -> list[dict]: + """Return public strategy metadata unless research templates are requested.""" result = [] for s in self._strategies.values(): + if s.meta.get("research_only") and not include_research: + continue result.append({ **s.meta, "source": s.source, @@ -655,11 +664,14 @@ class StrategyEngine: required = 1 for strategy_id in strategy_ids: strategy = self.get(strategy_id) + overrides = overrides_map.get(strategy_id) or {} + scoring = effective_scoring(strategy.meta.get("scoring"), overrides) + required = max(required, scoring_warmup_bars(scoring)) if strategy.execution_backend == "matrix_native": params = self.resolve_params( strategy, params_map.get(strategy_id), - overrides_map.get(strategy_id), + overrides, ) required = max( required, @@ -714,9 +726,19 @@ class StrategyEngine: max_warmup = max( max_warmup, int(strategy.matrix_strategy.required_warmup_bars(params)) + 1, + scoring_warmup_bars( + effective_scoring( + strategy.meta.get("scoring"), + overrides_map.get(strategy_id), + ) + ), ) field_columns.update( - self._matrix_field_columns(strategy, overrides_map.get(strategy_id)) + self._matrix_field_columns( + strategy, + overrides_map.get(strategy_id), + params, + ) ) if not matrix_ids: return None @@ -832,7 +854,15 @@ class StrategyEngine: started_at=t0, ) - signal_df = context.current if context.current is not None else context.history + scoring = effective_scoring(s.meta.get("scoring"), overrides) + scoring_directions = effective_scoring_directions(overrides) + current, history = self._materialize_scoring_frames( + context.current, + context.history, + scoring, + ) + + signal_df = current if current is not None else history if signal_df is None: signal_df = pl.DataFrame() if not signal_df.is_empty() and "date" in signal_df.columns: @@ -843,9 +873,9 @@ class StrategyEngine: # 普通策略只读目标日期;历史策略读取调用方注入的历史窗口。 if s.filter_history_fn: - if context.history is None: + if history is None: raise ValueError(f"strategy {strategy_id} requires history data") - df = context.history + df = history if df.is_empty(): return StrategyResult( as_of=as_of, @@ -856,9 +886,9 @@ class StrategyEngine: if "date" in df.columns: df = df.filter(pl.col("date") == as_of) else: - if context.current is None: + if current is None: raise ValueError(f"strategy {strategy_id} requires current data") - df = context.current + df = current if df.is_empty(): return StrategyResult( @@ -887,11 +917,7 @@ class StrategyEngine: df = df.filter(expr) # Stage 3: 评分 - scoring = s.meta.get("scoring", {}) - scoring_overrides = overrides.get("scoring") - if scoring_overrides: - scoring = {**scoring, **scoring_overrides} - df = self._apply_scoring(df, scoring) + df = self._apply_scoring(df, scoring, scoring_directions) entry_signal_hits = self._collect_signal_hits(df, entry_signals) if not entry_signals and (s.filter_history_fn or s.filter_fn): entry_signal_hits = [ @@ -1007,7 +1033,11 @@ class StrategyEngine: field_columns: set[str] = set() for sid, strategy in matrix_strats: field_columns.update( - self._matrix_field_columns(strategy, overrides_map.get(sid)) + self._matrix_field_columns( + strategy, + overrides_map.get(sid), + params_map.get(sid), + ) ) shared_matrix = build_market_data_matrix( shared_history, @@ -1032,8 +1062,26 @@ class StrategyEngine: return results @staticmethod - def _matrix_field_columns(strategy: StrategyDef, overrides: dict | None = None) -> set[str]: + def _matrix_field_columns( + strategy: StrategyDef, + overrides: dict | None = None, + params: dict | None = None, + ) -> set[str]: fields = set(strategy.matrix_strategy.required_fields()) + # 参数评分字段 (如挖掘策略的因子组合) 需展开为实际数据依赖, + # 与 backtest._resolve_matrix_native 保持同一语义, 否则虚拟因子 + # (limit_up_count_* -> consecutive_limit_ups) 在矩阵里缺字段。 + parameter_fields = getattr( + strategy.matrix_strategy, + "required_fields_for_params", + None, + ) + if callable(parameter_fields): + fields.update( + scoring_dependencies( + {str(name): 1.0 for name in parameter_fields(params or {})} + ) + ) basic_filter = dict(strategy.basic_filter or {}) if (overrides or {}).get("basic_filter"): basic_filter.update(overrides["basic_filter"]) @@ -1048,8 +1096,7 @@ class StrategyEngine: or basic_filter.get(f"{prefix}_max") is not None ): fields.add(field_name) - scoring = dict(strategy.meta.get("scoring", {}) or {}) - scoring.update((overrides or {}).get("scoring") or {}) + scoring = effective_scoring(strategy.meta.get("scoring"), overrides) fields.update(scoring_dependencies(scoring)) order_by = strategy.meta.get("order_by") if order_by and order_by != "score": @@ -1083,7 +1130,7 @@ class StrategyEngine: return StrategyResult(as_of=as_of, strategy_id=strategy_id) market = build_market_data_matrix( source_panel, - field_columns=self._matrix_field_columns(strategy, overrides), + field_columns=self._matrix_field_columns(strategy, overrides, params), ) if source_panel is None or source_panel.is_empty(): @@ -1094,8 +1141,7 @@ class StrategyEngine: basic_filter = dict(strategy.basic_filter or {}) if overrides.get("basic_filter"): basic_filter.update(overrides["basic_filter"]) - scoring = dict(strategy.meta.get("scoring", {}) or {}) - scoring.update(overrides.get("scoring") or {}) + scoring = effective_scoring(strategy.meta.get("scoring"), overrides) asset_mask = None if pool: pool_set = set(pool) @@ -1112,6 +1158,7 @@ class StrategyEngine: MatrixPipelineConfig( basic_filter=basic_filter, scoring=scoring, + scoring_directions=effective_scoring_directions(overrides), order_by=strategy.meta.get("order_by"), descending=bool(strategy.meta.get("descending", True)), asset_mask=asset_mask, @@ -1398,28 +1445,34 @@ class StrategyEngine: # ================================================================ @staticmethod - def _apply_scoring(df: pl.DataFrame, weights: dict) -> pl.DataFrame: + def _apply_scoring( + df: pl.DataFrame, + weights: dict, + directions: Mapping[str, str] | None = None, + ) -> pl.DataFrame: """通用评分: min-max 归一化 → 加权求和 → 0~100 分""" if not weights: return df executable = [ - (value, weight) + (str(col), value, weight) for col, weight in weights.items() if weight and (value := scoring_value_expr(df.columns, str(col))) is not None ] - total_weight = sum(weight for _, weight in executable) + total_weight = sum(weight for _, _, weight in executable) if total_weight <= 0: return df score_parts: list[pl.Expr] = [] - for value, weight in executable: + for name, value, weight in executable: w = weight / total_weight col_min = value.min() col_range = value.max() - col_min normalized = pl.when(col_range > 0).then( (value - col_min) / col_range ).otherwise(pl.lit(0.5)) + if (directions or {}).get(name) == SCORING_DIRECTION_LOW: + normalized = 1.0 - normalized score_parts.append(normalized * w) if not score_parts: @@ -1430,6 +1483,31 @@ class StrategyEngine: score_expr = score_expr + part return df.with_columns((score_expr * 100).alias("score")) + @staticmethod + def _materialize_scoring_frames( + current: pl.DataFrame | None, + history: pl.DataFrame | None, + scoring: Mapping[str, Any], + ) -> tuple[pl.DataFrame | None, pl.DataFrame | None]: + names = [str(name) for name, weight in scoring.items() if weight] + if not names: + return current, history + if history is None or history.is_empty(): + return ( + materialize_scoring_columns(current, names) if current is not None else None, + history, + ) + + scored_history = materialize_scoring_columns(history, names) + if current is None or current.is_empty(): + return current, scored_history + join_keys = [key for key in ("symbol", "date", "datetime") if key in current.columns and key in scored_history.columns] + added = [name for name in names if name not in current.columns and name in scored_history.columns] + if not join_keys or not added: + return materialize_scoring_columns(current, names), scored_history + values = scored_history.select([*join_keys, *added]).unique(subset=join_keys, keep="last") + return current.join(values, on=join_keys, how="left"), scored_history + def _sanitize(rows: list[dict]) -> list[dict]: for r in rows: diff --git a/backend/app/strategy/monitor.py b/backend/app/strategy/monitor.py index 8eabbd2..162b324 100644 --- a/backend/app/strategy/monitor.py +++ b/backend/app/strategy/monitor.py @@ -1,6 +1,6 @@ -"""策略实时监控 — 订阅行情更新,检查策略买卖信号和提醒条件。 +"""策略实时监控 — 订阅行情更新,检查策略买卖信号。 -职责: 接收实时行情 DataFrame → 检查监控中策略的信号/提醒 → 推送告警。 +职责: 接收实时行情 DataFrame → 检查监控中策略的信号 → 推送告警。 不知道: 策略加载逻辑、AI、API、配置持久化、回测。 依赖: 外部调用 on_quote_update() 传入实时数据。 @@ -13,6 +13,7 @@ from __future__ import annotations import datetime as _dt import logging +import math import threading import time from dataclasses import dataclass, field @@ -72,7 +73,7 @@ def _signal_cn_name(name: str) -> str: @dataclass class StrategyAlert: """策略告警""" - type: str # "entry" | "exit" | "alert" + type: str # "entry" | "exit" strategy_id: str symbol: str name: str | None @@ -104,7 +105,6 @@ class StrategyMonitorService: config: { "entry_signals": ["signal_n_day_high", ...], "exit_signals": ["signal_ma20_breakdown", ...], - "alerts": [{"field": "rsi_14", "op": ">", "value": 80, "message": "..."}], } """ with self._watching_lock: @@ -152,7 +152,7 @@ class StrategyMonitorService: strategy_id=strategy_id, symbol=sym, name=name, - message=f"入场信号触发", + message="入场信号触发", price=price, change_pct=pct, signals=hit_sigs, @@ -169,7 +169,7 @@ class StrategyMonitorService: strategy_id=strategy_id, symbol=sym, name=name, - message=f"出场信号触发", + message="出场信号触发", price=price, change_pct=pct, signals=hit_sigs, @@ -177,21 +177,6 @@ class StrategyMonitorService: all_alerts.append(alert) self._emit(alert) - # 提醒条件 - for alert_cfg in cfg.get("alerts", []): - for sym, name, price, pct in self._check_alert(df, alert_cfg): - alert = StrategyAlert( - type="alert", - strategy_id=strategy_id, - symbol=sym, - name=name, - message=alert_cfg.get("message", "提醒"), - price=price, - change_pct=pct, - ) - all_alerts.append(alert) - self._emit(alert) - return all_alerts def _emit(self, alert: StrategyAlert) -> None: @@ -230,46 +215,6 @@ class StrategyMonitorService: results.append((sym, name, price, pct, hit_sigs)) return results - @staticmethod - def _check_alert( - df: pl.DataFrame, - alert: dict, - ) -> list[tuple[str, str | None, float | None, float | None]]: - """检查阈值型提醒条件""" - field = alert.get("field", "") - if field not in df.columns: - return [] - - if "op" in alert: - # 阈值比较 - op = alert["op"] - value = alert["value"] - col = pl.col(field) - ops = { - ">": col > value, - ">=": col >= value, - "<": col < value, - "<=": col <= value, - } - expr = ops.get(op) - if expr is None: - return [] - else: - # 信号列 (布尔) - expr = pl.col(field).fill_null(False) - - hit_df = df.filter(expr) - results = [] - for row in hit_df.iter_rows(named=True): - results.append(( - row.get("symbol", ""), - row.get("name"), - row.get("close"), - row.get("change_pct"), - )) - return results - - # ================================================================ # 通用监控规则引擎 MonitorRuleEngine # ================================================================ @@ -277,6 +222,58 @@ class StrategyMonitorService: _SIGNAL_PREFIXES = ("signal_", "csg_") +# ── 自选分组作用域: group_id → 成员集合解析 (进程内缓存) ──── +# 缓存按 watchlist 数据版本号失效: 版本不变时零磁盘 IO; 自选页任何增删 +# 分组/成员的操作都会 bump 版本号, 下一轮评估立即拿到新成员 (无需等 TTL)。 +_group_cache_lock = threading.Lock() +_group_cache: dict[str, Any] = {} +# 已告警过的「分组已删除」(rule_id, group_id), 防止每轮评估刷日志 +_warned_missing_groups: set[tuple[str, str]] = set() + + +def _watchlist_groups_snapshot() -> dict[str, frozenset[str]]: + """返回 {group_id: 成员symbol集}。读前后版本一致才写缓存, 避免缓存住写竞态下的旧数据。""" + from app.services import watchlist + + rev_before = watchlist.revision() + with _group_cache_lock: + cached = _group_cache.get("groups") + if cached is not None and _group_cache.get("_rev") == rev_before: + return cached + groups: dict[str, set[str]] = {g["id"]: set() for g in watchlist.list_groups()} + for row in watchlist.list_symbols(): + for gid in row.get("group_ids") or []: + members = groups.get(gid) + if members is not None: + members.add(str(row["symbol"])) + frozen = {gid: frozenset(syms) for gid, syms in groups.items()} + if watchlist.revision() == rev_before: + with _group_cache_lock: + _group_cache["_rev"] = rev_before + _group_cache["groups"] = frozen + return frozen + + +def _group_members_or_none(rule: dict) -> frozenset[str] | None: + """解析规则绑定的分组成员; 分组已删除返回 None, 解析异常返回 None 并记日志。""" + group_id = str(rule.get("group_id") or "") + try: + groups = _watchlist_groups_snapshot() + except Exception as exc: # noqa: BLE001 + logger.warning("自选分组数据读取失败, 规则 %s 本轮跳过: %s", rule.get("id"), exc) + return None + members = groups.get(group_id) + if members is None: + key = (str(rule.get("id") or ""), group_id) + if key not in _warned_missing_groups: + _warned_missing_groups.add(key) + logger.warning( + "监控规则 %s 绑定的自选分组 %s 已删除, 本轮跳过 (fail-closed, 恢复分组后自动生效)", + rule.get("id"), group_id, + ) + return members + + def _is_signal_field(field: str) -> bool: return any(field.startswith(p) for p in _SIGNAL_PREFIXES) @@ -355,6 +352,9 @@ class MonitorRuleEngine: self._latest_strategy_result_ids: set[str] = set() self._sector_monitor_service = None self._sector_condition_state: dict[tuple[str, str], bool] = {} + # abnormal 规则边缘触发状态: (rule_id, symbol) → 上一轮是否已达阈值。 + # 只在 False → True 跳变时告警 (首轮观测不触发, 防止新建规则瞬间刷屏)。 + self._abnormal_condition_state: dict[tuple[str, str], bool] = {} def set_strategy_engine(self, engine) -> None: """注入 StrategyEngine, type=strategy 规则据此跑选股。""" @@ -414,6 +414,8 @@ class MonitorRuleEngine: return ( rule.get("type"), rule.get("strategy_id"), + rule.get("score_min"), + rule.get("score_max"), rule.get("asset_type", "stock"), rule.get("scope", "symbols"), tuple(sorted(str(symbol) for symbol in rule.get("symbols", []))), @@ -424,6 +426,7 @@ class MonitorRuleEngine: rule.get("direction"), rule.get("threshold_pct"), rule.get("window_minutes"), + rule.get("abnormal_window"), ) def set_rules(self, rules: list[dict]) -> None: @@ -466,6 +469,11 @@ class MonitorRuleEngine: for key, value in list(self._sector_condition_state.items()) if key[0] in active_ids } + self._abnormal_condition_state = { + key: value + for key, value in list(self._abnormal_condition_state.items()) + if key[0] in active_ids + } logger.info("MonitorRuleEngine: 装载 %d 条规则", len(self._rules)) def add_rule(self, rule: dict) -> None: @@ -660,7 +668,7 @@ class MonitorRuleEngine: for rule_id, rule in list(self._rules.items()): if rule.get("asset_type", "stock") != asset_type: continue - if rule.get("type") == "sector": + if rule.get("type") in ("sector", "abnormal"): continue try: events.extend(self._evaluate_rule(df, rule, now)) @@ -824,6 +832,139 @@ class MonitorRuleEngine: ) return "|".join(parts) + def min_abnormal_closeness(self) -> float: + """启用的 abnormal 规则中最小的接近度阈值 (小数)。 + + 供调用方 (quote_service) 构建异动快照时预过滤, 不必按最高阈值拉全量。 + """ + thresholds = [ + float(r.get("threshold_pct", 70)) / 100 + for r in list(self._rules.values()) + if r.get("enabled", True) and r.get("type") == "abnormal" + ] + return min(thresholds) if thresholds else 1.0 + + def evaluate_abnormal(self, rows: list[dict], *, now: float | None = None) -> list[dict]: + """按异动边缘快照评估 type=abnormal 规则。 + + rows 为 abnormal_moves.build_overview 的 rows (调用方已按 + min_abnormal_closeness 预过滤)。rows 为空也照常评估 —— 用于把 + 已消失标的的边缘状态清理回 False。 + """ + rules = [ + rule for rule in list(self._rules.values()) + if rule.get("enabled", True) and rule.get("type") == "abnormal" + ] + if not rules: + return [] + timestamp = time.time() if now is None else now + events: list[dict] = [] + for rule in rules: + try: + events.extend(self._evaluate_abnormal_rule(rule, rows, timestamp)) + except Exception as exc: # noqa: BLE001 + logger.warning("异动规则评估失败 %s: %s", rule.get("id"), exc) + return events + + def _evaluate_abnormal_rule(self, rule: dict, rows: list[dict], now: float) -> list[dict]: + events: list[dict] = [] + threshold = float(rule.get("threshold_pct", 70)) / 100 + if not 0 < threshold <= 1.5: + threshold = 0.7 + direction = rule.get("direction", "both") + window_filter = str(rule.get("abnormal_window", "any")) + if rule.get("scope") == "symbols": + scope_symbols = {str(s) for s in rule.get("symbols", []) if s} + elif rule.get("scope") == "watchlist_group": + # 异动规则同样支持动态分组; 分组已删除返回 None → 本轮整体跳过 + members = _group_members_or_none(rule) + if members is None: + return events + scope_symbols = set(members) + else: + scope_symbols = None + + seen: set[str] = set() + for row in rows: + symbol = str(row.get("symbol") or "") + if not symbol or (scope_symbols is not None and symbol not in scope_symbols): + continue + seen.add(symbol) + # 方向/窗口过滤后取接近度最高的窗口作为代表 + best: tuple[str, float, float, float] | None = None # (窗口, 接近度, 偏离值, 阈值) + for key, win in (row.get("windows") or {}).items(): + if window_filter != "any" and key != window_filter: + continue + value = win.get("value") + if value is None: + continue + if direction == "up" and value <= 0: + continue + if direction == "down" and value >= 0: + continue + closeness = float(win.get("closeness") or 0) + if best is None or closeness > best[1]: + best = (key, closeness, float(value), float(win.get("threshold") or 0)) + condition = best is not None and best[1] >= threshold + state_key = (rule["id"], symbol) + previous = self._abnormal_condition_state.get(state_key) + self._abnormal_condition_state[state_key] = condition + if previous is None or previous or not condition: + continue + + event_type = f"abnormal_{'up' if best[2] > 0 else 'down'}" + cooldown_key = (rule["id"], symbol, event_type) + last = self._last_fire.get(cooldown_key) + cooldown = int(rule.get("cooldown_seconds", 3600)) + if last is not None and now - last < cooldown: + continue + self._last_fire[cooldown_key] = now + event = { + "ts": int(now * 1000), + "rule_id": rule["id"], + "rule_name": rule.get("name", ""), + "strategy_id": None, + "source": "abnormal", + "type": event_type, + "symbol": symbol, + "name": row.get("name"), + "message": rule.get("message", "") or self._abnormal_message(row, best), + "price": row.get("close"), + "change_pct": row.get("rt_pct"), + "signals": [], + "severity": rule.get("severity", "info"), + "conditions": [], + "logic": "and", + "abnormal_window": best[0], + "abnormal_value": round(best[2], 4), + "abnormal_threshold": best[3], + "abnormal_closeness": round(best[1], 4), + } + events.append(event) + if self._alert_handler: + try: + self._alert_handler(event) + except Exception as exc: # noqa: BLE001 + logger.warning("alert handler failed: %s", exc) + # 本轮未出现的标的 (跌出预过滤区间) 状态置 False 而非删除: + # 删除会被当成「首轮观测」而不触发, 置 False 才能在回升穿过阈值时再次告警。 + for key, value in list(self._abnormal_condition_state.items()): + if key[0] == rule["id"] and key[1] not in seen and value: + self._abnormal_condition_state[key] = False + return events + + @staticmethod + def _abnormal_message(row: dict, best: tuple[str, float, float, float]) -> str: + window, closeness, value, threshold = best + board = row.get("board") or "" + tag = f"{board}{'·ST' if row.get('st') else ''}" + state = "已达异常波动阈值" if closeness >= 1 else "接近异常波动阈值" + return ( + f"{row.get('name') or row.get('symbol')} {window}偏离值 " + f"{value * 100:+.2f}%/阈值{threshold * 100:.0f}% ({tag}) " + f"接近度{closeness * 100:.0f}%, {state}" + ) + def _evaluate_rule(self, df: pl.DataFrame, rule: dict, now: float) -> list[dict]: """评估单条规则,返回触发的 events。""" # 1. 按 scope 过滤作用域 @@ -917,6 +1058,13 @@ class MonitorRuleEngine: if not syms: return df.head(0) return df.filter(pl.col("symbol").is_in(syms)) + if scope == "watchlist_group": + # 动态绑定自选分组: 每轮评估按分组当前成员过滤 (带版本号缓存)。 + # 分组已删除/暂时为空 → fail-closed 返回空, 绝不退化为全市场。 + members = _group_members_or_none(rule) + if not members: + return df.head(0) + return df.filter(pl.col("symbol").is_in(list(members))) if scope == "sector": # sector 过滤需 df 含板块列 (后续接入 ext_data JOIN)。在 JOIN 落地前 # fail-closed 返回空 —— 绝不退化为「全市场」误触发 (旧行为 return df 会让 @@ -987,7 +1135,16 @@ class MonitorRuleEngine: current=df, market=matrix, ) - elif s.filter_history_fn: + required_history_bars = 1 + history_resolver = getattr(self._strategy_engine, "required_history_bars", None) + if callable(history_resolver): + required_history_bars = history_resolver( + [sid], + overrides_map={sid: overrides}, + ) + if getattr(s, "execution_backend", "polars_expr") not in {"composite", "matrix_native"} and ( + s.filter_history_fn or required_history_bars > 1 + ): history_loader = self._history_loader_for(rule) if history_loader is None: logger.debug("策略 %s 需要历史数据但未注入 history_loader (asset_type=%s), 跳过实时监控", @@ -995,7 +1152,7 @@ class MonitorRuleEngine: return [] try: today = cn_today() - lookback = max(1, getattr(s, "lookback_days", 30)) + lookback = max(1, getattr(s, "lookback_days", 1), required_history_bars) hist_df = history_loader(today, lookback) if hist_df is None or hist_df.is_empty(): logger.debug("策略 %s 历史数据为空, 跳过本轮实时监控", sid) @@ -1039,7 +1196,6 @@ class MonitorRuleEngine: # 避免并发读到半填充状态。 if at == "stock": try: - import math self._building_strategy_results[sid] = { "total": result.total, "as_of": str(cn_today()), @@ -1053,7 +1209,27 @@ class MonitorRuleEngine: except Exception: # noqa: BLE001 pass - current_pool: set[str] = {r["symbol"] for r in result.rows} + score_min = rule.get("score_min") + score_max = rule.get("score_max") + score_filter_enabled = score_min is not None or score_max is not None + eligible_symbols: set[str] = set() + if score_filter_enabled: + for row in result.rows: + symbol = str(row.get("symbol", "")) + score = row.get("score", result.scores.get(symbol)) + if isinstance(score, bool) or not isinstance(score, (int, float)): + continue + if not math.isfinite(score): + continue + if score_min is not None and score < score_min: + continue + if score_max is not None and score > score_max: + continue + eligible_symbols.add(symbol) + else: + eligible_symbols = {str(row["symbol"]) for row in result.rows} + + current_pool = eligible_symbols prev_pool = self._strategy_pools.get(pool_key) self._strategy_pools[pool_key] = current_pool @@ -1066,9 +1242,15 @@ class MonitorRuleEngine: except Exception: pass + entry_signal_hits = result.entry_signal_hits + if score_filter_enabled: + entry_signal_hits = [ + hit for hit in entry_signal_hits + if str(hit.get("symbol", "")) in eligible_symbols + ] changes: dict[str, set[str]] = { "buy_signal": self._new_strategy_signals( - pool_key, "buy_signal", result.as_of, result.entry_signal_hits, + pool_key, "buy_signal", result.as_of, entry_signal_hits, ), "sell_signal": self._new_strategy_signals( pool_key, "sell_signal", result.as_of, result.exit_signal_hits, @@ -1081,7 +1263,7 @@ class MonitorRuleEngine: signal_map = { "buy_signal": { str(hit["symbol"]): list(hit.get("signals") or []) - for hit in result.entry_signal_hits + for hit in entry_signal_hits }, "sell_signal": { str(hit["symbol"]): list(hit.get("signals") or []) diff --git a/backend/app/strategy/monitor_rules.py b/backend/app/strategy/monitor_rules.py index 8d9b877..35cd0bc 100644 --- a/backend/app/strategy/monitor_rules.py +++ b/backend/app/strategy/monitor_rules.py @@ -16,6 +16,7 @@ from __future__ import annotations import json import logging +import math import re from datetime import datetime, timezone from pathlib import Path @@ -27,8 +28,8 @@ logger = logging.getLogger(__name__) # ── 常量 ──────────────────────────────────────────────── ID_RE = re.compile(r"^[a-z0-9_]{1,40}$") -RULE_TYPES = {"strategy", "signal", "price", "market", "ladder", "sector"} -SCOPES = {"symbols", "all", "sector"} +RULE_TYPES = {"strategy", "signal", "price", "market", "ladder", "sector", "abnormal"} +SCOPES = {"symbols", "all", "sector", "watchlist_group"} LOGICS = {"and", "or"} DIRECTIONS = {"entry", "exit", "both"} STRATEGY_NOTIFY_EVENTS = {"buy_signal", "sell_signal", "pool_entry", "pool_exit"} @@ -41,6 +42,9 @@ LADDER_DIRECTIONS = {"up", "down"} SECTOR_KINDS = {"index", "concept", "industry"} SECTOR_TRIGGERS = {"change_pct", "momentum"} SECTOR_WINDOWS = {1, 3, 5, 10, 15} +# abnormal 规则 (异动边缘): 接近度方向 / 关注窗口 +ABNORMAL_DIRECTIONS = {"up", "down", "both"} +ABNORMAL_WINDOWS = {"any", "3d", "10d", "30d"} # 布尔信号列前缀 (op=truth 时 field 取这些) _SIGNAL_PREFIXES = ("signal_", "csg_") @@ -132,6 +136,17 @@ def validate(rule: dict) -> None: invalid_events = set(notify_events) - STRATEGY_NOTIFY_EVENTS if invalid_events: raise ValueError(f"notify_events 包含非法事件: {sorted(invalid_events)}") + score_min = rule.get("score_min") + score_max = rule.get("score_max") + for label, value in (("评分下限", score_min), ("评分上限", score_max)): + if value is None: + continue + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise ValueError(f"{label}必须是 0 到 100 之间的数字") + if value < 0 or value > 100: + raise ValueError(f"{label}必须是 0 到 100 之间的数字") + if score_min is not None and score_max is not None and score_min > score_max: + raise ValueError("评分下限不能大于评分上限") elif rule.get("type") == "ladder": # 连板梯队封单监控: 需 metric + threshold + direction(up/down), 不用 conditions if rule.get("metric", "sealed_vol") not in LADDER_METRICS: @@ -164,6 +179,17 @@ def validate(rule: dict) -> None: raise ValueError("板块监控阈值必须大于 0 且不超过 20%") if rule.get("sector_trigger") == "momentum" and rule.get("window_minutes") not in SECTOR_WINDOWS: raise ValueError(f"板块异动窗口必须是 {sorted(SECTOR_WINDOWS)} 分钟之一") + elif rule.get("type") == "abnormal": + # 异动边缘监控: threshold_pct = 接近度阈值% (|偏离值|/规则阈值), 不用 conditions + if rule.get("asset_type", "stock") != "stock": + raise ValueError("异动监控仅支持个股 (偏离值仅对个股计算)") + if rule.get("direction", "both") not in ABNORMAL_DIRECTIONS: + raise ValueError(f"异动监控 direction 必须是 {ABNORMAL_DIRECTIONS} 之一") + if rule.get("abnormal_window", "any") not in ABNORMAL_WINDOWS: + raise ValueError(f"异动监控窗口必须是 {sorted(ABNORMAL_WINDOWS)} 之一") + threshold_pct = rule.get("threshold_pct") + if not isinstance(threshold_pct, (int, float)) or not 1 <= threshold_pct <= 150: + raise ValueError("异动接近度阈值必须是 1 到 150 之间的百分比数字") else: # 信号/价格/市场类型: 需要 conditions conds = rule.get("conditions") @@ -198,6 +224,14 @@ def validate(rule: dict) -> None: syms = rule.get("symbols") if not isinstance(syms, list) or len(syms) == 0: raise ValueError("scope=symbols 时 symbols 不能为空") + if rule.get("scope") == "watchlist_group": + # 动态绑定自选分组: 评估时实时解析成员 (分组后续增删自动生效)。 + # 分组存在性由 API 层在保存时校验 (strategy 层不依赖 services)。 + gid = rule.get("group_id") + if not isinstance(gid, str) or not gid.strip(): + raise ValueError("scope=watchlist_group 时必须选择自选分组") + if rule.get("asset_type", "stock") != "stock": + raise ValueError("自选分组作用域仅支持个股") if uses_intraday_signals(rule) and rule.get("scope") != "symbols": raise ValueError("分时穿越信号仅支持指定标的") # sector 作用域的板块 JOIN 尚未实现: _apply_scope 目前会退化为「全市场」, @@ -219,18 +253,30 @@ def normalize(rule: dict) -> dict: r = dict(rule) r.setdefault("enabled", True) r.setdefault("asset_type", "stock") - r.setdefault("scope", "symbols") + # sector/abnormal 默认全市场 (sector 随后强制 all; abnormal 支持指定标的) + r.setdefault("scope", "all" if r.get("type") in {"sector", "abnormal"} else "symbols") r.setdefault("symbols", []) + r.setdefault("group_id", None) + # watchlist_group 作用域: 成员动态来自分组, symbols 不参与; 其他作用域清掉残留 group_id + if r.get("scope") == "watchlist_group": + r["symbols"] = [] + else: + r["group_id"] = None r.setdefault("sector", None) r.setdefault("sector_kind", None) r.setdefault("sector_targets", []) r.setdefault("sector_trigger", "change_pct") - r.setdefault("threshold_pct", 1.0) + r.setdefault("threshold_pct", 70.0 if r.get("type") == "abnormal" else 1.0) r.setdefault("window_minutes", 5) r.setdefault("strategy_id", None) - # direction 默认值: ladder/sector 用 "up", 其余用 "entry" - r.setdefault("direction", "up" if r.get("type") in {"ladder", "sector"} else "entry") + # direction 默认值: ladder/sector 用 "up", abnormal 用 "both", 其余用 "entry" + r.setdefault( + "direction", + "up" if r.get("type") in {"ladder", "sector"} else "both" if r.get("type") == "abnormal" else "entry", + ) if r.get("type") == "strategy": + r.setdefault("score_min", None) + r.setdefault("score_max", None) if r.get("notify_events") is None: # 兼容统一监控上线后的旧规则: 当时实际行为是同时通知进入和移出。 r["notify_events"] = ["pool_entry", "pool_exit"] @@ -238,6 +284,8 @@ def normalize(rule: dict) -> dict: r["notify_events"] = list(dict.fromkeys(r["notify_events"])) else: r.pop("notify_events", None) + r.pop("score_min", None) + r.pop("score_max", None) r.setdefault("conditions", []) # ladder 专属默认字段 r.setdefault("metric", "sealed_vol") @@ -245,6 +293,9 @@ def normalize(rule: dict) -> dict: if r.get("type") == "sector": r["scope"] = "all" r["symbols"] = [] + r["group_id"] = None + # abnormal 专属默认字段 (异动边缘监控) + r.setdefault("abnormal_window", "any") r.setdefault("logic", "and") r.setdefault("cooldown_seconds", 3600) r.setdefault("severity", "info") diff --git a/backend/app/strategy/prompt_builder.py b/backend/app/strategy/prompt_builder.py index f3f008d..1118133 100644 --- a/backend/app/strategy/prompt_builder.py +++ b/backend/app/strategy/prompt_builder.py @@ -30,7 +30,7 @@ def build_step1( strategy_id: str = "", execution_backend: str = "polars_expr", ) -> str: - """步骤1:规则 → 完整策略代码(参数 + 信号 + 评分 + 告警) + """步骤1:规则 → 完整策略代码(参数 + 信号 + 评分) 注意: 生成规范已在 ai_generator.py 的 system prompt 中加载, 此处只拼用户输入以降低网关超时概率。 diff --git a/backend/app/strategy/prompts/strategy-builder-step1.md b/backend/app/strategy/prompts/strategy-builder-step1.md index a11c5f5..c6475f9 100644 --- a/backend/app/strategy/prompts/strategy-builder-step1.md +++ b/backend/app/strategy/prompts/strategy-builder-step1.md @@ -35,9 +35,8 @@ 1. **META**:id(name, description, tags, params, scoring, basic_filter, limit 等) 2. **ENTRY_SIGNALS / EXIT_SIGNALS**:根据策略逻辑自行选择合适的信号列(参考下方可用信号表),不要照抄示例 3. **STOP_LOSS / MAX_HOLD_DAYS**:根据策略类型合理设定,做多止损一般为 -5%~-8%,短线持有 5~20 天 -4. **ALERTS**:列出需要监控提醒的条件 -5. **RULES**:中文逐条列出核心筛选逻辑(至少 3 条),准确完整 -6. **EXECUTION_BACKEND + filter() 或 filter_history()**:只选择一个后端和一份核心筛选逻辑 +4. **RULES**:中文逐条列出核心筛选逻辑(至少 3 条),准确完整 +5. **EXECUTION_BACKEND + filter() 或 filter_history()**:只选择一个后端和一份核心筛选逻辑 ## 性能原则 @@ -84,8 +83,6 @@ EXIT_SIGNALS = [] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 20 -ALERTS = [] - RULES = """ 1. 规则一 2. 规则二 @@ -136,8 +133,6 @@ EXIT_SIGNALS = [] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 20 -ALERTS = [] - RULES = """ 1. 规则一(包含时序逻辑) 2. 规则二 diff --git a/backend/app/strategy/prompts/strategy-builder-step2.md b/backend/app/strategy/prompts/strategy-builder-step2.md index 05d5e67..6330ba6 100644 --- a/backend/app/strategy/prompts/strategy-builder-step2.md +++ b/backend/app/strategy/prompts/strategy-builder-step2.md @@ -23,7 +23,6 @@ - 增/删/改参数 → 更新 META["params"],同步修改当前执行后端对应的 `filter()`、`filter_history()` 或 `MATRIX_STRATEGY` - 调整信号 → 更新 ENTRY_SIGNALS / EXIT_SIGNALS - 修改止损/持有 → 更新 STOP_LOSS / MAX_HOLD_DAYS -- 增减告警 → 更新 ALERTS - 调整评分 → 更新 META["scoring"];只使用真实数值字段或受控虚拟字段 `ma20_bias`,权重总和保持 1.0 - 修改筛选逻辑 → 更新唯一公式;新增历史回溯时切换为 `python_history_legacy` + `filter_history()`,移除回溯时切回 `polars_expr` + `filter()`,不得同时保留两套公式 @@ -38,3 +37,4 @@ 7. 优先使用 Polars 表达式、窗口函数、聚合和 join,不要默认改成逐行/逐股 Python 循环 8. **输出前自我检查**:完整通读修改后的代码,确认 Python 语法正确、括号匹配、引号闭合、缩进一致。有错误直接修正再输出。 9. 直接输出完整 Python 代码 +10. 历史代码中的 `ALERTS` 已废弃,输出时删除;实时提醒由监控中心统一管理 diff --git a/backend/app/strategy/prompts/strategy-example.md b/backend/app/strategy/prompts/strategy-example.md index 788c2cc..4f8be73 100644 --- a/backend/app/strategy/prompts/strategy-example.md +++ b/backend/app/strategy/prompts/strategy-example.md @@ -15,7 +15,7 @@ | 方向 | 做多 | | 规则 | 前一交易日为明显阴线且跌幅不低于2%,今日阳线收盘反包前一日实体,收盘价接近或高于前一日高点,成交量较前一日放大1.2倍以上,当前 close > ma5 或 close > ma10;使用 filter_history,并优先用 Polars shift/with_columns/filter 实现。 | -点击「AI 生成」,AI 返回完整策略代码(含参数、信号、评分、告警): +点击「AI 生成」,AI 返回完整策略代码(含参数、信号、评分): ```python """强势反包 — 前日阴线下跌 + 今日放量阳线反包""" @@ -82,9 +82,6 @@ ENTRY_SIGNALS = ["signal_broken_board_recovery"] EXIT_SIGNALS = ["signal_ma20_breakdown"] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 10 -ALERTS = [ - {"field": "signal_broken_board_recovery", "message": "反包信号"}, -] RULES = """ 1. 前一交易日为阴线,且跌幅不小于设定阈值 diff --git a/backend/app/strategy/prompts/strategy-guide-compact.md b/backend/app/strategy/prompts/strategy-guide-compact.md index 27b2e68..ecdba53 100644 --- a/backend/app/strategy/prompts/strategy-guide-compact.md +++ b/backend/app/strategy/prompts/strategy-guide-compact.md @@ -47,7 +47,6 @@ ENTRY_SIGNALS = [] EXIT_SIGNALS = [] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 20 -ALERTS = [] RULES = """ 1. 规则一 diff --git a/backend/app/strategy/prompts/strategy-guide.md b/backend/app/strategy/prompts/strategy-guide.md index 069711b..bfa2943 100644 --- a/backend/app/strategy/prompts/strategy-guide.md +++ b/backend/app/strategy/prompts/strategy-guide.md @@ -61,10 +61,6 @@ STOP_LOSS = -0.05 # 最长持有天数 (短线 5~20, 中线 20~60) MAX_HOLD_DAYS = 20 -# 提醒条件 (监控用) -ALERTS = [] - - # 策略规则(人类可读,逐条编号,至少 3 条) RULES = """ 1. 规则描述一 @@ -330,7 +326,6 @@ ENTRY_SIGNALS = ["signal_broken_board_recovery"] EXIT_SIGNALS = ["signal_ma20_breakdown"] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 10 -ALERTS = [{"field": "signal_broken_board_recovery", "message": "反包信号"}] RULES = """ 1. 前一交易日为阴线,且跌幅不小于设定阈值 diff --git a/backend/app/strategy/scoring.py b/backend/app/strategy/scoring.py index 8bff57a..dca28ca 100644 --- a/backend/app/strategy/scoring.py +++ b/backend/app/strategy/scoring.py @@ -6,11 +6,94 @@ from typing import Any import polars as pl +SCORING_DIRECTION_HIGH = "high" +SCORING_DIRECTION_LOW = "low" +SCORING_DIRECTIONS = frozenset({SCORING_DIRECTION_HIGH, SCORING_DIRECTION_LOW}) VIRTUAL_SCORING_DEPENDENCIES: dict[str, frozenset[str]] = { - "ma20_bias": frozenset({"close", "ma20"}), + **{ + f"ma{period}_bias": frozenset({"close", f"ma{period}"}) + for period in (5, 10, 20, 30, 60) + }, + **{ + f"ema{period}_bias": frozenset({"close", f"ema{period}"}) + for period in (5, 10, 20, 30, 60) + }, + "macd_dif_pct": frozenset({"close", "macd_dif"}), + "macd_dea_pct": frozenset({"close", "macd_dea"}), + "macd_hist_pct": frozenset({"close", "macd_hist"}), + "boll_position": frozenset({"close", "boll_upper", "boll_lower"}), + "atr_pct": frozenset({"close", "atr_14"}), + "boll_width": frozenset({"ma20", "boll_upper", "boll_lower"}), + "vol_ratio_10d": frozenset({"volume"}), + "vol_trend_5_10": frozenset({"vol_ma5", "vol_ma10"}), + "turnover_ratio_5d": frozenset({"turnover_rate"}), + "log_amount": frozenset({"amount"}), + "amount_ratio_5d": frozenset({"amount"}), + "gap_return": frozenset({"open", "prev_close"}), + "intraday_return": frozenset({"open", "close"}), + "close_position": frozenset({"high", "low", "close"}), + "distance_to_high_60d": frozenset({"close", "high_60d"}), + "distance_from_low_60d": frozenset({"close", "low_60d"}), + "max_ret_20d": frozenset({"close"}), + "ret_skew_20d": frozenset({"close"}), + "up_days_20d": frozenset({"close"}), + "amihud_20d": frozenset({"close", "amount"}), + "turnover_z_60d": frozenset({"turnover_rate"}), + "vol_price_corr_20d": frozenset({"close", "volume"}), + "vwap_bias": frozenset({"close", "volume", "amount"}), + "vol_trend_5_60": frozenset({"volume"}), + "limit_up_count_20d": frozenset({"consecutive_limit_ups"}), + "limit_up_count_60d": frozenset({"consecutive_limit_ups"}), } +_ROLLING_SCORING_WARMUP: dict[str, int] = { + "vol_ratio_10d": 11, + "turnover_ratio_5d": 6, + "amount_ratio_5d": 6, + "max_ret_20d": 21, + "ret_skew_20d": 21, + "up_days_20d": 21, + "amihud_20d": 21, + "turnover_z_60d": 61, + "vol_price_corr_20d": 21, + "vol_trend_5_60": 60, + "limit_up_count_20d": 21, + "limit_up_count_60d": 61, +} + + +def effective_scoring( + defaults: Mapping[str, Any] | None, + overrides: Mapping[str, Any] | None, +) -> dict[str, Any]: + """解析有效评分;新配置可完整替换,历史配置保持局部覆盖。""" + override_values = (overrides or {}).get("scoring") + if (overrides or {}).get("scoring_replace") is True: + return dict(override_values) if isinstance(override_values, Mapping) else {} + scoring = dict(defaults or {}) + if isinstance(override_values, Mapping): + scoring.update(override_values) + return scoring + + +def effective_scoring_directions(overrides: Mapping[str, Any] | None) -> dict[str, str]: + values = (overrides or {}).get("scoring_directions") + if not isinstance(values, Mapping): + return {} + return { + str(name): str(direction) + for name, direction in values.items() + if direction in SCORING_DIRECTIONS + } + + +def scoring_warmup_bars(scoring: Mapping[str, Any]) -> int: + return max( + (_ROLLING_SCORING_WARMUP.get(str(name), 1) for name, weight in scoring.items() if weight), + default=1, + ) + def scoring_dependencies(scoring: Mapping[str, Any]) -> set[str]: """把受控虚拟评分字段展开为实际数据依赖。""" @@ -30,8 +113,140 @@ def scoring_value_expr(columns: Collection[str], name: str) -> pl.Expr | None: dependencies = VIRTUAL_SCORING_DEPENDENCIES.get(name) if dependencies is None or not dependencies.issubset(available): return None - if name == "ma20_bias": - return pl.when(pl.col("ma20") != 0).then( - pl.col("close") / pl.col("ma20") - 1.0 - ).otherwise(None) + if name.startswith("ma") and name.endswith("_bias"): + period = name.removeprefix("ma").removesuffix("_bias") + if period.isdigit(): + return _relative(pl.col("close"), pl.col(f"ma{period}")) + if name.startswith("ema") and name.endswith("_bias"): + period = name.removeprefix("ema").removesuffix("_bias") + if period.isdigit(): + return _relative(pl.col("close"), pl.col(f"ema{period}")) + if name in {"macd_dif_pct", "macd_dea_pct", "macd_hist_pct"}: + source = name.removesuffix("_pct") + return _ratio(pl.col(source), pl.col("close")) + if name == "atr_pct": + return _ratio(pl.col("atr_14"), pl.col("close")) + if name == "boll_position": + return _ratio( + pl.col("close") - pl.col("boll_lower"), + pl.col("boll_upper") - pl.col("boll_lower"), + ) + if name == "boll_width": + return _ratio(pl.col("boll_upper") - pl.col("boll_lower"), pl.col("ma20")) + if name == "vol_ratio_10d": + return _ratio( + pl.col("volume"), + pl.col("volume").shift(1).rolling_mean(10).over("symbol"), + ) + if name == "vol_trend_5_10": + return _relative(pl.col("vol_ma5"), pl.col("vol_ma10")) + if name == "turnover_ratio_5d": + return _relative( + pl.col("turnover_rate"), + pl.col("turnover_rate").shift(1).rolling_mean(5).over("symbol"), + ) + if name == "log_amount": + return pl.when(pl.col("amount") >= 0).then((pl.col("amount") + 1).log()).otherwise(None) + if name == "amount_ratio_5d": + return _relative( + pl.col("amount"), + pl.col("amount").shift(1).rolling_mean(5).over("symbol"), + ) + if name == "gap_return": + return _relative(pl.col("open"), pl.col("prev_close")) + if name == "intraday_return": + return _relative(pl.col("close"), pl.col("open")) + if name == "close_position": + return _ratio(pl.col("close") - pl.col("low"), pl.col("high") - pl.col("low")) + if name == "distance_to_high_60d": + return _relative(pl.col("close"), pl.col("high_60d")) + if name == "distance_from_low_60d": + return _relative(pl.col("close"), pl.col("low_60d")) + if name in { + "max_ret_20d", "ret_skew_20d", "up_days_20d", + "amihud_20d", "vol_price_corr_20d", + }: + change = _daily_change_expr() + if name == "max_ret_20d": + return change.rolling_max(20, min_samples=20).over("symbol") + if name == "ret_skew_20d": + return change.rolling_skew(20, bias=True).over("symbol") + if name == "up_days_20d": + return ( + (change > 0).cast(pl.Float64) + .rolling_sum(20, min_samples=20).over("symbol") + ) + if name == "amihud_20d": + illiquidity = _ratio(change.abs(), pl.col("amount") / 1e8) + return illiquidity.rolling_mean(20, min_samples=20).over("symbol") + volume = pl.col("volume") + product = change * volume + return _rolling_corr_expr(change, volume, product, 20).over("symbol") + if name == "turnover_z_60d": + baseline = pl.col("turnover_rate").shift(1) + mean = baseline.rolling_mean(60, min_samples=60) + std = baseline.rolling_std(60, min_samples=60) + return ( + pl.when(std > 0).then((pl.col("turnover_rate") - mean) / std) + .otherwise(None) + .over("symbol") + ) + if name == "vwap_bias": + vwap = _ratio(pl.col("amount"), pl.col("volume") * 100.0) + return _relative(pl.col("close"), vwap) + if name == "vol_trend_5_60": + fast = pl.col("volume").rolling_mean(5) + slow = pl.col("volume").rolling_mean(60) + return _relative(fast, slow).over("symbol") + if name in {"limit_up_count_20d", "limit_up_count_60d"}: + window = 20 if name == "limit_up_count_20d" else 60 + hit = (pl.col("consecutive_limit_ups").fill_null(0) > 0).cast(pl.Float64) + return hit.rolling_sum(window, min_samples=window).over("symbol") return None + + +def _daily_change_expr() -> pl.Expr: + previous = pl.col("close").shift(1) + return _ratio(pl.col("close"), previous) - 1.0 + + +def _rolling_corr_expr( + left: pl.Expr, right: pl.Expr, product: pl.Expr, window: int +) -> pl.Expr: + """Pearson correlation over a rolling window, matching the matrix kernel formula.""" + mean_left = left.rolling_mean(window, min_samples=window) + mean_right = right.rolling_mean(window, min_samples=window) + mean_product = product.rolling_mean(window, min_samples=window) + mean_left_sq = (left * left).rolling_mean(window, min_samples=window) + mean_right_sq = (right * right).rolling_mean(window, min_samples=window) + covariance = mean_product - mean_left * mean_right + variance_left = mean_left_sq - mean_left * mean_left + variance_right = mean_right_sq - mean_right * mean_right + return pl.when( + (variance_left > 0) & (variance_right > 0) + ).then( + covariance / (variance_left * variance_right).sqrt() + ).otherwise(None) + + +def materialize_scoring_columns( + frame: pl.DataFrame, + names: Collection[str], +) -> pl.DataFrame: + expressions = [ + expression.alias(name) + for name in names + if name not in frame.columns + and (expression := scoring_value_expr(frame.columns, str(name))) is not None + ] + return frame.with_columns(expressions) if expressions else frame + + +def _ratio(numerator: pl.Expr, denominator: pl.Expr) -> pl.Expr: + return pl.when(denominator.is_not_null() & (denominator != 0)).then( + numerator / denominator + ).otherwise(None) + + +def _relative(numerator: pl.Expr, denominator: pl.Expr) -> pl.Expr: + return _ratio(numerator, denominator) - 1.0 diff --git a/backend/app/tickflow/policy.py b/backend/app/tickflow/policy.py index 1974bfb..54b475a 100644 --- a/backend/app/tickflow/policy.py +++ b/backend/app/tickflow/policy.py @@ -285,11 +285,11 @@ def _load_cached_capset(cache_path: Path) -> CapabilitySet | None: def detect_capabilities(force: bool = False) -> CapabilitySet: - """探测当前可用的能力集 (TickFlow API Key 档位 + 自定义数据源)。 + """探测当前可用的能力集 (TickFlow API Key 档位 + 自定义/插件数据源)。 - 自定义数据源补能力: 用户配了自定义分钟数据源时, 即使无 TickFlow Pro+ - 也补上 KLINE_MINUTE_BATCH, 使分时图/自动同步/回测等功能不再被权限门拦。 - 取数函数内部会按 preferences.get_minute_data_provider() 分流到自定义源, + 能力标准对所有数据源一致: 自定义源被选为某数据集的当前 provider 且声明了 + 该数据集时, 补上对应能力 (见 _DATASET_CAP_MAP), 使功能不再被权限门拦。 + 取数函数内部会按 preferences.get_*_data_provider() 分流到对应数据源, 不会错误调用 TickFlow。 """ capset = _detect_tickflow_caps(force) @@ -297,16 +297,41 @@ def detect_capabilities(force: bool = False) -> CapabilitySet: return capset +# 数据集 → 能力映射: 第三方源声明某数据集且被选为当前 provider 时补授的能力。 +# 实时行情无对应能力键 (权限由 QuoteService.is_realtime_allowed 判定); +# 五档盘口/WebSocket 暂无第三方数据集契约, 不增广。 +_DATASET_CAP_MAP: tuple[tuple[str, Cap], ...] = ( + ("daily", Cap.KLINE_DAILY_BATCH), + ("adj_factor", Cap.ADJ_FACTOR), + ("minute", Cap.KLINE_MINUTE_BATCH), + ("financial", Cap.FINANCIAL), +) + + def _augment_custom_sources(capset: CapabilitySet) -> None: - """根据用户配置的自定义数据源, 补充对应能力 (不覆盖 TickFlow 已有的)。""" + """根据用户配置的数据源, 补充对应能力 (不覆盖 TickFlow 已有的)。""" try: from app.services import preferences - provider = preferences.get_minute_data_provider() - if provider != "tickflow": - from app.data_providers import custom as custom_sources - if custom_sources.provider_has_dataset(provider, "minute"): - capset.grant(Cap.KLINE_MINUTE_BATCH) - logger.info("custom minute source '%s' detected: granted KLINE_MINUTE_BATCH", provider) + from app.data_providers import custom as custom_sources + + daily_provider = preferences.get_daily_data_provider() + adj_provider = preferences.get_adj_factor_provider() + if adj_provider == "same_as_daily": + adj_provider = daily_provider + active_providers = { + "daily": daily_provider, + "adj_factor": adj_provider, + "minute": preferences.get_minute_data_provider(), + "financial": preferences.get_financial_provider(), + } + for dataset, cap in _DATASET_CAP_MAP: + provider = active_providers[dataset] + if provider != "tickflow" and custom_sources.provider_has_dataset(provider, dataset): + capset.grant(cap) + logger.info( + "custom source '%s' provides dataset '%s': granted %s", + provider, dataset, cap.value, + ) except Exception as e: # noqa: BLE001 logger.debug("custom source augment skipped: %s", e) diff --git a/backend/app/tickflow/repository.py b/backend/app/tickflow/repository.py index 62ede68..c97c4f0 100644 --- a/backend/app/tickflow/repository.py +++ b/backend/app/tickflow/repository.py @@ -26,6 +26,13 @@ import duckdb import polars as pl from app.config import settings +from app.enriched_generation import ( + EnrichedGenerationUnavailableError, + EnrichedPublication, + bump_enriched_generation, + get_enriched_generation, +) +from app.market_time import cn_today from app.parquet import scan_enriched_parquet logger = logging.getLogger(__name__) @@ -318,6 +325,7 @@ class KlineRepository: # 完整 enriched 历史 (含所有指标, 供 filter_history 策略使用) self._enriched_history_cache: pl.DataFrame | None = None # ~100万行 self._enriched_history_start: date | None = None + self._enriched_history_generation: str | None = None self._index_instruments_cache: pl.DataFrame | None = None self._etf_enriched_cache: pl.DataFrame | None = None self._etf_enriched_cache_date: date | None = None @@ -355,12 +363,20 @@ class KlineRepository: def execute_all(self, sql: str, params: list | None = None) -> list[tuple]: """线程安全的 SELECT → fetchall。DuckDB 单 connection 非线程安全,所有读路径须走此方法。""" with self._lock: - return self.db.execute(sql, params or []).fetchall() + cursor = self.db.cursor() + try: + return cursor.execute(sql, params or []).fetchall() + finally: + cursor.close() def execute_one(self, sql: str, params: list | None = None) -> tuple | None: """线程安全的 SELECT → fetchone。""" with self._lock: - return self.db.execute(sql, params or []).fetchone() + cursor = self.db.cursor() + try: + return cursor.execute(sql, params or []).fetchone() + finally: + cursor.close() # ================================================================ # Polars 缓存管理 @@ -477,6 +493,7 @@ class KlineRepository: self._enriched_cache_date = None self._enriched_history_cache = None self._enriched_history_start = None + self._enriched_history_generation = None self._live_agg_cache = None self._live_agg_cache_date = None self._live_agg_check_date = None @@ -489,6 +506,7 @@ class KlineRepository: self._etf_instruments_cache = None self._index_symbol_set_cache = None self._etf_symbol_set_cache = None + self._name_map_cache = None self._index_enriched_cache = None self._index_enriched_cache_date = None @@ -502,6 +520,7 @@ class KlineRepository: """ try: started = time.perf_counter() + refresh_generation = self.get_matrix_data_generation("stock") logger.info("enriched refresh start") step = time.perf_counter() @@ -559,6 +578,10 @@ class KlineRepository: df_full = compute_indicators(df_hist) logger.info("enriched refresh step done: compute indicators rows=%d (%.2fs)", len(df_full), time.perf_counter() - step) + # 异动偏离列 (deviate_Nd = 个股动量 - 基准指数动量), 运行时附着 + from app.indicators.pipeline import attach_deviation_columns + df_full = attach_deviation_columns(df_full, self.store.data_dir) + step = time.perf_counter() logger.info("enriched refresh step start: compute signals") df_full = compute_signals(df_full) @@ -588,8 +611,13 @@ class KlineRepository: logger.info("enriched refresh step done: join instruments (%.2fs)", time.perf_counter() - step) # 缓存完整历史 (含指标+必要基础信息) 供 filter_history/backtest 直接复用 + if self.get_matrix_data_generation("stock") != refresh_generation: + raise EnrichedGenerationUnavailableError( + "enriched data changed while refreshing its history cache" + ) self._enriched_history_cache = df_full self._enriched_history_start = df_full["date"].min() + self._enriched_history_generation = refresh_generation logger.info("enriched 历史缓存: %d rows, %s ~ %s", len(df_full), self._enriched_history_start, latest) @@ -618,6 +646,8 @@ class KlineRepository: logger.info("enriched 缓存已计算: %d 只, 日期 %s (即时计算)", len(df_today), latest) logger.info("enriched refresh done (%.2fs)", time.perf_counter() - started) return + except EnrichedGenerationUnavailableError: + raise except Exception as e: # noqa: BLE001 logger.warning("enriched 即时计算失败, 使用原始 14 列缓存: %s", e) @@ -875,6 +905,8 @@ class KlineRepository: pl.col("high").tail(59).max().alias("_high_59d"), pl.col("low").tail(59).min().alias("_low_59d"), + # 异动偏离 deviate_3d 用 (与 5d/10d/30d 同语义: 尾部第 N 个收盘) + pl.col("close").tail(3).first().alias("_close_3d_ago"), pl.col("close").tail(5).first().alias("_close_5d_ago"), pl.col("close").tail(10).first().alias("_close_10d_ago"), pl.col("close").tail(20).first().alias("_close_20d_ago"), @@ -900,7 +932,7 @@ class KlineRepository: def _live_agg_baseline_date(self, latest: date) -> date: """盘中递推基准日期。当天实时分区存在时使用上一可用交易日。""" - if latest != date.today(): + if latest != cn_today(): return latest try: row = self.execute_one( @@ -1043,6 +1075,7 @@ class KlineRepository: df = pl.scan_parquet(self._inst_glob).collect() if not df.is_empty(): self._instruments_cache = df + self._name_map_cache = None logger.info("instruments 缓存已加载: %d 只", len(df)) except Exception as e: # noqa: BLE001 logger.warning("instruments 缓存刷新失败: %s", e) @@ -1054,6 +1087,7 @@ class KlineRepository: if not df.is_empty(): self._index_instruments_cache = df self._index_symbol_set_cache = None + self._name_map_cache = None logger.info("index instruments 缓存已加载: %d 只", len(df)) except Exception as e: # noqa: BLE001 logger.debug("index instruments 缓存刷新跳过: %s", e) @@ -1079,6 +1113,7 @@ class KlineRepository: df_all = pl.concat(parts, how="diagonal_relaxed").unique(subset=["symbol"], keep="last").sort("symbol") self._etf_instruments_cache = df_all self._etf_symbol_set_cache = None + self._name_map_cache = None logger.info("ETF instruments 缓存已加载: %d 只", len(df_all)) def get_enriched_latest(self) -> tuple[pl.DataFrame, date | None]: @@ -1156,8 +1191,21 @@ class KlineRepository: ) -> pl.DataFrame | None: """从预计算 enriched 历史缓存返回完整区间;缓存不覆盖时返回 None。""" if self._enriched_history_cache is None: + if self._enriched_warming: + # 后台预热中: 返回 None (缓存不覆盖), 调用方各自走慢路径; + # 否则请求线程会与预热线程并发重复 300 天全量重算 + # (同 get_enriched_latest 的守卫语义)。 + return None self._refresh_enriched() cache = self._enriched_history_cache + data_dir = getattr(getattr(self, "store", None), "data_dir", None) + if data_dir is not None: + try: + current_generation = self.get_matrix_data_generation("stock") + except EnrichedGenerationUnavailableError: + return None + if self._enriched_history_generation != current_generation: + return None if cache is None or cache.is_empty() or "date" not in cache.columns: return None @@ -1196,9 +1244,9 @@ class KlineRepository: # 后台预热中: 返回空表, 不触发同步重算 (同 get_enriched_latest 守卫) return pl.DataFrame() self._refresh_enriched() - self._live_agg_check_date = date.today() # 刚建过, 当天不必再查磁盘 + self._live_agg_check_date = cn_today() # 刚建过, 当天不必再查磁盘 else: - today = date.today() + today = cn_today() if self._live_agg_check_date != today: # today 翻天了 (次日开盘首次轮询): 校验基准日是否需要前移重建。 # 同一天内多次调用直接跳过, 避免每轮都扫 parquet。 @@ -1298,16 +1346,28 @@ class KlineRepository: 自选列表/名称批查等场景的统一名称解析入口, 避免各调用方自行合并两份缓存。 symbols 非 None 时只返回命中的条目。 + 全量结果缓存在 _name_map_cache (随三份 instruments 维表刷新失效), + 避免每请求对 ~7000 行维表做 iter_rows 重建。 """ + if self._name_map_cache is not None: + if symbols is None: + return dict(self._name_map_cache) + wanted = set(symbols) + return {s: n for s, n in self._name_map_cache.items() if s in wanted} + # 只构建并缓存全量映射; symbols 过滤只作用于返回值。 + # 若把过滤后的结果写入缓存, 后续不同 symbols 的查询会命中残缺缓存, + # 导致新加入自选的标的查不到名称。 name_map: dict[str, str] = {} for df in (self.get_instruments(), self.get_etf_instruments(), self.get_instruments_asset("index")): if df.is_empty() or "symbol" not in df.columns or "name" not in df.columns: continue - if symbols is not None: - df = df.filter(pl.col("symbol").is_in(symbols)) for symbol, name in df.select(["symbol", "name"]).iter_rows(): name_map.setdefault(symbol, name) - return name_map + self._name_map_cache = name_map + if symbols is None: + return dict(name_map) + wanted = set(symbols) + return {s: n for s, n in name_map.items() if s in wanted} def enriched_latest_date(self) -> date | None: """返回缓存中的 enriched 最新日期。""" @@ -1347,10 +1407,26 @@ class KlineRepository: # 扩展范围用于指标预热 (MA60 需要 ~60 交易日 ≈ 120 日历日) warmup_start = start - timedelta(days=150) - # 扫描14列 parquet - df = self._scan_daily_symbol(symbol, warmup_start, end, None) - if not df.is_empty(): - df = self._compute_enriched_range(df) + # 优先复用预计算 enriched 历史缓存 (300 天全指标, 与回测引擎同源): + # 个股对话框打开时本接口每个行情 tick 被调一次, 逐请求 150 天扫描 + 全套 + # 指标重算是热路径上最大的重复计算。缓存最新日可能不含当日实时行, + # 由下方 get_enriched_latest 覆盖逻辑补齐; 覆盖不足时回退单股计算路径。 + df = pl.DataFrame() + hist = self._enriched_history_cache + if hist is not None and not hist.is_empty() and "date" in hist.columns: + hist_min = self._enriched_history_start + hist_max = hist["date"].max() + if hist_min is not None and hist_min <= start and hist_max >= start: + df = hist.filter( + (pl.col("symbol") == symbol) + & (pl.col("date") >= start) + & (pl.col("date") <= end) + ) + if df.is_empty(): + # 扫描14列 parquet + df = self._scan_daily_symbol(symbol, warmup_start, end, None) + if not df.is_empty(): + df = self._compute_enriched_range(df) # 尝试用缓存数据覆盖最新日 (盘中更准确) cached, cache_date = self.get_enriched_latest() @@ -1821,30 +1897,11 @@ class KlineRepository: return latest def get_matrix_data_generation(self, asset_type: str = "stock") -> str: - """Return a persistent generation bumped by every managed enriched write.""" - path = self.store.data_dir / f".matrix_generation_{asset_type}.json" - try: - payload = json.loads(path.read_text(encoding="utf-8")) - generation = str(payload.get("generation") or "") - if generation: - return generation - except (OSError, TypeError, ValueError, json.JSONDecodeError): - pass - return self._bump_matrix_data_generation(asset_type) + """Return the stable generation for managed enriched readers.""" + return get_enriched_generation(self.store.data_dir, asset_type) def _bump_matrix_data_generation(self, asset_type: str) -> str: - generation = uuid.uuid4().hex - path = self.store.data_dir / f".matrix_generation_{asset_type}.json" - temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") - temporary.write_text( - json.dumps({ - "generation": generation, - "updated_at_ns": time.time_ns(), - }, separators=(",", ":")), - encoding="utf-8", - ) - temporary.replace(path) - return generation + return bump_enriched_generation(self.store.data_dir, asset_type) def symbols_lagging(self, reference_date: date, min_gap_days: int = 3) -> list[str]: """返回日K覆盖落后的标的: 其最新 bar 早于 reference_date - min_gap_days。 @@ -1954,6 +2011,7 @@ class KlineRepository: self._atomic_write_parquet(df.unique(subset=["symbol"], keep="last").sort("symbol"), out) self._index_instruments_cache = None self._etf_instruments_cache = None + self._name_map_cache = None self._refresh_index_instruments() def save_etf_instruments(self, df: pl.DataFrame) -> None: @@ -1966,6 +2024,7 @@ class KlineRepository: out.parent.mkdir(parents=True, exist_ok=True) self._atomic_write_parquet(df.unique(subset=["symbol"], keep="last").sort("symbol"), out) self._etf_instruments_cache = None + self._name_map_cache = None self._refresh_etf_instruments() def refresh_index_views(self) -> None: @@ -2044,25 +2103,36 @@ class KlineRepository: def _write_daily_partition(self, df: pl.DataFrame, table: str) -> None: """按 date 分区写入 parquet,每个日期一个文件,支持 merge-upsert。""" base = self.store.data_dir / table + generation_asset = { + "kline_daily_enriched": "stock", + "kline_etf_enriched": "etf", + }.get(table) + publication = ( + EnrichedPublication(self.store.data_dir, generation_asset) + if generation_asset is not None + else None + ) with self._write_lock: for date_df in df.partition_by("date"): dt = date_df["date"][0] ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt) out = base / f"date={ds}" / "part.parquet" out.parent.mkdir(parents=True, exist_ok=True) + existing = pl.DataFrame() if out.exists(): existing = pl.read_parquet(out) date_df = pl.concat([existing, date_df], how="diagonal_relaxed").unique( subset=["symbol", "date"], keep="last" ) date_df = date_df.sort(["symbol", "date"]) - self._atomic_write_parquet(date_df, out) - generation_asset = { - "kline_daily_enriched": "stock", - "kline_etf_enriched": "etf", - }.get(table) - if generation_asset is not None: - self._bump_matrix_data_generation(generation_asset) + if not existing.is_empty() and existing.equals(date_df): + continue + if publication is None: + self._atomic_write_parquet(date_df, out) + else: + publication.write_parquet(date_df, out) + if publication is not None: + publication.commit() def merge_live_daily_asset(self, asset_type: str, df: pl.DataFrame) -> None: """按 symbol 合并当天指定资产日K分区。用于少量自选实时,不覆盖全市场。""" @@ -2131,6 +2201,35 @@ class KlineRepository: subset=["symbol", "date"], keep="last" ) merged_cache = merged_cache.sort(["symbol"]) + + from app.indicators.pipeline import ENRICHED_STORAGE_COLS + storage_cols = [c for c in ENRICHED_STORAGE_COLS if c in df.columns] + df_storage = df.select(storage_cols).sort(["symbol"]) + base = self.store.data_dir / table + ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt) + out = base / f"date={ds}" / "part.parquet" + out.parent.mkdir(parents=True, exist_ok=True) + publication = ( + EnrichedPublication(self.store.data_dir, asset_type) + if asset_type in {"stock", "etf"} + else None + ) + with self._write_lock: + existing = pl.DataFrame() + if out.exists(): + existing = pl.read_parquet(out) + df_storage = pl.concat([existing, df_storage], how="diagonal_relaxed").unique( + subset=["symbol", "date"], keep="last" + ) + df_storage = df_storage.sort(["symbol"]) + if existing.is_empty() or not existing.equals(df_storage): + if publication is None: + self._atomic_write_parquet(df_storage, out) + else: + publication.write_parquet(df_storage, out) + if publication is not None: + publication.commit() + if asset_type == "stock": self._enriched_cache = merged_cache self._enriched_cache_date = dt @@ -2141,23 +2240,6 @@ class KlineRepository: self._index_enriched_cache = merged_cache self._index_enriched_cache_date = dt - from app.indicators.pipeline import ENRICHED_STORAGE_COLS - storage_cols = [c for c in ENRICHED_STORAGE_COLS if c in df.columns] - df_storage = df.select(storage_cols).sort(["symbol"]) - base = self.store.data_dir / table - ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt) - out = base / f"date={ds}" / "part.parquet" - out.parent.mkdir(parents=True, exist_ok=True) - with self._write_lock: - if out.exists(): - existing = pl.read_parquet(out) - df_storage = pl.concat([existing, df_storage], how="diagonal_relaxed").unique( - subset=["symbol", "date"], keep="last" - ) - self._atomic_write_parquet(df_storage.sort(["symbol"]), out) - if asset_type in {"stock", "etf"}: - self._bump_matrix_data_generation(asset_type) - def flush_live_daily(self, df: pl.DataFrame) -> None: """覆写当天 kline_daily 分区 (实时行情落盘, 非merge)。""" if df.is_empty() or "date" not in df.columns: @@ -2197,16 +2279,10 @@ class KlineRepository: dt = df["date"][0] cache_df = self._with_instrument_metadata(asset_type, df).sort(["symbol"]) if asset_type == "stock": - self._enriched_cache = cache_df - self._enriched_cache_date = dt table = "kline_daily_enriched" elif asset_type == "etf": - self._etf_enriched_cache = cache_df - self._etf_enriched_cache_date = dt table = "kline_etf_enriched" elif asset_type == "index": - self._index_enriched_cache = cache_df - self._index_enriched_cache_date = dt table = "kline_index_enriched" else: return @@ -2218,7 +2294,27 @@ class KlineRepository: ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt) out = base / f"date={ds}" / "part.parquet" out.parent.mkdir(parents=True, exist_ok=True) + publication = ( + EnrichedPublication(self.store.data_dir, asset_type) + if asset_type in {"stock", "etf"} + else None + ) with self._write_lock: - self._atomic_write_parquet(df_storage, out) - if asset_type in {"stock", "etf"}: - self._bump_matrix_data_generation(asset_type) + existing = pl.read_parquet(out) if out.exists() else pl.DataFrame() + if existing.is_empty() or not existing.equals(df_storage): + if publication is None: + self._atomic_write_parquet(df_storage, out) + else: + publication.write_parquet(df_storage, out) + if publication is not None: + publication.commit() + + if asset_type == "stock": + self._enriched_cache = cache_df + self._enriched_cache_date = dt + elif asset_type == "etf": + self._etf_enriched_cache = cache_df + self._etf_enriched_cache_date = dt + elif asset_type == "index": + self._index_enriched_cache = cache_df + self._index_enriched_cache_date = dt diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 70e3f31..99a4a38 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tickflow-stock-panel-backend" -version = "0.1.88" +version = "0.2.1" description = "A 股选股 + 监控 + 回测面板 — TickFlow 适配" readme = "../README.md" requires-python = ">=3.11" diff --git a/backend/tests/backtest/test_dependencies.py b/backend/tests/backtest/test_dependencies.py index 891dbe9..5983d69 100644 --- a/backend/tests/backtest/test_dependencies.py +++ b/backend/tests/backtest/test_dependencies.py @@ -1,5 +1,8 @@ from __future__ import annotations +import types +from datetime import date, timedelta + import polars as pl from app.backtest.strategy import StrategyDependencyResolver @@ -17,7 +20,6 @@ def _strategy(**overrides) -> StrategyDef: trailing_take_profit_activate=None, trailing_take_profit_drawdown=None, max_hold_days=None, - alerts=[], filter_fn=lambda df, params: pl.col("rsi_14") < params["rsi_max"], filter_history_fn=None, lookback_days=20, @@ -39,11 +41,79 @@ def test_resolver_merges_signals_scoring_filter_and_execution_columns(): assert {"macd_dif", "macd_dea", "ma20", "momentum_20d", "rsi_14"} <= set(plan.indicator_columns) assert {"signal_macd_golden", "signal_ma20_breakdown", "signal_limit_up", "signal_limit_down"} <= set(plan.signal_columns) assert {"symbol", "date", "open", "high", "low", "close", "volume", "raw_close", "raw_high"} <= set(plan.base_columns) - assert "raw_low" not in plan.base_columns + # 涨跌停信号族统一加载不复权三价 (翘板用 raw_low 判定"曾触及跌停") + assert "raw_low" in plan.base_columns assert "rsi_6" not in plan.indicator_columns assert plan.full_feature_fallback is False +def test_resolver_includes_raw_low_for_limit_signal_family(): + """回归: 翘板信号 (signal_limit_down_recovery) 依赖不复权 raw_low。 + + 旧 bug: 涨跌停基础列只声明 raw_close/raw_high, panel 加载缺 raw_low, + 因子用到翘板信号时 compute_limit_signals 抛 + "unable to find column raw_low" → 回测特征准备失败。 + """ + plan = StrategyDependencyResolver().resolve( + _strategy(), + params={"rsi_max": 30}, + basic_filter={"enabled": False}, + entry_signals=["signal_limit_down_recovery"], + exit_signals=[], + ) + + assert "signal_limit_down_recovery" in plan.signal_columns + assert {"raw_close", "raw_high", "raw_low"} <= set(plan.base_columns) + + +def test_load_panel_for_backtest_supplies_raw_low_for_recovery(monkeypatch, tmp_path): + """回归 (端到端): resolver → load_panel_for_backtest → compute_limit_signals 全链路。 + + 旧 bug: 涨跌停基础列漏 raw_low, 因子用到翘板信号时回测特征准备抛 + "unable to find column raw_low"。 + """ + from app.backtest.engine import BacktestEngine + + n_days = 40 + start = date(2024, 1, 1) + dates = [start + timedelta(days=i) for i in range(n_days)] + px = [10.0 + 0.01 * i for i in range(n_days)] + panel_lf = pl.LazyFrame({ + "symbol": ["600001.SH"] * n_days, + "date": dates, + "open": px, "high": px, "low": px, "close": px, + "volume": [100_000.0] * n_days, + "amount": [1_000_000.0] * n_days, + "raw_close": px, "raw_high": px, "raw_low": px, + }) + monkeypatch.setattr("app.backtest.engine.pl.scan_parquet", lambda path, *a, **k: panel_lf) + + instruments = pl.DataFrame({ + "symbol": ["600001.SH"], "name": ["普通股"], + "limit_up": [11.0], "limit_down": [9.0], + }) + repo = types.SimpleNamespace( + store=types.SimpleNamespace(data_dir=tmp_path), + get_enriched_range=lambda *a, **k: None, + get_instruments_asset=lambda at: instruments, + get_historical_shares=lambda: pl.DataFrame(), + ) + plan = StrategyDependencyResolver().resolve( + _strategy(), + params={"rsi_max": 30}, + basic_filter={"enabled": False}, + entry_signals=["signal_limit_down_recovery"], + exit_signals=[], + ) + + df = BacktestEngine(repo).load_panel_for_backtest( + ["600001.SH"], start, dates[-1], plan, asset_type="stock", + ) + + assert "raw_low" in df.columns + assert "signal_limit_down_recovery" in df.columns + + def test_resolver_expands_virtual_scoring_dependencies(): strategy = _strategy(meta={ "id": "deps", @@ -65,6 +135,23 @@ def test_resolver_expands_virtual_scoring_dependencies(): assert "ma20_bias" not in plan.indicator_columns +def test_resolver_honors_full_scoring_replacement(): + plan = StrategyDependencyResolver().resolve( + _strategy(), + params={"rsi_max": 30}, + basic_filter={"enabled": False}, + entry_signals=[], + exit_signals=[], + overrides={ + "scoring": {"amount_ratio_5d": 1.0}, + "scoring_replace": True, + }, + ) + + assert "amount" in plan.base_columns + assert "momentum_20d" not in plan.indicator_columns + + def test_history_strategy_without_required_features_falls_back_to_full(caplog): strategy = _strategy( filter_fn=None, diff --git a/backend/tests/backtest/test_factor_batch.py b/backend/tests/backtest/test_factor_batch.py new file mode 100644 index 0000000..f945d78 --- /dev/null +++ b/backend/tests/backtest/test_factor_batch.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +from datetime import date, timedelta + +import numpy as np +import polars as pl +import pytest + +from app.backtest.factor import ( + DERIVED_FACTOR_DEPENDENCIES, + FACTOR_COLUMNS, + FactorBacktestService, + FactorBatchConfig, + FactorConfig, +) + + +def _panel() -> pl.DataFrame: + rows = [] + start = date(2026, 1, 1) + for day in range(8): + for index, symbol in enumerate(("000001.SZ", "000002.SZ", "600000.SH")): + rows.append({ + "symbol": symbol, + "date": start + timedelta(days=day), + "open": 10.0 + index + day * 0.1, + "high": 10.5 + index + day * 0.1, + "low": 9.5 + index + day * 0.1, + "close": 10.0 + index + day * (index + 1) * 0.1, + "volume": 1000.0 + index * 100 + day, + "change_pct": 0.01 * (index + 1) + day * 0.001, + "turnover_rate": 0.02 * (3 - index) + day * 0.001, + }) + return pl.DataFrame(rows) + + +class _Engine: + def __init__(self, panel: pl.DataFrame) -> None: + self.panel = panel + self.calls: list[dict] = [] + + def load_panel(self, symbols, start, end, columns, asset_type): + self.calls.append({ + "symbols": symbols, + "start": start, + "end": end, + "columns": columns, + "asset_type": asset_type, + }) + selected = [column for column in columns if column in self.panel.columns] + return self.panel.select(selected) + + +def _batch_config(factor_names: list[str]) -> FactorBatchConfig: + return FactorBatchConfig( + factor_names=factor_names, + symbols=None, + start=date(2026, 1, 1), + end=date(2026, 1, 8), + n_groups=3, + rebalance="daily", + ) + + +def test_batch_loads_panel_once_and_deduplicates_factors(): + engine = _Engine(_panel()) + result = FactorBacktestService(engine).run_batch( + _batch_config(["change_pct", "turnover_rate", "change_pct"]), + ) + + assert len(engine.calls) == 1 + assert result.config["factor_names"] == ["change_pct", "turnover_rate"] + assert [item.factor_name for item in result.results] == ["change_pct", "turnover_rate"] + assert all(item.error is None for item in result.results) + + +def test_batch_isolates_a_single_factor_failure(monkeypatch): + engine = _Engine(_panel()) + service = FactorBacktestService(engine) + original = service._evaluate_panel + + def evaluate(panel, config, run_id, started_at, **kwargs): + if config.factor_name == "turnover_rate": + raise ValueError("broken factor") + return original(panel, config, run_id, started_at, **kwargs) + + monkeypatch.setattr(service, "_evaluate_panel", evaluate) + result = service.run_batch(_batch_config(["change_pct", "turnover_rate"])) + + assert result.results[0].error is None + assert result.results[1].error == "broken factor" + + +def test_batch_empty_panel_returns_batch_error(): + engine = _Engine(pl.DataFrame()) + result = FactorBacktestService(engine).run_batch(_batch_config(["change_pct"])) + + assert len(engine.calls) == 1 + assert result.results == [] + assert result.error + + +def test_single_factor_contract_remains_compatible(): + engine = _Engine(_panel()) + result = FactorBacktestService(engine).run(FactorConfig( + factor_name="change_pct", + symbols=None, + start=date(2026, 1, 1), + end=date(2026, 1, 8), + n_groups=3, + rebalance="daily", + )) + + assert result.error is None + assert result.config["factor_name"] == "change_pct" + assert result.config["asset_type"] == "stock" + assert result.n_symbols == 3 + assert result.ic_series + + +def test_factor_catalog_covers_normalized_indicator_families(): + factor_ids = [item["id"] for item in FACTOR_COLUMNS] + + assert len(factor_ids) == len(set(factor_ids)) + assert len(factor_ids) > 16 + assert { + "ma5_bias", + "ema60_bias", + "macd_hist_pct", + "boll_position", + "atr_pct", + "kdj_d", + "vol_ratio_10d", + "turnover_ratio_5d", + "log_amount", + "gap_return", + "distance_to_high_60d", + "max_ret_20d", + "ret_skew_20d", + "up_days_20d", + "amihud_20d", + "turnover_z_60d", + "vol_price_corr_20d", + "vwap_bias", + "vol_trend_5_60", + "limit_up_count_20d", + "limit_up_count_60d", + "pb_latest", + "roe_latest", + "revenue_yoy_latest", + "debt_ratio_latest", + } <= set(factor_ids) + assert set(DERIVED_FACTOR_DEPENDENCIES) <= set(factor_ids) + + +def test_derived_factors_are_computed_from_shared_base_panel(): + start = date(2026, 1, 1) + rows = [] + for day in range(70): + close = 10.0 + day + rows.append({ + "symbol": "000001.SZ", + "date": start + timedelta(days=day), + "open": close * 0.99, + "high": close * 1.01, + "low": close * 0.98, + "close": close, + "volume": 1000.0 + day, + "amount": (1000.0 + day) * close, + "turnover_rate": 2.0 + day * 0.01, + }) + engine = _Engine(pl.DataFrame(rows)) + service = FactorBacktestService(engine) + factor_names = [ + "ma20_bias", + "atr_pct", + "boll_position", + "vol_ratio_10d", + "turnover_ratio_5d", + "log_amount", + "gap_return", + "intraday_return", + "close_position", + "distance_to_high_60d", + ] + + panel = service._load_factor_panel(_batch_config(factor_names), factor_names) + last = panel.tail(1).to_dicts()[0] + + assert set(factor_names) <= set(panel.columns) + assert last["ma20_bias"] == pytest.approx(79.0 / 69.5 - 1) + assert last["atr_pct"] == pytest.approx(last["atr_14"] / 79.0) + assert last["boll_position"] == pytest.approx( + (79.0 - last["boll_lower"]) / (last["boll_upper"] - last["boll_lower"]), + ) + assert last["vol_ratio_10d"] == pytest.approx(1069.0 / 1063.5) + assert last["turnover_ratio_5d"] == pytest.approx(2.69 / 2.66 - 1) + assert last["log_amount"] == pytest.approx(float(np.log1p(1069.0 * 79.0))) + assert last["gap_return"] == pytest.approx((79.0 * 0.99) / 78.0 - 1) + assert last["intraday_return"] == pytest.approx(1 / 0.99 - 1) + assert last["close_position"] == pytest.approx(2 / 3) + assert last["distance_to_high_60d"] == pytest.approx(0.0) diff --git a/backend/tests/backtest/test_factor_metrics.py b/backend/tests/backtest/test_factor_metrics.py new file mode 100644 index 0000000..ae13e7d --- /dev/null +++ b/backend/tests/backtest/test_factor_metrics.py @@ -0,0 +1,481 @@ +from __future__ import annotations + +from datetime import date, timedelta +from types import SimpleNamespace + +import polars as pl +import pytest + +from app.backtest.factor import ( + FactorBacktestService, + FactorBatchConfig, + FactorBatchItem, + FactorConfig, + FactorResult, +) +from app.backtest.regime_alignment import ( + align_regime_t_minus_one, + build_regime_filter_mask, +) + + +class _Engine: + def __init__(self, panel: pl.DataFrame, data_dir=None) -> None: + self.panel = panel + self.calls = 0 + self.repo = ( + SimpleNamespace(store=SimpleNamespace(data_dir=data_dir)) + if data_dir is not None + else None + ) + + def load_panel(self, symbols, start, end, columns, asset_type): + self.calls += 1 + selected = [column for column in columns if column in self.panel.columns] + return self.panel.select(selected) + + +def _config( + factor_name: str = "turnover_rate", + **overrides, +) -> FactorConfig: + values = { + "factor_name": factor_name, + "symbols": None, + "start": date(2026, 1, 5), + "end": date(2026, 1, 12), + "n_groups": 2, + "rebalance": "daily", + "fees_pct": 0.0, + "slippage_bps": 0.0, + } + values.update(overrides) + return FactorConfig(**values) + + +def _batch_config(**overrides) -> FactorBatchConfig: + values = { + "factor_names": ["turnover_rate"], + "symbols": None, + "start": date(2026, 1, 5), + "end": date(2026, 1, 12), + "n_groups": 2, + "rebalance": "daily", + "fees_pct": 0.0, + "slippage_bps": 0.0, + } + values.update(overrides) + return FactorBatchConfig(**values) + + +def _daily_panel(days: int = 8, symbols: int = 4) -> pl.DataFrame: + rows = [] + start = date(2026, 1, 5) + for day in range(days): + for index in range(symbols): + rows.append({ + "symbol": f"S{index}", + "date": start + timedelta(days=day), + "open": 10.0 + index, + "high": 10.5 + index, + "low": 9.5 + index, + "close": (10.0 + index) * (1.0 + (index - 1) * day * 0.01), + "volume": 1_000.0, + "amount": 10_000.0, + "turnover_rate": float(index + 1), + }) + return pl.DataFrame(rows) + + +def test_single_and_batch_use_same_full_price_axis_with_internal_factor_null(): + panel = _daily_panel(days=5, symbols=3).with_columns( + pl.when((pl.col("symbol") == "S2") & (pl.col("date") == date(2026, 1, 6))) + .then(None) + .otherwise(pl.col("turnover_rate")) + .alias("turnover_rate") + ) + engine = _Engine(panel) + service = FactorBacktestService(engine) + + single = service.run(_config(end=date(2026, 1, 9))) + batch = service.run_batch(_batch_config(end=date(2026, 1, 9))) + + assert single.error is None + assert batch.results[0].error is None + assert single.ic_mean == batch.results[0].ic_mean + assert single.ir == batch.results[0].ir + assert single.long_short_stats["total_return"] == batch.results[0].long_short_return + + +def test_daily_forward_returns_join_exact_global_trading_dates_on_suspension(): + dates = [date(2026, 1, 5) + timedelta(days=offset) for offset in range(6)] + rows = [ + {"symbol": "B", "date": current, "close": 20.0 + index} + for index, current in enumerate(dates) + ] + rows.extend( + {"symbol": "A", "date": current, "close": 10.0 + index} + for index, current in enumerate(dates) + if index != 3 + ) + panel = pl.DataFrame(rows) + + prepared = FactorBacktestService._attach_shared_next_return( + panel, + _batch_config(end=dates[-1]), + ) + first = prepared.filter( + (pl.col("symbol") == "A") & (pl.col("date") == dates[0]) + ).row(0, named=True) + second = prepared.filter( + (pl.col("symbol") == "A") & (pl.col("date") == dates[1]) + ).row(0, named=True) + + assert first["_forward_return_1d"] == pytest.approx(11.0 / 10.0 - 1.0) + assert first["_forward_return_3d"] is None + assert first["_forward_return_5d"] == pytest.approx(15.0 / 10.0 - 1.0) + assert second["_forward_return_3d"] == pytest.approx(14.0 / 11.0 - 1.0) + + +def test_service_forward_axis_uses_market_partitions_when_selected_universe_has_gap( + tmp_path, +): + dates = [date(2026, 1, 5) + timedelta(days=offset) for offset in range(3)] + panel = pl.DataFrame({ + "symbol": ["A", "A"], + "date": [dates[0], dates[2]], + "open": [10.0, 12.0], + "high": [10.0, 12.0], + "low": [10.0, 12.0], + "close": [10.0, 12.0], + "volume": [1_000.0, 1_000.0], + "amount": [10_000.0, 12_000.0], + "turnover_rate": [1.0, 2.0], + }) + for current in dates: + partition = tmp_path / "kline_daily_enriched" / f"date={current.isoformat()}" + partition.mkdir(parents=True) + pl.DataFrame({"date": [current]}).write_parquet(partition / "part.parquet") + service = FactorBacktestService(_Engine(panel, tmp_path)) + config = _batch_config(start=dates[0], end=dates[-1]) + + loaded = service._load_factor_panel(config, ["turnover_rate"]) + prepared = service._attach_shared_next_return( + loaded, + config, + trading_dates=service._global_trading_dates(config), + ) + + first = prepared.filter(pl.col("date") == dates[0]).row(0, named=True) + assert first["_forward_return_1d"] is None + assert first["_forward_return_3d"] is None + + +def test_tie_aware_groups_do_not_split_constant_factor_by_symbol_order(): + panel = pl.DataFrame({ + "symbol": ["C", "A", "D", "B"], + "date": [date(2026, 1, 5)] * 4, + "factor": [7.0] * 4, + }) + + first = FactorBacktestService._add_groups(panel, "factor", 5).sort("symbol") + second = FactorBacktestService._add_groups( + panel.reverse(), "factor", 5 + ).sort("symbol") + + assert first["_group"].n_unique() == 1 + assert first["_group"].to_list() == second["_group"].to_list() + assert first["_factor_strength"].to_list() == second["_factor_strength"].to_list() + + +def test_weekly_uses_first_actual_trading_day_not_monday(): + panel = pl.DataFrame({ + "symbol": ["A", "A", "A", "A"], + "date": [ + date(2026, 1, 6), + date(2026, 1, 7), + date(2026, 1, 13), + date(2026, 1, 14), + ], + "close": [10.0, 11.0, 12.0, 13.0], + }) + + result = FactorBacktestService._calc_period_return(panel, "weekly") + + assert result.filter(pl.col("date") == date(2026, 1, 6))["_next_return"][0] == pytest.approx(0.2) + assert result.filter(pl.col("date") == date(2026, 1, 7))["_next_return"][0] is None + + +def test_monthly_uses_first_actual_trading_day(): + panel = pl.DataFrame({ + "symbol": ["A", "A", "A", "A"], + "date": [ + date(2026, 1, 6), + date(2026, 1, 7), + date(2026, 2, 3), + date(2026, 2, 4), + ], + "close": [10.0, 11.0, 12.0, 13.0], + }) + + result = FactorBacktestService._calc_period_return(panel, "monthly") + + assert result.filter(pl.col("date") == date(2026, 1, 6))["_next_return"][0] == pytest.approx(0.2) + assert result.filter(pl.col("date") == date(2026, 1, 7))["_next_return"][0] is None + + +def test_factor_weight_and_decomposed_costs_change_group_results(): + panel = _daily_panel(days=2, symbols=4).with_columns( + pl.when(pl.col("date") == date(2026, 1, 6)) + .then( + pl.when(pl.col("symbol") == "S0").then(9.0) + .when(pl.col("symbol") == "S1").then(10.0) + .when(pl.col("symbol") == "S2").then(14.3) + .otherwise(16.9) + ) + .otherwise(pl.col("close")) + .alias("close") + ) + service = FactorBacktestService(_Engine(panel)) + + equal = service.run(_config(end=date(2026, 1, 6), weight="equal")) + weighted = service.run(_config(end=date(2026, 1, 6), weight="factor_weight")) + costly = service.run(_config( + end=date(2026, 1, 6), + weight="factor_weight", + fees_pct=0.009, + commission_pct=0.001, + stamp_tax_pct=0.002, + slippage_bps=10.0, + )) + + equal_q2 = next(item for item in equal.group_stats if item["label"] == "Q2") + weighted_q2 = next(item for item in weighted.group_stats if item["label"] == "Q2") + costly_q2 = next(item for item in costly.group_stats if item["label"] == "Q2") + assert weighted_q2["total_return"] != equal_q2["total_return"] + assert weighted_q2["total_return"] - costly_q2["total_return"] == pytest.approx(0.006) + assert costly.long_short_stats["total_return"] < weighted.long_short_stats["total_return"] + assert costly.config["commission_pct"] == 0.001 + assert costly.config["stamp_tax_pct"] == 0.002 + + +def test_factor_v2_metrics_and_defaults_are_backward_compatible(): + default_result = FactorResult(run_id="r", config={}) + default_item = FactorBatchItem(factor_name="f", label="F", group="G") + + assert default_result.methodology_version == "factor_v2" + assert default_result.yearly_ic == [] + assert default_result.ic_decay == [] + assert default_result.regime_stats == [] + assert default_item.methodology_version == "factor_v2" + assert default_item.yearly_ic == [] + + result = FactorBacktestService(_Engine(_daily_panel())).run(_config()) + assert result.methodology_version == "factor_v2" + assert result.coverage == 1.0 + assert result.turnover is not None + assert result.long_short_sharpe is not None + assert [item["horizon"] for item in result.ic_decay] == [1, 3, 5] + assert result.yearly_ic[0]["year"] == 2026 + assert result.long_short_stats["portfolio_type"] == "theoretical_factor_spread" + assert result.long_short_stats["executable_short"] is False + + +def test_factor_regime_stats_accept_injected_t_minus_one_mapping(tmp_path): + panel = _daily_panel(days=5) + market_dates = [date(2026, 1, 2), *panel["date"].unique().sort().to_list()] + for current in market_dates: + partition = tmp_path / "kline_daily_enriched" / f"date={current.isoformat()}" + partition.mkdir(parents=True) + pl.DataFrame({"date": [current]}).write_parquet(partition / "part.parquet") + regimes = { + date(2026, 1, 2): {"state": "range", "score": 50}, + date(2026, 1, 5): {"state": "weak", "score": 20}, + date(2026, 1, 6): {"state": "strong", "score": 80}, + date(2026, 1, 7): {"state": "strong", "score": 85}, + date(2026, 1, 8): {"state": "range", "score": 50}, + } + + result = FactorBacktestService(_Engine(panel, tmp_path)).run( + _config(end=date(2026, 1, 9)), + regime_by_date=regimes, + ) + + assert result.error is None + assert {item["state"] for item in result.regime_stats} == {"range", "strong", "weak"} + + +def test_factor_regime_stats_tolerates_boundary_formal_start(tmp_path): + """数据边界=正式首日 (如「全部」/「1年」范围起点=本地数据首日) 时不再报错。 + + 首日无 T-1 环境 → 首日不参与环境分组, 其余日期正常分组, 回测不阻断; + 与策略回测 clamp_formal_start_for_regime 的「首日让渡为预热」同口径。 + """ + panel = _daily_panel(days=4) + market_dates = panel["date"].unique().sort().to_list() # 日历从正式首日开始, 无预热日 + for current in market_dates: + partition = tmp_path / "kline_daily_enriched" / f"date={current.isoformat()}" + partition.mkdir(parents=True) + pl.DataFrame({"date": [current]}).write_parquet(partition / "part.parquet") + regimes = { + date(2026, 1, 5): {"state": "weak", "score": 20}, + date(2026, 1, 6): {"state": "strong", "score": 80}, + date(2026, 1, 7): {"state": "strong", "score": 85}, + } + + result = FactorBacktestService(_Engine(panel, tmp_path)).run( + _config(end=date(2026, 1, 8)), + regime_by_date=regimes, + ) + + assert result.error is None + stats = {item["state"]: item for item in result.regime_stats} + assert set(stats) == {"strong", "weak"} + # 首日 (01-05, 无 T-1 环境) 被跳过: weak 桶只含以 01-05 为 T-1 的 01-06 + assert stats["weak"]["n_dates"] == 1 + # strong 桶 = 01-07 (T-1=01-06) + 01-08 (T-1=01-07) + assert stats["strong"]["n_dates"] == 2 + + +def test_align_first_day_boundary_flag_only_tolerates_first_day(): + labels = ("2026-01-05", "2026-01-06", "2026-01-07") + regimes = { + "2026-01-05": ("weak", 20), + "2026-01-06": ("strong", 80), + } + + # 默认 (过滤场景): 正式首日=labels[0] 无前驱 → fail-closed + with pytest.raises(ValueError, match="正式首日"): + align_regime_t_minus_one( + labels, regimes, required_start=date(2026, 1, 5), required_end=None, + ) + + # 统计场景: 首日容差 → 首日 None, 其余正常 T-1 对齐 + aligned = align_regime_t_minus_one( + labels, regimes, + required_start=date(2026, 1, 5), required_end=None, + first_day_boundary_ok=True, + ) + assert aligned == [None, ("weak", 20.0), ("strong", 80.0)] + + # 统计场景内部缺口仍 fail-closed (次日 T-1 缺环境) + with pytest.raises(ValueError, match="缺少前一交易日环境"): + align_regime_t_minus_one( + labels, {"2026-01-05": ("weak", 20)}, + required_start=date(2026, 1, 5), required_end=None, + first_day_boundary_ok=True, + ) + + +def test_factor_regime_stats_reject_missing_actual_market_predecessor(tmp_path): + panel = _daily_panel(days=3) + market_dates = [date(2026, 1, 2), *panel["date"].unique().sort().to_list()] + for current in market_dates: + partition = tmp_path / "kline_daily_enriched" / f"date={current.isoformat()}" + partition.mkdir(parents=True) + pl.DataFrame({"date": [current]}).write_parquet(partition / "part.parquet") + regimes = { + date(2026, 1, 1): {"state": "range", "score": 50}, + date(2026, 1, 5): {"state": "weak", "score": 20}, + date(2026, 1, 6): {"state": "strong", "score": 80}, + } + + with pytest.raises(ValueError, match="2026-01-02"): + FactorBacktestService(_Engine(panel, tmp_path)).run( + _config(end=date(2026, 1, 7)), + regime_by_date=regimes, + ) + + +def test_align_regime_t_minus_one_is_pure_and_fail_closed_in_required_range(): + labels = ("2026-01-05", "2026-01-06", "2026-01-07") + regimes = { + "2026-01-05": ("weak", 20), + "2026-01-06": {"state": "strong", "score": 80}, + } + + aligned = align_regime_t_minus_one( + labels, + regimes, + required_start=date(2026, 1, 6), + required_end=date(2026, 1, 7), + ) + mask = build_regime_filter_mask( + labels, + {"states": ["strong"], "min_score": 60}, + regimes, + required_start=date(2026, 1, 6), + required_end=date(2026, 1, 7), + ) + + assert aligned == [None, ("weak", 20.0), ("strong", 80.0)] + assert mask is not None + assert mask.tolist() == [True, False, True] + + with pytest.raises(ValueError, match="正式首日"): + align_regime_t_minus_one( + labels, + regimes, + required_start=date(2026, 1, 5), + required_end=date(2026, 1, 7), + ) + + lean_regimes = { + "2026-01-05": ("lean_strong", 60), + "2026-01-06": ("range", 50), + } + strong_only_mask = build_regime_filter_mask( + labels, + {"states": ["strong"]}, + lean_regimes, + required_start=date(2026, 1, 6), + required_end=date(2026, 1, 7), + ) + assert strong_only_mask is not None + assert strong_only_mask.tolist() == [True, False, False] + + aggregated_mask = build_regime_filter_mask( + labels, + {"states": ["strong", "lean_strong"]}, + lean_regimes, + required_start=date(2026, 1, 6), + required_end=date(2026, 1, 7), + ) + assert aggregated_mask is not None + assert aggregated_mask.tolist() == [True, True, False] + + with pytest.raises(ValueError, match="缺少前一交易日环境"): + align_regime_t_minus_one( + labels, + {"2026-01-05": ("weak", 20)}, + required_start=date(2026, 1, 6), + required_end=date(2026, 1, 7), + ) + + +def test_clamp_formal_start_for_regime(): + from app.backtest.regime_alignment import clamp_formal_start_for_regime + + # 复现「全部」范围: 正式起点 = 面板首日 (数据边界), 环境过滤启用 → 顺延到次日 + labels = ("2025-08-18", "2025-08-19", "2025-08-20") + filt = {"states": ["strong", "lean_strong"]} + assert clamp_formal_start_for_regime(labels, date(2025, 8, 18), filt) == date(2025, 8, 19) + # 正式起点早于面板首日 (用户选的日期早于数据) 同样顺延 + assert clamp_formal_start_for_regime(labels, date(2025, 1, 1), filt) == date(2025, 8, 19) + + # 面板首日早于正式起点 (已有预热日) → 不动 + assert clamp_formal_start_for_regime(labels, date(2025, 8, 19), filt) == date(2025, 8, 19) + + # 过滤未启用 / 空 filter (无 states 无 min_score) / 起点 None → 不动 + assert clamp_formal_start_for_regime(labels, date(2025, 8, 18), None) == date(2025, 8, 18) + assert clamp_formal_start_for_regime(labels, date(2025, 8, 18), {}) == date(2025, 8, 18) + assert clamp_formal_start_for_regime(labels, None, filt) is None + + # 面板只有一天, 无法顺延 → 原样返回 (由 fail-closed 校验兜底) + assert clamp_formal_start_for_regime(("2025-08-18",), date(2025, 8, 18), filt) == date(2025, 8, 18) + + # 顺延后 T-1 对齐不再报「正式首日」错误: 首日环境成为次日 T-1 + regimes = {"2025-08-18": ("weak", 20), "2025-08-19": ("strong", 80)} + clamped = clamp_formal_start_for_regime(labels, date(2025, 8, 18), filt) + aligned = align_regime_t_minus_one(labels, regimes, required_start=clamped, required_end=None) + assert aligned[0] is None and aligned[1] == ("weak", 20.0) diff --git a/backend/tests/backtest/test_factor_rank_research.py b/backend/tests/backtest/test_factor_rank_research.py new file mode 100644 index 0000000..26379b8 --- /dev/null +++ b/backend/tests/backtest/test_factor_rank_research.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +from datetime import date +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import polars as pl +import pytest +from fastapi import HTTPException + +from app.api import screener as screener_api +from app.api import strategy as strategy_api +from app.backtest.matrix import build_market_data_matrix, validate_signal_matrix +from app.backtest.optimizer import expand_param_grid +from app.backtest.strategy import StrategyDependencyResolver +from app.strategy.engine import StrategyEngine + +STRATEGY_PATH = ( + Path(__file__).resolve().parents[2] + / "app" + / "strategy" + / "builtin" + / "factor_rank_research.py" +) + + +def _market(): + panel = pl.DataFrame({ + "symbol": ["000001.SZ", "000002.SZ", "000003.SZ", "000004.SZ"] * 2, + "date": [date(2024, 1, 2)] * 4 + [date(2024, 1, 3)] * 4, + "open": [10.0] * 8, + "high": [10.5] * 8, + "low": [9.5] * 8, + "close": [10.0] * 8, + "volume": [1_000.0] * 8, + "amount": [1.0, 2.0, 3.0, 4.0, 4.0, 3.0, 2.0, 1.0], + "turnover_rate": [4.0, 3.0, 2.0, 1.0, 1.0, 2.0, 3.0, 4.0], + }) + return build_market_data_matrix( + panel, + field_columns={"amount", "turnover_rate"}, + ) + + +def test_strategy_loads_as_builtin_matrix_native_and_grid_params_validate(): + strategy = StrategyEngine._load_file(STRATEGY_PATH) + + assert strategy.meta["id"] == "factor_rank_research" + assert strategy.meta["research_only"] is True + assert strategy.execution_backend == "matrix_native" + assert strategy.matrix_strategy is not None + assert strategy.meta["scoring"] == {} + combos = expand_param_grid( + strategy.meta["params"], + { + "entry_score": [50.0, 75.0], + "exit_score": [20.0], + "top_rank": [1, 2], + }, + ) + assert len(combos) == 4 + assert strategy.matrix_strategy.required_warmup_bars({}) == 60 + assert {"amount", "turnover_rate", "close"}.issubset( + strategy.matrix_strategy.required_fields() + ) + + +def test_research_template_is_hidden_from_ordinary_strategy_apis(tmp_path): + engine = StrategyEngine(strategy_dirs=[STRATEGY_PATH.parent]) + repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path)) + request = SimpleNamespace( + app=SimpleNamespace(state=SimpleNamespace(strategy_engine=engine, repo=repo)) + ) + + screener_payload = screener_api.strategies(request) + strategy_payload = strategy_api.list_strategies(request) + + assert engine.has("factor_rank_research") + assert "factor_rank_research" in { + item["id"] for item in engine.list_strategies(include_research=True) + } + assert "factor_rank_research" not in { + item["id"] for item in screener_payload["presets"] + } + assert "factor_rank_research" not in { + item["id"] for item in strategy_payload["strategies"] + } + + with pytest.raises(HTTPException) as screener_error: + screener_api.run_preset( + screener_api.PresetRequest( + strategy_id="factor_rank_research", + as_of=date(2024, 1, 2), + ), + request, + ) + assert screener_error.value.status_code == 404 + + with pytest.raises(HTTPException) as strategy_error: + strategy_api.run_strategy( + strategy_api.RunRequest( + strategy_id="factor_rank_research", + as_of=date(2024, 1, 2), + ), + request, + ) + assert strategy_error.value.status_code == 404 + + +def test_dependency_resolver_includes_parameter_scoring_fields(): + strategy = StrategyEngine._load_file(STRATEGY_PATH) + + plan = StrategyDependencyResolver().resolve( + strategy, + params={"scoring": {"amount": 1.0, "ma20_bias": 1.0}}, + basic_filter={"enabled": False}, + entry_signals=strategy.entry_signals, + exit_signals=strategy.exit_signals, + ) + + assert {"amount", "close"}.issubset(plan.base_columns) + assert plan.indicator_columns == frozenset() + assert {"amount", "close"}.issubset(plan.matrix_columns) + + +def test_strategy_uses_controlled_scoring_directions_thresholds_and_top_rank(): + strategy = StrategyEngine._load_file(STRATEGY_PATH).matrix_strategy + market = _market() + + signals = strategy.compute_signals( + market, + { + "scoring": {"amount": 1.0, "turnover_rate": 1.0}, + "directions": {"amount": "high", "turnover_rate": "low"}, + "entry_score": 60.0, + "exit_score": 25.0, + "top_rank": 1, + }, + ) + + validate_signal_matrix(signals, market.shape) + assert signals.entry.sum(axis=1).tolist() == [1, 1] + assert signals.entry.tolist() == [[0, 0, 0, 1], [1, 0, 0, 0]] + assert signals.exit.tolist() == [[1, 0, 0, 0], [0, 0, 0, 1]] + assert signals.entry_signal_ids == ("signal_factor_rank_entry",) + assert signals.exit_signal_ids == ("signal_factor_rank_exit",) + assert not signals.score.flags.writeable + + +def test_strategy_direction_changes_score_without_dynamic_formula_execution(): + strategy = StrategyEngine._load_file(STRATEGY_PATH).matrix_strategy + market = _market() + + high = strategy.compute_signals( + market, + { + "scoring": {"amount": 1.0}, + "directions": {"amount": "high"}, + "entry_score": 0.0, + "exit_score": 0.0, + "top_rank": 4, + }, + ) + low = strategy.compute_signals( + market, + { + "scoring": {"amount": 1.0}, + "directions": {"amount": "low"}, + "entry_score": 0.0, + "exit_score": 0.0, + "top_rank": 4, + }, + ) + + np.testing.assert_allclose(high.score + low.score, 100.0) + with pytest.raises(ValueError, match="unsupported matrix feature"): + strategy.compute_signals( + market, + { + "scoring": {"__import__('os').system('bad')": 1.0}, + "entry_score": 50.0, + "exit_score": 20.0, + "top_rank": 1, + }, + ) + + +@pytest.mark.parametrize( + ("params", "message"), + [ + ({"scoring": {}}, "non-empty scoring"), + ( + {"scoring": {f"factor_{index}": 1.0 for index in range(5)}}, + "at most 4 factors", + ), + ( + { + "scoring": {"amount": 1.0}, + "directions": {"turnover_rate": "low"}, + }, + "absent from scoring", + ), + ( + { + "scoring": {"amount": 1.0}, + "entry_score": 20.0, + "exit_score": 30.0, + }, + "exit_score must not exceed", + ), + ], +) +def test_strategy_rejects_uncontrolled_or_invalid_research_params(params, message): + strategy = StrategyEngine._load_file(STRATEGY_PATH).matrix_strategy + + with pytest.raises(ValueError, match=message): + strategy.compute_signals(_market(), params) diff --git a/backend/tests/backtest/test_matrix_strategy.py b/backend/tests/backtest/test_matrix_strategy.py index 8f1b8be..c1db050 100644 --- a/backend/tests/backtest/test_matrix_strategy.py +++ b/backend/tests/backtest/test_matrix_strategy.py @@ -1,6 +1,7 @@ from __future__ import annotations import gc +import threading from dataclasses import replace from datetime import date, timedelta from pathlib import Path @@ -11,6 +12,7 @@ import polars as pl import pytest from app.backtest import matrix as matrix_module +from app.backtest.factor import FACTOR_COLUMNS, FactorBacktestService from app.backtest.matrix import ( MatrixPipelineConfig, MatrixStrategyPipeline, @@ -80,6 +82,62 @@ def test_common_matrix_features_match_polars_indicator_pipeline(): ) +def test_research_factor_catalog_matches_matrix_features(): + rows = [] + start = date(2024, 1, 1) + for offset in range(120): + for asset_id, symbol in enumerate(("000001.SZ", "600000.SH")): + close = 10.0 + asset_id * 5.0 + offset * (0.02 + asset_id * 0.01) + np.sin(offset / 5.0) + volume = 1000.0 + asset_id * 250.0 + (offset % 9) * 80.0 + rows.append({ + "symbol": symbol, + "date": start + timedelta(days=offset), + "open": close - 0.15 + (offset % 3) * 0.02, + "high": close + 0.35 + asset_id * 0.03, + "low": close - 0.3, + "close": close, + "volume": volume, + "amount": volume * close, + "turnover_rate": 1.0 + asset_id * 0.2 + (offset % 7) * 0.05, + "consecutive_limit_ups": ( + (offset % 17) + 1 if (asset_id == 0 and offset % 17 == 0) + else (2 if (asset_id == 1 and offset % 23 == 0) else 0) + ), + }) + panel = pl.DataFrame(rows) + factor_names = {item["id"] for item in FACTOR_COLUMNS} + expected = FactorBacktestService._compute_missing_factors(panel, factor_names) + from app.backtest.fundamentals import ( + FUNDAMENTAL_FACTOR_NAMES, + attach_matrix_fundamental_fields, + ) + + fundamental_names = sorted(factor_names & FUNDAMENTAL_FACTOR_NAMES) + if fundamental_names: + # 夹具无财务数据: 两条路径都应得到全 null/NaN 列, 而不是报错。 + expected = expected.with_columns([ + pl.lit(None, dtype=pl.Float64).alias(name) + for name in fundamental_names + if name not in expected.columns + ]) + market = build_market_data_matrix( + panel, + field_columns={"amount", "turnover_rate", "consecutive_limit_ups"}, + ) + market = attach_matrix_fundamental_fields(market, None, fundamental_names) + + for name in sorted(factor_names): + expected_values = expected.sort(["date", "symbol"])[name].to_numpy().reshape(market.shape) + np.testing.assert_allclose( + matrix_feature(market, name), + expected_values, + rtol=2e-4, + atol=2e-4, + equal_nan=True, + err_msg=name, + ) + + def _panel_with_missing_asset_bar() -> pl.DataFrame: rows = [] start = date(2024, 1, 1) @@ -261,7 +319,7 @@ def test_builtin_matrix_strategies_use_their_declared_formula_modules(): path for path in strategy_dir.glob("*.py") if path.name != "__init__.py" ) - assert len(strategy_files) == 18 + assert len(strategy_files) == 19 for strategy_path in strategy_files: strategy = StrategyEngine._load_file(strategy_path) assert strategy.execution_backend == "matrix_native" @@ -568,6 +626,61 @@ def test_matrix_cache_can_be_disabled(tmp_path): assert not isinstance(market.close, np.memmap) +def test_matrix_cache_cancellation_removes_staging_output(tmp_path, monkeypatch): + market_root = tmp_path / "kline_daily_enriched" + current = date(2024, 3, 2) + partition = market_root / f"date={current.isoformat()}" + partition.mkdir(parents=True) + pl.DataFrame({ + "symbol": ["000001.SZ"], + "date": [current], + "open": [10.0], + "high": [10.0], + "low": [10.0], + "close": [10.0], + "volume": [1_000.0], + }).write_parquet(partition / "part.parquet") + cache_root = tmp_path / "matrix_cache" + cancel_event = threading.Event() + original_scan = matrix_module._scan_matrix_values + + def cancel_after_scan(*args, **kwargs): + original_scan(*args, **kwargs) + cancel_event.set() + + monkeypatch.setattr(matrix_module, "_scan_matrix_values", cancel_after_scan) + + with pytest.raises( + matrix_module.MatrixPrewarmCancelledError, + match="prewarm cancelled", + ): + load_market_data_matrix_from_parquet( + market_root, + current, + current, + field_columns=set(), + cache_root=cache_root, + cancel_event=cancel_event, + ) + + assert list(cache_root.glob("v*-*")) == [] + assert list(cache_root.glob(".*.tmp")) == [] + + +def test_matrix_cache_can_be_cancelled_before_scan(tmp_path): + cancel_event = threading.Event() + cancel_event.set() + + with pytest.raises(matrix_module.MatrixPrewarmCancelledError): + load_market_data_matrix_from_parquet( + tmp_path / "missing", + date(2024, 3, 1), + date(2024, 3, 1), + field_columns=set(), + cancel_event=cancel_event, + ) + + def test_matrix_cache_prunes_by_bytes_and_leaves_no_staging_directory(tmp_path): market_root = tmp_path / "kline_daily_enriched" current = date(2024, 3, 4) @@ -673,7 +786,7 @@ def test_registered_builtin_matrix_strategies_share_one_cache_profile(): profile = build_matrix_cache_profile(engine, "stock") strategies = engine.strategy_definitions() - assert len(strategies) == 18 + assert len(strategies) == 19 assert all(strategy.execution_backend == "matrix_native" for strategy in strategies) assert profile.warmup_bars > 0 assert profile.forward_bars == max(int(strategy.max_hold_days or 0) for strategy in strategies) @@ -740,6 +853,22 @@ def test_chunked_matrix_score_matches_previous_full_matrix_formula(): ) np.testing.assert_array_equal(actual, expected) + inverted = build_matrix_score( + market, + universe, + weights, + "score", + True, + fallback=np.zeros(market.shape, dtype=np.float32), + directions={"feature_a": "low", "feature_b": "low"}, + ) + np.testing.assert_allclose( + inverted[universe], + np.float32(100.0) - expected[universe], + rtol=1e-6, + atol=1e-6, + ) + def test_signal_slice_is_zero_copy_and_masking_only_allocates_final_flags(): entry = np.ones((5, 2), dtype=np.uint8) diff --git a/backend/tests/backtest/test_mining.py b/backend/tests/backtest/test_mining.py new file mode 100644 index 0000000..5c73e23 --- /dev/null +++ b/backend/tests/backtest/test_mining.py @@ -0,0 +1,941 @@ +from __future__ import annotations + +import json +from dataclasses import asdict, replace +from datetime import date, timedelta + +import numpy as np +import polars as pl +import pytest + +import app.backtest.mining as mining_module +from app.backtest.mining import ( + CandidateEvaluation, + CorrelationResult, + FactorMetric, + MiningBudget, + MiningRequest, + MiningService, + NestedValidationConfig, + _searchable_factors, + beam_search_factor_combinations, + compute_rank_correlation, + generate_nested_folds, + nested_fold_count, + prune_correlated_factors, + required_outer_folds, + required_trading_bars, + validation_config_for_profile, +) + + +def _panel(days: int = 12, assets: int = 6) -> pl.DataFrame: + rows = [] + start = date(2024, 1, 2) + for day_id in range(days): + current = start + timedelta(days=day_id * 2) + for asset_id in range(assets): + target = float(asset_id + (day_id % 2) * 0.1) + rows.append({ + "symbol": f"{asset_id:06d}.SZ", + "date": current, + "good": target, + "inverse": -target, + "copy": target * 10.0, + "noise": float((asset_id * 7 + day_id * 3) % assets), + "_next_return": target, + }) + return pl.DataFrame(rows) + + +def _metrics() -> tuple[FactorMetric, ...]: + return ( + FactorMetric("good", 1.0, 1.0, 1.0, 0.2, 1.0), + FactorMetric("copy", 0.9, 0.9, 1.0, 0.1, 1.0), + FactorMetric("inverse", 0.8, 0.8, 1.0, 0.1, -1.0), + FactorMetric("noise", 0.2, 0.1, 0.9, 0.5, 0.0), + ) + + +def test_profiles_enforce_hard_limits_and_are_json_serializable(): + request = MiningRequest.for_profile("exploratory", ["good", "noise"]) + + assert request.budget.beam_width == 8 + assert request.validation.outer_train_bars == 126 + assert request.validation.outer_test_bars == 63 + assert NestedValidationConfig.balanced().outer_train_bars == 504 + assert NestedValidationConfig.strict().outer_train_bars == 756 + assert json.loads(request.to_json())["factor_names"] == ["good", "noise"] + assert asdict(request)["validation"]["purge_bars"] == 30 + with pytest.raises(ValueError, match="max_factors"): + MiningBudget(max_factors=49) + with pytest.raises(ValueError, match="beam_width"): + MiningBudget(beam_width=33) + with pytest.raises(ValueError, match="max_trials"): + MiningBudget(max_trials=257) + with pytest.raises(ValueError, match="existing strategy count"): + MiningRequest( + factor_names=("good",), + existing_strategy_ids=tuple(str(index) for index in range(9)), + ) + + +@pytest.mark.parametrize( + ("profile", "required_bars", "folds"), + [ + ("exploratory", 219, 1), + ("balanced", 786, 3), + ("strict", 1164, 3), + ], +) +def test_profile_trading_bar_requirements(profile, required_bars, folds): + config = validation_config_for_profile(profile) + + assert required_outer_folds(profile) == folds + assert required_trading_bars(config, folds) == required_bars + assert nested_fold_count(required_bars - 1, config) == folds - 1 + assert nested_fold_count(required_bars, config) == folds + + +def test_rank_correlation_is_pairwise_finite_symmetric_and_average_ranked(): + panel = pl.DataFrame({ + "date": [date(2024, 1, 2)] * 5 + [date(2024, 1, 3)] * 5, + "a": [1.0, 1.0, 3.0, np.nan, 5.0, 5.0, 4.0, 3.0, 2.0, 1.0], + "b": [2.0, 2.0, 6.0, 8.0, np.inf, 1.0, 2.0, 3.0, 4.0, 5.0], + "c": [1.0, None, 2.0, 3.0, 4.0, None, None, None, None, None], + "constant": [7.0] * 10, + }) + + result = compute_rank_correlation( + panel, + ["a", "b", "c", "constant"], + date(2024, 1, 2), + date(2024, 1, 3), + ) + + matrix = np.asarray(result.matrix) + counts = np.asarray(result.pair_counts) + np.testing.assert_allclose(matrix, matrix.T) + np.testing.assert_array_equal(np.diag(matrix), np.ones(4)) + assert counts[0, 1] == 2 + assert counts[0, 2] == 1 + assert counts[2, 2] == 1 + assert counts[3, 3] == 2 + assert counts[0, 3] == 0 + assert np.isnan(matrix[0, 3]) + assert result.n_dates == 2 + assert set(result.timing_ms) == {"filter", "rank_accumulate", "finalize", "total"} + + daily_correlations = [] + for daily in panel.partition_by("date"): + valid = daily.filter(pl.col("a").is_finite() & pl.col("b").is_finite()) + daily_correlations.append(np.corrcoef( + valid["a"].rank(method="average").to_numpy(), + valid["b"].rank(method="average").to_numpy(), + )[0, 1]) + assert daily_correlations == pytest.approx([1.0, -1.0]) + assert matrix[0, 1] == pytest.approx(np.mean(daily_correlations)) + + +def test_rank_correlation_reranks_after_pairwise_finite_intersection(): + panel = pl.DataFrame({ + "date": [date(2024, 1, 2)] * 4, + "a": [1.0, 2.0, 3.0, 4.0], + "b": [10.0, None, 20.0, 30.0], + "c": [None, 10.0, 20.0, 30.0], + }) + + result = compute_rank_correlation(panel, ["a", "b", "c"]) + matrix = np.asarray(result.matrix) + + assert matrix[0, 1] == pytest.approx(1.0) + assert matrix[0, 2] == pytest.approx(1.0) + assert matrix[1, 2] == pytest.approx(1.0) + + +def test_pruning_uses_deterministic_metric_order_and_reports_representative(): + correlation = CorrelationResult( + factor_names=("a", "b", "c"), + matrix=((1.0, 0.9, 0.1), (0.9, 1.0, 0.2), (0.1, 0.2, 1.0)), + pair_counts=((10, 10, 10), (10, 10, 10), (10, 10, 10)), + elapsed_ms=1.0, + n_dates=2, + n_rows=10, + ) + metrics = ( + FactorMetric("b", 1.0, 2.0, 0.8, 0.1), + FactorMetric("c", 0.5, 1.0, 1.0, 0.1), + FactorMetric("a", 1.0, 2.0, 0.8, 0.2), + ) + + result = prune_correlated_factors(metrics, correlation, 0.8) + + assert result.selected == ("b", "c") + assert result.excluded[0].factor_id == "a" + assert result.excluded[0].representative == "b" + assert result.excluded[0].rho == pytest.approx(0.9) + + +def test_pruning_ignores_unestimable_factor_pairs(): + correlation = CorrelationResult( + factor_names=("a", "b"), + matrix=((1.0, float("nan")), (float("nan"), 1.0)), + pair_counts=((10, 0), (0, 10)), + elapsed_ms=1.0, + n_dates=2, + n_rows=10, + ) + metrics = ( + FactorMetric("a", 1.0, 1.0, 1.0, 0.1), + FactorMetric("b", 0.9, 0.9, 1.0, 0.1), + ) + + result = prune_correlated_factors(metrics, correlation, 0.8) + + assert result.selected == ("a", "b") + assert result.excluded == () + + +def test_beam_search_learns_direction_from_train_and_honors_real_proxy_budget(): + panel = _panel() + + first = beam_search_factor_combinations( + panel, + ["noise", "inverse", "good"], + max_combination_size=4, + beam_width=32, + max_trials=7, + ) + second = beam_search_factor_combinations( + panel, + ["good", "noise", "inverse"], + max_combination_size=4, + beam_width=32, + max_trials=7, + ) + reversed_rows = beam_search_factor_combinations( + panel.reverse(), + ["good", "noise", "inverse"], + max_combination_size=4, + beam_width=32, + max_trials=7, + ) + + assert first.trials_used == 7 + assert first.budget_exhausted is True + assert first.candidates == second.candidates == reversed_rows.candidates + inverse = next( + candidate + for candidate in first.candidates + if candidate.factor_names == ("inverse",) + ) + assert inverse.directions == (-1,) + assert all(len(candidate.factor_names) <= 4 for candidate in first.candidates) + assert all( + set(candidate.weights) <= {1.0, 2.0} + and sum(weight == 2.0 for weight in candidate.weights) <= 1 + for candidate in first.candidates + ) + + +def test_beam_search_does_not_access_rows_outside_explicit_train_range(): + panel = _panel(days=10) + train_end = sorted(panel["date"].unique().to_list())[5] + changed = panel.with_columns( + pl.when(pl.col("date") > train_end) + .then(-pl.col("_next_return") * 1_000.0) + .otherwise(pl.col("_next_return")) + .alias("_next_return") + ) + + original_result = beam_search_factor_combinations( + panel, + ["good", "inverse", "noise"], + train_end=train_end, + max_trials=30, + ) + changed_result = beam_search_factor_combinations( + changed, + ["good", "inverse", "noise"], + train_end=train_end, + max_trials=30, + ) + + assert original_result.candidates == changed_result.candidates + + +def test_nested_folds_use_trading_labels_with_explicit_purge_and_embargo(): + labels = [f"T{index:02d}" for index in range(22)] + config = NestedValidationConfig( + outer_train_bars=12, + outer_test_bars=4, + outer_step_bars=4, + inner_train_bars=5, + inner_test_bars=2, + inner_step_bars=2, + purge_bars=1, + embargo_bars=2, + min_train_bars=5, + ) + + folds = generate_nested_folds(labels, config) + + assert folds[0].outer.train_labels == tuple(labels[:12]) + assert folds[0].outer.purge_labels == ("T12",) + assert folds[0].outer.test_labels == tuple(labels[13:17]) + assert folds[0].outer.embargo_labels == ("T17", "T18") + assert folds[0].inner[0].train_labels == tuple(labels[:5]) + assert folds[0].inner[0].purge_labels == ("T05",) + assert folds[0].inner[0].test_labels == ("T06", "T07") + assert set(folds[0].inner[-1].test_labels).issubset(folds[0].outer.train_labels) + with pytest.raises(ValueError, match="insufficient trading bars"): + generate_nested_folds(labels[:10], config) + + +def _frame_labels(frame) -> tuple[str, ...]: + return tuple( + frame.select(pl.col("date").cast(pl.Utf8).str.slice(0, 10).unique().sort()) + .to_series() + .to_list() + ) + + +class _Evaluator: + def __init__(self) -> None: + self.calls: list[tuple[tuple[str, ...], tuple[str, ...], dict]] = [] + + def evaluate_candidate(self, train, test, definition): + train_labels = _frame_labels(train) + test_labels = _frame_labels(test) + self.calls.append((train_labels, test_labels, dict(definition))) + return {"score": len(definition.get("factor_names", ())) or 0.1} + + +class _AlternatingWinnerEvaluator(_Evaluator): + """Prefer the good factor only in windows before 2024-01-25; every later window prefers the runner-up. + + With a 20-day panel (dates spaced two calendar days apart) and a + non-overlapping outer step, every inner and outer window of the first + fold falls in January while every window of the second fold falls after + the boundary, so the per-fold winner flips and cross-fold evaluation + has two distinct definitions to score. + """ + + def evaluate_candidate(self, train, test, definition): + self.calls.append((_frame_labels(train), _frame_labels(test), dict(definition))) + prefer_good = _frame_labels(test)[0] < "2024-01-25" + names = definition.get("factor_names") or () + is_good = bool(names) and names[0] == "good" + return {"score": 2.0 if is_good == prefer_good else 1.0} + + +class _LabelEvaluator(_Evaluator): + def __init__(self) -> None: + super().__init__() + self.label_calls: list[tuple[tuple[str, ...], tuple[str, ...], dict]] = [] + + def evaluate_candidate(self, train, test, definition): + raise AssertionError("label evaluator must not receive fold DataFrames") + + def evaluate_candidate_labels(self, train_labels, test_labels, definition): + self.label_calls.append(( + tuple(train_labels), + tuple(test_labels), + dict(definition), + )) + return {"score": len(definition.get("factor_names", ())) or 0.1} + + +@pytest.mark.parametrize("evaluator_type", [_Evaluator, _LabelEvaluator]) +def test_mining_service_reselects_per_inner_fold_and_keeps_tests_out_of_selection( + evaluator_type, +): + panel = _panel(days=10) + validation = NestedValidationConfig( + outer_train_bars=8, + outer_test_bars=2, + outer_step_bars=2, + inner_train_bars=4, + inner_test_bars=2, + inner_step_bars=2, + purge_bars=0, + embargo_bars=0, + min_train_bars=4, + ) + request = MiningRequest( + factor_names=("good", "copy", "inverse", "noise"), + correlation_threshold=0.95, + budget=MiningBudget( + max_combination_size=2, + beam_width=4, + max_proxy_trials=18, + max_trials=3, + ), + validation=validation, + profile="exploratory", + ) + folds = generate_nested_folds( + sorted(str(value) for value in panel["date"].unique().to_list()), + validation, + ) + assert len(folds) == 1 + metric_calls: list[tuple[str, ...]] = [] + + def metric_provider(train, factor_names): + assert tuple(factor_names) == request.factor_names + labels = tuple( + train.select(pl.col("date").cast(pl.Utf8).str.slice(0, 10).unique().sort()) + .to_series() + .to_list() + ) + metric_calls.append(labels) + return _metrics() + + evaluator = evaluator_type() + result = MiningService().run( + panel, + request, + metric_provider=metric_provider, + evaluator=evaluator, + ) + + nested = folds[0] + expected_selection_labels = [ + *(inner.train_labels for inner in nested.inner), + nested.outer.train_labels, + ] + assert metric_calls == expected_selection_labels + assert result.trials_used == request.budget.max_trials + assert result.proxy_trials_used <= request.budget.max_proxy_trials + evaluator_calls = ( + evaluator.label_calls + if isinstance(evaluator, _LabelEvaluator) + else evaluator.calls + ) + assert len(evaluator_calls) == len(nested.inner) + 1 + for call, inner in zip(evaluator_calls[:-1], nested.inner, strict=True): + train_labels, test_labels, _ = call + assert train_labels == inner.train_labels + assert test_labels == inner.test_labels + assert not set(nested.outer.test_labels).intersection(train_labels + test_labels) + outer_train, outer_test, outer_definition = evaluator_calls[-1] + assert outer_train == nested.outer.train_labels + assert outer_test == nested.outer.test_labels + assert result.folds[0].selected_candidate_id is not None + selected = next( + candidate + for candidate in result.folds[0].candidates + if candidate.candidate_id == result.folds[0].selected_candidate_id + ) + assert outer_definition == selected.definition() + + changed = panel.with_columns( + pl.when( + pl.col("date") + .cast(pl.Utf8) + .str.slice(0, 10) + .is_in(nested.outer.test_labels) + ) + .then(-pl.col("good") * 999.0) + .otherwise(pl.col("good")) + .alias("good") + ) + without_evaluation = MiningService().run( + panel, + request, + factor_metrics=_metrics(), + ) + changed_result = MiningService().run( + changed, + request, + factor_metrics=_metrics(), + ) + assert without_evaluation.folds[0].selected_factors == ( + changed_result.folds[0].selected_factors + ) + assert without_evaluation.folds[0].candidates == changed_result.folds[0].candidates + + with pytest.raises(ValueError, match="requires at least 3 outer folds"): + MiningService().run( + panel, + replace(request, profile="balanced"), + factor_metrics=_metrics(), + ) + + +def test_mining_service_requires_fold_local_metrics_with_evaluator(): + panel = _panel(days=10) + request = MiningRequest( + factor_names=("good",), + budget=MiningBudget(max_combination_size=1, max_proxy_trials=12, max_trials=3), + validation=NestedValidationConfig( + outer_train_bars=8, + outer_test_bars=2, + outer_step_bars=2, + inner_train_bars=4, + inner_test_bars=2, + inner_step_bars=2, + purge_bars=0, + embargo_bars=0, + min_train_bars=4, + ), + profile="exploratory", + ) + + with pytest.raises(ValueError, match="metric_provider is required"): + MiningService().run(panel, request, evaluator=_Evaluator()) + with pytest.raises(ValueError, match="factor_metrics must not be supplied"): + MiningService().run( + panel, + request, + factor_metrics=(FactorMetric("good", 1.0, 1.0, 1.0, 0.1),), + metric_provider=lambda _train, _names: ( + FactorMetric("good", 1.0, 1.0, 1.0, 0.1), + ), + evaluator=_Evaluator(), + ) + + +def test_target_endpoint_is_removed_from_every_train_phase_but_not_tests(monkeypatch): + panel = _panel(days=10) + labels = sorted(panel["date"].unique().to_list()) + target_dates = pl.DataFrame({ + "date": labels, + "_target_date": [*labels[1:], labels[-1] + timedelta(days=2)], + }) + panel = panel.join(target_dates, on="date", how="left") + validation = NestedValidationConfig( + outer_train_bars=8, + outer_test_bars=2, + outer_step_bars=2, + inner_train_bars=4, + inner_test_bars=2, + inner_step_bars=2, + purge_bars=0, + embargo_bars=0, + min_train_bars=4, + ) + request = MiningRequest( + factor_names=("good",), + budget=MiningBudget(max_combination_size=1, max_proxy_trials=12, max_trials=3), + validation=validation, + profile="exploratory", + ) + nested = generate_nested_folds([str(label) for label in labels], validation)[0] + train_ends = [*(inner.train_end for inner in nested.inner), nested.outer.train_end] + metric_frames = [] + correlation_frames = [] + beam_frames = [] + + def assert_target_bounded(frame, train_end): + assert frame.filter( + pl.col("_target_date").cast(pl.Utf8).str.slice(0, 10) > train_end + ).is_empty() + + def metric_provider(train, factor_names): + train_end = train_ends[len(metric_frames)] + assert tuple(factor_names) == request.factor_names + assert_target_bounded(train, train_end) + metric_frames.append(train) + return (FactorMetric("good", 1.0, 1.0, 1.0, 0.1),) + + original_correlation = mining_module.compute_rank_correlation + original_beam = mining_module.beam_search_factor_combinations + + def checked_correlation(frame, *args, **kwargs): + train_end = train_ends[len(correlation_frames)] + assert_target_bounded(frame, train_end) + correlation_frames.append(frame) + return original_correlation(frame, *args, **kwargs) + + def checked_beam(frame, *args, **kwargs): + train_end = train_ends[len(beam_frames)] + assert_target_bounded(frame, train_end) + beam_frames.append(frame) + return original_beam(frame, *args, **kwargs) + + class EndpointEvaluator(_Evaluator): + def __init__(self): + super().__init__() + self.test_frames = [] + + def evaluate_candidate(self, train, test, definition): + self.test_frames.append(test) + return super().evaluate_candidate(train, test, definition) + + monkeypatch.setattr(mining_module, "compute_rank_correlation", checked_correlation) + monkeypatch.setattr(mining_module, "beam_search_factor_combinations", checked_beam) + evaluator = EndpointEvaluator() + + result = MiningService().run( + panel, + request, + metric_provider=metric_provider, + evaluator=evaluator, + ) + + assert result.folds[0].error is None + assert len(metric_frames) == len(correlation_frames) == len(beam_frames) == 3 + evaluation_folds = [*nested.inner, nested.outer] + assert len(evaluator.calls) == len(evaluation_folds) + for (train_labels, test_labels, _), test_frame, fold in zip( + evaluator.calls, + evaluator.test_frames, + evaluation_folds, + strict=True, + ): + assert train_labels == fold.train_labels[:-1] + assert test_labels == fold.test_labels + assert test_frame.height == len(fold.test_labels) * 6 + assert not test_frame.filter( + pl.col("_target_date").cast(pl.Utf8).str.slice(0, 10) > fold.test_end + ).is_empty() + + +def test_outer_refit_fails_when_selected_factor_structure_is_missing(): + panel = _panel(days=10) + request = MiningRequest( + factor_names=("good", "copy"), + correlation_threshold=0.8, + budget=MiningBudget(max_combination_size=1, max_proxy_trials=24, max_trials=8), + validation=NestedValidationConfig( + outer_train_bars=8, + outer_test_bars=2, + outer_step_bars=2, + inner_train_bars=4, + inner_test_bars=2, + inner_step_bars=2, + purge_bars=0, + embargo_bars=0, + min_train_bars=4, + ), + profile="exploratory", + ) + + def metric_provider(train, _factor_names): + if train["date"].n_unique() < 8: + return ( + FactorMetric("good", 1.0, 1.0, 1.0, 0.1), + FactorMetric("copy", 0.5, 0.5, 1.0, 0.1), + ) + return ( + FactorMetric("good", 0.5, 0.5, 1.0, 0.1), + FactorMetric("copy", 1.0, 1.0, 1.0, 0.1), + ) + + evaluator = _Evaluator() + result = MiningService().run( + panel, + request, + metric_provider=metric_provider, + evaluator=evaluator, + ) + + fold = result.folds[0] + assert len(evaluator.calls) == 2 + assert fold.selected_candidate_id is None + assert fold.outer_evaluation is None + assert fold.error == "outer retraining did not reproduce selected candidate structure" + assert {candidate.factor_names for candidate in fold.candidates} == {("copy",)} + + +def test_candidate_evaluation_dataclass_score_must_be_finite(): + panel = _panel(days=10) + request = MiningRequest( + factor_names=("good",), + budget=MiningBudget(max_combination_size=1, max_proxy_trials=12, max_trials=8), + validation=NestedValidationConfig( + outer_train_bars=8, + outer_test_bars=2, + outer_step_bars=2, + inner_train_bars=4, + inner_test_bars=2, + inner_step_bars=2, + purge_bars=0, + embargo_bars=0, + min_train_bars=4, + ), + profile="exploratory", + ) + + class NonFiniteEvaluator: + def evaluate_candidate(self, train, test, definition): + return CandidateEvaluation(score=float("nan"), metrics={"source": "test"}) + + result = MiningService().run( + panel, + request, + metric_provider=lambda _train, _names: ( + FactorMetric("good", 1.0, 1.0, 1.0, 0.1), + ), + evaluator=NonFiniteEvaluator(), + ) + + assert result.folds[0].selected_candidate_id is None + assert result.folds[0].error == "no candidate completed inner validation within budget" + + +def test_beam_search_skips_factors_without_sufficient_valid_observations(): + panel = _panel().with_columns( + pl.lit(None).cast(pl.Float64).alias("all_null"), + pl.lit(7.0).alias("constant"), + pl.col("inverse").alias("valid_inverse"), + pl.when(pl.col("symbol").is_in(["000000.SZ", "000001.SZ"])) + .then(pl.col("good")) + .otherwise(None) + .alias("sparse"), + ) + + result = beam_search_factor_combinations( + panel, + ["all_null", "constant", "good", "sparse", "valid_inverse"], + max_combination_size=2, + max_trials=30, + ) + invalid_only = beam_search_factor_combinations( + panel, + ["all_null", "constant", "sparse"], + max_combination_size=2, + max_trials=30, + ) + + assert result.candidates + assert all( + set(candidate.factor_names) <= {"good", "valid_inverse"} + for candidate in result.candidates + ) + assert {candidate.factor_names for candidate in result.candidates} >= { + ("good",), + ("valid_inverse",), + ("good", "valid_inverse"), + } + assert all(candidate.dates > 0 and candidate.observations >= 3 for candidate in result.candidates) + assert invalid_only.candidates == () + + +def test_small_real_allowance_evaluates_factor_and_finalists_stay_capped(): + panel = _panel(days=10) + request = MiningRequest( + factor_names=("good", "noise"), + existing_strategy_ids=tuple(f"existing-{index}" for index in range(8)), + budget=MiningBudget(max_combination_size=1, max_proxy_trials=12, max_trials=1), + validation=NestedValidationConfig( + outer_train_bars=8, + outer_test_bars=2, + outer_step_bars=2, + inner_train_bars=4, + inner_test_bars=2, + inner_step_bars=2, + purge_bars=0, + embargo_bars=0, + min_train_bars=4, + ), + profile="exploratory", + ) + evaluator = _Evaluator() + + result = MiningService().run( + panel, + request, + metric_provider=lambda _train, _names: ( + FactorMetric("good", 1.0, 1.0, 1.0, 0.1), + FactorMetric("noise", 0.5, 0.5, 1.0, 0.1), + ), + evaluator=evaluator, + ) + + assert len(evaluator.calls) == 1 + assert evaluator.calls[0][2]["kind"] == "factor_rank" + finalists = result.folds[0].candidates + assert all(candidate.kind == "factor_rank" for candidate in finalists) + assert len(finalists) == 2 + assert result.folds[0].selected_candidate_id is not None + + +def test_existing_strategies_are_benchmarked_on_every_outer_fold(): + panel = _panel(days=18) + request = MiningRequest( + factor_names=("good",), + existing_strategy_ids=("alpha", "beta"), + budget=MiningBudget(max_combination_size=1, max_proxy_trials=24, max_trials=64), + validation=NestedValidationConfig( + outer_train_bars=8, + outer_test_bars=2, + outer_step_bars=4, + inner_train_bars=4, + inner_test_bars=2, + inner_step_bars=2, + purge_bars=0, + embargo_bars=0, + min_train_bars=4, + ), + profile="exploratory", + ) + evaluator = _Evaluator() + + result = MiningService().run( + panel, + request, + metric_provider=lambda _train, _names: (FactorMetric("good", 1.0, 1.0, 1.0, 0.1),), + evaluator=evaluator, + ) + + assert len(result.folds) == 3 + for fold in result.folds: + assert all(candidate.kind == "factor_rank" for candidate in fold.candidates) + signatures = [candidate_id for candidate_id, _ in fold.benchmark_evaluations] + assert signatures == ["strategy:alpha", "strategy:beta"] + for _, evaluation in fold.benchmark_evaluations: + assert evaluation.error is None + evaluated_strategy_kinds = [ + definition["kind"] + for *_, definition in evaluator.calls + if definition["kind"] == "existing_strategy" + ] + assert len(evaluated_strategy_kinds) == 6 + + +def test_winner_definitions_are_cross_evaluated_on_all_outer_folds(): + panel = _panel(days=20) + request = MiningRequest( + factor_names=("good", "noise"), + budget=MiningBudget(max_combination_size=1, max_proxy_trials=24, max_trials=64), + validation=NestedValidationConfig( + outer_train_bars=8, + outer_test_bars=2, + outer_step_bars=10, + inner_train_bars=4, + inner_test_bars=2, + inner_step_bars=2, + purge_bars=0, + embargo_bars=0, + min_train_bars=4, + ), + profile="exploratory", + ) + evaluator = _AlternatingWinnerEvaluator() + + result = MiningService().run( + panel, + request, + metric_provider=lambda _train, _names: ( + FactorMetric("good", 1.0, 1.0, 1.0, 0.1), + FactorMetric("noise", 0.9, 0.9, 1.0, 0.1), + ), + evaluator=evaluator, + ) + + assert len(result.folds) == 2 + winners = {fold.selected_candidate_id for fold in result.folds} + assert len(winners) == 2 + for fold in result.folds: + cross_ids = [candidate_id for candidate_id, _ in fold.cross_evaluations] + assert cross_ids == sorted(winners - {fold.selected_candidate_id}) + for _, evaluation in fold.cross_evaluations: + assert evaluation.error is None + + +def test_benchmarks_run_even_when_factor_track_fails_on_a_fold(): + panel = _panel(days=18) + request = MiningRequest( + factor_names=("good",), + existing_strategy_ids=("alpha",), + budget=MiningBudget(max_combination_size=1, max_proxy_trials=24, max_trials=64), + validation=NestedValidationConfig( + outer_train_bars=8, + outer_test_bars=2, + outer_step_bars=4, + inner_train_bars=4, + inner_test_bars=2, + inner_step_bars=2, + purge_bars=0, + embargo_bars=0, + min_train_bars=4, + ), + profile="exploratory", + ) + + class _FailingEvaluator(_Evaluator): + def evaluate_candidate(self, train, test, definition): + raise RuntimeError("backtest exploded") + + result = MiningService().run( + panel, + request, + metric_provider=lambda _train, _names: (FactorMetric("good", 1.0, 1.0, 1.0, 0.1),), + evaluator=_FailingEvaluator(), + ) + + assert len(result.folds) == 1 + fold = result.folds[0] + assert fold.error == "no candidate completed inner validation within budget" + assert fold.selected_candidate_id is None + assert [candidate_id for candidate_id, _ in fold.benchmark_evaluations] == [ + "strategy:alpha" + ] + benchmark = fold.benchmark_evaluations[0][1] + assert benchmark.error == "backtest exploded" + + +def test_searchable_factors_are_capped_by_beam_width(): + budget = MiningBudget(beam_width=3) + assert _searchable_factors(("a", "b", "c", "d", "e"), budget) == ("a", "b", "c") + assert _searchable_factors(("a",), MiningBudget(beam_width=8)) == ("a",) + + +def test_beam_search_only_receives_capped_factor_inputs(): + names = tuple(f"factor_{index:02d}" for index in range(20)) + rows = [] + start = date(2024, 1, 2) + for day_id in range(12): + for asset_id in range(6): + row = { + "symbol": f"{asset_id:06d}.SZ", + "date": start + timedelta(days=day_id), + "_next_return": float(asset_id), + } + for factor_id, name in enumerate(names): + row[name] = float((asset_id * (factor_id + 1)) % 7) + factor_id * 0.5 + rows.append(row) + panel = pl.DataFrame(rows) + request = MiningRequest( + factor_names=names, + correlation_threshold=1.0, + budget=MiningBudget( + max_combination_size=2, + beam_width=4, + max_proxy_trials=96, + max_trials=8, + ), + validation=NestedValidationConfig( + outer_train_bars=8, + outer_test_bars=2, + outer_step_bars=4, + inner_train_bars=4, + inner_test_bars=2, + inner_step_bars=2, + purge_bars=0, + embargo_bars=0, + min_train_bars=4, + ), + profile="exploratory", + ) + evaluator = _Evaluator() + + result = MiningService().run( + panel, + request, + metric_provider=lambda _train, factor_names: tuple( + FactorMetric(name, 1.0 - index * 0.01, 1.0, 1.0, 0.1) + for index, name in enumerate(factor_names) + ), + evaluator=evaluator, + ) + + searched = { + factor_name + for candidate in result.folds[0].candidates + for factor_name in candidate.factor_names + } + assert searched <= set(names[:4]) + assert any(len(candidate.factor_names) == 2 for candidate in result.folds[0].candidates) diff --git a/backend/tests/backtest/test_mining_runtime.py b/backend/tests/backtest/test_mining_runtime.py new file mode 100644 index 0000000..b982700 --- /dev/null +++ b/backend/tests/backtest/test_mining_runtime.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +from datetime import date, timedelta +from types import SimpleNamespace + +import polars as pl +import pytest + +from app.backtest.mining import MiningCandidate +from app.backtest.mining_runtime import ( + TrainingMetricProvider, + _decode_runtime_request, + _load_compact_factor_panel, + _prepare_base_market, + _rank_artifact_candidates, + _regime_date_count, + attach_single_forward_return, +) +from app.services import regime_builder + + +def test_runtime_rejects_insufficient_balanced_range_before_loading_panel( + tmp_path, +) -> None: + first = date(2023, 10, 13) + dates = [first + timedelta(days=offset) for offset in range(690)] + for value in dates: + partition = tmp_path / "kline_daily_enriched" / f"date={value.isoformat()}" + partition.mkdir(parents=True) + (partition / "part.parquet").touch() + + payload = { + "run_id": "insufficient-balanced", + "request": { + "factor_names": ["turnover_rate"], + "strategy_ids": [], + "asset_type": "stock", + "budget_profile": "balanced", + "start": dates[0].isoformat(), + "end": dates[-1].isoformat(), + }, + } + + with pytest.raises( + ValueError, + match=( + r"balanced mining requires at least 786 enriched trading bars for " + r"3 outer folds; effective range .* has 690" + ), + ): + _decode_runtime_request(payload, tmp_path, SimpleNamespace()) + + +def test_single_forward_label_uses_global_trading_axis_without_jump() -> None: + first = date(2024, 1, 2) + missing = first + timedelta(days=1) + resumed = first + timedelta(days=2) + panel = pl.DataFrame({ + "symbol": ["000001.SZ", "000001.SZ"], + "date": [first, resumed], + "close": [10.0, 12.0], + "turnover_rate": [1.0, 2.0], + "unused_factor": [9.0, 10.0], + }) + + result = attach_single_forward_return( + panel, + start=first, + end=resumed, + horizon=1, + trading_dates=[first, missing, resumed], + factor_names=["turnover_rate"], + ) + + first_row = result.filter(pl.col("date") == first).row(0, named=True) + assert first_row["_target_date"] == missing + assert first_row["_next_return"] is None + assert "_forward_return_1d" not in result.columns + assert "close" not in result.columns + assert "unused_factor" not in result.columns + assert result.columns == [ + "symbol", + "date", + "turnover_rate", + "_next_return", + "_target_date", + ] + assert result.schema["_next_return"] == pl.Float32 + assert result.schema["turnover_rate"] == pl.Float32 + + fast = attach_single_forward_return( + panel.sort(["date", "symbol"]), + start=first, + end=resumed, + horizon=1, + trading_dates=[first, missing, resumed], + factor_names=["turnover_rate"], + assume_unique_symbol_date=True, + ) + assert fast.equals(result) + + +def test_compact_factor_panel_matches_full_symbol_independent_calculation( + monkeypatch, +) -> None: + first = date(2024, 1, 2) + rows = [] + for symbol, offset in (("a", 0.0), ("b", 2.0), ("c", 4.0)): + for day in range(70): + close = 10.0 + offset + day * 0.1 + rows.append({ + "symbol": symbol, + "date": first + timedelta(days=day), + "open": close - 0.1, + "high": close + 0.2, + "low": close - 0.2, + "close": close, + "volume": 1000.0 + day, + "amount": close * (1000.0 + day), + "turnover_rate": 1.0 + day / 100.0, + }) + raw = pl.DataFrame(rows).sort(["symbol", "date"]) + + class Engine: + def load_panel(self, *_args, **_kwargs): + return raw + + engine = Engine() + from app.backtest.factor import FactorBacktestService + + factor_service = FactorBacktestService(engine) + config = SimpleNamespace( + symbols=None, + start=first, + end=first + timedelta(days=69), + asset_type="stock", + ) + names = ("momentum_20d", "rsi_14", "ma20_bias") + full = factor_service._compute_missing_factors( + raw, + set(names), + assume_sorted=True, + ).select(["symbol", "date", "close", *names]).with_columns([ + pl.col(name).cast(pl.Float32) for name in names + ]).sort(["date", "symbol"]) + + monkeypatch.setattr("app.backtest.mining_runtime._SYMBOL_BATCH_SIZE", 1) + compact = _load_compact_factor_panel( + factor_service, + config, + names, + expected_generation="generation", + cancel_check=None, + ) + + assert compact.equals(full) + + +def test_compact_factor_panel_rejects_noncanonical_symbol_date_keys() -> None: + first = date(2024, 1, 2) + canonical = pl.DataFrame({ + "symbol": ["a", "a", "b"], + "date": [first, first + timedelta(days=1), first], + "open": [1.0, 1.0, 1.0], + "high": [1.0, 1.0, 1.0], + "low": [1.0, 1.0, 1.0], + "close": [1.0, 1.0, 1.0], + "volume": [1.0, 1.0, 1.0], + "amount": [1.0, 1.0, 1.0], + "turnover_rate": [1.0, 1.0, 1.0], + }) + config = SimpleNamespace( + symbols=None, + start=first, + end=first + timedelta(days=1), + asset_type="stock", + ) + + class Engine: + def __init__(self, panel): + self.panel = panel + + def load_panel(self, *_args, **_kwargs): + return self.panel + + from app.backtest.factor import FactorBacktestService + + for invalid in ( + canonical.with_columns(pl.Series( + "date", + [first + timedelta(days=1), first, first], + )), + pl.concat([canonical.slice(0, 1), canonical]), + ): + with pytest.raises(ValueError, match="unique symbol/date"): + _load_compact_factor_panel( + FactorBacktestService(Engine(invalid)), + config, + ("turnover_rate",), + expected_generation="generation", + cancel_check=None, + ) + + +def test_artifact_finalists_are_truncated_by_oos_sharpe_before_signature() -> None: + low = MiningCandidate(candidate_id="a-low", kind="existing_strategy", strategy_id="low") + high = MiningCandidate(candidate_id="z-high", kind="existing_strategy", strategy_id="high") + rows = [ + {"candidate_signature": "a-low", "sharpe": 0.2, "skipped": False}, + {"candidate_signature": "z-high", "sharpe": 1.4, "skipped": False}, + ] + + assert _rank_artifact_candidates([low, high], rows, limit=1) == [high] + + +def test_prepare_base_market_forwards_cancel_event(monkeypatch, tmp_path) -> None: + cancel_event = object() + captured = {} + plan = SimpleNamespace( + base_columns=frozenset(), + intermediate_columns=frozenset(), + indicator_columns=frozenset(), + signal_columns=frozenset(), + matrix_columns=frozenset(), + instrument_columns=frozenset(), + warmup_bars=1, + full_feature_fallback=False, + execution_backend="matrix_native", + fundamental_columns=frozenset(), + ) + research = SimpleNamespace(entry_signals=[], exit_signals=[]) + strategy_engine = SimpleNamespace(get=lambda _strategy_id: research) + service = SimpleNamespace( + _effective_basic_filter=lambda *_args: {}, + engine=SimpleNamespace(), + ) + request = SimpleNamespace( + factor_names=("turnover_rate",), + strategy_ids=(), + asset_type="stock", + forward_horizon=1, + start=date(2024, 1, 2), + end=date(2024, 1, 3), + symbols=None, + ) + + monkeypatch.setattr( + "app.backtest.mining_runtime.StrategyDependencyResolver.resolve", + lambda *_args, **_kwargs: plan, + ) + monkeypatch.setattr( + "app.backtest.mining_runtime.build_matrix_cache_profile", + lambda *_args, **_kwargs: SimpleNamespace(), + ) + + def load_matrix(*_args, **kwargs): + captured.update(kwargs) + return "market" + + service.engine.load_market_data_matrix_for_backtest = load_matrix + + result = _prepare_base_market( + service, + strategy_engine, + tmp_path, + request, + expected_generation="generation", + cancel_check=cancel_event, + ) + + assert result == "market" + assert captured["cancel_event"] is cancel_event + + +def test_training_metric_provider_uses_only_supplied_fold() -> None: + start = date(2024, 1, 2) + rows = [] + for day_offset in range(3): + for asset_id in range(4): + rows.append({ + "symbol": f"{asset_id:06d}.SZ", + "date": start + timedelta(days=day_offset), + "factor": float(asset_id), + "_next_return": ( + float(asset_id) if day_offset < 2 else float(-asset_id) + ), + }) + panel = pl.DataFrame(rows) + train = panel.filter(pl.col("date") < start + timedelta(days=2)) + + provider = TrainingMetricProvider("_next_return") + metric = provider(train, ["factor"])[0] + + assert metric.rank_ic == pytest.approx(1.0) + assert metric.coverage == pytest.approx(1.0) + assert provider.calls[0]["end"] == (start + timedelta(days=1)).isoformat() + assert provider.calls[0]["rows"] == 8 + + +def test_regime_date_count_uses_t_minus_one_market_labels(tmp_path) -> None: + labels = [date(2024, 1, 2) + timedelta(days=offset) for offset in range(4)] + panel = pl.DataFrame({"date": labels}) + regime_builder.upsert_regime_history(tmp_path, pl.DataFrame({ + "date": labels[:3], + "state": ["weak", "strong", "lean_strong"], + "score": [20, 80, 70], + })) + fold = SimpleNamespace( + test_start=labels[1].isoformat(), + test_end=labels[3].isoformat(), + ) + + assert _regime_date_count(panel, fold, "strong", tmp_path) == 2 + assert _regime_date_count(panel, fold, "weak", tmp_path) == 1 diff --git a/backend/tests/backtest/test_research_api.py b/backend/tests/backtest/test_research_api.py new file mode 100644 index 0000000..af0b920 --- /dev/null +++ b/backend/tests/backtest/test_research_api.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from datetime import date +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from app.api import backtest as api +from app.backtest.factor import FACTOR_COLUMNS + + +def test_factor_batch_api_rejects_unknown_factor(): + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace())) + req = api.FactorBatchRequest(factor_names=["unknown"]) + + with pytest.raises(HTTPException) as exc_info: + api.factor_batch(req, request) + assert exc_info.value.status_code == 400 + assert "unknown" in str(exc_info.value.detail) + + +def test_factor_batch_request_accepts_full_research_catalog(): + factor_names = [item["id"] for item in FACTOR_COLUMNS] + + request = api.FactorBatchRequest(factor_names=factor_names) + + assert len(request.factor_names) > 16 + assert request.factor_names == factor_names + + +def test_candidate_api_create_list_and_update(monkeypatch, tmp_path): + monkeypatch.setattr(api.settings, "data_dir", tmp_path) + created = api.candidate_create(api.CandidateCreateRequest( + kind="factor", + name="RSI 候选", + source_id="rsi_14", + config={"factor_name": "rsi_14"}, + metrics={"ic_mean": 0.03}, + data_as_of=date(2026, 8, 11), + )) + + assert api.candidates_list()["items"][0]["id"] == created["id"] + updated = api.candidate_update( + created["id"], + api.CandidateUpdateRequest(status="validated"), + ) + assert updated["status"] == "validated" + + +def test_candidate_api_returns_clear_error_for_corrupt_file(monkeypatch, tmp_path): + monkeypatch.setattr(api.settings, "data_dir", tmp_path) + path = tmp_path / "user_data" / "research_candidates.json" + path.parent.mkdir(parents=True) + path.write_text("not-json", encoding="utf-8") + + with pytest.raises(HTTPException) as exc_info: + api.candidates_list() + assert exc_info.value.status_code == 500 + assert "损坏" in str(exc_info.value.detail) diff --git a/backend/tests/backtest/test_research_candidates.py b/backend/tests/backtest/test_research_candidates.py new file mode 100644 index 0000000..d9f4d3a --- /dev/null +++ b/backend/tests/backtest/test_research_candidates.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +import json + +import pytest + +from app.backtest.candidates import ( + CandidateStore, + CandidateStoreError, + CandidateValidationError, +) + + +def _create(store: CandidateStore): + return store.create( + kind="factor", + name="20日动量候选", + source_id="momentum_20d", + config={"factor_name": "momentum_20d", "start": "2026-01-01"}, + metrics={"ic_mean": 0.04, "ir": 0.8}, + data_as_of="2026-08-11", + ) + + +def test_candidate_crud_and_atomic_file(tmp_path): + store = CandidateStore(tmp_path) + created = _create(store) + + assert store.path.exists() + assert not store.path.with_suffix(".json.tmp").exists() + assert store.list()[0]["id"] == created["id"] + + updated = store.update(created["id"], status="validated", name="动量候选 A") + assert updated["status"] == "validated" + assert store.list()[0]["name"] == "动量候选 A" + + store.delete(created["id"]) + assert store.list() == [] + + +def test_candidate_mining_provenance_is_idempotent_and_conflict_safe(tmp_path): + store = CandidateStore(tmp_path) + kwargs = { + "origin_run_id": "mining-run-idempotent", + "candidate_signature": "factor-signature", + "kind": "strategy", + "name": "挖掘组合候选", + "source_id": "mined_factor_example", + "config": { + "strategy_id": "mined_factor_example", + "origin_run_id": "mining-run-idempotent", + "candidate_signature": "factor-signature", + "factor_names": ["turnover_rate"], + "directions": ["high"], + "weights": [1.0], + }, + "metrics": {"oos_sharpe": 0.9}, + "data_as_of": "2026-08-11", + } + + first = store.create_or_get_by_provenance(**kwargs) + second = store.create_or_get_by_provenance(**kwargs) + + assert second == first + assert len(store.list()) == 1 + with pytest.raises(CandidateValidationError, match="冲突"): + store.create_or_get_by_provenance( + **{**kwargs, "metrics": {"oos_sharpe": 1.1}} + ) + assert store.list() == [first] + + +def test_candidate_rejects_full_result_fields(tmp_path): + store = CandidateStore(tmp_path) + + with pytest.raises(CandidateValidationError, match="不允许的字段"): + store.create( + kind="strategy", + name="策略候选", + source_id="demo", + config={"strategy_id": "demo", "equity_curve": [1, 2]}, + metrics={}, + data_as_of=None, + ) + + +def test_candidate_stores_factor_mining_summary(tmp_path): + store = CandidateStore(tmp_path) + config = { + "factor_name": "momentum_20d", + "origin_run_id": "mining-run-001", + "candidate_signature": "factor-signature", + "regime_state": "bull", + "algorithm_version": "mining-v1", + "methodology_version": "factor-v2", + } + metrics = { + "oos_sharpe": 1.24, + "oos_return": 0.18, + "oos_max_drawdown": -0.09, + "oos_positive_fold_ratio": 0.75, + "oos_n_trades": 48, + "valid_folds": 4, + "skipped_folds": 1, + "confidence": 0.9, + "coverage": 0.82, + "turnover": 0.36, + "long_short_sharpe": 1.11, + } + + created = store.create( + kind="factor", + name="挖掘因子候选", + source_id="momentum_20d", + config=config, + metrics=metrics, + data_as_of="2026-08-11", + ) + + assert created["config"] == config + assert created["metrics"] == metrics + assert store.list()[0]["metrics"] == metrics + + +def test_candidate_stores_strategy_mining_factor_combination(tmp_path): + store = CandidateStore(tmp_path) + config = { + "strategy_id": "mined-factor-combination", + "origin_run_id": "mining-run-002", + "candidate_signature": "strategy-signature", + "regime_state": "sideways", + "algorithm_version": "mining-v1", + "methodology_version": "strategy-v1", + "factor_names": ["momentum_20d", "rsi_14"], + "directions": ["high", "low"], + "weights": [0.6, 0.4], + } + metrics = { + "oos_sharpe": 0.98, + "oos_return": 0.12, + "oos_max_drawdown": -0.07, + "oos_positive_fold_ratio": 0.8, + "oos_n_trades": 31, + "valid_folds": 5, + "skipped_folds": 0, + "confidence": 0.86, + } + + created = store.create( + kind="strategy", + name="挖掘组合候选", + source_id="mined-factor-combination", + config=config, + metrics=metrics, + data_as_of="2026-08-11", + ) + + assert created["config"] == config + assert created["metrics"] == metrics + assert store.list()[0]["config"] == config + + +@pytest.mark.parametrize("nested_value", [{"fold_1": 1.2}, [1.2, 0.8]]) +def test_candidate_rejects_nested_mining_metrics(tmp_path, nested_value): + store = CandidateStore(tmp_path) + + with pytest.raises(CandidateValidationError, match="只允许保存标量"): + store.create( + kind="factor", + name="挖掘因子候选", + source_id="momentum_20d", + config={"factor_name": "momentum_20d"}, + metrics={"oos_sharpe": nested_value}, + data_as_of=None, + ) + + +def test_candidate_rejects_unknown_metric_field(tmp_path): + store = CandidateStore(tmp_path) + + with pytest.raises(CandidateValidationError, match="不允许的字段"): + store.create( + kind="factor", + name="挖掘因子候选", + source_id="momentum_20d", + config={"factor_name": "momentum_20d"}, + metrics={"fold_metrics": 1.0}, + data_as_of=None, + ) + + +def test_candidate_rejects_non_json_config(tmp_path): + store = CandidateStore(tmp_path) + + with pytest.raises(CandidateValidationError, match="无法序列化"): + store.create( + kind="strategy", + name="策略候选", + source_id="demo", + config={"strategy_id": object()}, + metrics={}, + data_as_of=None, + ) + + +def test_candidate_loads_legacy_missing_optional_fields(tmp_path): + path = tmp_path / "user_data" / "research_candidates.json" + path.parent.mkdir(parents=True) + path.write_text( + json.dumps( + [ + { + "id": "legacy", + "kind": "factor", + "name": "旧候选", + "config": {"factor_name": "rsi_14", "equity_curve": [1, 2]}, + "metrics": {"ic_mean": 0.03, "trades": [{"symbol": "000001.SZ"}]}, + } + ] + ), + encoding="utf-8", + ) + + item = CandidateStore(tmp_path).list()[0] + assert item["source_id"] == "rsi_14" + assert item["config"] == {"factor_name": "rsi_14"} + assert item["metrics"] == {"ic_mean": 0.03} + assert item["status"] == "pending" + + +def test_candidate_corrupt_file_fails_closed(tmp_path): + path = tmp_path / "user_data" / "research_candidates.json" + path.parent.mkdir(parents=True) + path.write_text("{broken", encoding="utf-8") + store = CandidateStore(tmp_path) + + with pytest.raises(CandidateStoreError, match="损坏"): + store.list() + with pytest.raises(CandidateStoreError, match="损坏"): + _create(store) + assert path.read_text(encoding="utf-8") == "{broken" diff --git a/backend/tests/backtest/test_strategy_backtest_correctness.py b/backend/tests/backtest/test_strategy_backtest_correctness.py index d9d8489..4dbde70 100644 --- a/backend/tests/backtest/test_strategy_backtest_correctness.py +++ b/backend/tests/backtest/test_strategy_backtest_correctness.py @@ -9,6 +9,7 @@ import polars as pl from app.backtest.engine import BacktestEngine, SimResult from app.backtest.matrix import build_market_data_matrix, make_signal_matrix, rolling_mean from app.backtest.strategy import StrategyBacktestConfig, StrategyBacktestService +from app.services import regime_builder from app.strategy.engine import StrategyDef @@ -23,7 +24,6 @@ def _strategy(**kwargs) -> StrategyDef: trailing_take_profit_activate=None, trailing_take_profit_drawdown=None, max_hold_days=None, - alerts=[], filter_fn=lambda df, params: pl.lit(True), filter_history_fn=None, lookback_days=1, @@ -43,14 +43,17 @@ class _StrategyEngineStub: class _RepoStub: + def __init__(self, data_dir=None) -> None: + self.store = SimpleNamespace(data_dir=data_dir) + def get_index_daily(self, *args, **kwargs) -> pl.DataFrame: return pl.DataFrame() class _EngineStub: - def __init__(self, panel: pl.DataFrame) -> None: + def __init__(self, panel: pl.DataFrame, data_dir=None) -> None: self.panel = panel - self.repo = _RepoStub() + self.repo = _RepoStub(data_dir) self.load_args = None self.load_count = 0 self.sim_panel: pl.DataFrame | None = None @@ -162,6 +165,114 @@ def test_basic_filter_only_limits_entries_not_panel_rows(): } +def test_non_matrix_strategy_applies_regime_filter_and_reports_config(tmp_path): + start = date(2024, 1, 1) + panel = pl.DataFrame([ + { + "symbol": "A", + "name": "A", + "date": start + timedelta(days=offset), + "open": 10.0, + "high": 10.0, + "low": 10.0, + "close": 10.0, + "volume": 1000.0, + "amount": 1000.0, + "signal_limit_up": False, + "signal_limit_down": False, + } + for offset in range(-1, 3) + ]).sort(["symbol", "date"]) + regime_builder.upsert_regime_history(tmp_path, pl.DataFrame({ + "date": [ + start - timedelta(days=1), + start, + start + timedelta(days=1), + start + timedelta(days=2), + ], + "state": ["weak", "weak", "strong", "strong"], + "score": [10, 10, 85, 85], + })) + engine = _EngineStub(panel, data_dir=tmp_path) + service = StrategyBacktestService(engine=engine, strategy_engine=_StrategyEngineStub(_strategy())) + regime_filter = {"states": ["strong"]} + + result = service.run(StrategyBacktestConfig( + strategy_id="test", + symbols=None, + start=start, + end=start + timedelta(days=2), + matching="close_t", + mode="position", + regime_filter=regime_filter, + )) + + assert result.error is None + assert engine.sim_matrix is not None + assert engine.sim_matrix.entry[:, 0].tolist() == [0, 0, 1] + assert result.config["regime_filter"] == regime_filter + assert result.stats["selection"] == { + "strategy_matches": 1, + "entry_candidates": 1, + "entry_trigger_filtered": 0, + "entry_trigger_enabled": False, + } + + +def test_regime_filter_matches_raw_five_level_states(tmp_path): + start = date(2024, 1, 1) + panel = pl.DataFrame([ + { + "symbol": "A", + "name": "A", + "date": start + timedelta(days=offset), + "open": 10.0, + "high": 10.0, + "low": 10.0, + "close": 10.0, + "volume": 1000.0, + "amount": 1000.0, + "signal_limit_up": False, + "signal_limit_down": False, + } + for offset in range(-1, 3) + ]).sort(["symbol", "date"]) + regime_builder.upsert_regime_history(tmp_path, pl.DataFrame({ + "date": [ + start - timedelta(days=1), + start, + start + timedelta(days=1), + start + timedelta(days=2), + ], + "state": ["weak", "lean_strong", "strong", "strong"], + "score": [10, 60, 85, 85], + })) + + def run_with(states: list[str]): + engine = _EngineStub(panel, data_dir=tmp_path) + service = StrategyBacktestService( + engine=engine, + strategy_engine=_StrategyEngineStub(_strategy()), + ) + result = service.run(StrategyBacktestConfig( + strategy_id="test", + symbols=None, + start=start, + end=start + timedelta(days=2), + matching="close_t", + mode="position", + regime_filter={"states": states}, + )) + assert result.error is None + assert engine.sim_matrix is not None + return engine.sim_matrix.entry[:, 0].tolist() + + # 强势与偏强是两个独立档位; 只选强势时偏强日不入场 + assert run_with(["strong"]) == [0, 0, 1] + assert run_with(["strong", "lean_strong"]) == [0, 1, 1] + assert run_with(["lean_strong"]) == [0, 1, 0] + + def test_selection_stats_explain_entry_trigger_filtering(): start = date(2024, 1, 1) panel = pl.DataFrame([ @@ -423,6 +534,21 @@ def test_matrix_optimizer_preparation_loads_and_builds_base_data_once(): assert all(result.stats["shared_market_data_bytes"] == prepared.market_data.nbytes for result in results) +def test_matrix_prepare_signature_includes_regime_filter(): + base = dict( + strategy_id="native", + symbols=None, + start=date(2024, 1, 1), + end=date(2024, 1, 2), + ) + without_filter = StrategyBacktestConfig(**base) + with_filter = StrategyBacktestConfig(**base, regime_filter={"states": ["strong"]}) + + assert StrategyBacktestService._matrix_prepare_signature(without_filter) != ( + StrategyBacktestService._matrix_prepare_signature(with_filter) + ) + + def test_matrix_cache_preserves_trades_daily_equity_and_core_stats(): start = date(2024, 1, 1) panel = pl.DataFrame([ diff --git a/backend/tests/backtest/test_worker_process.py b/backend/tests/backtest/test_worker_process.py index 30bba1a..18cd68f 100644 --- a/backend/tests/backtest/test_worker_process.py +++ b/backend/tests/backtest/test_worker_process.py @@ -1,13 +1,21 @@ from __future__ import annotations +import queue +import threading from datetime import date, timedelta +from types import SimpleNamespace import polars as pl +import pytest +from app.backtest import worker as worker_module +from app.backtest.mining import benchmark_candidate from app.backtest.optimizer import OptimizeConfig from app.backtest.strategy import StrategyBacktestConfig from app.backtest.walkforward import WalkForwardConfig -from app.backtest.worker import make_worker_task, run_worker_task +from app.backtest.worker import BacktestWorkerError, make_worker_task, run_worker_task +from app.enriched_generation import bump_enriched_generation, get_enriched_generation +from app.services.mining_jobs import MiningRunStore def _write_worker_strategy(data_dir) -> None: @@ -33,7 +41,6 @@ ENTRY_SIGNALS = [] EXIT_SIGNALS = [] STOP_LOSS = None MAX_HOLD_DAYS = 1 -ALERTS = [] class AlwaysEntry: def required_fields(self): @@ -89,6 +96,49 @@ def _write_market_data(data_dir, start: date, days: int = 3) -> None: }).write_parquet(instruments_dir / "part.parquet") +def _write_mining_market_data( + data_dir, + start: date, + *, + days: int = 219, + assets: int = 4, +) -> None: + symbols = [f"60000{asset}.SH" for asset in range(assets)] + for offset in range(days): + current = start + timedelta(days=offset) + rows = [] + for asset_id, symbol in enumerate(symbols): + close = 10.0 + asset_id + offset * (0.01 + asset_id * 0.002) + rows.append({ + "symbol": symbol, + "date": current, + "open": close, + "high": close * 1.01, + "low": close * 0.99, + "close": close, + "volume": 1000.0 + asset_id * 100.0, + "amount": close * (100000.0 + asset_id * 1000.0), + "raw_close": close, + "raw_high": close * 1.01, + "raw_low": close * 0.99, + "turnover_rate": 1.0 + asset_id * 0.5 + offset * 0.001, + "consecutive_limit_ups": 0, + "consecutive_limit_downs": 0, + }) + partition = data_dir / "kline_daily_enriched" / f"date={current.isoformat()}" + partition.mkdir(parents=True) + pl.DataFrame(rows).write_parquet(partition / "part.parquet") + + instruments_dir = data_dir / "instruments" + instruments_dir.mkdir(parents=True) + pl.DataFrame({ + "symbol": symbols, + "name": [f"测试{asset}" for asset in range(assets)], + "total_shares": [1_000_000_000.0] * assets, + "float_shares": [1_000_000_000.0] * assets, + }).write_parquet(instruments_dir / "part.parquet") + + def test_spawn_worker_returns_compact_result_and_memory_metrics(tmp_path): start = date(2024, 1, 1) data_dir = tmp_path / "data" @@ -188,6 +238,216 @@ def test_spawn_walkforward_reuses_shared_matrix_across_folds(tmp_path): assert result["worker"]["worker_exitcode"] == 0 +def test_spawn_mining_writes_four_artifacts_and_returns_compact_summary(tmp_path): + start = date(2023, 1, 2) + data_dir = tmp_path / "data" + _write_mining_market_data(data_dir, start) + store = MiningRunStore(data_dir) + manifest = store.create( + { + "factor_names": ["turnover_rate"], + "strategy_ids": [], + "symbols": None, + "asset_type": "stock", + "start": (start - timedelta(days=7)).isoformat(), + "end": (start + timedelta(days=225)).isoformat(), + "budget_profile": "exploratory", + "forward_horizon": 1, + "commission_pct": 0.0, + "stamp_tax_pct": 0.0, + "slippage_bps": 0.0, + "correlation_threshold": 0.75, + "max_combination_factors": 1, + "beam_width": 2, + "max_finalists": 2, + "require_regime": False, + }, + {"generation": get_enriched_generation(data_dir, "stock")}, + run_id="spawn_mining", + ) + payload = { + "run_id": manifest["run_id"], + "request": manifest["request"], + "data_fingerprint": manifest["data_fingerprint"], + "source": "manual", + } + + result = run_worker_task(make_worker_task("mining", data_dir, payload)) + + assert result["status"] in {"succeeded", "succeeded_with_budget_exhausted"} + assert result["factor_count"] == 1 + assert result["data_as_of"] == (start + timedelta(days=218)).isoformat() + assert result["panel_scans"] == 1 + assert result["matrix_bytes"] > 0 + assert result["worker"]["worker_exitcode"] == 0 + assert result["worker"]["serialized_result_bytes"] < 100_000 + registered = store.get("spawn_mining")["artifacts"] # type: ignore[index] + assert set(registered) == {"factors", "correlation", "candidates", "folds"} + for name in registered: + artifact = store.artifact_path("spawn_mining", name) + assert artifact.is_file() + frame = pl.read_parquet(artifact) + assert frame.columns + if name == "folds": + assert "n_dates" in frame.columns + assert frame.filter(pl.col("regime_state") == "overall")["n_dates"].min() > 0 + +def test_spawn_mining_benchmarks_strategy_on_every_outer_fold(tmp_path): + start = date(2023, 1, 2) + data_dir = tmp_path / "data" + _write_mining_market_data(data_dir, start) + store = MiningRunStore(data_dir) + manifest = store.create( + { + "factor_names": ["turnover_rate"], + "strategy_ids": ["low_volatility_leader"], + "symbols": None, + "asset_type": "stock", + "start": (start - timedelta(days=7)).isoformat(), + "end": (start + timedelta(days=225)).isoformat(), + "budget_profile": "exploratory", + "forward_horizon": 1, + "commission_pct": 0.0, + "stamp_tax_pct": 0.0, + "slippage_bps": 0.0, + "correlation_threshold": 0.75, + "max_combination_factors": 1, + "beam_width": 2, + "max_finalists": 2, + "require_regime": False, + }, + {"generation": get_enriched_generation(data_dir, "stock")}, + run_id="spawn_mining_benchmark", + ) + payload = { + "run_id": manifest["run_id"], + "request": manifest["request"], + "data_fingerprint": manifest["data_fingerprint"], + "source": "manual", + } + + result = run_worker_task(make_worker_task("mining", data_dir, payload)) + + assert result["status"] in {"succeeded", "succeeded_with_budget_exhausted"} + folds = pl.read_parquet(store.artifact_path("spawn_mining_benchmark", "folds")) + benchmark_signature = benchmark_candidate("low_volatility_leader").candidate_id + benchmark_rows = folds.filter( + (pl.col("evaluation_kind") == "benchmark") + & (pl.col("candidate_signature") == benchmark_signature) + & (pl.col("regime_state") == "overall") + ) + outer_folds = result["valid_fold_count"] + result["skipped_fold_count"] + assert outer_folds >= 1 + assert benchmark_rows.height == outer_folds + selected_rows = folds.filter( + (pl.col("evaluation_kind") == "selected") + & (pl.col("regime_state") == "overall") + ) + assert selected_rows.height == outer_folds + candidates = pl.read_parquet(store.artifact_path("spawn_mining_benchmark", "candidates")) + assert benchmark_signature in candidates["signature"].to_list() + assert "existing_strategy" in candidates["kind"].to_list() + + +def test_spawn_mining_rejects_generation_change_after_queue(tmp_path): + start = date(2023, 1, 2) + data_dir = tmp_path / "data" + _write_mining_market_data(data_dir, start) + store = MiningRunStore(data_dir) + queued_generation = get_enriched_generation(data_dir, "stock") + manifest = store.create( + { + "factor_names": ["turnover_rate"], + "strategy_ids": [], + "symbols": None, + "asset_type": "stock", + "start": (start - timedelta(days=7)).isoformat(), + "end": (start + timedelta(days=225)).isoformat(), + "budget_profile": "exploratory", + "forward_horizon": 1, + "commission_pct": 0.0, + "stamp_tax_pct": 0.0, + "slippage_bps": 0.0, + "correlation_threshold": 0.75, + "max_combination_factors": 1, + "beam_width": 2, + "max_finalists": 2, + "require_regime": False, + }, + {"generation": queued_generation}, + run_id="stale_generation_mining", + ) + bump_enriched_generation(data_dir, "stock") + payload = { + "run_id": manifest["run_id"], + "request": manifest["request"], + "data_fingerprint": manifest["data_fingerprint"], + "source": "manual", + } + + with pytest.raises( + BacktestWorkerError, + match="changed after the run was queued", + ): + run_worker_task(make_worker_task("mining", data_dir, payload)) + + assert store.get("stale_generation_mining")["artifacts"] == {} # type: ignore[index] + + +def test_worker_terminates_child_after_cancel_grace(monkeypatch, tmp_path): + class FakeQueue: + def get(self, timeout): + raise queue.Empty + + def close(self): + pass + + def join_thread(self): + pass + + class FakeEvent: + def set(self): + pass + + class FakeProcess: + def __init__(self): + self.alive = True + self.exitcode = None + + def start(self): + pass + + def is_alive(self): + return self.alive + + def join(self, timeout=None): + pass + + def terminate(self): + self.alive = False + self.exitcode = -15 + + process = FakeProcess() + context = SimpleNamespace( + Queue=FakeQueue, + Event=FakeEvent, + Process=lambda **_kwargs: process, + ) + clock = iter([0.0, 0.0, 0.0, 6.0]) + monkeypatch.setattr(worker_module.mp, "get_context", lambda _method: context) + monkeypatch.setattr(worker_module.time, "monotonic", lambda: next(clock)) + cancel_event = threading.Event() + cancel_event.set() + + with pytest.raises(BacktestWorkerError, match="after cancellation"): + run_worker_task( + {"kind": "mining", "data_dir": str(tmp_path), "config": {}}, + cancel_event=cancel_event, + ) + + assert process.exitcode == -15 + + def test_spawn_walkforward_skips_folds_before_available_matrix_data(tmp_path): configured_start = date(2024, 1, 1) market_start = configured_start + timedelta(days=4) diff --git a/backend/tests/test_abnormal_moves.py b/backend/tests/test_abnormal_moves.py new file mode 100644 index 0000000..521de28 --- /dev/null +++ b/backend/tests/test_abnormal_moves.py @@ -0,0 +1,436 @@ +"""异动边缘统计测试 — 偏离列附着 + 规则口径 + 快照接近度。""" +from __future__ import annotations + +from datetime import date, timedelta + +import polars as pl + +from app.indicators.pipeline import ( + attach_deviation_columns, + attach_deviation_columns_today, + benchmark_momentum_today, + load_benchmark_momentum, +) +from app.services.abnormal_moves import ( + _hist_cache, + _hist_cache_lock, + board_of, + build_overview, + is_st_name, + rule_for, +) + + +def _write_index_daily(tmp_path, rows: list[tuple[str, date, float]]) -> None: + df = pl.DataFrame( + { + "symbol": [r[0] for r in rows], + "date": [r[1] for r in rows], + "close": [r[2] for r in rows], + } + ) + for dt in sorted({r[1] for r in rows}): + target = tmp_path / "kline_index_daily" / f"date={dt.isoformat()}" + target.mkdir(parents=True, exist_ok=True) + df.filter(pl.col("date") == dt).write_parquet(target / "part.parquet") + + +def test_attach_deviation_columns_math(tmp_path) -> None: + # 上证指数 4 天等差 +1: 3日动量 = 13/10-1 = 0.30 + # 个股 close 与指数同序列 → momentum_3d 缺失时按 close 就地补算, 偏离 = 0 + days = [date(2026, 8, 13), date(2026, 8, 14), date(2026, 8, 15), date(2026, 8, 18)] + index_rows = [("000001.SH", d, 10.0 + i) for i, d in enumerate(days)] + _write_index_daily(tmp_path, index_rows) + + stock = pl.DataFrame( + { + "symbol": ["600000.SH"] * len(days), + "date": days, + "close": [10.0 + i for i in range(len(days))], + # 10/30 日窗口已有动量列 → 直接使用 + "momentum_10d": [None] * 4, + "momentum_30d": [None] * 4, + } + ) + out = attach_deviation_columns(stock, tmp_path) + assert "deviate_3d" in out.columns + assert "momentum_3d" in out.columns # 就地补算 + last = out.sort("date").row(-1, named=True) + assert abs(last["deviate_3d"] - 0.0) < 1e-9 + + +def test_attach_deviation_columns_missing_benchmark(tmp_path) -> None: + # 无指数数据: 偏离列为 null, 不抛异常 + stock = pl.DataFrame( + { + "symbol": ["600000.SH"], + "date": [date(2026, 8, 18)], + "momentum_3d": [0.2], + "momentum_10d": [0.5], + "momentum_30d": [1.0], + } + ) + out = attach_deviation_columns(stock, tmp_path) + assert out["deviate_3d"][0] is None + + +# ── 盘中路径: 今日基准动量外推 + 单日帧偏离附着 ────────────────── + +_BENCH_DAYS = [date(2026, 8, 11), date(2026, 8, 12), date(2026, 8, 13), + date(2026, 8, 14), date(2026, 8, 15), date(2026, 8, 18)] + + +def _write_sh_bench(tmp_path) -> None: + # 上证指数 6 日收盘 10..15, 末值 15 为昨收 + _write_index_daily(tmp_path, [("000001.SH", d, 10.0 + i) for i, d in enumerate(_BENCH_DAYS)]) + + +def test_benchmark_momentum_today_math(tmp_path) -> None: + _write_sh_bench(tmp_path) + quotes = pl.DataFrame({"symbol": ["000001.SH"], "change_pct": [0.10]}) + + out = benchmark_momentum_today(tmp_path, quotes) + row = out.row(0, named=True) + # 今收 = 15 x 1.10 = 16.5; 3 个交易日前的收盘 = 13 (与全量路径 shift(3) 同口径) + # mom3d = 16.5/13 - 1 + assert abs(row["bench_mom3d"] - (16.5 / 13 - 1)) < 1e-9 + # 10/30 日窗口收盘数不足 → null + assert row["bench_mom10d"] is None + assert row["bench_mom30d"] is None + + # 无实时行情 → rt 按 0 处理: mom3d = 15/13 - 1 + out0 = benchmark_momentum_today(tmp_path, None) + assert abs(out0.row(0, named=True)["bench_mom3d"] - (15.0 / 13 - 1)) < 1e-9 + + +def test_benchmark_momentum_today_excludes_today_rows(tmp_path) -> None: + # 指数监控盘写入的今日行不能当昨收 (否则实时涨跌被重复叠加) + today = date.today() + rows = [("000001.SH", d, 10.0 + i) for i, d in enumerate(_BENCH_DAYS)] + rows.append(("000001.SH", today, 99.0)) # 今日脏行 + _write_index_daily(tmp_path, rows) + + out = benchmark_momentum_today(tmp_path, None) + assert abs(out.row(0, named=True)["bench_mom3d"] - (15.0 / 13 - 1)) < 1e-9 + + +def test_attach_deviation_columns_today(tmp_path) -> None: + _write_sh_bench(tmp_path) + quotes = pl.DataFrame({"symbol": ["000001.SH"], "change_pct": [0.10]}) + # 单日帧: 增量路径产出的 momentum 列 (无 date 历史, 无法 shift 补算) + today_df = pl.DataFrame( + { + "symbol": ["600000.SH", "000001.SZ"], + "momentum_3d": [0.5, 0.2], + "momentum_10d": [0.2, None], + "momentum_30d": [1.0, None], + } + ) + out = attach_deviation_columns_today(today_df, tmp_path, quotes) + # SH: 0.5 - (16.5/13 - 1) + assert abs(out["deviate_3d"][0] - (0.5 - (16.5 / 13 - 1))) < 1e-9 + # SZ 无深证基准 → 按选基设计回退上证基准 (rt=0): 0.2 - (15/13 - 1) + assert abs(out["deviate_3d"][1] - (0.2 - (15.0 / 13 - 1))) < 1e-9 + assert "bench_close" not in out.columns + + +def test_attach_deviation_columns_today_missing_momentum(tmp_path) -> None: + # 全量回退路径可能缺 momentum_3d: 该窗口置 null, 其余窗口正常 + days = [date(2026, 7, 1) + timedelta(days=i) for i in range(35)] + _write_index_daily(tmp_path, [("000001.SH", d, 10.0 + i) for i, d in enumerate(days)]) + df = pl.DataFrame( + { + "symbol": ["600000.SH"], + "momentum_10d": [0.2], + "momentum_30d": [1.0], + } + ) + out = attach_deviation_columns_today(df, tmp_path, None) + assert out["deviate_3d"][0] is None + assert out["deviate_10d"][0] is not None + assert out["deviate_30d"][0] is not None + + +def test_attach_deviation_columns_no_bench_close_leak(tmp_path) -> None: + # load_benchmark_momentum 新增 bench_close 列后, 冷路径输出不应泄漏该列 + _write_sh_bench(tmp_path) + stock = pl.DataFrame( + { + "symbol": ["600000.SH"], + "date": [date(2026, 8, 18)], + "close": [15.0], + } + ) + out = attach_deviation_columns(stock, tmp_path) + assert "bench_close" not in out.columns + frame = load_benchmark_momentum(tmp_path) + assert "bench_close" in frame.columns + + +def test_board_and_st_rules() -> None: + assert board_of("600000.SH") == "主板" + assert board_of("000001.SZ") == "主板" + assert board_of("301123.SZ") == "创业板" + assert board_of("688123.SH") == "科创板" + assert board_of("920001.BJ") == "北交所" + assert is_st_name("*ST 某某") is True + assert is_st_name("正常股") is False + + main = rule_for("600000.SH", "正常股") + # 3日对称 ±20%; 严重异动负向更严: 10日+100%(-50%), 30日+200%(-70%) + assert main.thresholds == {3: (0.20, 0.20), 10: (1.00, 0.50), 30: (2.00, 0.70)} + # 2026-07-06 起主板风险警示股票与普通股票同标准 (原±15%特别规定已废止) + st = rule_for("600000.SH", "ST 某某") + assert st.thresholds == main.thresholds + assert st.st is True + gem = rule_for("301123.SZ", "正常股") + assert gem.thresholds[3] == (0.30, 0.30) + assert gem.thresholds[10] == (1.00, 0.50) + bse = rule_for("920001.BJ", "正常股") + assert bse.thresholds[3] == (0.40, 0.40) + + +class _FakeRepo: + """最小 repo: get_enriched_latest 返回构造帧。""" + + def __init__(self, df: pl.DataFrame) -> None: + self._df = df + + def get_enriched_latest(self): + return self._df, date(2026, 8, 19) + + +class _FakeQuotes: + def get_index_quotes(self): + return pl.DataFrame( + {"symbol": ["000001.SH"], "close": [3300.0], "prev_close": [3270.0]} + ) + + +def test_build_overview_closeness_and_status() -> None: + with _hist_cache_lock: + _hist_cache.clear() + df = pl.DataFrame( + { + "symbol": ["600000.SH", "300001.SZ", "000002.SZ"], + "name": ["股A", "股B", "股C"], + "close": [10.0, 20.0, 30.0], + "change_pct": [0.05, 0.02, 0.01], + "deviate_3d": [0.19, 0.35, 0.05], + "deviate_10d": [0.99, 0.40, 0.20], + "deviate_30d": [1.95, 2.10, 0.60], + } + ) + result = build_overview(_FakeRepo(df), _FakeQuotes(), min_closeness=0.5, limit=10) + + by_symbol = {r["symbol"]: r for r in result["rows"]} + # 主板: 3d阈值0.2 → 0.19/0.2=0.95 边缘; 指数实时 +30/3270≈0.00917 叠加后略增 + a = by_symbol["600000.SH"] + assert a["status"] in ("edge", "triggered") + # 创业板: 30日 2.10/2.00 ≥ 1 → triggered + b = by_symbol["300001.SZ"] + assert b["status"] == "triggered" + # 000002: 3d 0.05/0.2=0.25, 10d 0.2/1=0.2, 30d 0.6/2=0.3 → 全部 < 0.5 被过滤 + assert "000002.SZ" not in by_symbol + # 排序按接近度降序 + closeness = [r["max_closeness"] for r in result["rows"]] + assert closeness == sorted(closeness, reverse=True) + assert result["counts"]["triggered"] >= 1 + + +def test_build_overview_cache_date_today_no_double_count() -> None: + """cache_date >= 今天时不再叠加实时涨跌 (避免重复计入)。""" + with _hist_cache_lock: + _hist_cache.clear() + + class _TodayRepo(_FakeRepo): + def get_enriched_latest(self): + return self._df, date.today() + + df = pl.DataFrame( + { + "symbol": ["600000.SH"], + "name": ["股A"], + "close": [10.0], + "change_pct": [0.05], + "deviate_3d": [0.19], + "deviate_10d": [None], + "deviate_30d": [None], + } + ) + result = build_overview(_TodayRepo(df), _FakeQuotes(), min_closeness=0.5) + row = result["rows"][0] + assert abs(row["windows"]["3d"]["value"] - 0.19) < 1e-9 + + +def test_build_overview_negative_side_stricter_threshold() -> None: + """严重异动负向阈值更严 (10日-50%/30日-70%), 跌方向更早触发。""" + with _hist_cache_lock: + _hist_cache.clear() + + class _TodayRepo(_FakeRepo): + def get_enriched_latest(self): + return self._df, date.today() + + df = pl.DataFrame( + { + "symbol": ["600000.SH", "600001.SH"], + "name": ["跌一", "跌二"], + "close": [10.0, 20.0], + "change_pct": [-0.05, -0.05], + # -0.55: 旧对称口径 0.55/1.00=0.55 (观察); 新口径 0.55/0.50=1.1 (触发) + # -0.75: 30日 0.75/0.70≈1.07 (触发) + "deviate_3d": [None, None], + "deviate_10d": [-0.55, None], + "deviate_30d": [None, -0.75], + } + ) + result = build_overview(_TodayRepo(df), None, min_closeness=0.5) + by_symbol = {r["symbol"]: r for r in result["rows"]} + a = by_symbol["600000.SH"] + assert a["windows"]["10d"]["threshold"] == 0.50 + assert abs(a["windows"]["10d"]["closeness"] - 1.1) < 1e-9 + assert a["status"] == "triggered" + b = by_symbol["600001.SH"] + assert b["windows"]["30d"]["threshold"] == 0.70 + assert abs(b["windows"]["30d"]["closeness"] - round(0.75 / 0.7, 4)) < 1e-9 + assert b["status"] == "triggered" + # 正向阈值不变: +100%/+200% (在正偏离用例中覆盖, 这里验证规则表) + main = rule_for("600000.SH", "正常股") + assert main.thresholds[10] == (1.00, 0.50) + assert main.thresholds[30] == (2.00, 0.70) + + +# ── 监控规则接入 (type=abnormal) ──────────────────────── + +import pytest + +from app.strategy import monitor_rules +from app.strategy.monitor import MonitorRuleEngine + + +def _ab_rule(**overrides) -> dict: + rule = { + "id": "r_ab", + "name": "异动边缘", + "type": "abnormal", + "scope": "all", + "symbols": [], + "threshold_pct": 70, + "direction": "both", + "abnormal_window": "any", + "cooldown_seconds": 0, + "severity": "warn", + } + rule.update(overrides) + return rule + + +def _row(symbol: str, *wins: tuple[str, float], name: str = "股A", + board: str = "主板", rt_pct: float = 0.05) -> dict: + # wins: (窗口, 偏离值) — 阈值按交易所口径: 主板 3d=0.2, 10d=1.0, 30d=2.0 + thresholds = {"3d": 0.2, "10d": 1.0, "30d": 2.0} + windows = { + key: {"value": value, "threshold": thresholds[key], + "closeness": round(abs(value) / thresholds[key], 4)} + for key, value in wins + } + return {"symbol": symbol, "name": name, "board": board, "st": False, + "close": 10.0, "rt_pct": rt_pct, "windows": windows} + + +def test_abnormal_rule_validation_and_defaults() -> None: + rule = monitor_rules.normalize({"id": "r1", "name": "n", "type": "abnormal"}) + assert rule["direction"] == "both" + assert rule["threshold_pct"] == 70.0 + assert rule["abnormal_window"] == "any" + monitor_rules.validate(rule) + + monitor_rules.validate(_ab_rule(threshold_pct=100, direction="up", abnormal_window="3d")) + + with pytest.raises(ValueError): + monitor_rules.validate(_ab_rule(abnormal_window="5d")) + with pytest.raises(ValueError): + monitor_rules.validate(_ab_rule(threshold_pct=0.5)) + with pytest.raises(ValueError): + monitor_rules.validate(_ab_rule(asset_type="etf")) + with pytest.raises(ValueError): + monitor_rules.validate(_ab_rule(direction="entry")) + + +def test_engine_abnormal_edge_trigger_and_cooldown() -> None: + engine = MonitorRuleEngine() + engine.set_rules([_ab_rule()]) + assert engine.min_abnormal_closeness() == pytest.approx(0.7) + + # 首轮观测不触发 (防新建规则刷屏); 0.10/0.2 = 50% 接近度, 低于阈值 + assert engine.evaluate_abnormal([_row("600000.SH", ("3d", 0.10))], now=1000.0) == [] + # 上穿 70% → 触发 (0.16/0.2 = 80%) + events = engine.evaluate_abnormal([_row("600000.SH", ("3d", 0.16))], now=1006.0) + assert len(events) == 1 + ev = events[0] + assert ev["source"] == "abnormal" + assert ev["type"] == "abnormal_up" + assert ev["symbol"] == "600000.SH" + assert ev["abnormal_window"] == "3d" + assert ev["abnormal_closeness"] == pytest.approx(0.8) + assert "接近" in ev["message"] or "已达" in ev["message"] + # 持续高于阈值: 不重复触发 (边缘语义) + assert engine.evaluate_abnormal([_row("600000.SH", ("3d", 0.18))], now=1012.0) == [] + # 回落再上穿: cooldown=0 时再次触发 + engine.evaluate_abnormal([_row("600000.SH", ("3d", 0.10))], now=1018.0) + assert len(engine.evaluate_abnormal([_row("600000.SH", ("3d", 0.17))], now=1024.0)) == 1 + + # cooldown 内的上穿被抑制 + engine_cd = MonitorRuleEngine() + engine_cd.set_rules([_ab_rule(cooldown_seconds=3600)]) + engine_cd.evaluate_abnormal([_row("600000.SH", ("3d", 0.10))], now=1000.0) + engine_cd.evaluate_abnormal([_row("600000.SH", ("3d", 0.16))], now=1006.0) + engine_cd.evaluate_abnormal([_row("600000.SH", ("3d", 0.10))], now=1012.0) + assert engine_cd.evaluate_abnormal([_row("600000.SH", ("3d", 0.16))], now=1018.0) == [] + + +def test_engine_abnormal_stale_symbol_state_cleared() -> None: + """标的跌出快照后状态应清回 False, 回升穿过阈值时可再次触发。""" + engine = MonitorRuleEngine() + engine.set_rules([_ab_rule()]) + engine.evaluate_abnormal([_row("600000.SH", ("3d", 0.10))], now=1000.0) # 首轮 False + assert len(engine.evaluate_abnormal([_row("600000.SH", ("3d", 0.18))], now=1006.0)) == 1 + # 跌出预过滤区间 (快照中消失) + engine.evaluate_abnormal([], now=1012.0) + # 重新出现且超阈值 → 重新触发 + assert len(engine.evaluate_abnormal([_row("600000.SH", ("3d", 0.18))], now=1018.0)) == 1 + + +def test_engine_abnormal_direction_window_scope_filters() -> None: + # 方向: 只报上涨偏离 + engine = MonitorRuleEngine() + engine.set_rules([_ab_rule(direction="up")]) + engine.evaluate_abnormal([_row("600000.SH", ("3d", -0.16))], now=1000.0) + assert engine.evaluate_abnormal([_row("600000.SH", ("3d", -0.19))], now=1006.0) == [] + + # 窗口: 只看 3d (10d/30d 的偏离不参与) + engine = MonitorRuleEngine() + engine.set_rules([_ab_rule(abnormal_window="3d")]) + engine.evaluate_abnormal([_row("600000.SH", ("10d", 0.98))], now=1000.0) + assert engine.evaluate_abnormal([_row("600000.SH", ("10d", 0.99))], now=1006.0) == [] + + # 作用域: 只监控指定标的 + engine = MonitorRuleEngine() + engine.set_rules([_ab_rule(scope="symbols", symbols=["600000.SH"])]) + engine.evaluate_abnormal( + [_row("600000.SH", ("3d", 0.10)), _row("000001.SZ", ("3d", 0.10))], now=1000.0, + ) + events = engine.evaluate_abnormal( + [_row("600000.SH", ("3d", 0.16)), _row("000001.SZ", ("3d", 0.19))], now=1006.0, + ) + assert [ev["symbol"] for ev in events] == ["600000.SH"] + + +def test_engine_abnormal_down_direction_event_type() -> None: + engine = MonitorRuleEngine() + engine.set_rules([_ab_rule(direction="down")]) + engine.evaluate_abnormal([_row("600000.SH", ("3d", -0.10))], now=1000.0) + events = engine.evaluate_abnormal([_row("600000.SH", ("3d", -0.16))], now=1006.0) + assert len(events) == 1 + assert events[0]["type"] == "abnormal_down" diff --git a/backend/tests/test_ai_provider.py b/backend/tests/test_ai_provider.py index 1b5a89c..79adf43 100644 --- a/backend/tests/test_ai_provider.py +++ b/backend/tests/test_ai_provider.py @@ -5,6 +5,9 @@ import tomllib import httpx import openai +from app import secrets_store +from app.api import settings as settings_api +from app.config import settings from app.services import ai_provider from app.services.ai_provider import ( _format_openai_error, @@ -108,7 +111,7 @@ def test_is_temperature_rejected_matches_moonshot_message(): assert _is_temperature_rejected(exc) is True -def test_is_temperature_rejected_matches_generic_temperature_hint(): +def test_optional_openai_params_use_targeted_400_fallbacks(): response = httpx.Response( 400, json={"error": {"message": "unsupported parameter: temperature"}}, @@ -120,6 +123,29 @@ def test_is_temperature_rejected_matches_generic_temperature_hint(): ) assert _is_temperature_rejected(exc) is True + kwargs = {"max_tokens": 1000, "temperature": 0.3, "reasoning_effort": "high"} + assert ai_provider._openai_retry_kwargs(exc, kwargs) == { + "max_tokens": 1000, + "reasoning_effort": "high", + } + + response = httpx.Response( + 400, + json={"error": {"message": "unrecognized request argument", "param": "reasoning_effort"}}, + request=httpx.Request("POST", "https://example.com/v1/chat/completions"), + ) + exc = openai.BadRequestError( + "bad request", response=response, + body={"error": {"message": "unrecognized request argument", "param": "reasoning_effort"}}, + ) + assert _is_temperature_rejected(exc) is False + assert ai_provider._is_reasoning_effort_rejected(exc) is True + assert ai_provider._openai_retry_kwargs(exc, kwargs) == { + "max_tokens": 1000, + "temperature": 0.3, + } + assert kwargs == {"max_tokens": 1000, "temperature": 0.3, "reasoning_effort": "high"} + def test_is_temperature_rejected_false_for_other_400(): """非 temperature 相关的 400 (如 model not found) 不应触发去 temperature 重试。""" @@ -145,6 +171,114 @@ def test_is_temperature_rejected_false_for_non_400(): assert _is_temperature_rejected(exc) is False +def test_openai_kwargs_include_configured_reasoning_effort(monkeypatch): + stored = {"ai_provider": "openai_compat"} + monkeypatch.setattr(secrets_store, "load", lambda: stored) + + assert "reasoning_effort" not in ai_provider._openai_kwargs(temperature=None, max_tokens=1000) + + stored["ai_provider"] = "openai" + assert ai_provider._openai_kwargs(temperature=None, max_tokens=1000)["reasoning_effort"] == "high" + + stored["ai_reasoning_effort"] = "custom-high" + kwargs = ai_provider._openai_kwargs(temperature=0.3, max_tokens=1000) + + assert kwargs == { + "max_tokens": 1000, + "temperature": 0.3, + "reasoning_effort": "custom-high", + } + + stored["ai_reasoning_effort"] = "" + assert "reasoning_effort" not in ai_provider._openai_kwargs(temperature=None, max_tokens=1000) + + stored["ai_reasoning_effort"] = "custom-high" + stored["ai_provider"] = "openai_compat" + assert "reasoning_effort" not in ai_provider._openai_kwargs(temperature=None, max_tokens=1000) + + +def test_openai_kwargs_none_max_tokens_omits_limit(): + """max_tokens=None → 不传上限(推理模型思考 token 计入预算, 分析类调用放开)。""" + kwargs = ai_provider._openai_kwargs(temperature=0.5, max_tokens=None) + assert "max_tokens" not in kwargs + assert kwargs.get("temperature") == 0.5 + + # 显式数值仍正常下发(策略标题生成等小任务依赖) + assert ai_provider._openai_kwargs(temperature=None, max_tokens=8) == {"max_tokens": 8} + + +def test_codex_prompt_none_max_tokens_skips_length_hint(): + prompt = ai_provider._codex_prompt([{"role": "user", "content": "hi"}], max_tokens=None) + assert "Keep the final answer" not in prompt + + bounded = ai_provider._codex_prompt([{"role": "user", "content": "hi"}], max_tokens=300) + assert "Keep the final answer" in bounded + + +def test_ai_settings_keep_provider_models_separate(monkeypatch): + stored = { + "ai_provider": "openai_compat", + "ai_model": "custom-api-model", + } + + def save(updates: dict) -> dict: + stored.update(updates) + return stored + + def clear(*keys: str) -> dict: + for key in keys: + stored.pop(key, None) + return stored + + monkeypatch.setattr(secrets_store, "load", lambda: stored) + monkeypatch.setattr(secrets_store, "save", save) + monkeypatch.setattr(secrets_store, "clear", clear) + monkeypatch.setattr(ai_provider, "ai_configured", lambda provider=None: True) + monkeypatch.setattr(settings, "ai_provider", "openai_compat") + monkeypatch.setattr(settings, "ai_base_url", "") + monkeypatch.setattr(settings, "ai_model", "") + monkeypatch.setattr(settings, "ai_codex_command", "codex") + monkeypatch.setattr(settings, "ai_codex_reasoning_effort", "") + monkeypatch.setattr(settings, "ai_user_agent", "") + + settings_api.save_ai_settings( + settings_api.AiSettingsIn( + provider="codex_cli", + model="gpt-5.6-sol", + codex_command="codex", + codex_reasoning_effort="high", + ) + ) + + assert stored["ai_model"] == "custom-api-model" + assert stored["ai_codex_model"] == "gpt-5.6-sol" + + settings_api.save_ai_settings( + settings_api.AiSettingsIn( + provider="openai", + base_url="https://api.openai.com/v1", + model="openai-model", + reasoning_effort="vendor-high", + ) + ) + + assert stored["ai_model"] == "openai-model" + assert stored["ai_reasoning_effort"] == "vendor-high" + assert stored["ai_codex_model"] == "gpt-5.6-sol" + + settings_api.save_ai_settings( + settings_api.AiSettingsIn( + provider="openai_compat", + base_url="https://example.com/v1", + model="new-custom-model", + ) + ) + + assert stored["ai_model"] == "new-custom-model" + assert stored["ai_reasoning_effort"] == "vendor-high" + assert stored["ai_codex_model"] == "gpt-5.6-sol" + + def test_codex_process_env_excludes_application_secrets(monkeypatch, tmp_path): monkeypatch.setenv("PATH", "test-path") monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example") @@ -201,7 +335,7 @@ def test_codex_config_adapts_local_access_provider_for_docker(monkeypatch, tmp_p assert provider["supports_websockets"] is False -def test_codex_config_does_not_copy_provider_without_docker_opt_in(monkeypatch, tmp_path): +def test_codex_config_preserves_remote_provider_without_docker_rewrite(monkeypatch, tmp_path): monkeypatch.delenv("CODEX_DOCKER_HOST", raising=False) monkeypatch.setattr(ai_provider, "current_ai_model", lambda: "") monkeypatch.setattr(ai_provider, "current_codex_reasoning_effort", lambda: "") @@ -209,11 +343,13 @@ def test_codex_config_does_not_copy_provider_without_docker_opt_in(monkeypatch, ai_provider, "_read_codex_config", lambda: { - "model_provider": "codex_local_access", + "model_provider": "remote-api", + "openai_base_url": "https://builtin.example/v1", "model_providers": { - "codex_local_access": { - "base_url": "http://localhost:62678/v1", - "experimental_bearer_token": "must-not-leak", + "remote-api": { + "base_url": "https://custom.example/v1", + "wire_api": "responses", + "requires_openai_auth": True, } }, }, @@ -222,7 +358,11 @@ def test_codex_config_does_not_copy_provider_without_docker_opt_in(monkeypatch, ai_provider._write_compatible_codex_config(path) - text = path.read_text(encoding="utf-8") - assert "model_provider" not in text - assert "model_providers" not in text - assert "must-not-leak" not in text + with path.open("rb") as f: + config = tomllib.load(f) + assert config["model_provider"] == "remote-api" + assert config["openai_base_url"] == "https://builtin.example/v1" + provider = config["model_providers"]["remote-api"] + assert provider["base_url"] == "https://custom.example/v1" + assert provider["wire_api"] == "responses" + assert provider["requires_openai_auth"] is True diff --git a/backend/tests/test_ai_strategy_meta_normalize.py b/backend/tests/test_ai_strategy_meta_normalize.py index 95c031b..ac3289e 100644 --- a/backend/tests/test_ai_strategy_meta_normalize.py +++ b/backend/tests/test_ai_strategy_meta_normalize.py @@ -22,7 +22,6 @@ ENTRY_SIGNALS = [] EXIT_SIGNALS = [] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 20 -ALERTS = [] def filter(df: pl.DataFrame, params: dict) -> pl.Expr: return pl.lit(True) diff --git a/backend/tests/test_auth_env_bootstrap.py b/backend/tests/test_auth_env_bootstrap.py new file mode 100644 index 0000000..efbb41c --- /dev/null +++ b/backend/tests/test_auth_env_bootstrap.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from collections.abc import Iterator +from pathlib import Path +from types import ModuleType + +import pytest + +from app import config as app_config +from app.config import Settings + + +@pytest.fixture(autouse=True) +def isolated_auth_store( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> Iterator[tuple[ModuleType, Path, Path]]: + monkeypatch.setattr(app_config.settings, "data_dir", tmp_path) + from app.services import auth + + auth_path = tmp_path / "user_data" / "auth.json" + env_path = tmp_path / ".env" + monkeypatch.setattr(app_config, "_ENV_FILE", env_path) + monkeypatch.setattr(app_config.settings, "auth_password", "") + auth._sessions.clear() + auth._configured_cache = None + yield auth, auth_path, env_path + auth._sessions.clear() + auth._configured_cache = None + + +def test_bootstrap_recovers_compose_interpolated_password_from_raw_env( + isolated_auth_store: tuple[ModuleType, Path, Path], + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + auth, auth_path, env_path = isolated_auth_store + password = "pw${special}-secret" + env_path.write_text( + f"AUTH_PASSWORD={password}\nDATA_DIR={tmp_path}\n", + encoding="utf-8", + ) + configured = Settings(_env_file=env_path) + configured.auth_password = "pw-secret" # 模拟 Compose 将未定义的 ${special} 插值为空串 + monkeypatch.setattr(app_config, "settings", configured) + + assert auth.bootstrap_from_env() is True + assert auth_path.exists() + assert password not in auth_path.read_text(encoding="utf-8") + assert auth.verify_and_create_session(password) is not None + assert auth.verify_and_create_session("pw-secret") is None + + +def test_bootstrap_from_env_does_not_override_existing_password( + isolated_auth_store: tuple[ModuleType, Path, Path], +) -> None: + auth, auth_path, env_path = isolated_auth_store + auth.set_password("web-managed-secret") + before = auth_path.read_bytes() + env_path.write_text("AUTH_PASSWORD=replacement-secret\n", encoding="utf-8") + app_config.settings.auth_password = "replacement-secret" + + assert auth.bootstrap_from_env() is False + assert auth_path.read_bytes() == before + assert auth.verify_and_create_session("web-managed-secret") is not None + assert auth.verify_and_create_session("replacement-secret") is None diff --git a/backend/tests/test_capability_augment.py b/backend/tests/test_capability_augment.py new file mode 100644 index 0000000..5543be6 --- /dev/null +++ b/backend/tests/test_capability_augment.py @@ -0,0 +1,120 @@ +"""能力标准统一: 自定义/插件数据源能力增广回归测试。 + +对应 _augment_custom_sources 的数据集→能力映射 (daily/adj_factor/minute/financial): +某数据集的当前 provider 非 tickflow 且声明了该数据集 → grant 对应能力; +取数路由仍按 preferences 分流, 不会误调 TickFlow。 +""" +from __future__ import annotations + +from unittest.mock import MagicMock + +from app.tickflow.capabilities import Cap, CapabilityLimits, CapabilitySet +from app.tickflow.policy import _augment_custom_sources + + +def _set_providers(monkeypatch, *, daily="tickflow", adj="same_as_daily", + minute="tickflow", financial="tickflow") -> None: + """mock preferences 各数据集 provider getter。""" + from app.services import preferences + monkeypatch.setattr(preferences, "get_daily_data_provider", lambda: daily) + monkeypatch.setattr(preferences, "get_adj_factor_provider", lambda: adj) + monkeypatch.setattr(preferences, "get_minute_data_provider", lambda: minute) + monkeypatch.setattr(preferences, "get_financial_provider", lambda: financial) + + +def _set_datasets(monkeypatch, datasets: set[str]) -> None: + """mock provider_has_dataset: 非 tickflow provider 对给定数据集返回 True。""" + monkeypatch.setattr( + "app.data_providers.custom.provider_has_dataset", + lambda name, ds: name != "tickflow" and ds in datasets, + ) + + +def test_daily_custom_source_grants_daily_batch(monkeypatch): + _set_providers(monkeypatch, daily="mock_src") + _set_datasets(monkeypatch, {"daily"}) + capset = CapabilitySet() + _augment_custom_sources(capset) + assert capset.has(Cap.KLINE_DAILY_BATCH) + # 未声明其他数据集 → 不补 + assert not capset.has(Cap.ADJ_FACTOR) + assert not capset.has(Cap.KLINE_MINUTE_BATCH) + assert not capset.has(Cap.FINANCIAL) + + +def test_adj_same_as_daily_resolves_to_daily_provider(monkeypatch): + """adj_factor_provider=same_as_daily → 跟随 daily provider 判定。""" + _set_providers(monkeypatch, daily="mock_src", adj="same_as_daily") + _set_datasets(monkeypatch, {"adj_factor"}) + capset = CapabilitySet() + _augment_custom_sources(capset) + assert capset.has(Cap.ADJ_FACTOR) + + +def test_minute_custom_source_grants_minute_batch(monkeypatch): + """原有 minute 增广行为保持。""" + _set_providers(monkeypatch, minute="mock_src") + _set_datasets(monkeypatch, {"minute"}) + capset = CapabilitySet() + _augment_custom_sources(capset) + assert capset.has(Cap.KLINE_MINUTE_BATCH) + + +def test_financial_custom_source_grants_financial(monkeypatch): + _set_providers(monkeypatch, financial="mock_src") + _set_datasets(monkeypatch, {"financial"}) + capset = CapabilitySet() + _augment_custom_sources(capset) + assert capset.has(Cap.FINANCIAL) + + +def test_provider_active_but_dataset_not_declared_no_grant(monkeypatch): + """provider 被选为当前源但未声明该数据集 → 不 grant (回退 TickFlow 语义)。""" + _set_providers(monkeypatch, daily="mock_src", minute="mock_src", + adj="mock_src", financial="mock_src") + _set_datasets(monkeypatch, set()) # 什么都不声明 + capset = CapabilitySet() + _augment_custom_sources(capset) + assert not capset.has(Cap.KLINE_DAILY_BATCH) + assert not capset.has(Cap.ADJ_FACTOR) + assert not capset.has(Cap.KLINE_MINUTE_BATCH) + assert not capset.has(Cap.FINANCIAL) + + +def test_tickflow_active_no_grant(monkeypatch): + """全部数据集仍走 tickflow → 不补任何能力。""" + _set_providers(monkeypatch) # 默认全 tickflow + _set_datasets(monkeypatch, {"daily", "adj_factor", "minute", "financial"}) + capset = CapabilitySet() + _augment_custom_sources(capset) + assert not capset.has(Cap.KLINE_DAILY_BATCH) + assert not capset.has(Cap.ADJ_FACTOR) + assert not capset.has(Cap.KLINE_MINUTE_BATCH) + assert not capset.has(Cap.FINANCIAL) + + +def test_grant_does_not_override_tickflow_limits(monkeypatch): + """grant 不覆盖 TickFlow 已有能力及其限制。""" + _set_providers(monkeypatch, minute="mock_src") + _set_datasets(monkeypatch, {"minute"}) + capset = CapabilitySet() + capset.grant(Cap.KLINE_MINUTE_BATCH, CapabilityLimits(rpm=30, batch=100)) + _augment_custom_sources(capset) + lim = capset.limits(Cap.KLINE_MINUTE_BATCH) + assert lim is not None and lim.rpm == 30 and lim.batch == 100 + + +def test_update_data_providers_refreshes_capability_snapshot(monkeypatch): + """切换数据源后 app.state.capabilities 快照应刷新 (读缓存+增广, 无网络)。""" + from app.api import settings as settings_api + + monkeypatch.setattr("app.services.preferences.save", lambda upd: None) + sentinel = CapabilitySet() + monkeypatch.setattr(settings_api, "detect_capabilities", lambda: sentinel) + + mock_request = MagicMock() + settings_api.update_data_providers( + MagicMock(model_dump=lambda exclude_none: {"daily_data_provider": "mock_src"}), + mock_request, + ) + assert mock_request.app.state.capabilities is sentinel diff --git a/backend/tests/test_config_env.py b/backend/tests/test_config_env.py new file mode 100644 index 0000000..1239d6e --- /dev/null +++ b/backend/tests/test_config_env.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from app.config import Settings + + +def test_settings_reads_server_and_auth_values_from_env( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + for name in ("HOST", "PORT", "LOG_LEVEL", "AUTH_PASSWORD"): + monkeypatch.delenv(name, raising=False) + env_path = tmp_path / ".env" + env_path.write_text( + "HOST=127.0.0.1\n" + "PORT=4318\n" + "LOG_LEVEL=DEBUG\n" + "AUTH_PASSWORD=config-secret\n", + encoding="utf-8", + ) + + configured = Settings(_env_file=env_path) + + assert configured.host == "127.0.0.1" + assert configured.port == 4318 + assert configured.log_level == "DEBUG" + assert configured.auth_password == "config-secret" diff --git a/backend/tests/test_data_clear_generation.py b/backend/tests/test_data_clear_generation.py new file mode 100644 index 0000000..6d0096d --- /dev/null +++ b/backend/tests/test_data_clear_generation.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import json +from datetime import date +from pathlib import Path +from types import SimpleNamespace + +import polars as pl +import pytest + +from app.api import data as data_api +from app.backtest.engine import PanelCache +from app.enriched_generation import ( + EnrichedGenerationUnavailableError, + get_enriched_generation, +) + + +class _RepoStub: + def __init__(self, data_dir: Path) -> None: + self.store = SimpleNamespace(data_dir=data_dir) + self.calls: list[str] = [] + + def clear_cache(self) -> None: + self.calls.append("clear_cache") + + def refresh_cache(self) -> None: + self.calls.append("refresh_cache") + + def rebuild_views(self) -> None: + self.calls.append("rebuild_views") + + +def _request(repo: _RepoStub) -> SimpleNamespace: + return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(repo=repo))) + + +def _write_parquet_placeholder(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"parquet-placeholder") + + +def _stub_clear_side_effects(monkeypatch: pytest.MonkeyPatch) -> None: + from app.api import overview + from app.services import alert_store + from app.services.pipeline_jobs import job_store + from app.services.screener import ScreenerService + + monkeypatch.setattr(job_store, "clear", lambda: None) + monkeypatch.setattr(alert_store, "clear", lambda _data_dir: None) + monkeypatch.setattr(ScreenerService, "clear_history_cache", lambda: None) + monkeypatch.setattr(overview, "invalidate_overview_cache", lambda: None) + monkeypatch.setattr(data_api, "invalidate_data_cache", lambda _table=None: None) + + +def test_clear_data_bumps_enriched_generations_and_invalidates_panel_cache( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _stub_clear_side_effects(monkeypatch) + repo = _RepoStub(tmp_path) + stock_file = tmp_path / "kline_daily_enriched" / "date=2026-08-14" / "part.parquet" + etf_file = tmp_path / "kline_etf_enriched" / "date=2026-08-14" / "part.parquet" + _write_parquet_placeholder(stock_file) + _write_parquet_placeholder(etf_file) + stock_before = get_enriched_generation(tmp_path, "stock") + etf_before = get_enriched_generation(tmp_path, "etf") + + cache = PanelCache() + cache_args = (["000001.SZ"], date(2026, 8, 14), date(2026, 8, 14), None) + computes: list[int] = [] + + def compute(*_args) -> pl.DataFrame: + computes.append(len(computes) + 1) + return pl.DataFrame({"value": [computes[-1]]}) + + cache.get_or_compute(*cache_args, compute, "stock", stock_before) + result = data_api.clear_data(_request(repo)) + stock_after = get_enriched_generation(tmp_path, "stock") + etf_after = get_enriched_generation(tmp_path, "etf") + cached_after = cache.get_or_compute(*cache_args, compute, "stock", stock_after) + + assert result == {"deleted_files": 2} + assert not stock_file.exists() + assert not etf_file.exists() + assert stock_after != stock_before + assert etf_after != etf_before + assert cached_after["value"].item() == 2 + assert cache.stats()["compute_count"] == 2 + assert repo.calls == ["clear_cache", "refresh_cache", "rebuild_views"] + + +def test_clear_data_restores_ready_generation_when_first_delete_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = _RepoStub(tmp_path) + target = tmp_path / "kline_daily_enriched" / "date=2026-08-14" / "part.parquet" + _write_parquet_placeholder(target) + generation_before = get_enriched_generation(tmp_path, "stock") + original_unlink = Path.unlink + + def fail_target(path: Path, *args, **kwargs) -> None: + if path == target: + raise PermissionError("injected delete failure") + original_unlink(path, *args, **kwargs) + + monkeypatch.setattr(Path, "unlink", fail_target) + + with pytest.raises(PermissionError, match="injected delete failure"): + data_api.clear_data(_request(repo)) + + assert target.is_file() + assert get_enriched_generation(tmp_path, "stock") == generation_before + marker = json.loads( + (tmp_path / ".matrix_generation_stock.json").read_text(encoding="utf-8") + ) + assert marker["state"] == "ready" + + +def test_clear_data_keeps_generation_publishing_after_partial_delete( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = _RepoStub(tmp_path) + enriched_dir = tmp_path / "kline_daily_enriched" + first = enriched_dir / "date=2026-08-13" / "part.parquet" + second = enriched_dir / "date=2026-08-14" / "part.parquet" + _write_parquet_placeholder(first) + _write_parquet_placeholder(second) + get_enriched_generation(tmp_path, "stock") + original_unlink = Path.unlink + parquet_unlinks = 0 + + def fail_second_parquet(path: Path, *args, **kwargs) -> None: + nonlocal parquet_unlinks + if path.suffix == ".parquet" and enriched_dir in path.parents: + parquet_unlinks += 1 + if parquet_unlinks == 2: + raise PermissionError("injected partial delete failure") + original_unlink(path, *args, **kwargs) + + monkeypatch.setattr(Path, "unlink", fail_second_parquet) + + with pytest.raises(PermissionError, match="injected partial delete failure"): + data_api.clear_data(_request(repo)) + + assert sum(path.exists() for path in (first, second)) == 1 + marker = json.loads( + (tmp_path / ".matrix_generation_stock.json").read_text(encoding="utf-8") + ) + assert marker["state"] == "publishing" + with pytest.raises(EnrichedGenerationUnavailableError, match="being published"): + get_enriched_generation(tmp_path, "stock") diff --git a/backend/tests/test_data_status_storage.py b/backend/tests/test_data_status_storage.py new file mode 100644 index 0000000..70e95b8 --- /dev/null +++ b/backend/tests/test_data_status_storage.py @@ -0,0 +1,61 @@ +"""storage 统计求和不变量测试 — total_size_mb 必须等于各部分之和, 不得重复累加。 + +历史 bug: other_dirs 循环被复制了两遍, 且 financials 既在 other_dirs 里又有专属 +统计块 —— financials 被计入 3 次、pools/backtest_results/screener_results/ai_cache +各计入 2 次, total_size_mb 虚高。此测试用精确整 MB 文件锁定总和不变量。 +""" +from __future__ import annotations + +from pathlib import Path + +from app.api.data import _compute_storage + +MB = 1024 * 1024 + + +def _write_mb(path: Path, mb: float) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"\0" * int(mb * MB)) + + +def _build_tree(data_dir: Path) -> None: + _write_mb(data_dir / "kline_daily" / "part.parquet", 1.0) + _write_mb(data_dir / "financials" / "metrics" / "part.parquet", 2.0) + _write_mb(data_dir / "pools" / "p.json", 1.0) + _write_mb(data_dir / "backtest_results" / "r.json", 1.0) + _write_mb(data_dir / "screener_results" / "s.json", 1.0) + _write_mb(data_dir / "ai_cache" / "c.json", 1.0) + _write_mb(data_dir / "capabilities.json", 0.5) # 根目录散文件 + + +def test_total_equals_sum_of_parts(tmp_path): + _build_tree(tmp_path) + stats = _compute_storage(tmp_path) + + parts_mb = ( + stats["daily_size_mb"] # 1.0 (subdirs 表) + + stats["financials_size_mb"] # 2.0 (专属统计块) + + 1.0 + 1.0 + 1.0 + 1.0 # pools/backtest/screener/ai_cache (单次循环) + + 0.5 # 根目录散文件 + ) + assert stats["total_size_mb"] == parts_mb == 7.5 + + +def test_financials_counted_once_not_three_times(tmp_path): + _build_tree(tmp_path) + stats = _compute_storage(tmp_path) + + assert stats["financials_files"] == 1 + assert stats["financials_size_mb"] == 2.0 + # 旧 bug: financials 计 3 次 (6MB) + 其余 4 目录各计 2 次 (8MB) → 15.5 + assert stats["total_size_mb"] != 15.5 + + +def test_missing_dirs_contribute_zero(tmp_path): + _write_mb(tmp_path / "kline_daily" / "part.parquet", 1.0) + stats = _compute_storage(tmp_path) + + # financials 目录不存在时不含其明细键 (既有行为), 且不贡献任何体积 + assert stats.get("financials_files", 0) == 0 + assert stats.get("financials_size_mb", 0.0) == 0.0 + assert stats["total_size_mb"] == 1.0 diff --git a/backend/tests/test_enriched_generation.py b/backend/tests/test_enriched_generation.py new file mode 100644 index 0000000..f9b9130 --- /dev/null +++ b/backend/tests/test_enriched_generation.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +import json +from datetime import date +from types import SimpleNamespace + +import polars as pl +import pytest + +from app.backtest.engine import BacktestEngine, PanelCache +from app.enriched_generation import ( + EnrichedGenerationUnavailableError, + EnrichedPublication, + get_enriched_generation, +) +from app.tickflow.repository import DataStore, KlineRepository + + +def _frame(value: float = 10.0) -> pl.DataFrame: + return pl.DataFrame({ + "symbol": ["000001.SZ"], + "date": [date(2026, 8, 14)], + "open": [value], + "high": [value], + "low": [value], + "close": [value], + "volume": [1_000.0], + }) + + +def test_repository_enriched_noop_does_not_bump_generation(tmp_path) -> None: + repo = KlineRepository(DataStore(tmp_path)) + frame = _frame() + + repo.append_enriched(frame) + first = repo.get_matrix_data_generation("stock") + repo.append_enriched(frame) + + assert repo.get_matrix_data_generation("stock") == first + + +def test_failed_multi_partition_publication_remains_fail_closed( + tmp_path, + monkeypatch, +) -> None: + publication = EnrichedPublication(tmp_path, recover=True) + first = tmp_path / "kline_daily_enriched" / "date=2026-08-13" / "part.parquet" + second = tmp_path / "kline_daily_enriched" / "date=2026-08-14" / "part.parquet" + publication.write_parquet(_frame(10.0), first) + + original_write = pl.DataFrame.write_parquet + + def fail_second(self, path, *args, **kwargs): + if "2026-08-14" in str(path): + raise OSError("injected write failure") + return original_write(self, path, *args, **kwargs) + + monkeypatch.setattr(pl.DataFrame, "write_parquet", fail_second) + with pytest.raises(OSError, match="injected"): + publication.write_parquet(_frame(11.0), second) + + marker = json.loads( + (tmp_path / ".matrix_generation_stock.json").read_text(encoding="utf-8") + ) + assert marker["state"] == "publishing" + assert first.is_file() + assert not second.is_file() + with pytest.raises(EnrichedGenerationUnavailableError, match="being published"): + get_enriched_generation(tmp_path, "stock") + + +def test_recovery_replaces_stale_publication_but_not_active_owner(tmp_path) -> None: + first = EnrichedPublication(tmp_path, recover=True) + out = tmp_path / "kline_daily_enriched" / "date=2026-08-14" / "part.parquet" + first.write_parquet(_frame(10.0), out) + + with pytest.raises(EnrichedGenerationUnavailableError, match="active"): + EnrichedPublication(tmp_path, recover=True).write_parquet(_frame(11.0), out) + + del first + recovered = EnrichedPublication(tmp_path, recover=True) + recovered.write_parquet(_frame(12.0), out) + recovered_generation = recovered.commit() + + assert recovered_generation == get_enriched_generation(tmp_path, "stock") + assert pl.read_parquet(out)["close"].item() == pytest.approx(12.0) + + +def test_recovery_takes_over_when_owner_pid_is_dead_on_windows(tmp_path, monkeypatch) -> None: + # 跨进程孤儿锁: 属主进程已死, 但 Windows 的 os.kill(pid, 0) 对不存在的 pid + # 抛 WinError 87 (ERROR_INVALID_PARAMETER) 而非 ProcessLookupError, + # 存活探测若把它当"存活", recover 将永远报 another publication is active。 + stale = { + "state": "publishing", + "generation": "stale-generation", + "publication_id": "stale-publication", + "owner_pid": 12345, + "updated_at_ns": 0, + } + (tmp_path / ".matrix_generation_stock.json").write_text( + json.dumps(stale), encoding="utf-8" + ) + + def probe(_pid: int, _sig: int) -> None: + error = OSError() + error.winerror = 87 + raise error + + monkeypatch.setattr("app.enriched_generation.os.kill", probe) + + out = tmp_path / "kline_daily_enriched" / "date=2026-08-14" / "part.parquet" + recovered = EnrichedPublication(tmp_path, recover=True) + recovered.write_parquet(_frame(10.0), out) + recovered.commit() + + marker = json.loads( + (tmp_path / ".matrix_generation_stock.json").read_text(encoding="utf-8") + ) + assert marker["state"] == "ready" + assert get_enriched_generation(tmp_path, "stock") == marker["generation"] + assert pl.read_parquet(out)["close"].item() == pytest.approx(10.0) + + +def test_panel_cache_generation_change_forces_recompute() -> None: + cache = PanelCache() + calls: list[int] = [] + args = (["000001.SZ"], date(2026, 8, 13), date(2026, 8, 14), None) + + def compute(*_args): + calls.append(len(calls) + 1) + return pl.DataFrame({"value": [calls[-1]]}) + + first = cache.get_or_compute(*args, compute, "stock", "generation-a") + second = cache.get_or_compute(*args, compute, "stock", "generation-b") + + assert first["value"].item() == 1 + assert second["value"].item() == 2 + assert cache.stats()["compute_count"] == 2 + + +def test_panel_reader_retries_when_generation_changes_during_scan(tmp_path) -> None: + generations = iter(["generation-a", "generation-b", "generation-b", "generation-b"]) + repo = SimpleNamespace( + store=SimpleNamespace(data_dir=tmp_path), + get_matrix_data_generation=lambda _asset_type: next(generations), + ) + engine = BacktestEngine(repo) + calls: list[str] = [] + + def load(*_args): + calls.append("scan") + return _frame(float(len(calls))) + + engine._load_panel_inner = load + panel = engine.load_panel( + None, + date(2026, 8, 14), + date(2026, 8, 14), + columns=["symbol", "date", "close"], + ) + + assert calls == ["scan", "scan"] + assert panel["close"].item() == pytest.approx(2.0) + + +def test_matrix_reader_retries_when_generation_changes_during_build( + tmp_path, + monkeypatch, +) -> None: + generations = iter(["generation-a", "generation-b", "generation-b", "generation-b"]) + repo = SimpleNamespace( + store=SimpleNamespace(data_dir=tmp_path), + get_matrix_data_generation=lambda _asset_type: next(generations), + get_instruments_asset=lambda _asset_type: pl.DataFrame(), + ) + engine = BacktestEngine(repo) + calls: list[str | None] = [] + expected = SimpleNamespace( + execution_backend="matrix_native", + base_columns={"open", "high", "low", "close", "volume"}, + instrument_columns=set(), + matrix_columns=set(), + ) + market = SimpleNamespace() + + def load_matrix(*_args, source_generation=None, **_kwargs): + calls.append(source_generation) + return market + + monkeypatch.setattr("app.backtest.engine.load_market_data_matrix_from_parquet", load_matrix) + + result = engine.load_market_data_matrix_for_backtest( + None, + date(2026, 8, 14), + date(2026, 8, 14), + expected, + ) + + assert result is market + assert calls == ["generation-a", "generation-b"] diff --git a/backend/tests/test_enriched_range_warming_guard.py b/backend/tests/test_enriched_range_warming_guard.py new file mode 100644 index 0000000..ece7f2b --- /dev/null +++ b/backend/tests/test_enriched_range_warming_guard.py @@ -0,0 +1,56 @@ +"""get_enriched_range 的预热守卫测试 — 预热期间不得触发同步全量重算。 + +启动后台预热线程正在 _refresh_enriched (300 天 scan + compute, 低配机 50s+) +时, 请求线程进入 get_enriched_range 应返回 None (缓存不覆盖语义), +不能在请求线程里并发跑第二次全量重算。与 get_enriched_latest 守卫对齐。 +""" +from __future__ import annotations + +from datetime import date + +import polars as pl + +from app.tickflow.repository import KlineRepository + + +def _bare_repo() -> KlineRepository: + """跳过 __init__ (避免 DataStore/目录依赖), 只装配守卫涉及的属性。""" + repo = KlineRepository.__new__(KlineRepository) + repo._enriched_history_cache = None + repo._enriched_warming = True + return repo + + +def test_get_enriched_range_returns_none_while_warming(): + repo = _bare_repo() + refresh_calls: list[int] = [] + + def _spy_refresh(): + refresh_calls.append(1) + + repo._refresh_enriched = _spy_refresh # type: ignore[method-assign] + + result = repo.get_enriched_range(date(2026, 1, 1), date(2026, 8, 14)) + + assert result is None + assert refresh_calls == [], "预热期间不得触发 _refresh_enriched" + + +def test_get_enriched_range_rebuilds_when_cold_and_not_warming(): + repo = _bare_repo() + repo._enriched_warming = False + built = pl.DataFrame({ + "symbol": ["600000.SH", "600000.SH"], + "date": [date(2026, 1, 1), date(2026, 8, 14)], + }) + + def _fake_refresh(): + repo._enriched_history_cache = built + + repo._refresh_enriched = _fake_refresh # type: ignore[method-assign] + + result = repo.get_enriched_range(date(2026, 1, 1), date(2026, 8, 14)) + + assert result is not None + assert result.height == 2 + assert result["symbol"].unique().to_list() == ["600000.SH"] diff --git a/backend/tests/test_ext_config_load_all_cache.py b/backend/tests/test_ext_config_load_all_cache.py new file mode 100644 index 0000000..7cbec33 --- /dev/null +++ b/backend/tests/test_ext_config_load_all_cache.py @@ -0,0 +1,99 @@ +"""ExtConfigStore.load_all 签名缓存测试 — 配置未变不重复读盘, 变更后立即可见, 返回副本。""" +from __future__ import annotations + +import json + +import pytest + +from app.services import ext_data +from app.services.ext_data import ExtConfig, ExtConfigStore, ExtField + + +@pytest.fixture(autouse=True) +def _clean_cache(): + ext_data._load_all_cache.clear() + yield + ext_data._load_all_cache.clear() + + +def _patched_loads(monkeypatch, counter: dict): + real_loads = json.loads + + def _counting(text): + counter["loads"] += 1 + return real_loads(text) + + monkeypatch.setattr(ext_data.json, "loads", _counting) + + +def _config(cid: str, label: str) -> ExtConfig: + return ExtConfig(id=cid, label=label, mode="snapshot", + fields=[ExtField(name="score", dtype="float")]) + + +def test_second_load_all_hits_cache_without_disk_parse(tmp_path, monkeypatch): + store = ExtConfigStore(tmp_path) + store.upsert(_config("cfg_a", "A")) + store.upsert(_config("cfg_b", "B")) + + counter = {"loads": 0} + _patched_loads(monkeypatch, counter) + first = store.load_all() + assert counter["loads"] == 2, "缓存为空时逐 config.json parse" + again = store.load_all() + assert counter["loads"] == 2, "签名未变时不得重复读盘+parse" + assert [c.id for c in first] == [c.id for c in again] == ["cfg_a", "cfg_b"] + + +def test_upsert_edit_invalidates_cache(tmp_path): + store = ExtConfigStore(tmp_path) + store.upsert(_config("cfg_a", "old")) + assert store.load_all()[0].label == "old" + + store.upsert(_config("cfg_a", "new")) + assert store.load_all()[0].label == "new" + + +def test_delete_invalidates_cache(tmp_path): + store = ExtConfigStore(tmp_path) + store.upsert(_config("cfg_a", "A")) + store.upsert(_config("cfg_b", "B")) + assert {c.id for c in store.load_all()} == {"cfg_a", "cfg_b"} + + assert store.delete("cfg_a") is True + assert {c.id for c in store.load_all()} == {"cfg_b"} + + +def test_external_config_change_visible(tmp_path): + import os + + store = ExtConfigStore(tmp_path) + store.upsert(_config("cfg_a", "A")) + assert store.load_all()[0].label == "A" + + cp = tmp_path / "ext_data" / "cfg_a" / "config.json" + raw = json.loads(cp.read_text(encoding="utf-8")) + raw["label"] = "externally-edited" + cp.write_text(json.dumps(raw), encoding="utf-8") + st = cp.stat() + os.utime(cp, ns=(st.st_atime_ns, st.st_mtime_ns + 1_000_000)) + + assert store.load_all()[0].label == "externally-edited" + + +def test_load_all_returns_copy_not_cached_object(tmp_path): + store = ExtConfigStore(tmp_path) + store.upsert(_config("cfg_a", "A")) + first = store.load_all() + first[0].label = "mutated" + first[0].fields.append(ExtField(name="junk", dtype="string")) + + again = store.load_all() + assert again[0].label == "A" + assert [f.name for f in again[0].fields] == ["score"] + + +def test_load_all_without_config_dir_returns_empty(tmp_path): + store = ExtConfigStore(tmp_path) + assert store.load_all() == [] + assert store.load_all() == [] diff --git a/backend/tests/test_ext_config_store_safety.py b/backend/tests/test_ext_config_store_safety.py new file mode 100644 index 0000000..1374b6b --- /dev/null +++ b/backend/tests/test_ext_config_store_safety.py @@ -0,0 +1,43 @@ +"""ExtConfigStore 的 config_id 安全校验 — 拒绝路径穿越等非法 id (fail-closed)。 + +删除端点的 config_id 来自 URL path 参数, 不经创建端点的 pattern 校验; +若直接拼接路径, `../victim` 可让 rmtree 删除 ext_data 之外的目录。 +""" +from __future__ import annotations + +import json + +from app.services.ext_data import ExtConfigStore + + +def _write_config(data_dir, config_id: str) -> None: + d = data_dir / "ext_data" / config_id + d.mkdir(parents=True, exist_ok=True) + (d / "config.json").write_text( + json.dumps({ + "id": config_id, + "label": "测试", + "mode": "snapshot", + "fields": [{"name": "score"}], + }), + encoding="utf-8", + ) + + +def test_delete_rejects_path_traversal_ids(tmp_path): + store = ExtConfigStore(tmp_path) + _write_config(tmp_path, "ok_config") + + # 穿越目标: ext_data 之外、含 config.json 的目录 (满足旧实现 rmtree 的前置条件) + victim = tmp_path / "victim" + victim.mkdir() + (victim / "config.json").write_text("{}", encoding="utf-8") + + for bad in ("../victim", "..\\victim", "a/b", "", "."): + assert store.delete(bad) is False, bad + assert store.get(bad) is None, bad + assert (victim / "config.json").exists(), "穿越删除必须被拒绝" + + # 合法 id 不受影响 + assert store.get("ok_config") is not None + assert store.delete("ok_config") is True diff --git a/backend/tests/test_extensions.py b/backend/tests/test_extensions.py new file mode 100644 index 0000000..43decdd --- /dev/null +++ b/backend/tests/test_extensions.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import types + +import pytest +from fastapi import APIRouter, FastAPI + +from app.extensions.contracts import ( + BACKEND_EXTENSION_API_VERSION, + NotificationFormatContext, + NotificationFormatter, +) +from app.extensions.loader import configure_backend_extensions +from app.extensions.registry import BackendExtensionRegistrar, BackendExtensionRegistry +from app.services.quote_service import QuoteService + + +class PrefixFormatter(NotificationFormatter): + def __init__(self, prefix: str) -> None: + self.prefix = prefix + + def format_message(self, event: dict, context: NotificationFormatContext) -> str: + assert context.api_version == BACKEND_EXTENSION_API_VERSION + return f"{self.prefix}{event['message']}" + + +class BrokenFormatter(NotificationFormatter): + def format_message(self, event: dict, context: NotificationFormatContext) -> str: + del event, context + raise RuntimeError("broken formatter") + + +def _registrar(extension_id: str = "company.test") -> BackendExtensionRegistrar: + return BackendExtensionRegistrar( + extension_id, + api_version=BACKEND_EXTENSION_API_VERSION, + ) + + +def test_empty_registry_preserves_existing_notification_objects() -> None: + registry = BackendExtensionRegistry() + registry.freeze() + service = QuoteService() + service._app_state = types.SimpleNamespace(extension_registry=registry) + events = [{"message": "原始消息", "source": "strategy"}] + + result = service._format_extension_notifications(events) + + assert result is events + assert result[0] is events[0] + + +def test_notification_formatters_are_ordered_and_do_not_mutate_input() -> None: + registry = BackendExtensionRegistry() + registrar = _registrar() + registrar.register_notification_formatter("company.second", PrefixFormatter("B"), order=20) + registrar.register_notification_formatter("company.first", PrefixFormatter("A"), order=10) + registry.register(registrar) + registry.freeze() + service = QuoteService() + service._app_state = types.SimpleNamespace(extension_registry=registry) + events = [{"message": "原始消息", "source": "strategy"}] + + result = service._format_extension_notifications(events) + + assert result == [{"message": "BA原始消息", "source": "strategy"}] + assert events == [{"message": "原始消息", "source": "strategy"}] + assert result is not events + assert result[0] is not events[0] + + +def test_broken_formatter_keeps_previous_message_and_later_formatters_run() -> None: + registry = BackendExtensionRegistry() + registrar = _registrar() + registrar.register_notification_formatter("company.first", PrefixFormatter("A"), order=10) + registrar.register_notification_formatter("company.broken", BrokenFormatter(), order=20) + registrar.register_notification_formatter("company.last", PrefixFormatter("B"), order=30) + registry.register(registrar) + registry.freeze() + service = QuoteService() + service._app_state = types.SimpleNamespace(extension_registry=registry) + + result = service._format_extension_notifications([{"message": "原始消息"}]) + + assert result[0]["message"] == "BA原始消息" + + +def test_registry_rejects_version_mismatch_without_partial_registration() -> None: + registry = BackendExtensionRegistry() + registrar = BackendExtensionRegistrar("company.future", api_version=999) + registrar.register_notification_formatter("company.future", PrefixFormatter("x")) + + with pytest.raises(ValueError, match="requires backend API"): + registry.register(registrar) + + registry.freeze() + assert not registry.has_customizations + assert not registry.has_notification_formatters + + +def test_registry_is_frozen_after_startup() -> None: + registry = BackendExtensionRegistry() + registry.freeze() + + with pytest.raises(RuntimeError, match="frozen"): + registry.register(_registrar()) + + +def test_loader_isolates_failed_setup_and_registers_valid_route( + monkeypatch: pytest.MonkeyPatch, +) -> None: + broken = types.ModuleType("app.custom.broken") + broken.EXTENSION_ID = "company.broken" + broken.EXTENSION_API_VERSION = BACKEND_EXTENSION_API_VERSION + + def broken_setup(registrar: BackendExtensionRegistrar) -> None: + registrar.register_notification_formatter("company.partial", PrefixFormatter("x")) + raise RuntimeError("setup failed") + + broken.setup = broken_setup + + valid = types.ModuleType("app.custom.valid") + valid.EXTENSION_ID = "company.valid" + valid.EXTENSION_API_VERSION = BACKEND_EXTENSION_API_VERSION + + def valid_setup(registrar: BackendExtensionRegistrar) -> None: + router = APIRouter(prefix="/api/custom/valid") + + @router.get("/status") + def status() -> dict: + return {"status": "ok"} + + registrar.include_router(router) + + valid.setup = valid_setup + modules = {broken.__name__: broken, valid.__name__: valid} + + monkeypatch.setattr( + "app.extensions.loader._custom_module_names", + lambda: [broken.__name__, valid.__name__], + ) + monkeypatch.setattr( + "app.extensions.loader.importlib.import_module", + lambda name: modules[name], + ) + app = FastAPI() + + registry, errors = configure_backend_extensions(app) + + assert registry.frozen + assert registry.extension_ids() == frozenset({"company.valid"}) + assert len(errors) == 1 + assert errors[0].module == broken.__name__ + assert any(getattr(route, "path", None) == "/api/custom/valid/status" for route in app.routes) diff --git a/backend/tests/test_factor_library_v2.py b/backend/tests/test_factor_library_v2.py new file mode 100644 index 0000000..b24462b --- /dev/null +++ b/backend/tests/test_factor_library_v2.py @@ -0,0 +1,173 @@ +"""新增因子维度的解析正确性测试 (收益形态/流动性/涨停基因/量价扩展)。 + +金标准路径一致性由 test_matrix_strategy.py::test_research_factor_catalog_matches_matrix_features +覆盖; 这里用可手算的小样本验证公式本身。 +""" +from __future__ import annotations + +import math +from datetime import date, timedelta + +import numpy as np +import polars as pl + +from app.backtest.matrix import build_market_data_matrix, matrix_feature +from app.strategy.scoring import materialize_scoring_columns + + +def _panel(rows: list[dict]) -> pl.DataFrame: + return pl.DataFrame(rows).sort(["symbol", "date"]) + + +def _single_symbol_panel(n_days: int = 70) -> pl.DataFrame: + start = date(2025, 1, 1) + rows = [] + for offset in range(n_days): + change = [0.0, 0.01, -0.02, 0.03, 0.05, -0.01, 0.02, -0.03, 0.04, 0.01][offset % 10] + close = 10.0 if offset == 0 else rows[-1]["close"] * (1 + change) + volume = 1000.0 + (offset % 5) * 200 + consecutive = 0 + if offset % 15 == 0: + consecutive = 1 + (offset // 15) % 3 if offset % 30 == 0 else 1 + rows.append({ + "symbol": "000001.SZ", + "date": start + timedelta(days=offset), + "open": close * 0.99, + "high": close * 1.02, + "low": close * 0.97, + "close": close, + "volume": volume, + "amount": volume * 100.0 * close, + "turnover_rate": 1.0 + (offset % 7) * 0.3, + "consecutive_limit_ups": consecutive, + }) + return _panel(rows) + + +def _tail_value(frame: pl.DataFrame, name: str) -> float: + return frame.tail(1).to_dicts()[0][name] + + +def test_max_ret_up_days_and_limit_up_counts(): + panel = _single_symbol_panel() + frame = materialize_scoring_columns(panel, { + "max_ret_20d", "up_days_20d", + "limit_up_count_20d", "limit_up_count_60d", + }) + + changes = [ + panel["close"][i] / panel["close"][i - 1] - 1 + for i in range(1, panel.height) + ] + window = changes[-20:] + assert math.isclose(_tail_value(frame, "max_ret_20d"), max(window), rel_tol=1e-9) + assert math.isclose( + _tail_value(frame, "up_days_20d"), + float(sum(1 for value in window if value > 0)), + rel_tol=1e-9, + ) + + hits = [ + 1 if (row["consecutive_limit_ups"] or 0) > 0 else 0 + for row in panel.iter_rows(named=True) + ] + assert math.isclose(_tail_value(frame, "limit_up_count_20d"), float(sum(hits[-20:])), rel_tol=1e-9) + assert math.isclose(_tail_value(frame, "limit_up_count_60d"), float(sum(hits[-60:])), rel_tol=1e-9) + + +def test_amihud_and_turnover_z(): + panel = _single_symbol_panel() + frame = materialize_scoring_columns(panel, {"amihud_20d", "turnover_z_60d"}) + + rows = panel.to_dicts() + illiq = [ + abs(rows[i]["close"] / rows[i - 1]["close"] - 1) / (rows[i]["amount"] / 1e8) + for i in range(1, panel.height) + ] + assert math.isclose(_tail_value(frame, "amihud_20d"), sum(illiq[-20:]) / 20, rel_tol=1e-6) + + baseline = [rows[i]["turnover_rate"] for i in range(panel.height - 61, panel.height - 1)] + mean = sum(baseline) / 60 + variance = sum((value - mean) ** 2 for value in baseline) / 59 + std = variance ** 0.5 + expected_z = (rows[-1]["turnover_rate"] - mean) / std + assert math.isclose(_tail_value(frame, "turnover_z_60d"), expected_z, rel_tol=1e-6) + + +def test_vwap_bias_and_vol_trend(): + panel = _single_symbol_panel() + frame = materialize_scoring_columns(panel, {"vwap_bias", "vol_trend_5_60"}) + + last = panel.tail(1).to_dicts()[0] + vwap = last["amount"] / (last["volume"] * 100.0) + assert math.isclose(_tail_value(frame, "vwap_bias"), last["close"] / vwap - 1, rel_tol=1e-9) + + volumes = panel["volume"].to_list() + fast = sum(volumes[-5:]) / 5 + slow = sum(volumes[-60:]) / 60 + assert math.isclose(_tail_value(frame, "vol_trend_5_60"), fast / slow - 1, rel_tol=1e-9) + + +def test_ret_skew_matches_population_skew(): + # 周期夹具的 20 日窗口恰含两个完整周期, 偏度恒为 0; 用不对称收益验证公式。 + start = date(2025, 3, 1) + changes = [0.01, -0.005, -0.004, 0.09, -0.006, 0.002, -0.003, -0.002, -0.005, 0.003] * 7 + rows = [] + close = 10.0 + for offset, change in enumerate(changes): + close = close * (1 + change) + rows.append({ + "symbol": "000001.SZ", + "date": start + timedelta(days=offset), + "open": close * 0.99, + "high": close * 1.02, + "low": close * 0.97, + "close": close, + "volume": 1000.0 + offset, + "amount": (1000.0 + offset) * close, + "turnover_rate": 1.0, + "consecutive_limit_ups": 0, + }) + panel = _panel(rows) + frame = materialize_scoring_columns(panel, {"ret_skew_20d"}) + + window = changes[-20:] + mean = sum(window) / 20 + central_second = sum((value - mean) ** 2 for value in window) / 20 + central_third = sum((value - mean) ** 3 for value in window) / 20 + expected = central_third / central_second ** 1.5 + assert abs(expected) > 0.1 + assert math.isclose(_tail_value(frame, "ret_skew_20d"), expected, rel_tol=1e-6) + + +def test_vol_price_corr_matches_pearson(): + panel = _single_symbol_panel() + frame = materialize_scoring_columns(panel, {"vol_price_corr_20d"}) + + closes = panel["close"].to_list() + changes = [closes[i] / closes[i - 1] - 1 for i in range(panel.height - 20, panel.height)] + volumes = panel["volume"].to_list()[-20:] + n = 20 + mean_x = sum(changes) / n + mean_y = sum(volumes) / n + cov = sum((x - mean_x) * (y - mean_y) for x, y in zip(changes, volumes, strict=True)) / n + var_x = sum((x - mean_x) ** 2 for x in changes) / n + var_y = sum((y - mean_y) ** 2 for y in volumes) / n + expected = cov / (var_x * var_y) ** 0.5 + assert math.isclose(_tail_value(frame, "vol_price_corr_20d"), expected, rel_tol=1e-6) + + +def test_matrix_limit_up_counts_use_consecutive_field(): + panel = _single_symbol_panel() + frame = materialize_scoring_columns(panel, {"limit_up_count_20d"}) + market = build_market_data_matrix( + panel, + field_columns={"amount", "turnover_rate", "consecutive_limit_ups"}, + ) + np.testing.assert_allclose( + matrix_feature(market, "limit_up_count_20d")[:, 0], + frame["limit_up_count_20d"].to_numpy(), + rtol=1e-6, + atol=1e-6, + equal_nan=True, + ) diff --git a/backend/tests/test_fundamental_factors.py b/backend/tests/test_fundamental_factors.py new file mode 100644 index 0000000..861a4c9 --- /dev/null +++ b/backend/tests/test_fundamental_factors.py @@ -0,0 +1,193 @@ +"""财务因子点时接入测试: 公告日门控 / 无数据 null 安全 / 双路径一致 / 历史累积同步。""" +from __future__ import annotations + +from datetime import date, timedelta +from pathlib import Path + +import numpy as np +import polars as pl + +from app.backtest.fundamentals import ( + FUNDAMENTAL_FACTOR_NAMES, + attach_fundamental_factors, + build_fundamental_matrices, + load_fundamental_snapshot, +) +from app.backtest.matrix import build_market_data_matrix + + +def _snapshot_frame(rows: list[dict]) -> pl.DataFrame: + frame = pl.DataFrame({ + "period_end": ["2026-03-31"] * len(rows), + "symbol": [r["symbol"] for r in rows], + "announce_date": [r["announce"] for r in rows], + "roe": [r.get("roe", 10.0) for r in rows], + "bps": [r.get("bps", 5.0) for r in rows], + "revenue_yoy": [r.get("revenue_yoy", 8.0) for r in rows], + }) + return ( + frame.with_columns( + pl.col("announce_date").str.slice(0, 10).str.to_date().alias("_announce") + ) + .sort(["symbol", "_announce"]) + ) + + +def _daily_panel(start: date, days: int, symbols: tuple[str, ...]) -> pl.DataFrame: + rows = [] + for offset in range(days): + for symbol in symbols: + rows.append({ + "symbol": symbol, + "date": start + timedelta(days=offset), + "open": 10.0, + "high": 10.5, + "low": 9.5, + "close": 10.0 + offset * 0.1, + "volume": 1000.0, + }) + return pl.DataFrame(rows).sort(["symbol", "date"]) + + +def test_attach_gates_on_announce_date_strictly(): + panel = _daily_panel(date(2026, 4, 1), 10, ("600000.SH", "000001.SZ")) + snapshot = _snapshot_frame([ + # 公告日 4-5: 4-5 当天不可用, 4-6 起 roe=20 + {"symbol": "600000.SH", "announce": "2026-04-05", "roe": 20.0, "bps": 4.0}, + # 无财务数据的标的 + ]) + attached = attach_fundamental_factors(panel, snapshot, ["roe_latest", "pb_latest"]) + + values = attached.filter(pl.col("symbol") == "600000.SH").sort("date") + roe = values["roe_latest"].to_list() + assert roe[:5] == [None] * 5 # 4-1 ~ 4-5 均不可用 (含公告当日) + assert roe[5:] == [20.0] * 5 # 4-6 起生效 + + # 无财务数据标的全 null, 绝不填 0 + other = attached.filter(pl.col("symbol") == "000001.SZ")["roe_latest"] + assert other.null_count() == other.len() + + # pb = close / bps, 公告前 null + pb = values["pb_latest"].to_list() + assert pb[:5] == [None] * 5 + close_6 = values.filter(pl.col("date") == date(2026, 4, 6))["close"].item() + assert abs(pb[5] - close_6 / 4.0) < 1e-12 + + +def test_attach_replaces_with_newer_announcement(): + panel = _daily_panel(date(2026, 4, 1), 20, ("600000.SH",)) + snapshot = _snapshot_frame([ + {"symbol": "600000.SH", "announce": "2026-04-05", "roe": 20.0}, + {"symbol": "600000.SH", "announce": "2026-04-15", "roe": 33.0}, + ]) + attached = attach_fundamental_factors(panel, snapshot, ["roe_latest"]).sort("date") + roe = attached["roe_latest"].to_list() + assert roe[4] is None # 4-5 公告日 + assert roe[5] == 20.0 # 4-6 起 20.0 + assert roe[13] == 20.0 # 4-14 + assert roe[14] is None # 4-15 二次公告日, 当天仍不可用 (严格大于) + assert roe[15] == 33.0 # 4-16 起新公告生效 + + +def test_attach_without_snapshot_keeps_null_columns(): + panel = _daily_panel(date(2026, 4, 1), 5, ("600000.SH",)) + attached = attach_fundamental_factors(panel, None, ["roe_latest", "pb_latest"]) + for name in ("roe_latest", "pb_latest"): + assert name in attached.columns + assert attached[name].null_count() == attached.height + + +def test_matrix_field_matches_polars_attach(): + panel = _daily_panel(date(2026, 4, 1), 12, ("600000.SH", "000001.SZ")) + snapshot = _snapshot_frame([ + {"symbol": "600000.SH", "announce": "2026-04-05", "roe": 20.0, "bps": 4.0}, + {"symbol": "000001.SZ", "announce": "2026-04-08", "roe": -5.0, "bps": -1.0}, + ]) + attached = attach_fundamental_factors(panel, snapshot, ["roe_latest", "pb_latest"]) + market = build_market_data_matrix(panel) + matrices = build_fundamental_matrices(market, snapshot, ["roe_latest", "pb_latest"]) + symbols = {s: i for i, s in enumerate(market.symbols)} + dates = {str(d): t for t, d in enumerate( + sorted({row["date"] for row in panel.iter_rows(named=True)}) + )} + for name in ("roe_latest", "pb_latest"): + matrix = matrices[name] + for row in attached.iter_rows(named=True): + expected = row[name] + actual = matrix[dates[str(row["date"])], symbols[row["symbol"]]] + if expected is None: + assert np.isnan(actual), (name, row["date"], row["symbol"], actual) + else: + np.testing.assert_allclose(actual, expected, rtol=1e-6) + + +def test_bps_nonpositive_gives_null_pb(): + panel = _daily_panel(date(2026, 4, 1), 8, ("000001.SZ",)) + snapshot = _snapshot_frame([ + {"symbol": "000001.SZ", "announce": "2026-04-02", "bps": -1.0}, + ]) + attached = attach_fundamental_factors(panel, snapshot, ["pb_latest"]) + assert attached["pb_latest"].null_count() == attached.height + + +def test_load_snapshot_from_missing_dir_returns_none(tmp_path: Path): + assert load_fundamental_snapshot(tmp_path) is None + assert load_fundamental_snapshot(None) is None + + +def test_snapshot_requires_announce_date(tmp_path: Path): + out = tmp_path / "financials" / "metrics" + out.mkdir(parents=True) + pl.DataFrame({ + "symbol": ["600000.SH"], + "announce_date": [None], + "roe": [10.0], + "gross_margin": [30.0], + "net_margin": [5.0], + "revenue_yoy": [8.0], + "net_income_yoy": [6.0], + "debt_to_asset_ratio": [40.0], + "bps": [5.0], + }).write_parquet(out / "part.parquet") + # 公告日缺失的行无法做点时门控, 视为无有效快照 + assert load_fundamental_snapshot(tmp_path) is None + + +def test_fundamental_factor_names_are_catalogued(): + from app.backtest.factor import FACTOR_COLUMNS + + catalog_ids = {item["id"] for item in FACTOR_COLUMNS} + assert catalog_ids >= FUNDAMENTAL_FACTOR_NAMES + + +def test_financial_sync_merges_history(tmp_path: Path): + from app.services import financial_sync as fs + + old = pl.DataFrame({ + "symbol": ["600000.SH", "600000.SH"], + "period_end": ["2025-09-30", "2025-12-31"], + "announce_date": ["2025-10-28", "2026-01-20"], + "roe": [8.0, 9.0], + }) + latest = pl.DataFrame({ + "symbol": ["600000.SH", "000001.SZ"], + "period_end": ["2026-03-31", "2026-03-31"], + "announce_date": ["2026-04-25", "2026-04-24"], + "roe": [10.0, 5.0], + }) + merged = fs._merge_report_history(old, latest) + assert merged.height == 4 # 旧各期保留 + 新一期并入 + # 同期修正: 旧 2025-12-31 公告 2026-01-20 vs 更晚的修正公告 + revised = pl.DataFrame({ + "symbol": ["600000.SH"], + "period_end": ["2025-12-31"], + "announce_date": ["2026-02-01"], + "roe": [9.5], + }) + merged2 = fs._merge_report_history(old, revised) + row = merged2.filter( + (pl.col("symbol") == "600000.SH") & (pl.col("period_end") == "2025-12-31") + ) + assert row.height == 1 + assert row["roe"].item() == 9.5 + assert merged2.height == 2 # 修正不增加行数 diff --git a/backend/tests/test_get_daily_cache_reuse.py b/backend/tests/test_get_daily_cache_reuse.py new file mode 100644 index 0000000..7c4d1df --- /dev/null +++ b/backend/tests/test_get_daily_cache_reuse.py @@ -0,0 +1,106 @@ +"""get_daily 复用 enriched 历史缓存的等价性测试。 + +个股对话框打开时 /api/kline/daily 每个行情 tick 调用一次; 旧路径每次 +150 天扫描 + 全套指标重算, 新路径优先从预计算历史缓存裁剪。 +本测试证明: 同一份数据下两条路径输出逐列一致, 且缓存命中时不触发扫描。 +""" +from __future__ import annotations + +from datetime import date, timedelta + +import polars as pl +from polars.testing import assert_frame_equal + +from app.tickflow.repository import KlineRepository + +SYM = "600001.SH" + + +def _raw_frame(days: int = 80) -> pl.DataFrame: + """构造 ~57 个交易日的 14 列形态数据 (复权价与原始价一致, 无除权)。""" + base = date(2026, 4, 1) + rows = [] + price = 10.0 + d = base + while len(rows) < days: + if d.weekday() < 5: + open_ = price * (1 + ((len(rows) % 7) - 3) * 0.004) + close = price * (1 + ((len(rows) % 5) - 2) * 0.006) + high = max(open_, close) * 1.01 + low = min(open_, close) * 0.99 + price = close + rows.append({ + "symbol": SYM, "date": d, + "open": round(open_, 4), "high": round(high, 4), + "low": round(low, 4), "close": round(close, 4), + "volume": 10000.0 + (len(rows) % 10) * 500.0, + "amount": 1.0e7, + "raw_close": round(close, 4), "raw_high": round(high, 4), + "raw_low": round(low, 4), + }) + d += timedelta(days=1) + return pl.DataFrame(rows).sort(["symbol", "date"]) + + +def _bare_repo(raw: pl.DataFrame) -> tuple[KlineRepository, dict]: + repo = KlineRepository.__new__(KlineRepository) + repo._enriched_history_cache = None + repo._enriched_history_start = None + repo._enriched_cache = None + repo._enriched_cache_date = None + repo.get_instruments = lambda: pl.DataFrame() # type: ignore[method-assign] + repo.get_historical_shares = lambda: pl.DataFrame() # type: ignore[method-assign] + repo.get_enriched_latest = lambda: (pl.DataFrame(), None) # type: ignore[method-assign] + calls = {"scan": 0} + + def _scan(symbol, start, end, columns): + calls["scan"] += 1 + return raw.filter((pl.col("date") >= start) & (pl.col("date") <= end)) + + repo._scan_daily_symbol = _scan # type: ignore[method-assign] + return repo, calls + + +def test_get_daily_cache_path_matches_scan_path(): + raw = _raw_frame() + dates = raw["date"].to_list() + start, end = dates[30], dates[-1] + + # 旧路径: 无历史缓存 → 扫描 + 即时计算 + repo_old, calls_old = _bare_repo(raw) + result_old = repo_old.get_daily(SYM, start, end) + assert calls_old["scan"] == 1 + + # 新路径: 预计算历史缓存 (同一真实计算栈 _compute_enriched_range 构建) + repo_new, calls_new = _bare_repo(raw) + hist = repo_new._compute_enriched_range(raw) + repo_new._enriched_history_cache = hist + repo_new._enriched_history_start = hist["date"].min() + result_new = repo_new.get_daily(SYM, start, end) + + assert calls_new["scan"] == 0, "缓存命中时不得回退到扫描路径" + assert result_new.height == result_old.height + + common = sorted(set(result_old.columns) & set(result_new.columns)) + assert {"open", "close", "ma5", "ma20", "ma60", "rsi_14"} <= set(common) + assert_frame_equal( + result_old.sort("date").select(common), + result_new.sort("date").select(common), + check_exact=False, + rel_tol=1e-9, + ) + + +def test_get_daily_falls_back_to_scan_when_cache_does_not_cover_start(): + raw = _raw_frame() + dates = raw["date"].to_list() + + repo, calls = _bare_repo(raw) + hist = repo._compute_enriched_range(raw) + repo._enriched_history_cache = hist + repo._enriched_history_start = hist["date"].min() + + # 请求起点早于缓存覆盖 → 必须回退扫描路径 + result = repo.get_daily(SYM, dates[0] - timedelta(days=5), dates[-1]) + assert calls["scan"] == 1 + assert not result.is_empty() diff --git a/backend/tests/test_heavy_job_limiter.py b/backend/tests/test_heavy_job_limiter.py new file mode 100644 index 0000000..5ebc56d --- /dev/null +++ b/backend/tests/test_heavy_job_limiter.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import threading +import time + +import pytest + +from app.services.heavy_job_limiter import ( + HeavyJobCancelledError, + HeavyJobLimiter, + HeavyJobLimitTimeoutError, + heavy_job_limiter, + shared_heavy_job_limiter, +) + + +def test_weighted_capacity_and_timeout() -> None: + limiter = HeavyJobLimiter(capacity=2) + + assert limiter.acquire("normal", timeout=0) + assert limiter.acquire("normal", timeout=0) + assert limiter.available == 0 + assert not limiter.acquire("normal", timeout=0.01) + limiter.release("normal") + assert not limiter.acquire("mining", timeout=0) + limiter.release("normal") + assert limiter.acquire("mining", timeout=0) + assert limiter.in_use == 2 + limiter.release("mining") + + +def test_waiting_acquire_can_be_cancelled() -> None: + limiter = HeavyJobLimiter(capacity=2, cancel_poll_interval=0.01) + cancel_event = threading.Event() + assert limiter.acquire("mining", timeout=0) + + result: list[bool] = [] + waiter = threading.Thread( + target=lambda: result.append( + limiter.acquire("normal", timeout=1, cancel_event=cancel_event) + ) + ) + waiter.start() + time.sleep(0.03) + cancel_event.set() + waiter.join(timeout=1) + + assert not waiter.is_alive() + assert result == [False] + assert limiter.in_use == 2 + limiter.release("mining") + + +def test_context_manager_releases_after_body_error() -> None: + limiter = HeavyJobLimiter(capacity=2) + + with pytest.raises(ValueError, match="body failed"), limiter.slot("mining", timeout=0): + raise ValueError("body failed") + + assert limiter.in_use == 0 + assert limiter.acquire("mining", timeout=0) + limiter.release("mining") + + +def test_context_manager_distinguishes_timeout_and_cancellation() -> None: + limiter = HeavyJobLimiter(capacity=2) + assert limiter.acquire("mining", timeout=0) + + with pytest.raises(HeavyJobLimitTimeoutError), limiter.slot("normal", timeout=0.01): + pytest.fail("unreachable") + + cancelled = threading.Event() + cancelled.set() + with pytest.raises(HeavyJobCancelledError), limiter.slot("normal", cancel_event=cancelled): + pytest.fail("unreachable") + + limiter.release("mining") + + +def test_invalid_release_does_not_overfill_capacity() -> None: + limiter = HeavyJobLimiter(capacity=2) + + with pytest.raises(RuntimeError): + limiter.release("normal") + assert limiter.available == 2 + + assert limiter.acquire("normal", timeout=0) + with pytest.raises(RuntimeError): + limiter.release("mining") + assert limiter.in_use == 1 + limiter.release("normal") + + +def test_module_aliases_share_the_capacity_two_singleton() -> None: + assert heavy_job_limiter is shared_heavy_job_limiter + assert shared_heavy_job_limiter.capacity == 2 diff --git a/backend/tests/test_indicator_needed.py b/backend/tests/test_indicator_needed.py index fe4d7ba..1e38b87 100644 --- a/backend/tests/test_indicator_needed.py +++ b/backend/tests/test_indicator_needed.py @@ -28,6 +28,19 @@ def _bars(n: int = 90) -> pl.DataFrame: return pl.DataFrame(rows) +def test_compute_indicators_assume_sorted_matches_default_values(): + bars = _bars().sort(["symbol", "date"]) + + default = compute_indicators(bars, needed={"macd_hist", "rsi_14", "momentum_20d"}) + fast = compute_indicators( + bars, + needed={"macd_hist", "rsi_14", "momentum_20d"}, + assume_sorted=True, + ) + + assert fast.equals(default) + + def test_compute_signals_subset_matches_full_values(): indicators = compute_indicators(_bars()) full = compute_signals(indicators) diff --git a/backend/tests/test_kline_hot_path_memory.py b/backend/tests/test_kline_hot_path_memory.py new file mode 100644 index 0000000..3f9707c --- /dev/null +++ b/backend/tests/test_kline_hot_path_memory.py @@ -0,0 +1,155 @@ +"""kline 每秒热路径测试 — _get_stock_info 走内存缓存, _attach_ext 复用签名缓存 value_map。 + +契约等值: 输出与旧的 DuckDB 扫描 / 逐配置读 parquet 路径一致。 +""" +from __future__ import annotations + +from types import SimpleNamespace + +import polars as pl + +from app.api import screener +from app.api.kline import _attach_ext, _get_stock_info +from app.services.ext_data import ExtConfig, ExtConfigStore, ExtField + + +class _FakeRepo: + """最小 repo 替身: _get_stock_info/_load_ext_value_maps 只用到这些成员。""" + + def __init__(self, instruments: pl.DataFrame, data_dir) -> None: + self._instruments = instruments + self.store = SimpleNamespace(data_dir=data_dir, db=None) + self.execute_one_calls = 0 + + def get_instruments(self) -> pl.DataFrame: + return self._instruments + + def execute_one(self, *args, **kwargs): + self.execute_one_calls += 1 + raise AssertionError("热路径不得再走 DuckDB 扫 instruments parquet") + + +def _instruments_df() -> pl.DataFrame: + return pl.DataFrame({ + "symbol": ["600000.SH", "000001.SZ", "510300.SH"], + "name": ["浦发银行", "平安银行", "沪深300ETF"], + "total_shares": [1.0e9, 2.0e9, None], + "float_shares": [5.0e8, 1.0e9, None], + }) + + +# ---------------------------------------------------------------- _get_stock_info + +def test_get_stock_info_reads_memory_cache(): + repo = _FakeRepo(_instruments_df(), None) + assert _get_stock_info(repo, "600000.SH") == { + "name": "浦发银行", "total_shares": 1.0e9, "float_shares": 5.0e8, + } + assert repo.execute_one_calls == 0 + + +def test_get_stock_info_null_shares_become_none(): + repo = _FakeRepo(_instruments_df(), None) + info = _get_stock_info(repo, "510300.SH") + assert info["name"] == "沪深300ETF" + assert info["total_shares"] is None + assert info["float_shares"] is None + + +def test_get_stock_info_missing_symbol_returns_empty(): + repo = _FakeRepo(_instruments_df(), None) + assert _get_stock_info(repo, "999999.SH") == {} + + +def test_get_stock_info_missing_columns_returns_empty_like_sql_error(): + df = pl.DataFrame({"symbol": ["600000.SH"], "name": ["浦发银行"]}) + repo = _FakeRepo(df, None) + assert _get_stock_info(repo, "600000.SH") == {} + + +def test_get_stock_info_repo_failure_returns_empty(): + repo = SimpleNamespace() + repo.get_instruments = lambda: (_ for _ in ()).throw(RuntimeError("boom")) + assert _get_stock_info(repo, "600000.SH") == {} + + +# ---------------------------------------------------------------- _attach_ext + +def test_attach_ext_empty_columns_untouched(): + resp = {"symbol": "600000.SH", "stock_info": {"name": "x"}, "rows": []} + repo = _FakeRepo(_instruments_df(), None) + out = _attach_ext(resp, repo, "600000.SH", None) + assert out is resp + assert "ext" not in out["stock_info"] + out = _attach_ext(resp, repo, "600000.SH", " ") + assert "ext" not in out["stock_info"] + + +def test_attach_ext_invalid_spec_untouched(): + resp = {"symbol": "600000.SH", "stock_info": {"name": "x"}, "rows": []} + repo = _FakeRepo(_instruments_df(), None) + out = _attach_ext(resp, repo, "600000.SH", "foo_bar") # 无 '.' 分隔 + assert out is resp + assert "ext" not in out["stock_info"] + + +def test_attach_ext_maps_values_from_loader(monkeypatch): + resp = {"symbol": "600000.SH", "stock_info": {"name": "x"}, "rows": []} + original_info = resp["stock_info"] + repo = _FakeRepo(_instruments_df(), None) + monkeypatch.setattr( + screener, "_load_ext_value_maps", + lambda r, cols: {"cfg1__score": {"600000.SH": 88.5}, "cfg1__note": {}}, + ) + out = _attach_ext(resp, repo, "600000.SH", "cfg1.score,cfg1.note") + assert out["stock_info"]["ext"] == {"cfg1__score": 88.5, "cfg1__note": None} + # 原地更新 resp 是该函数契约; 但原 stock_info 字典对象不被修改 (与旧实现一致) + assert out is resp + assert "ext" not in original_info + assert out["stock_info"] is not original_info + + +def test_attach_ext_loader_failure_yields_none_values(monkeypatch): + resp = {"symbol": "600000.SH", "stock_info": {}, "rows": []} + repo = _FakeRepo(_instruments_df(), None) + + def _boom(r, cols): + raise RuntimeError("boom") + + monkeypatch.setattr(screener, "_load_ext_value_maps", _boom) + out = _attach_ext(resp, repo, "600000.SH", "cfg1.score") + assert out["stock_info"]["ext"] == {"cfg1__score": None} + + +# ------------------------------------------- _attach_ext 与真实 _load_ext_value_maps 集成 + +def _setup_ext_snapshot(tmp_path): + store = ExtConfigStore(tmp_path) + store.upsert(ExtConfig( + id="benchx", label="基准扩展", mode="snapshot", + fields=[ExtField(name="score", dtype="float")], + )) + cfg_dir = tmp_path / "ext_data" / "benchx" + cfg_dir.mkdir(parents=True, exist_ok=True) + pl.DataFrame({ + "symbol": [f"{600000 + i}.SH" for i in range(50)], + "score": [float(i) for i in range(50)], + }).write_parquet(cfg_dir / "part.parquet") + return store + + +def test_attach_ext_real_loader_returns_parquet_values(tmp_path): + _setup_ext_snapshot(tmp_path) + repo = _FakeRepo(_instruments_df(), tmp_path) + resp = {"symbol": "600005.SH", "stock_info": {"name": "x"}, "rows": []} + out = _attach_ext(resp, repo, "600005.SH", "benchx.score") + assert out["stock_info"]["ext"] == {"benchx__score": 5.0} + assert isinstance(out["stock_info"]["ext"]["benchx__score"], float) + + +def test_attach_ext_real_loader_unknown_symbol_is_none(tmp_path): + _setup_ext_snapshot(tmp_path) + repo = _FakeRepo(_instruments_df(), tmp_path) + resp = {"symbol": "999999.SH", "stock_info": {}, "rows": []} + out = _attach_ext(resp, repo, "999999.SH", "benchx.score") + assert out["stock_info"]["ext"] == {"benchx__score": None} diff --git a/backend/tests/test_kline_sync_timezone.py b/backend/tests/test_kline_sync_timezone.py new file mode 100644 index 0000000..44d3c78 --- /dev/null +++ b/backend/tests/test_kline_sync_timezone.py @@ -0,0 +1,38 @@ +"""时区契约测试 — 分时拉取窗口必须按北京时间解释, 与服务器本地时区无关。 + +fetch_minute_single 构造的 naive datetime 会被 _datetime_to_ms 的 .timestamp() +按服务器本地时区解释: UTC 容器 (Docker 默认) 上窗口偏移 8 小时, 补拉必为空。 +""" +from __future__ import annotations + +from datetime import date, datetime + +from app.market_time import CN_TZ +from app.services import kline_sync + + +def test_fetch_minute_single_window_is_beijing_wall_clock(monkeypatch): + captured: dict[str, int] = {} + + def _fake_try_custom_minute(*args, **kwargs): + return (None, True) # 未配自定义源 → 走 TickFlow 分支 + + class _FakeKlines: + @staticmethod + def batch(symbols, period, start_time, end_time, **kwargs): + captured["start_ms"] = start_time + captured["end_ms"] = end_time + return [] + + class _FakeClient: + klines = _FakeKlines + + monkeypatch.setattr(kline_sync, "_try_custom_minute", _fake_try_custom_minute) + monkeypatch.setattr(kline_sync, "get_client", lambda: _FakeClient()) + + kline_sync.fetch_minute_single("600000.SH", date(2026, 8, 14)) + + start = datetime.fromtimestamp(captured["start_ms"] / 1000, tz=CN_TZ) + end = datetime.fromtimestamp(captured["end_ms"] / 1000, tz=CN_TZ) + assert (start.date(), start.hour, start.minute) == (date(2026, 8, 14), 9, 25) + assert (end.date(), end.hour, end.minute) == (date(2026, 8, 14), 15, 5) diff --git a/backend/tests/test_last_fetch_throttle.py b/backend/tests/test_last_fetch_throttle.py new file mode 100644 index 0000000..05d4073 --- /dev/null +++ b/backend/tests/test_last_fetch_throttle.py @@ -0,0 +1,64 @@ +"""last_fetch 落盘节流测试 — 30s 内只写一次盘, 内存值路径不受影响。""" +from __future__ import annotations + +import pytest + +from app.services import preferences, quote_service + + +@pytest.fixture(autouse=True) +def _reset_throttle_state(monkeypatch): + monkeypatch.setattr(quote_service, "_last_fetch_written_at_ms", 0.0) + yield + quote_service._last_fetch_written_at_ms = 0.0 + + +@pytest.fixture() +def save_counter(monkeypatch): + counter = {"saves": 0, "values": []} + + def _counting(updates: dict) -> dict: + counter["saves"] += 1 + counter["values"].append(updates) + return {} + + monkeypatch.setattr(preferences, "save", _counting) + return counter + + +def test_first_call_after_start_writes(save_counter): + quote_service._persist_last_fetch(1_700_000_000_000.0) + assert save_counter["saves"] == 1 + assert save_counter["values"][0] == {"last_fetch_ms": 1_700_000_000_000.0} + + +def test_writes_within_window_are_skipped(save_counter): + t0 = 1_700_000_000_000.0 + quote_service._persist_last_fetch(t0) + quote_service._persist_last_fetch(t0 + 10_000.0) # +10s: 跳过 + quote_service._persist_last_fetch(t0 + 29_999.0) # 仍不足 30s: 跳过 + assert save_counter["saves"] == 1 + + +def test_write_resumes_after_window(save_counter): + t0 = 1_700_000_000_000.0 + quote_service._persist_last_fetch(t0) + quote_service._persist_last_fetch(t0 + 30_000.0) # 恰好 30s: 恢复写盘 + assert save_counter["saves"] == 2 + assert save_counter["values"][1] == {"last_fetch_ms": 1_700_000_030_000.0} + + +def test_write_failure_does_not_propagate_and_retries(monkeypatch): + calls = {"n": 0} + + def _flaky(updates: dict) -> dict: + calls["n"] += 1 + if calls["n"] == 1: + raise OSError("disk full") + return {} + + monkeypatch.setattr(preferences, "save", _flaky) + t0 = 1_700_000_000_000.0 + quote_service._persist_last_fetch(t0) # 失败但不抛出 + quote_service._persist_last_fetch(t0 + 1_000.0) # 未成功过 -> 立即重试 + assert calls["n"] == 2 diff --git a/backend/tests/test_main_mining_lifespan.py b/backend/tests/test_main_mining_lifespan.py new file mode 100644 index 0000000..8b6e9e0 --- /dev/null +++ b/backend/tests/test_main_mining_lifespan.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from types import SimpleNamespace + +import pytest + +import app.main as main_module + + +def test_lifespan_holds_mining_process_lock_around_application(monkeypatch) -> None: + events: list[str] = [] + + class LockStub: + def __init__(self, data_dir) -> None: + del data_dir + events.append("lock_created") + + def acquire(self) -> None: + events.append("lock_acquired") + + def release(self) -> None: + events.append("lock_released") + + @asynccontextmanager + async def application_lifespan(_app): + events.append("application_started") + try: + yield + finally: + events.append("application_stopped") + + monkeypatch.setattr(main_module, "MiningProcessLock", LockStub) + monkeypatch.setattr(main_module, "_application_lifespan", application_lifespan) + + async def exercise() -> None: + async with main_module.lifespan(SimpleNamespace()): + events.append("request_serving") + + asyncio.run(exercise()) + + assert events == [ + "lock_created", + "lock_acquired", + "application_started", + "request_serving", + "application_stopped", + "lock_released", + ] + + +def test_lifespan_releases_lock_when_application_shutdown_raises(monkeypatch) -> None: + events: list[str] = [] + + class LockStub: + def __init__(self, data_dir) -> None: + del data_dir + + def acquire(self) -> None: + events.append("acquired") + + def release(self) -> None: + events.append("released") + + @asynccontextmanager + async def application_lifespan(_app): + try: + yield + finally: + raise RuntimeError("shutdown failed") + + monkeypatch.setattr(main_module, "MiningProcessLock", LockStub) + monkeypatch.setattr(main_module, "_application_lifespan", application_lifespan) + + async def exercise() -> None: + async with main_module.lifespan(SimpleNamespace()): + pass + + with pytest.raises(RuntimeError, match="shutdown failed"): + asyncio.run(exercise()) + + assert events == ["acquired", "released"] diff --git a/backend/tests/test_market_mainline.py b/backend/tests/test_market_mainline.py new file mode 100644 index 0000000..c312039 --- /dev/null +++ b/backend/tests/test_market_mainline.py @@ -0,0 +1,228 @@ +"""市场主线(market_mainline)与过滤配置单元测试。""" +from __future__ import annotations + +from datetime import date + +import polars as pl + +from app.services import market_mainline, preferences + + +def _write_enriched(root, rows: list[dict]) -> None: + enriched = root / "kline_daily_enriched" + by_date: dict[date, list[dict]] = {} + for r in rows: + by_date.setdefault(r["date"], []).append(r) + for d, day_rows in by_date.items(): + part = enriched / f"date={d.isoformat()}" / "part.parquet" + part.parent.mkdir(parents=True, exist_ok=True) + pl.DataFrame(day_rows).write_parquet(part) + + +def _fake_repo(tmp_path): + import types + + return types.SimpleNamespace(store=types.SimpleNamespace(data_dir=tmp_path)) + + +def _patch_map(monkeypatch, mapping: dict[str, list[str]], kind: str = "concept") -> None: + map_df = pl.DataFrame( + {"_sym_up": [s for s, ms in mapping.items() for _ in ms], + kind: [m for _, ms in mapping.items() for m in ms]}, + schema={"_sym_up": pl.Utf8, kind: pl.Utf8}, + ).unique() + + def fake_load(repo, k="concept"): + return (map_df, map_df[kind].n_unique()) if k == kind else (pl.DataFrame(), 0) + + monkeypatch.setattr(market_mainline, "_load_concept_map_df", fake_load) + + +def _mk_rows(d: date, spec: list[tuple[str, int, float]]) -> list[dict]: + return [ + {"symbol": sym, "date": d, "consecutive_limit_ups": consec, "amount": amt} + for sym, consec, amt in spec + ] + + +class TestComputeMainline: + def _setup(self, tmp_path, monkeypatch): + d1, d2 = date(2024, 1, 2), date(2024, 1, 3) + # 概念 X: d1 三个涨停(2,1,1), d2 三个涨停(3,2,1); 概念 Y: 单股 2 板 + # S5 无概念映射; 大概念 BIG 成员 700 家但只有 5 家涨停(数据里只写 5 行) + rows = _mk_rows(d1, [("S1.SH", 2, 5e8), ("S2.SH", 1, 1e8), ("S3.SH", 1, 2e8), + ("S4.SH", 2, 3e8), ("S5.SH", 1, 1e8), + ("B1.SH", 1, 1e8), ("B2.SH", 1, 1e8)]) + rows += _mk_rows(d2, [("S1.SH", 3, 6e8), ("S2.SH", 2, 2e8), ("S3.SH", 0, 1e8), + ("S4.SH", 3, 4e8), ("S5.SH", 1, 1e8), + ("B1.SH", 2, 1e8), ("B2.SH", 0, 1e8)]) + _write_enriched(tmp_path, rows) + mapping = { + "S1.SH": ["X"], "S2.SH": ["X"], "S3.SH": ["X"], + "S4.SH": ["X", "Y"], "S5.SH": [], + "B1.SH": ["BIG"], "B2.SH": ["BIG"], + **{f"F{i}.SH": ["BIG"] for i in range(700)}, # BIG 成员 702 → 超 600 上限 + } + _patch_map(monkeypatch, mapping) + return _fake_repo(tmp_path), d1, d2 + + def test_aggregation_and_big_concept_filter(self, tmp_path, monkeypatch): + repo, d1, d2 = self._setup(tmp_path, monkeypatch) + out = market_mainline.compute_mainline_range( + repo, tmp_path, d1, d2, kind="concept", + filter_cfg={"min_members": 4, "max_members": 600, "blacklist": []}, + ) + members = set(out["member"].to_list()) + assert "BIG" not in members # 成员数超上限被过滤 + assert "X" in members + x_d2 = out.filter((pl.col("date") == d2) & (pl.col("member") == "X")).to_dicts()[0] + assert x_d2["limit_up_count"] == 3 # S1,S2,S4 + assert x_d2["ge2_count"] == 3 + assert x_d2["max_boards"] == 3 + assert x_d2["rungs_filled"] == 2 # 档位 {2,3} + assert x_d2["leader_symbol"] == "S1.SH" # 最高板且成交额大 + assert x_d2["rank"] == 1 + + def test_blacklist_and_min_limit_up(self, tmp_path, monkeypatch): + repo, d1, d2 = self._setup(tmp_path, monkeypatch) + out = market_mainline.compute_mainline_range( + repo, tmp_path, d1, d2, kind="concept", + filter_cfg={"min_members": 1, "max_members": 5000, "blacklist": ["X"]}, + ) + # X 被黑名单; BIG 只有 2-3 家涨停 < _MIN_LIMIT_UP=3 也不参与 → 只剩空/无 X + assert "X" not in set(out["member"].to_list()) + + def test_upsert_replaces_same_day_kind(self, tmp_path, monkeypatch): + repo, d1, d2 = self._setup(tmp_path, monkeypatch) + cfg = {"min_members": 4, "max_members": 600, "blacklist": []} + first = market_mainline.compute_mainline_range(repo, tmp_path, d1, d1, kind="concept", filter_cfg=cfg) + market_mainline.upsert_mainline_history(tmp_path, first) + both = market_mainline.compute_mainline_range(repo, tmp_path, d1, d2, kind="concept", filter_cfg=cfg) + market_mainline.upsert_mainline_history(tmp_path, both) + stored = pl.read_parquet(market_mainline.mainline_path(tmp_path)) + assert set(stored["date"].to_list()) == {d1, d2} + # 同日重算不产生重复行 + assert stored.filter(pl.col("date") == d1).height == first.height + + def test_incremental_fills_missing_days(self, tmp_path, monkeypatch): + repo, d1, d2 = self._setup(tmp_path, monkeypatch) + cfg = {"min_members": 4, "max_members": 600, "blacklist": []} + first = market_mainline.compute_mainline_range(repo, tmp_path, d1, d1, kind="concept", filter_cfg=cfg) + market_mainline.upsert_mainline_history(tmp_path, first) + new = market_mainline.compute_mainline_incremental(repo, tmp_path, kind="concept") + assert not new.is_empty() + assert set(new["date"].to_list()) == {d2} + + def test_industry_level_truncation(self, tmp_path, monkeypatch): + d1 = date(2024, 1, 2) + rows = _mk_rows(d1, [("S1.SH", 2, 5e8), ("S2.SH", 1, 1e8), + ("S3.SH", 1, 2e8), ("S4.SH", 3, 4e8)]) + _write_enriched(tmp_path, rows) + _patch_map( + monkeypatch, + {"S1.SH": ["计算机-软件开发-垂直应用软件"], + "S2.SH": ["计算机-软件开发-垂直应用软件"], + "S3.SH": ["计算机-IT服务-IT服务Ⅲ"], + "S4.SH": ["计算机-软件开发-垂直应用软件"]}, + kind="industry", + ) + out = market_mainline.compute_mainline_range( + _fake_repo(tmp_path), tmp_path, d1, d1, kind="industry", + filter_cfg={"min_members": 1, "max_members": 5000, "blacklist": []}, + ) + members = set(out["member"].to_list()) + assert "计算机-软件开发" in members + assert all(m.count("-") <= 1 for m in members) + sw = out.filter(pl.col("member") == "计算机-软件开发").to_dicts()[0] + assert sw["limit_up_count"] == 3 + assert sw["max_boards"] == 3 + + +class TestMainlineFilterPreferences: + def test_blacklist_string_parsing_and_clamp(self, tmp_path, monkeypatch): + path = tmp_path / "preferences.json" + monkeypatch.setattr(preferences, "_path", lambda: path) + got = preferences.set_mainline_filter_config({ + "max_members": 99999, # 超上限被夹到 5000 + "min_members": 0, # 低于下限被夹到 1 + "blacklist": "融资融券, 沪股通;深股通", # noqa: RUF001 + }) + assert got["max_members"] == 5000 + assert got["min_members"] == 1 + assert set(got["blacklist"]) == {"融资融券", "沪股通", "深股通"} + # 部分更新: 只改黑名单, 其他保持 + got2 = preferences.set_mainline_filter_config({"blacklist": ["ST板块"]}) + assert got2["blacklist"] == ["ST板块"] + assert got2["max_members"] == 5000 + + def test_defaults(self, tmp_path, monkeypatch): + path = tmp_path / "preferences.json" + monkeypatch.setattr(preferences, "_path", lambda: path) + cfg = preferences.get_mainline_filter_config() + assert cfg == {"min_members": 4, "max_members": 600, "blacklist": [], "exclude_st": True} + + def test_sentiment_exclude_st_roundtrip(self, tmp_path, monkeypatch): + path = tmp_path / "preferences.json" + monkeypatch.setattr(preferences, "_path", lambda: path) + assert preferences.get_sentiment_exclude_st() is True # 默认剔除 + assert preferences.set_sentiment_exclude_st(False) is False + assert preferences.get_sentiment_exclude_st() is False + # 经主线过滤配置部分更新同样生效 + got = preferences.set_mainline_filter_config({"exclude_st": True}) + assert got["exclude_st"] is True + + +class TestExcludeST: + """风险警示股剔除: 维表名称含 ST → 主线聚合前过滤。""" + + @staticmethod + def _write_instruments(tmp_path, names: dict[str, str]) -> None: + part = tmp_path / "instruments" / "part.parquet" + part.parent.mkdir(parents=True, exist_ok=True) + pl.DataFrame({ + "symbol": list(names), + "name": list(names.values()), + }).write_parquet(part) + + def _reset_cache(self, monkeypatch): + monkeypatch.setattr(market_mainline, "_ST_SYMBOLS_CACHE", None) + + def test_load_risk_warning_symbols(self, tmp_path, monkeypatch): + self._reset_cache(monkeypatch) + self._write_instruments(tmp_path, { + "s1.SH": "*ST环保", "S2.SH": "ST万邦", "S3.SZ": "正常股", + "s4.BJ": "S*ST京", "S5.SH": "斯太尔", # 中文名含"斯"不含 ST 标记 + }) + got = market_mainline.load_risk_warning_symbols(tmp_path) + assert got == frozenset({"S1.SH", "S2.SH", "S4.BJ"}) # 大写归一 + # 缓存命中: 再次读取不重扫磁盘 + self._write_instruments(tmp_path, {"S9.SH": "ST新增"}) + assert market_mainline.load_risk_warning_symbols(tmp_path) == got + + def test_load_risk_warning_symbols_empty_dir(self, tmp_path, monkeypatch): + self._reset_cache(monkeypatch) + assert market_mainline.load_risk_warning_symbols(tmp_path) == frozenset() + + def test_compute_mainline_excludes_st(self, tmp_path, monkeypatch): + """S1(ST) 涨停被剔除 → 概念 X 计数/高度/龙头随之变化; 关闭开关恢复。""" + self._reset_cache(monkeypatch) + self._write_instruments(tmp_path, {"S1.SH": "*ST一", "S2.SH": "正常一"}) + repo, d1, d2 = TestComputeMainline()._setup(tmp_path, monkeypatch) + cfg = {"min_members": 4, "max_members": 600, "blacklist": []} + + out = market_mainline.compute_mainline_range( + repo, tmp_path, d1, d2, kind="concept", filter_cfg=cfg, exclude_st=True, + ) + x_d1 = out.filter((pl.col("date") == d1) & (pl.col("member") == "X")).to_dicts()[0] + assert x_d1["limit_up_count"] == 3 # S1(ST) 被剔除, 剩 S2,S3,S4 + assert x_d1["ge2_count"] == 1 # 仅 S4=2板 + assert x_d1["max_boards"] == 2 + assert x_d1["leader_symbol"] == "S4.SH" + + out_keep = market_mainline.compute_mainline_range( + repo, tmp_path, d1, d2, kind="concept", filter_cfg=cfg, exclude_st=False, + ) + x_d1_keep = out_keep.filter((pl.col("date") == d1) & (pl.col("member") == "X")).to_dicts()[0] + assert x_d1_keep["limit_up_count"] == 4 + assert x_d1_keep["ge2_count"] == 2 # S1=2板, S4=2板 + assert x_d1_keep["leader_symbol"] == "S1.SH" diff --git a/backend/tests/test_market_phase.py b/backend/tests/test_market_phase.py new file mode 100644 index 0000000..0468b4b --- /dev/null +++ b/backend/tests/test_market_phase.py @@ -0,0 +1,200 @@ +"""市场情绪周期阶段(market_phase)单元测试 — 梯队指标与阶段规则引擎。""" +from __future__ import annotations + +from datetime import date, timedelta +from itertools import pairwise + +import polars as pl + +from app.services.market_phase import ( + CLIMAX_GE2, + EBB_PROMO, + ICE_FIRST_BOARD, + ICE_GE2, + ICE_HEIGHT, + PHASE_CLIMAX, + PHASE_EBB, + PHASE_ICE, + PHASE_IGNITE, + PHASE_RALLY, + PHASE_REPAIR, + classify_phase_series, + finalize_ladder_row, + with_prev_consecutive, +) +from app.services.regime_builder import _aggregate_daily, refresh_phase_labels, regime_path + + +def _days(n: int, start: str = "2024-01-01") -> list[date]: + d0 = date.fromisoformat(start) + out, cur = [], d0 + while len(out) < n: + if cur.weekday() < 5: + out.append(cur) + cur += timedelta(days=1) + return out + + +def _frame(rows: list[dict]) -> pl.DataFrame: + base = { + "change_pct": 0.01, "amount": 1e8, "close": 10.0, "ma20": 9.5, + "signal_limit_up": True, "signal_limit_down": False, + "signal_broken_limit_up": False, + } + return pl.DataFrame([{**base, **r} for r in rows]) + + +class TestLadderMetrics: + def test_prev_consecutive_and_counts(self): + d1, d2, d3 = _days(3) + df = _frame([ + {"symbol": "A", "date": d1, "consecutive_limit_ups": 1}, + {"symbol": "A", "date": d2, "consecutive_limit_ups": 2}, + {"symbol": "A", "date": d3, "consecutive_limit_ups": 0}, + {"symbol": "B", "date": d1, "consecutive_limit_ups": 1}, + {"symbol": "B", "date": d2, "consecutive_limit_ups": 0}, + {"symbol": "C", "date": d2, "consecutive_limit_ups": 3}, + ]) + out = with_prev_consecutive(df) + prev = {r["symbol"] + str(r["date"]): r["_prev_consec"] for r in out.iter_rows(named=True)} + assert prev["A" + str(d1)] is None + assert prev["A" + str(d2)] == 1 + assert prev["B" + str(d2)] == 1 + + agg = _aggregate_daily(df) + by_date = {r["date"]: r for r in agg.iter_rows(named=True)} + assert by_date[d1]["first_board"] == 2 + assert by_date[d1]["ge2_count"] == 0 + # d2: A=2板(晋级), B=断板(昨1今0), C=3板(新面孔); pool=2(A,B) <10 → promo null + assert by_date[d2]["ge2_count"] == 2 + assert by_date[d2]["promo_pool"] == 2 + assert by_date[d2]["promo_rate"] is None + assert by_date[d2]["ladder_completeness"] == 1.0 # 档位 {2,3}, height=3 → 2/2 + assert by_date[d3]["first_board"] == 0 + + def test_promo_rate_with_sufficient_pool(self): + d1, d2 = _days(2) + rows = [] + for i in range(12): + rows.append({"symbol": f"S{i}", "date": d1, "consecutive_limit_ups": 1}) + rows.append({ + "symbol": f"S{i}", "date": d2, + "consecutive_limit_ups": 2 if i < 4 else 0, # 4 晋级, 8 断板 + }) + agg = _aggregate_daily(_frame(rows)) + d2_row = {r["date"]: r for r in agg.iter_rows(named=True)}[d2] + assert d2_row["promo_pool"] == 12 + assert d2_row["promo_rate"] == round(4 / 12, 4) + + def test_promo_small_pool_is_null(self): + row = {"promo_pool": 5, "promo_ok": 5, "max_consecutive": 3, "rungs_filled": 2} + assert finalize_ladder_row(row)["promo_rate"] is None + row2 = {"promo_pool": 20, "promo_ok": 8, "max_consecutive": 3, "rungs_filled": 2} + assert finalize_ladder_row(row2)["promo_rate"] == 0.4 + + def test_completeness_gap(self): + # height=5, 只有 2板和5板 → rungs {2,5} → 2/4 + row = {"max_consecutive": 5, "rungs_filled": 2, "promo_pool": 0, "promo_ok": 0} + assert finalize_ladder_row(row)["ladder_completeness"] == 0.5 + + +def _series(specs: list[dict]) -> pl.DataFrame: + """specs: [{days, height, first, ge2, promo, seal, state}] 逐段展开成日序。""" + rows = [] + for sp in specs: + for _ in range(sp["days"]): + rows.append({ + "date": None, # 由调用方生成后填充 + "max_consecutive": sp["height"], + "first_board": sp["first"], + "ge2_count": sp["ge2"], + "promo_rate": sp["promo"], + "seal_rate": sp["seal"], + **({"state": sp.get("state", "range")} if sp.get("state") else {}), + }) + dates = _days(len(rows)) + for r, d in zip(rows, dates, strict=True): + r["date"] = d + return pl.DataFrame(rows) + + +class TestClassifyPhaseSeries: + def _labels(self, specs): + df = _series(specs) + return classify_phase_series(df)["phase"].to_list() + + def test_climax_and_persistence(self): + labels = self._labels([ + {"days": 6, "height": 5, "first": 40, "ge2": 12, "promo": 0.2, "seal": 0.6, "state": "strong"}, + {"days": 5, "height": 12, "first": 300, "ge2": CLIMAX_GE2 + 40, "promo": 0.5, "seal": 0.7, "state": "strong"}, + {"days": 8, "height": 5, "first": 40, "ge2": 12, "promo": 0.2, "seal": 0.6, "state": "range"}, + ]) + assert PHASE_CLIMAX in labels + assert labels[-1] == PHASE_REPAIR + # EMA 平滑 + 2 日确认: 阶段切换总数有限, 不出现 1 日翻转噪声 + switches = sum(1 for a, b in pairwise(labels) if a != b) + assert switches <= 4 + + def test_rally_positive_state(self): + labels = self._labels([ + {"days": 8, "height": 8, "first": 60, "ge2": 18, "promo": 0.3, "seal": 0.7, "state": "strong"}, + ]) + assert PHASE_RALLY in labels + + def test_rally_vetoed_in_weak_state(self): + labels = self._labels([ + {"days": 8, "height": 8, "first": 60, "ge2": 18, "promo": 0.3, "seal": 0.7, "state": "weak"}, + ]) + assert PHASE_RALLY not in labels + assert all(p == PHASE_REPAIR for p in labels) + + def test_ice(self): + labels = self._labels([ + {"days": 6, "height": ICE_HEIGHT - 1, "first": ICE_FIRST_BOARD - 1, + "ge2": ICE_GE2 - 1, "promo": 0.1, "seal": 0.5, "state": "range"}, + ]) + assert PHASE_ICE in labels + + def test_ebb_from_high(self): + labels = self._labels([ + {"days": 8, "height": 9, "first": 60, "ge2": 20, "promo": 0.3, "seal": 0.7, "state": "strong"}, + {"days": 6, "height": 6, "first": 30, "ge2": 6, "promo": EBB_PROMO - 0.03, "seal": 0.5, "state": "range"}, + ]) + assert PHASE_EBB in labels + + def test_ignite_expansion(self): + labels = self._labels([ + {"days": 6, "height": 4, "first": 25, "ge2": 5, "promo": 0.15, "seal": 0.6, "state": "range"}, + {"days": 8, "height": 6, "first": 50, "ge2": 14, "promo": 0.24, "seal": 0.68, "state": "lean_strong"}, + ]) + assert PHASE_IGNITE in labels + + def test_promo_null_leading_days_ffilled(self): + specs = [ + {"days": 4, "height": 5, "first": 40, "ge2": 10, "promo": None, "seal": 0.6, "state": "range"}, + {"days": 4, "height": 6, "first": 50, "ge2": 14, "promo": 0.25, "seal": 0.68, "state": "strong"}, + ] + df = _series(specs) + out = classify_phase_series(df) + assert out["phase"].null_count() == 0 + + +class TestRefreshPhaseLabels: + def test_roundtrip_writes_phase_keeps_state(self, tmp_path): + specs = [ + {"days": 10, "height": 8, "first": 60, "ge2": 18, "promo": 0.3, "seal": 0.7, "state": "strong"}, + ] + df = _series(specs) + regime_path(tmp_path).parent.mkdir(parents=True) + df.write_parquet(regime_path(tmp_path)) + n = refresh_phase_labels(tmp_path) + assert n == 10 + out = pl.read_parquet(regime_path(tmp_path)) + assert "phase" in out.columns + assert PHASE_RALLY in set(out["phase"].to_list()) + assert set(out["state"].to_list()) == {"strong"} + + def test_missing_columns_returns_zero(self, tmp_path): + regime_path(tmp_path).parent.mkdir(parents=True) + pl.DataFrame({"date": _days(3), "state": ["range"] * 3}).write_parquet(regime_path(tmp_path)) + assert refresh_phase_labels(tmp_path) == 0 diff --git a/backend/tests/test_matrix_field_columns_expansion.py b/backend/tests/test_matrix_field_columns_expansion.py new file mode 100644 index 0000000..ae41ebe --- /dev/null +++ b/backend/tests/test_matrix_field_columns_expansion.py @@ -0,0 +1,65 @@ +"""每日信号路径的字段依赖展开回归测试。 + +挖掘发布的 FactorRankResearchMatrixStrategy 把因子权重放在类级 SCORING, +META["scoring"] 为空。每日信号/实时矩阵路径通过 _matrix_field_columns 决定 +矩阵字段, 必须展开 required_fields_for_params 的虚拟因子依赖 +(limit_up_count_* -> consecutive_limit_ups), 否则 compute_signals 抛 +"MarketDataMatrix missing field: consecutive_limit_ups"。 +""" +from __future__ import annotations + +import types +from datetime import date, timedelta + +import polars as pl + +from app.backtest.matrix import build_market_data_matrix +from app.strategy.builtin.factor_rank_research import FactorRankResearchMatrixStrategy +from app.strategy.engine import StrategyEngine + + +def _mined_limit_up_strategy() -> types.SimpleNamespace: + strategy = FactorRankResearchMatrixStrategy( + {"amplitude": 2.0, "limit_up_count_60d": 1.0}, + {"amplitude": "low", "limit_up_count_60d": "low"}, + ) + return types.SimpleNamespace( + matrix_strategy=strategy, + basic_filter=None, + meta={"scoring": {}}, + ) + + +def test_matrix_field_columns_expand_parameter_scoring_dependencies(): + strategy = _mined_limit_up_strategy() + fields = StrategyEngine._matrix_field_columns(strategy, None, {}) + assert "consecutive_limit_ups" in fields + assert "amplitude" in fields + + +def test_mined_limit_up_strategy_signals_build_from_panel_fields(): + rows = [] + start = date(2024, 1, 1) + for offset in range(80): + close = 10.0 + offset * 0.04 + rows.append({ + "symbol": "000001.SZ", + "date": start + timedelta(days=offset), + "open": close - 0.05, + "high": close + 0.15, + "low": close - 0.15, + "close": close, + "volume": 1000.0 + offset * 5.0, + "amount": 100000.0, + "amplitude": 1.5, + "turnover_rate": 5.0, + "consecutive_limit_ups": (offset % 17) + 1 if offset % 17 == 0 else 0, + }) + panel = pl.DataFrame(rows) + strategy = _mined_limit_up_strategy() + market = build_market_data_matrix( + panel, + field_columns=StrategyEngine._matrix_field_columns(strategy, None, {}), + ) + signals = strategy.matrix_strategy.compute_signals(market, {}) + assert signals.shape == market.shape diff --git a/backend/tests/test_matrix_prewarm_owner.py b/backend/tests/test_matrix_prewarm_owner.py new file mode 100644 index 0000000..256f73c --- /dev/null +++ b/backend/tests/test_matrix_prewarm_owner.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import threading +import time + +from app.services.heavy_job_limiter import HeavyJobCancelledError, HeavyJobLimiter +from app.services.matrix_prewarm_owner import MatrixCachePrewarmOwner + + +def test_owner_deduplicates_running_work_and_reuses_after_completion() -> None: + owner = MatrixCachePrewarmOwner() + started = threading.Event() + release = threading.Event() + calls: list[str] = [] + + def target() -> None: + calls.append("run") + started.set() + assert release.wait(2) + + assert owner.schedule(target) + assert started.wait(1) + assert not owner.schedule(target) + + release.set() + deadline = time.monotonic() + 1 + while time.monotonic() < deadline and len(calls) < 2: + if owner.schedule(lambda: calls.append("run")): + break + time.sleep(0.005) + + assert calls == ["run", "run"] + assert owner.shutdown(timeout=1) + + +def test_shutdown_cancels_limiter_wait_and_rejects_new_work() -> None: + limiter = HeavyJobLimiter(capacity=2, cancel_poll_interval=0.005) + owner = MatrixCachePrewarmOwner() + waiting = threading.Event() + cancelled = threading.Event() + assert limiter.acquire("mining", timeout=0) + + def target() -> None: + waiting.set() + try: + with limiter.slot("normal", cancel_event=owner.cancel_event): + raise AssertionError("cancelled prewarm must not acquire capacity") + except HeavyJobCancelledError: + cancelled.set() + + assert owner.schedule(target) + assert waiting.wait(1) + assert owner.shutdown(timeout=1) + assert cancelled.is_set() + assert not owner.schedule(target) + limiter.release("mining") + + +def test_shutdown_join_is_bounded_for_uncooperative_target() -> None: + owner = MatrixCachePrewarmOwner() + release = threading.Event() + started = threading.Event() + + def target() -> None: + started.set() + release.wait(2) + + assert owner.schedule(target) + assert started.wait(1) + before = time.monotonic() + assert not owner.shutdown(timeout=0.02) + assert time.monotonic() - before < 0.2 + + release.set() + assert owner.shutdown(timeout=1) diff --git a/backend/tests/test_mining_api.py b/backend/tests/test_mining_api.py new file mode 100644 index 0000000..bd49847 --- /dev/null +++ b/backend/tests/test_mining_api.py @@ -0,0 +1,606 @@ +from __future__ import annotations + +import json +from datetime import date, timedelta +from pathlib import Path +from types import SimpleNamespace +from urllib.parse import quote + +import polars as pl +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.mining import router +from app.backtest.mining import compute_candidate_signature +from app.services.mining_jobs import MiningRunStore +from app.strategy.engine import StrategyEngine + +_FACTOR_DEFINITION = { + "kind": "factor_rank", + "factor_names": ["turnover_rate"], + "scoring": {"turnover_rate": 1.0}, + "directions": {"turnover_rate": "high"}, +} +_FACTOR_SIGNATURE = compute_candidate_signature(_FACTOR_DEFINITION) + + +class _Repo: + def __init__(self, data_dir) -> None: + self.store = SimpleNamespace(data_dir=data_dir) + + @staticmethod + def get_matrix_data_generation(asset_type="stock"): + return f"generation-{asset_type}" + + @staticmethod + def latest_enriched_date(asset_type="stock"): + del asset_type + return date(2026, 1, 9) + + @staticmethod + def get_instruments_asset(asset_type): + del asset_type + return pl.DataFrame({"symbol": ["000001.SZ"], "name": ["测试"]}) + + +class _Manager: + def __init__(self, data_dir) -> None: + self.store = MiningRunStore(data_dir) + + def start(self, request, fingerprint, force=False, source="manual", run_id=None): + del force + manifest = self.store.create(request, fingerprint, run_id=run_id) + self.store.append_event( + manifest["run_id"], + "queued", + {"status": "queued", "source": source}, + ) + return manifest + + def cancel(self, run_id): + manifest = self.store.get(run_id) + if manifest is None: + raise KeyError(run_id) + if manifest["status"] == "queued": + manifest = self.store.transition_status(run_id, "cancelled") + self.store.append_event(run_id, "cancelled", {"status": "cancelled"}) + return manifest + + +def _write_enriched_dates( + data_dir: Path, + count: int, + *, + asset_type: str = "stock", + first: date = date(2020, 1, 1), +) -> list[date]: + dirname = "kline_etf_enriched" if asset_type == "etf" else "kline_daily_enriched" + values = [first + timedelta(days=index) for index in range(count)] + for value in values: + partition = data_dir / dirname / f"date={value.isoformat()}" + partition.mkdir(parents=True, exist_ok=True) + (partition / "part.parquet").touch() + return values + + +def _client(tmp_path): + _write_enriched_dates(tmp_path, 219, first=date(2022, 8, 15)) + app = FastAPI() + app.include_router(router) + app.state.repo = _Repo(tmp_path) + app.state.mining_manager = _Manager(tmp_path) + app.state.strategy_engine = SimpleNamespace() + return TestClient(app), app.state.mining_manager.store + + +def _successful_run(store: MiningRunStore, run_id: str = "result-run"): + manifest = store.create( + { + "factor_names": ["turnover_rate"], + "strategy_ids": [], + "asset_type": "stock", + "budget_profile": "exploratory", + "correlation_threshold": 0.75, + }, + {"generation": "test"}, + run_id=run_id, + ) + store.append_event(run_id, "queued", {"status": "queued", "source": "manual"}) + store.transition_status(run_id, "running") + store.append_event(run_id, "running", {"status": "running"}) + + frames = { + "factors": pl.DataFrame({ + "factor_name": ["turnover_rate"], + "label": ["换手率"], + "direction": [1], + "score": [1.2], + "ic_mean": [0.1], + "ir": [0.8], + "coverage": [1.0], + "turnover": [0.2], + "spread_return": [None], + "spread_sharpe": [None], + "selected": [True], + "excluded_reason": [None], + }), + "correlation": pl.DataFrame({ + "factor_x": ["turnover_rate"], + "factor_y": ["turnover_rate"], + "rho": [1.0], + "pair_count": [3], + }), + "candidates": pl.DataFrame({ + "signature": [_FACTOR_SIGNATURE], + "name": ["因子组合 · 换手率"], + "kind": ["factor_combination"], + "factor_names_json": ['["turnover_rate"]'], + "strategy_id": [None], + "definition_json": [json.dumps(_FACTOR_DEFINITION, sort_keys=True)], + "regime_state": ["overall"], + "score": [0.9], + "oos_return": [0.08], + "oos_sharpe": [0.9], + "oos_max_drawdown": [-0.12], + "oos_positive_fold_ratio": [1.0], + "oos_n_trades": [70], + "confidence": ["standard"], + "valid_folds": [3], + "skipped_folds": [0], + "promoted_candidate_id": [None], + "published_strategy_id": [None], + }, schema_overrides={ + "strategy_id": pl.String, + "promoted_candidate_id": pl.String, + "published_strategy_id": pl.String, + }), + "folds": pl.DataFrame({ + "candidate_signature": [_FACTOR_SIGNATURE, _FACTOR_SIGNATURE], + "fold": [0, 0], + "label": ["OOS 1", "OOS 1"], + "regime_state": ["overall", "strong"], + "n_dates": [3, 2], + "train_start": ["2025-01-01", "2025-01-01"], + "train_end": ["2025-12-31", "2025-12-31"], + "test_start": ["2026-01-05", "2026-01-05"], + "test_end": ["2026-01-09", "2026-01-09"], + "selected_factors_json": ['["turnover_rate"]'] * 2, + "total_return": [0.08, 0.1], + "sharpe": [0.9, 1.1], + "max_drawdown": [-0.12, -0.08], + "n_trades": [70, 40], + "skipped": [False, False], + "reason": [None, None], + }), + } + for name, frame in frames.items(): + frame.write_parquet(store.artifact_path(run_id, name)) + store.register_artifact(run_id, name) + store.write_summary(run_id, { + "status": "succeeded", + "factor_count": 1, + "selected_factor_count": 1, + "candidate_count": 1, + "valid_fold_count": 1, + "skipped_fold_count": 0, + "confidence": "low", + "budget_exhausted": False, + "elapsed_ms": 123.4, + "data_as_of": "2026-01-09", + "methodology_version": "factor_v2", + "algorithm_version": "mining-v1", + "panel_scans": 1, + "matrix_bytes": 4096, + "phase_ms": {"panel": 1.0, "total": 123.4}, + "worker": {"peak_rss_bytes": 1000, "serialized_result_bytes": 2000}, + }) + store.transition_status(run_id, "succeeded") + store.append_event(run_id, "succeeded", {"status": "succeeded"}) + return manifest + + +def test_start_is_strict_and_projects_server_signature(tmp_path): + client, store = _client(tmp_path) + payload = { + "factor_names": ["turnover_rate"], + "budget_profile": "exploratory", + "force": False, + } + + response = client.post("/api/backtest/mining/runs", json=payload) + + assert response.status_code == 200 + body = response.json() + assert body["status"] == "queued" + assert body["signature"] == store.get(body["run_id"])["run_signature"] + assert body["request"]["factor_names"] == ["turnover_rate"] + assert "force" not in body["request"] + assert body["source"] == "manual" + assert client.post( + "/api/backtest/mining/runs", + json={**payload, "matching": "close_t"}, + ).status_code == 422 + assert client.post( + "/api/backtest/mining/runs", + json={"factor_names": ["unknown_factor"]}, + ).status_code == 422 + + +def test_start_accepts_iso_dates_and_persists_json_safe_request(tmp_path): + client, store = _client(tmp_path) + + response = client.post( + "/api/backtest/mining/runs", + json={ + "factor_names": ["turnover_rate"], + "budget_profile": "exploratory", + "start": "2022-08-15", + "end": "2026-08-15", + }, + ) + + assert response.status_code == 200 + run_id = response.json()["run_id"] + manifest = store.get(run_id) + assert manifest is not None + assert manifest["request"]["start"] == "2022-08-15" + assert manifest["request"]["end"] == "2026-08-15" + assert client.post( + "/api/backtest/mining/runs", + json={ + "factor_names": ["turnover_rate"], + "start": "2022/08/15", + }, + ).status_code == 422 + + +@pytest.mark.parametrize( + ("profile", "required_bars"), + [("exploratory", 219), ("balanced", 786), ("strict", 1164)], +) +def test_availability_enforces_exact_profile_boundaries( + tmp_path, + profile, + required_bars, +): + client, _store = _client(tmp_path) + dates = _write_enriched_dates( + tmp_path, + required_bars, + first=date(2015, 1, 1), + ) + + eligible = client.get( + "/api/backtest/mining/availability", + params={ + "asset_type": "stock", + "budget_profile": profile, + "start": dates[0].isoformat(), + "end": dates[-1].isoformat(), + }, + ) + insufficient = client.get( + "/api/backtest/mining/availability", + params={ + "asset_type": "stock", + "budget_profile": profile, + "start": dates[1].isoformat(), + "end": dates[-1].isoformat(), + }, + ) + + assert eligible.status_code == 200 + assert eligible.json() == { + "asset_type": "stock", + "budget_profile": profile, + "trading_bars": required_bars, + "required_bars": required_bars, + "outer_folds": 1 if profile == "exploratory" else 3, + "required_outer_folds": 1 if profile == "exploratory" else 3, + "eligible": True, + "available_start": dates[0].isoformat(), + "available_end": "2023-03-21", + "effective_start": dates[0].isoformat(), + "effective_end": dates[-1].isoformat(), + "suggested_start": dates[0].isoformat(), + } + assert insufficient.status_code == 200 + assert insufficient.json()["trading_bars"] == required_bars - 1 + assert insufficient.json()["eligible"] is False + assert insufficient.json()["suggested_start"] == dates[0].isoformat() + + +def test_start_rejects_balanced_625_bar_range_before_creating_run(tmp_path): + client, store = _client(tmp_path) + dates = _write_enriched_dates(tmp_path, 625, first=date(2024, 1, 15)) + + response = client.post( + "/api/backtest/mining/runs", + json={ + "factor_names": ["turnover_rate"], + "budget_profile": "balanced", + "start": dates[0].isoformat(), + "end": dates[-1].isoformat(), + "force": True, + }, + ) + + assert response.status_code == 400 + assert response.json()["detail"] == ( + "balanced mining requires at least 786 enriched trading bars for 3 outer " + "folds; effective range 2024-01-15 to 2025-09-30 has 625" + ) + assert store.list_runs() == [] + + +def test_availability_uses_asset_specific_valid_partitions(tmp_path): + client, _store = _client(tmp_path) + etf_dates = _write_enriched_dates( + tmp_path, + 219, + asset_type="etf", + first=date(2024, 1, 1), + ) + malformed = tmp_path / "kline_etf_enriched" / "date=not-a-date" + malformed.mkdir(parents=True) + (malformed / "part.parquet").touch() + (tmp_path / "kline_etf_enriched" / "date=2023-12-31").mkdir(parents=True) + + response = client.get( + "/api/backtest/mining/availability", + params={ + "asset_type": "etf", + "budget_profile": "exploratory", + "start": etf_dates[0].isoformat(), + "end": etf_dates[-1].isoformat(), + }, + ) + + assert response.status_code == 200 + assert response.json()["trading_bars"] == 219 + assert response.json()["eligible"] is True + + +def test_result_reconstructs_artifacts_without_exposing_definition(tmp_path): + client, store = _client(tmp_path) + _successful_run(store) + + response = client.get("/api/backtest/mining/runs/result-run/result") + + assert response.status_code == 200 + body = response.json() + assert body["summary"]["peak_rss_bytes"] == 1000 + assert body["correlation"]["matrix"] == [[1.0]] + assert body["candidates"][0]["factor_names"] == ["turnover_rate"] + assert "definition_json" not in body["candidates"][0] + assert body["candidates"][0]["folds"][0]["selected_factors"] == ["turnover_rate"] + assert body["candidates"][0]["gate"] == {"qualified": True, "reasons": []} + assert body["request_summary"] == { + "asset_type": "stock", + "budget_profile": "exploratory", + "start": None, + "end": None, + "factor_count": 1, + "strategy_count": 0, + "commission_pct": None, + "stamp_tax_pct": None, + "slippage_bps": None, + "correlation_threshold": 0.75, + } + regimes = {row["state"]: row for row in body["regimes"]} + assert regimes["overall"]["n_dates"] == 3 + assert regimes["strong"]["n_dates"] == 2 + assert regimes["range"]["total_return"] is None + assert body["telemetry"]["panel_scans"] == 1 + + +def test_result_marks_legacy_fold_rows_without_evaluation_kind(tmp_path): + client, store = _client(tmp_path) + _successful_run(store) + + response = client.get("/api/backtest/mining/runs/result-run/result") + + assert response.status_code == 200 + fold = response.json()["candidates"][0]["folds"][0] + assert fold["evaluation_kind"] is None + + +def test_publish_endpoint_rejects_candidate_below_evidence_gate(tmp_path): + client, store = _client(tmp_path) + _successful_run(store) + path = store.artifact_path("result-run", "candidates") + pl.read_parquet(path).with_columns( + pl.lit("low").alias("confidence"), + ).write_parquet(path) + + response = client.post( + f"/api/backtest/mining/runs/result-run/candidates/" + f"{quote(_FACTOR_SIGNATURE, safe='')}/publish", + ) + + assert response.status_code == 400 + assert "exploratory results can only be saved" in response.json()["detail"] + + +def test_promote_endpoint_uses_persisted_definition_only(tmp_path): + client, store = _client(tmp_path) + _successful_run(store) + + response = client.post( + f"/api/backtest/mining/runs/result-run/candidates/" + f"{quote(_FACTOR_SIGNATURE, safe='')}/promote" + ) + + assert response.status_code == 200 + body = response.json() + assert body["status"] == "pending" + assert body["config"]["origin_run_id"] == "result-run" + assert body["config"]["candidate_signature"] == _FACTOR_SIGNATURE + assert body["config"]["factor_names"] == ["turnover_rate"] + assert "definition_json" not in body + persisted = pl.read_parquet(store.artifact_path("result-run", "candidates")) + assert persisted["promoted_candidate_id"][0] == body["id"] + + +def test_publish_endpoint_uses_persisted_definition_and_invalidates_runtime( + tmp_path, +) -> None: + client, store = _client(tmp_path) + _successful_run(store) + builtin_dir = Path(__file__).resolve().parents[1] / "app" / "strategy" / "builtin" + custom_dir = tmp_path / "strategies" / "custom" + engine = StrategyEngine(strategy_dirs=[builtin_dir, custom_dir]) + invalidations: list[str] = [] + client.app.state.strategy_engine = engine + client.app.state.monitor_engine = SimpleNamespace( + invalidate_strategy_state=lambda: invalidations.append("monitor") + ) + + response = client.post( + f"/api/backtest/mining/runs/result-run/candidates/" + f"{quote(_FACTOR_SIGNATURE, safe='')}/publish", + json={ + "strategy_id": "caller-selected", + "scoring": {"rsi_14": 999.0}, + }, + ) + + assert response.status_code == 200 + strategy_id = response.json()["strategy_id"] + strategy = engine.get(strategy_id) + assert strategy.meta["origin_run_id"] == "result-run" + assert strategy.meta["candidate_signature"] == _FACTOR_SIGNATURE + assert strategy.matrix_strategy._scoring == {"turnover_rate": 1.0} + assert strategy.matrix_strategy._directions == {"turnover_rate": "high"} + assert invalidations == ["monitor"] + persisted = pl.read_parquet(store.artifact_path("result-run", "candidates")) + assert persisted["published_strategy_id"][0] == strategy_id + + +def test_result_fails_closed_when_artifact_is_missing(tmp_path): + client, store = _client(tmp_path) + _successful_run(store) + store.artifact_path("result-run", "folds").unlink() + + response = client.get("/api/backtest/mining/runs/result-run/result") + + assert response.status_code == 500 + assert response.json()["detail"] == "mining result artifacts are unavailable" + + +def test_sse_maps_failed_event_and_honors_last_event_id(tmp_path): + client, store = _client(tmp_path) + store.create( + {"factor_names": ["turnover_rate"]}, + {"generation": "test"}, + run_id="failed-run", + ) + queued = store.append_event( + "failed-run", "queued", {"status": "queued", "source": "manual"} + ) + store.transition_status("failed-run", "failed", error="worker failed") + failed = store.append_event( + "failed-run", "error", {"status": "failed", "message": "worker failed"} + ) + + response = client.get( + "/api/backtest/mining/runs/failed-run/events", + headers={"Last-Event-ID": str(queued["id"])}, + ) + + assert response.status_code == 200 + assert f"id: {failed['id']}" in response.text + assert "event: failed" in response.text + assert "event: error" not in response.text + assert "worker failed" in response.text + + +def test_sse_recovers_progress_snapshot_when_history_is_truncated(tmp_path): + client, store = _client(tmp_path) + store.create( + {"factor_names": ["turnover_rate"]}, + {"generation": "test"}, + run_id="truncated-run", + ) + store.write_summary( + "truncated-run", + {"progress": {"phase": "search", "done": 7, "total": 10}}, + ) + for index in range(260): + store.append_event( + "truncated-run", + "progress", + {"phase": "search", "done": index, "total": 260}, + ) + store.transition_status("truncated-run", "failed", error="worker failed") + store.append_event( + "truncated-run", + "error", + {"status": "failed", "message": "worker failed"}, + ) + + response = client.get("/api/backtest/mining/runs/truncated-run/events") + + assert response.status_code == 200 + assert '"done": 7' in response.text + assert "event: failed" in response.text + + +def test_start_rejects_incompatible_strategy_before_creating_run(tmp_path): + client, store = _client(tmp_path) + client.app.state.strategy_engine = SimpleNamespace( + get=lambda _strategy_id: SimpleNamespace( + meta={ + "research_only": True, + "asset_types": ["stock"], + "timeframes": ["1d"], + }, + execution_backend="matrix_native", + ) + ) + + response = client.post( + "/api/backtest/mining/runs", + json={ + "factor_names": ["turnover_rate"], + "strategy_ids": ["factor_rank_research"], + "budget_profile": "exploratory", + }, + ) + + assert response.status_code == 400 + assert store.list_runs() == [] + + +def test_config_patch_merges_current_values(tmp_path, monkeypatch): + client, _store = _client(tmp_path) + current = { + "mining_schedule_enabled": False, + "mining_schedule_weekday": 4, + "mining_budget_profile": "balanced", + } + saved = [] + monkeypatch.setattr( + "app.api.mining.preferences.get_mining_schedule", + lambda: dict(current), + ) + + def set_schedule(enabled, weekday, profile): + saved.append((enabled, weekday, profile)) + return { + "mining_schedule_enabled": enabled, + "mining_schedule_weekday": weekday, + "mining_budget_profile": profile, + } + + monkeypatch.setattr("app.api.mining.preferences.set_mining_schedule", set_schedule) + + response = client.patch( + "/api/backtest/mining/config", + json={"mining_schedule_enabled": True}, + ) + + assert response.status_code == 200 + assert saved == [(True, 4, "balanced")] + assert client.patch("/api/backtest/mining/config", json={}).status_code == 400 diff --git a/backend/tests/test_mining_candidates.py b/backend/tests/test_mining_candidates.py new file mode 100644 index 0000000..78c2d28 --- /dev/null +++ b/backend/tests/test_mining_candidates.py @@ -0,0 +1,679 @@ +from __future__ import annotations + +import json +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from types import SimpleNamespace + +import polars as pl +import pytest + +from app.backtest.candidates import CandidateStore, CandidateValidationError +from app.backtest.mining import compute_candidate_signature +from app.services.mining_candidates import ( + MiningCandidateService, + _published_strategy_id, +) +from app.services.mining_jobs import MiningRunStore +from app.strategy import config as strategy_config +from app.strategy.engine import StrategyEngine + + +class _StrategyEngine: + def __init__(self) -> None: + self.strategies = { + "existing_daily": SimpleNamespace( + id="existing_daily", + execution_backend="matrix_native", + meta={ + "id": "existing_daily", + "research_only": False, + "timeframes": ["1d"], + "asset_types": ["stock"], + }, + ) + } + + def get(self, strategy_id: str): + if strategy_id not in self.strategies: + raise KeyError(strategy_id) + return self.strategies[strategy_id] + + def list_strategies(self): + return [strategy.meta for strategy in self.strategies.values()] + + +def _factor_definition() -> dict: + return { + "kind": "factor_rank", + "factor_names": ["turnover_rate", "rsi_14"], + "scoring": {"turnover_rate": 1.0, "rsi_14": 2.0}, + "directions": {"turnover_rate": "high", "rsi_14": "low"}, + } + + +def _create_run( + tmp_path, + *, + definition: dict | None = None, + status: str = "succeeded", + run_id: str = "mining-run", +) -> tuple[MiningRunStore, str, str]: + definition = definition or _factor_definition() + signature = compute_candidate_signature(definition) + store = MiningRunStore(tmp_path) + store.create( + { + "factor_names": ["turnover_rate", "rsi_14"], + "strategy_ids": ["existing_daily", "ma_golden_cross"], + "asset_type": "stock", + "start": "2025-01-01", + "end": "2026-01-09", + "budget_profile": "exploratory", + "commission_pct": 0.0002, + "stamp_tax_pct": 0.0005, + "slippage_bps": 5.0, + }, + {"generation": "test"}, + run_id=run_id, + ) + frame = pl.DataFrame({ + "signature": [signature], + "name": ["因子组合候选"], + "kind": [ + "existing_strategy" + if definition["kind"] == "existing_strategy" + else "factor_combination" + ], + "factor_names_json": [json.dumps(definition.get("factor_names", []))], + "strategy_id": [definition.get("strategy_id")], + "definition_json": [json.dumps(definition, sort_keys=True)], + "regime_state": ["overall"], + "score": [0.9], + "oos_return": [0.08], + "oos_sharpe": [0.9], + "oos_max_drawdown": [-0.12], + "oos_positive_fold_ratio": [1.0], + "oos_n_trades": [70], + "confidence": ["standard"], + "valid_folds": [3], + "skipped_folds": [0], + "promoted_candidate_id": [None], + "published_strategy_id": [None], + }, schema_overrides={ + "strategy_id": pl.String, + "promoted_candidate_id": pl.String, + "published_strategy_id": pl.String, + }) + frame.write_parquet(store.artifact_path(run_id, "candidates")) + store.register_artifact(run_id, "candidates") + store.write_summary(run_id, { + "data_as_of": "2026-01-09", + "algorithm_version": "mining-v1", + "methodology_version": "factor_v2", + }) + if status != "queued": + store.transition_status(run_id, "running") + store.transition_status(run_id, status) + return store, run_id, signature + + +def _service(tmp_path, store: MiningRunStore) -> MiningCandidateService: + return MiningCandidateService( + tmp_path, + store, + CandidateStore(tmp_path), + _StrategyEngine(), + strategy_cache_invalidator=lambda _data_dir: None, + ) + + +def _real_service( + tmp_path, + store: MiningRunStore, + *, + cache_invalidator=None, + monitor_invalidator=None, +) -> tuple[MiningCandidateService, StrategyEngine]: + builtin_dir = Path(__file__).resolve().parents[1] / "app" / "strategy" / "builtin" + custom_dir = tmp_path / "strategies" / "custom" + engine = StrategyEngine(strategy_dirs=[builtin_dir, custom_dir]) + service = MiningCandidateService( + tmp_path, + store, + CandidateStore(tmp_path), + engine, + strategy_cache_invalidator=cache_invalidator or (lambda _data_dir: None), + monitor_state_invalidator=monitor_invalidator, + ) + return service, engine + + +def test_promote_rereads_artifact_and_repairs_backlink_idempotently(tmp_path) -> None: + store, run_id, signature = _create_run(tmp_path) + service = _service(tmp_path, store) + + first = service.promote(run_id, signature) + second = service.promote(run_id, signature) + + assert second == first + assert first["kind"] == "strategy" + assert first["source_id"].startswith("mined_factor_") + assert first["status"] == "pending" + assert first["config"]["origin_run_id"] == run_id + assert first["config"]["candidate_signature"] == signature + assert first["config"]["factor_names"] == ["turnover_rate", "rsi_14"] + assert first["config"]["directions"] == ["high", "low"] + assert first["config"]["weights"] == [1.0, 2.0] + assert first["metrics"]["oos_sharpe"] == pytest.approx(0.9) + persisted = pl.read_parquet(store.artifact_path(run_id, "candidates")).row(0, named=True) + assert persisted["promoted_candidate_id"] == first["id"] + assert len(CandidateStore(tmp_path).list()) == 1 + + +def test_promote_repairs_backlink_after_partial_failure(tmp_path, monkeypatch) -> None: + store, run_id, signature = _create_run(tmp_path) + service = _service(tmp_path, store) + original = service._write_backlink + calls = 0 + + def fail_once(*args, **kwargs): + nonlocal calls + calls += 1 + if calls == 1: + raise RuntimeError("injected backlink failure") + return original(*args, **kwargs) + + monkeypatch.setattr(service, "_write_backlink", fail_once) + with pytest.raises(RuntimeError, match="injected"): + service.promote(run_id, signature) + + created = CandidateStore(tmp_path).list() + assert len(created) == 1 + recovered = service.promote(run_id, signature) + assert recovered["id"] == created[0]["id"] + persisted = pl.read_parquet(store.artifact_path(run_id, "candidates")).row(0, named=True) + assert persisted["promoted_candidate_id"] == created[0]["id"] + assert len(CandidateStore(tmp_path).list()) == 1 + + +def test_promote_rejects_non_successful_run(tmp_path) -> None: + store, run_id, signature = _create_run(tmp_path, status="queued") + + with pytest.raises(ValueError, match="successful"): + _service(tmp_path, store).promote(run_id, signature) + + assert CandidateStore(tmp_path).list() == [] + + +def test_promote_rejects_tampered_definition_before_candidate_write(tmp_path) -> None: + store, run_id, signature = _create_run(tmp_path) + path = store.artifact_path(run_id, "candidates") + frame = pl.read_parquet(path).with_columns( + pl.lit(json.dumps({ + "kind": "factor_rank", + "factor_names": ["turnover_rate"], + "scoring": {"turnover_rate": 1.0}, + "directions": {"turnover_rate": "high"}, + })).alias("definition_json") + ) + frame.write_parquet(path) + + with pytest.raises(ValueError, match=r"signature|definition"): + _service(tmp_path, store).promote(run_id, signature) + + assert CandidateStore(tmp_path).list() == [] + + +def test_promote_rejects_factor_not_selected_in_origin_request(tmp_path) -> None: + definition = { + "kind": "factor_rank", + "factor_names": ["momentum_20d"], + "scoring": {"momentum_20d": 1.0}, + "directions": {"momentum_20d": "high"}, + } + store, run_id, signature = _create_run(tmp_path, definition=definition) + + with pytest.raises(ValueError, match="origin request"): + _service(tmp_path, store).promote(run_id, signature) + + +def test_promote_existing_strategy_revalidates_current_engine_contract(tmp_path) -> None: + definition = {"kind": "existing_strategy", "strategy_id": "existing_daily"} + store, run_id, signature = _create_run(tmp_path, definition=definition) + + promoted = _service(tmp_path, store).promote(run_id, signature) + + assert promoted["source_id"] == "existing_daily" + assert promoted["config"]["strategy_id"] == "existing_daily" + + +def test_publish_existing_strategy_returns_verified_id_and_repairs_backlink(tmp_path) -> None: + definition = {"kind": "existing_strategy", "strategy_id": "existing_daily"} + store, run_id, signature = _create_run(tmp_path, definition=definition) + service = _service(tmp_path, store) + + first = service.publish(run_id, signature) + second = service.publish(run_id, signature) + with pytest.raises(TypeError): + service.publish(run_id, signature, "existing_daily") + + assert first == second == {"ok": True, "strategy_id": "existing_daily"} + persisted = pl.read_parquet(store.artifact_path(run_id, "candidates")).row(0, named=True) + assert persisted["published_strategy_id"] == "existing_daily" + assert CandidateStore(tmp_path).list() == [] + + +def test_promote_requires_unique_artifact_signature(tmp_path) -> None: + store, run_id, signature = _create_run(tmp_path) + path = store.artifact_path(run_id, "candidates") + frame = pl.read_parquet(path) + pl.concat([frame, frame]).write_parquet(path) + + with pytest.raises(ValueError, match="duplicate"): + _service(tmp_path, store).promote(run_id, signature) + + +def test_promote_concurrent_calls_create_one_authoritative_candidate(tmp_path) -> None: + store, run_id, signature = _create_run(tmp_path) + service = _service(tmp_path, store) + + with ThreadPoolExecutor(max_workers=8) as executor: + items = list(executor.map( + lambda _index: service.promote(run_id, signature), + range(16), + )) + + assert len({item["id"] for item in items}) == 1 + assert len(CandidateStore(tmp_path).list()) == 1 + + +def test_promote_rejects_artifact_conflicting_with_existing_store_record( + tmp_path, +) -> None: + store, run_id, signature = _create_run(tmp_path) + service = _service(tmp_path, store) + original = service.promote(run_id, signature) + path = store.artifact_path(run_id, "candidates") + pl.read_parquet(path).with_columns( + pl.lit(1.1).alias("oos_sharpe"), + pl.lit(None, dtype=pl.String).alias("promoted_candidate_id"), + ).write_parquet(path) + + with pytest.raises(CandidateValidationError, match="冲突"): + service.promote(run_id, signature) + + persisted = pl.read_parquet(path).row(0, named=True) + assert persisted["promoted_candidate_id"] is None + stored = CandidateStore(tmp_path).list()[0] + assert stored == original + assert stored["metrics"]["oos_sharpe"] == pytest.approx(0.9) + + +@pytest.mark.parametrize( + ("definition", "message"), + [ + ( + { + "kind": "factor_rank", + "factor_names": ["turnover_rate"], + "scoring": {"turnover_rate": 0.0}, + "directions": {"turnover_rate": "high"}, + }, + "positive", + ), + ( + { + "kind": "factor_rank", + "factor_names": ["turnover_rate"], + "scoring": {"turnover_rate": float("inf")}, + "directions": {"turnover_rate": "high"}, + }, + "finite", + ), + ( + { + "kind": "factor_rank", + "factor_names": ["turnover_rate"], + "scoring": {"turnover_rate": 1.0}, + "directions": {"turnover_rate": "sideways"}, + }, + "directions", + ), + ( + { + "kind": "factor_rank", + "factor_names": ["not_a_factor"], + "scoring": {"not_a_factor": 1.0}, + "directions": {"not_a_factor": "high"}, + }, + "unknown factors", + ), + ( + { + "kind": "factor_rank", + "factor_names": [ + "momentum_5d", + "momentum_10d", + "momentum_20d", + "momentum_30d", + "momentum_60d", + ], + "scoring": { + "momentum_5d": 1.0, + "momentum_10d": 1.0, + "momentum_20d": 1.0, + "momentum_30d": 1.0, + "momentum_60d": 1.0, + }, + "directions": { + "momentum_5d": "high", + "momentum_10d": "high", + "momentum_20d": "high", + "momentum_30d": "high", + "momentum_60d": "high", + }, + }, + "1 to 4", + ), + ], +) +def test_promote_rejects_invalid_factor_contract(tmp_path, definition, message) -> None: + store, run_id, signature = _create_run(tmp_path) + path = store.artifact_path(run_id, "candidates") + frame = pl.read_parquet(path).with_columns( + pl.lit(signature).alias("signature"), + pl.lit(json.dumps(definition)).alias("definition_json"), + pl.lit(json.dumps(definition["factor_names"])).alias("factor_names_json"), + ) + frame.write_parquet(path) + + with pytest.raises(ValueError, match=message): + _service(tmp_path, store).promote(run_id, signature) + + +@pytest.mark.parametrize("column", ["oos_sharpe", "oos_return", "score"]) +def test_promote_rejects_nonfinite_artifact_values(tmp_path, column) -> None: + store, run_id, signature = _create_run(tmp_path) + path = store.artifact_path(run_id, "candidates") + pl.read_parquet(path).with_columns(pl.lit(float("nan")).alias(column)).write_parquet(path) + + with pytest.raises(ValueError, match="finite"): + _service(tmp_path, store).promote(run_id, signature) + + +def test_promote_rejects_corrupt_or_missing_schema_artifact(tmp_path) -> None: + store, run_id, signature = _create_run(tmp_path) + path = store.artifact_path(run_id, "candidates") + path.write_bytes(b"not parquet") + with pytest.raises(RuntimeError, match="failed to read"): + _service(tmp_path, store).promote(run_id, signature) + + path.unlink() + pl.DataFrame({"signature": [signature]}).write_parquet(path) + with pytest.raises(ValueError, match="schema"): + _service(tmp_path, store).promote(run_id, signature) + + +def test_publish_factor_discovers_public_strategy_and_repairs_runtime_state(tmp_path) -> None: + store, run_id, signature = _create_run(tmp_path) + invalidations: list[object] = [] + service, engine = _real_service( + tmp_path, + store, + cache_invalidator=lambda data_dir: invalidations.append(data_dir), + monitor_invalidator=lambda: invalidations.append("monitor"), + ) + strategy_config.save_override( + tmp_path, + "factor_rank_research", + {"params": {"entry_score": 99.0}}, + ) + + result = service.publish(run_id, signature) + repeated = service.publish(run_id, signature) + + assert repeated == result + strategy = engine.get(result["strategy_id"]) + assert strategy.source == "custom" + assert strategy.execution_backend == "matrix_native" + assert strategy.meta["origin_run_id"] == run_id + assert strategy.meta["candidate_signature"] == signature + assert strategy.meta["research_only"] is False + assert strategy.meta["asset_types"] == ["stock"] + assert strategy.matrix_strategy._scoring == {"turnover_rate": 1.0, "rsi_14": 2.0} + assert strategy.matrix_strategy._directions == { + "turnover_rate": "high", + "rsi_14": "low", + } + assert result["strategy_id"] in { + meta["id"] for meta in engine.list_strategies() + } + assert strategy_config.load_override( + tmp_path, "factor_rank_research" + ) == {"params": {"entry_score": 99.0}} + assert not (tmp_path / "user_data" / "strategy_overrides" / f"{result['strategy_id']}.json").exists() + persisted = pl.read_parquet(store.artifact_path(run_id, "candidates")).row(0, named=True) + assert persisted["published_strategy_id"] == result["strategy_id"] + assert invalidations == [tmp_path, "monitor", tmp_path, "monitor"] + + +def test_publish_factor_repairs_backlink_after_source_was_verified( + tmp_path, + monkeypatch, +) -> None: + store, run_id, signature = _create_run(tmp_path) + service, _engine = _real_service(tmp_path, store) + original = service._write_backlink + calls = 0 + + def fail_once(*args, **kwargs): + nonlocal calls + calls += 1 + if calls == 1: + raise RuntimeError("injected publication backlink failure") + return original(*args, **kwargs) + + monkeypatch.setattr(service, "_write_backlink", fail_once) + with pytest.raises(RuntimeError, match="injected publication"): + service.publish(run_id, signature) + + result = service.publish(run_id, signature) + strategy_path = tmp_path / "strategies" / "custom" / f"{result['strategy_id']}.py" + assert strategy_path.is_file() + persisted = pl.read_parquet(store.artifact_path(run_id, "candidates")).row(0, named=True) + assert persisted["published_strategy_id"] == result["strategy_id"] + + +def test_publish_factor_uses_server_run_scoped_id_and_refuses_collision(tmp_path) -> None: + store, run_id, signature = _create_run(tmp_path) + service, _engine = _real_service(tmp_path, store) + expected_id = _published_strategy_id(run_id, signature) + + with pytest.raises(TypeError): + service.publish(run_id, signature, "mined_factor_caller_selected") + + target = tmp_path / "strategies" / "custom" / f"{expected_id}.py" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("user owned", encoding="utf-8") + with pytest.raises(ValueError, match="collision"): + service.publish(run_id, signature) + assert target.read_text(encoding="utf-8") == "user owned" + + +def test_publish_factor_same_signature_in_different_runs_has_independent_ids( + tmp_path, +) -> None: + store, first_run_id, signature = _create_run(tmp_path, run_id="mining-run-one") + _other_store, second_run_id, second_signature = _create_run( + tmp_path, + run_id="mining-run-two", + ) + service, engine = _real_service(tmp_path, store) + + first = service.publish(first_run_id, signature) + second = service.publish(second_run_id, second_signature) + + assert signature == second_signature + assert first["strategy_id"] == _published_strategy_id(first_run_id, signature) + assert second["strategy_id"] == _published_strategy_id( + second_run_id, + second_signature, + ) + assert first["strategy_id"] != second["strategy_id"] + assert engine.has(first["strategy_id"]) + assert engine.has(second["strategy_id"]) + + +def test_publish_factor_rejects_inconsistent_backlink(tmp_path) -> None: + store, run_id, signature = _create_run(tmp_path) + path = store.artifact_path(run_id, "candidates") + pl.read_parquet(path).with_columns( + pl.lit("mined_factor_other").alias("published_strategy_id") + ).write_parquet(path) + service, engine = _real_service(tmp_path, store) + + with pytest.raises(ValueError, match="backlink is inconsistent"): + service.publish(run_id, signature) + + expected_id = _published_strategy_id(run_id, signature) + assert not engine.has(expected_id) + assert not (tmp_path / "strategies" / "custom" / f"{expected_id}.py").exists() + + +def test_publish_factor_rolls_back_file_and_skips_invalidations_on_reload_failure( + tmp_path, + monkeypatch, +) -> None: + store, run_id, signature = _create_run(tmp_path) + invalidations: list[str] = [] + service, engine = _real_service( + tmp_path, + store, + cache_invalidator=lambda _data_dir: invalidations.append("cache"), + monitor_invalidator=lambda: invalidations.append("monitor"), + ) + real_reload = engine.reload + calls = 0 + + def fail_once() -> None: + nonlocal calls + calls += 1 + if calls == 1: + raise ValueError("injected reload failure") + real_reload() + + monkeypatch.setattr(engine, "reload", fail_once) + expected_id = _published_strategy_id(run_id, signature) + with pytest.raises(RuntimeError, match="injected reload failure"): + service.publish(run_id, signature) + + assert not (tmp_path / "strategies" / "custom" / f"{expected_id}.py").exists() + assert not engine.has(expected_id) + assert invalidations == [] + persisted = pl.read_parquet(store.artifact_path(run_id, "candidates")).row(0, named=True) + assert persisted["published_strategy_id"] is None + + +def test_publish_factor_rolls_back_on_runtime_invalidation_failure(tmp_path) -> None: + store, run_id, signature = _create_run(tmp_path) + invalidations: list[str] = [] + + def fail_monitor() -> None: + invalidations.append("monitor") + raise ValueError("injected monitor invalidation failure") + + service, engine = _real_service( + tmp_path, + store, + cache_invalidator=lambda _data_dir: invalidations.append("cache"), + monitor_invalidator=fail_monitor, + ) + expected_id = _published_strategy_id(run_id, signature) + + with pytest.raises(RuntimeError, match="injected monitor invalidation failure"): + service.publish(run_id, signature) + + assert invalidations == ["cache", "monitor"] + assert not (tmp_path / "strategies" / "custom" / f"{expected_id}.py").exists() + assert not engine.has(expected_id) + persisted = pl.read_parquet(store.artifact_path(run_id, "candidates")).row(0, named=True) + assert persisted["published_strategy_id"] is None + + +def test_publish_existing_does_not_reload_or_invalidate(tmp_path) -> None: + definition = {"kind": "existing_strategy", "strategy_id": "ma_golden_cross"} + store, run_id, signature = _create_run(tmp_path, definition=definition) + invalidations: list[str] = [] + service, engine = _real_service( + tmp_path, + store, + cache_invalidator=lambda _data_dir: invalidations.append("cache"), + monitor_invalidator=lambda: invalidations.append("monitor"), + ) + original = engine.get("ma_golden_cross") + + result = service.publish(run_id, signature) + + assert result == {"ok": True, "strategy_id": "ma_golden_cross"} + assert engine.get("ma_golden_cross") is original + assert invalidations == [] + +def _rewrite_candidate_metric( + store: MiningRunStore, + run_id: str, + column: str, + value, +) -> None: + path = store.artifact_path(run_id, "candidates") + frame = pl.read_parquet(path) + frame.with_columns( + pl.lit(value, dtype=frame.schema[column]).alias(column), + ).write_parquet(path) + + +def test_publish_rejects_low_confidence_exploratory_candidate(tmp_path) -> None: + store, run_id, signature = _create_run(tmp_path) + _rewrite_candidate_metric(store, run_id, "confidence", "low") + service = _service(tmp_path, store) + + with pytest.raises(ValueError, match="exploratory results can only be saved"): + service.publish(run_id, signature) + + +def test_publish_rejects_candidate_below_evidence_gate(tmp_path) -> None: + store, run_id, signature = _create_run(tmp_path) + _rewrite_candidate_metric(store, run_id, "oos_sharpe", 0.2) + service = _service(tmp_path, store) + + with pytest.raises(ValueError, match=r"OOS Sharpe of at least 0.5"): + service.publish(run_id, signature) + + +def test_publish_rejects_candidate_with_insufficient_folds(tmp_path) -> None: + store, run_id, signature = _create_run(tmp_path) + _rewrite_candidate_metric(store, run_id, "valid_folds", 1) + service = _service(tmp_path, store) + + with pytest.raises(ValueError, match="at least 2 valid outer folds"): + service.publish(run_id, signature) + + +def test_publish_rejects_candidate_missing_required_metrics(tmp_path) -> None: + store, run_id, signature = _create_run(tmp_path) + _rewrite_candidate_metric(store, run_id, "oos_n_trades", None) + service = _service(tmp_path, store) + + with pytest.raises(ValueError, match="does not meet the promotion gate"): + service.publish(run_id, signature) + + +def test_promote_still_allowed_for_gated_candidates(tmp_path) -> None: + store, run_id, signature = _create_run(tmp_path) + _rewrite_candidate_metric(store, run_id, "confidence", "low") + _rewrite_candidate_metric(store, run_id, "oos_sharpe", 0.1) + service = _service(tmp_path, store) + + result = service.promote(run_id, signature) + + assert result["status"] == "pending" diff --git a/backend/tests/test_mining_jobs.py b/backend/tests/test_mining_jobs.py new file mode 100644 index 0000000..9263bff --- /dev/null +++ b/backend/tests/test_mining_jobs.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import json +import threading +from datetime import date +from pathlib import Path + +import pytest + +from app.services.mining_jobs import ( + ACTIVE_RUN_STATUSES, + MAX_EVENT_PAYLOAD_BYTES, + SUCCESS_RUN_STATUSES, + InvalidMiningStatusTransitionError, + MiningRunStore, + MiningRunValidationError, + canonicalize_request, + compute_run_signature, +) + + +def _store(tmp_path: Path) -> MiningRunStore: + return MiningRunStore(tmp_path) + + +def test_create_and_atomic_summary_reads(tmp_path: Path) -> None: + store = _store(tmp_path) + manifest = store.create( + {"symbols": ["000001.SZ"], "start": date(2025, 1, 1)}, + {"daily_generation": 7}, + run_id="run_atomic", + ) + run_dir = tmp_path / "research" / "mining" / "runs" / "run_atomic" + + assert manifest["status"] == "queued" + assert manifest["request"]["start"] == "2025-01-01" + assert json.loads((run_dir / "manifest.json").read_text(encoding="utf-8")) == manifest + assert store.read_summary("run_atomic") == {} + + failures: list[Exception] = [] + stop = threading.Event() + + def reader() -> None: + while not stop.is_set(): + try: + value = store.read_summary("run_atomic") + assert isinstance(value.get("iteration"), int) + except Exception as exc: + failures.append(exc) + return + + store.write_summary("run_atomic", {"iteration": -1}) + thread = threading.Thread(target=reader) + thread.start() + for iteration in range(50): + store.write_summary("run_atomic", {"iteration": iteration}) + stop.set() + thread.join(timeout=1) + + assert failures == [] + assert store.read_summary("run_atomic") == {"iteration": 49} + assert not list(run_dir.glob("*.tmp")) + assert not list(run_dir.glob(".*.tmp")) + + +def test_signature_is_canonical_and_covers_request_dimensions() -> None: + first = { + "symbols": ["000001.SZ", "600000.SH"], + "window": {"start": "2025-01-01", "end": "2025-06-30"}, + "budget": 100, + } + reordered = { + "budget": 100, + "window": {"end": "2025-06-30", "start": "2025-01-01"}, + "symbols": ["000001.SZ", "600000.SH"], + } + + assert canonicalize_request(first) == canonicalize_request(reordered) + signature = compute_run_signature(first, {"daily": "v7", "enriched": "v2"}) + assert signature == compute_run_signature(reordered, {"enriched": "v2", "daily": "v7"}) + assert len(signature) == 64 + assert signature != compute_run_signature( + {**first, "budget": 101}, {"daily": "v7", "enriched": "v2"} + ) + assert signature != compute_run_signature(first, {"daily": "v8", "enriched": "v2"}) + assert signature != compute_run_signature( + {**first, "symbols": list(reversed(first["symbols"]))}, + {"daily": "v7", "enriched": "v2"}, + ) + + +def test_events_are_bounded_monotonic_and_support_after_id(tmp_path: Path) -> None: + store = _store(tmp_path) + store.create({}, "data-v1", run_id="event_run") + + for index in range(300): + event = store.append_event("event_run", "progress", {"step": index}) + assert event["id"] == index + 1 + + retained = store.read_events("event_run") + assert len(retained) == 256 + assert [event["id"] for event in retained] == list(range(45, 301)) + assert [event["id"] for event in store.read_events("event_run", after_id=295)] == list( + range(296, 301) + ) + + with pytest.raises(MiningRunValidationError, match="payload exceeds"): + store.append_event( + "event_run", + "result", + {"large_result": "x" * (MAX_EVENT_PAYLOAD_BYTES + 1)}, + ) + + +def test_status_transitions_summary_and_artifact_registration(tmp_path: Path) -> None: + store = _store(tmp_path) + store.create({"budget": 10}, "data-v1", run_id="lifecycle") + + running = store.transition_status("lifecycle", "running") + assert running["started_at"] is not None + cancelling = store.transition_status("lifecycle", "cancelling") + assert cancelling["cancellation_requested_at"] is not None + cancelled = store.transition_status("lifecycle", "cancelled") + assert cancelled["finished_at"] is not None + + with pytest.raises(InvalidMiningStatusTransitionError): + store.transition_status("lifecycle", "running") + + store.create({}, "data-v1", run_id="artifacts") + artifact = store.artifact_path("artifacts", "factors") + manifest = store.register_artifact("artifacts", "factors", artifact) + assert artifact.name == "factors.parquet" + assert manifest["artifacts"] == {"factors": "factors.parquet"} + assert store.write_summary("artifacts", {"candidate_count": 3}) == {"candidate_count": 3} + + with pytest.raises(MiningRunValidationError, match="escapes"): + store.register_artifact("artifacts", "folds", "../other/folds.parquet") + + +def test_historical_manifest_defaults_and_startup_recovery(tmp_path: Path) -> None: + store = _store(tmp_path) + runs_root = store.runs_root + old_dir = runs_root / "old_running" + old_dir.mkdir() + (old_dir / "manifest.json").write_text( + json.dumps( + { + "run_id": "old_running", + "status": "running", + "request": {"budget": 10}, + "data_fingerprint": "v1", + "created_at": "2025-01-01T00:00:00+00:00", + "artifacts": { + "factors": "factors.parquet", + "folds": "../escaped/folds.parquet", + "unknown": "unknown.parquet", + }, + } + ), + encoding="utf-8", + ) + store.create({}, "v1", run_id="was_cancelling") + store.transition_status("was_cancelling", "cancelling") + store.create({}, "v1", run_id="still_queued") + + historical = store.get("old_running") + assert historical is not None + assert historical["artifacts"] == {"factors": "factors.parquet"} + assert historical["finished_at"] is None + assert historical["run_signature"] == compute_run_signature({"budget": 10}, "v1") + + assert store.recover_interrupted() == 3 + assert store.get("old_running")["status"] == "interrupted" # type: ignore[index] + assert store.get("was_cancelling")["status"] == "interrupted" # type: ignore[index] + assert store.get("still_queued")["status"] == "interrupted" # type: ignore[index] + assert store.recover_interrupted() == 0 + + +def test_run_ids_and_paths_are_restricted_to_runs_root(tmp_path: Path) -> None: + store = _store(tmp_path) + + for run_id in ["", ".", "..", "../escape", "a/b", "a\\b", "with space"]: + with pytest.raises(MiningRunValidationError): + store.get(run_id) + + with pytest.raises(MiningRunValidationError): + store.create({}, "v1", run_id="../escape") + with pytest.raises(MiningRunValidationError): + store.create({}, "v1", run_id="") + assert not (tmp_path / "research" / "mining" / "escape").exists() + + +def test_find_by_signature_can_filter_active_and_success_runs(tmp_path: Path) -> None: + store = _store(tmp_path) + queued = store.create({"budget": 1}, "v1", run_id="queued_match") + store.create({"budget": 1}, "v1", run_id="failed_match") + store.transition_status("failed_match", "failed", error="failed") + store.create({"budget": 2}, "v1", run_id="success_match") + store.transition_status("success_match", "running") + store.transition_status("success_match", "succeeded_with_budget_exhausted") + + assert ( + store.find_by_signature(queued["run_signature"], statuses=ACTIVE_RUN_STATUSES)["run_id"] + == "queued_match" + ) # type: ignore[index] + assert store.find_by_signature(queued["run_signature"], statuses=SUCCESS_RUN_STATUSES) is None + success = store.get("success_match") + assert success is not None + assert ( + store.find_by_signature(success["run_signature"], statuses=SUCCESS_RUN_STATUSES)["run_id"] + == "success_match" + ) # type: ignore[index] + + +def test_list_runs_is_bounded_sorted_and_filters_status(tmp_path: Path) -> None: + store = _store(tmp_path) + store.create({"budget": 1}, "v1", run_id="first") + store.create({"budget": 2}, "v1", run_id="second") + store.transition_status("second", "running") + store.transition_status("second", "succeeded") + + runs = store.list_runs(limit=1) + assert [run["run_id"] for run in runs] == ["second"] + assert [run["run_id"] for run in store.list_runs(statuses={"queued"})] == ["first"] + + with pytest.raises(MiningRunValidationError, match="limit"): + store.list_runs(limit=0) + with pytest.raises(MiningRunValidationError, match="statuses"): + store.list_runs(statuses={"unknown"}) # type: ignore[arg-type] + + +def test_concurrent_event_appends_have_unique_contiguous_ids(tmp_path: Path) -> None: + first_store = _store(tmp_path) + second_store = _store(tmp_path) + first_store.create({}, "v1", run_id="concurrent") + barrier = threading.Barrier(5) + failures: list[Exception] = [] + + def append_batch(store: MiningRunStore, worker: int) -> None: + try: + barrier.wait() + for index in range(30): + store.append_event("concurrent", "progress", {"worker": worker, "index": index}) + except Exception as exc: + failures.append(exc) + + threads = [ + threading.Thread( + target=append_batch, + args=(first_store if worker % 2 == 0 else second_store, worker), + ) + for worker in range(4) + ] + for thread in threads: + thread.start() + barrier.wait() + for thread in threads: + thread.join(timeout=3) + + assert all(not thread.is_alive() for thread in threads) + assert failures == [] + ids = [event["id"] for event in first_store.read_events("concurrent")] + assert ids == list(range(1, 121)) diff --git a/backend/tests/test_mining_manager.py b/backend/tests/test_mining_manager.py new file mode 100644 index 0000000..dbaa6f0 --- /dev/null +++ b/backend/tests/test_mining_manager.py @@ -0,0 +1,352 @@ +from __future__ import annotations + +import threading +import time +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest + +import app.services.mining_manager as mining_manager_module +from app.services.heavy_job_limiter import HeavyJobLimiter +from app.services.mining_manager import MiningJobManager + + +def _task_factory(kind: str, data_dir: Path, payload: dict[str, Any]) -> dict[str, Any]: + return { + "kind": kind, + "data_dir": str(data_dir), + "payload": payload, + } + + +def _wait_for_status( + manager: MiningJobManager, + run_id: str, + status: str, + *, + timeout: float = 2.0, +) -> dict[str, Any]: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + manifest = manager.store.get(run_id) + assert manifest is not None + if manifest["status"] == status: + return manifest + time.sleep(0.005) + pytest.fail(f"run {run_id} did not reach {status}") + + +@pytest.fixture +def isolated_limiter(monkeypatch: pytest.MonkeyPatch) -> HeavyJobLimiter: + limiter = HeavyJobLimiter(capacity=2, cancel_poll_interval=0.005) + monkeypatch.setattr(mining_manager_module, "shared_heavy_job_limiter", limiter) + return limiter + + +@pytest.fixture +def make_manager( + tmp_path: Path, + isolated_limiter: HeavyJobLimiter, +): + managers: list[MiningJobManager] = [] + + def factory( + runner: Callable[ + [dict[str, Any], Callable[[dict[str, Any]], None], threading.Event], + dict[str, Any], + ], + ) -> MiningJobManager: + manager = MiningJobManager( + tmp_path, + worker_runner=runner, + task_factory=_task_factory, + ) + managers.append(manager) + return manager + + yield factory + + for manager in managers: + manager.shutdown() + assert isolated_limiter.in_use == 0 + + +def test_start_records_states_events_progress_and_worker_payload( + make_manager, tmp_path: Path +) -> None: + progress_recorded = threading.Event() + finish = threading.Event() + tasks: list[dict[str, Any]] = [] + progress = {"phase": "screen", "done": 1, "total": 2} + result = {"status": "succeeded", "candidate_count": 3, "elapsed_ms": 12.5} + + def runner(task, progress_cb, cancel_event): + tasks.append(task) + progress_cb(progress) + progress_recorded.set() + assert finish.wait(2) + assert not cancel_event.is_set() + return result + + manager = make_manager(runner) + request = {"factor_names": ["momentum"], "budget_profile": "balanced"} + created = manager.start(request, {"daily": "v1"}, source="scheduled") + run_id = created["run_id"] + + assert created["status"] == "queued" + assert progress_recorded.wait(1) + assert manager.store.read_summary(run_id) == {"progress": progress} + assert [event["type"] for event in manager.store.read_events(run_id)] == [ + "queued", + "running", + "progress", + ] + assert manager.store.read_events(run_id)[0]["payload"]["source"] == "scheduled" + assert tasks == [ + { + "kind": "mining", + "data_dir": str(tmp_path.resolve()), + "payload": { + "run_id": run_id, + "request": request, + "data_fingerprint": {"daily": "v1"}, + "source": "scheduled", + }, + } + ] + + finish.set() + terminal = _wait_for_status(manager, run_id, "succeeded") + assert terminal["started_at"] is not None + assert terminal["finished_at"] is not None + assert manager.store.read_summary(run_id) == result + assert [event["type"] for event in manager.store.read_events(run_id)] == [ + "queued", + "running", + "progress", + "succeeded", + ] + + +def test_start_accepts_valid_persistent_run_id(make_manager) -> None: + def runner(task, progress_cb, cancel_event): + return {"status": "succeeded"} + + manager = make_manager(runner) + created = manager.start( + {"factor_names": ["value"]}, + "data-v1", + run_id="weekly_claim_2026_33", + ) + + assert created["run_id"] == "weekly_claim_2026_33" + terminal = _wait_for_status(manager, created["run_id"], "succeeded") + assert terminal["run_id"] == "weekly_claim_2026_33" + + +def test_duplicate_persistent_run_id_reuses_existing_without_starting_runner( + make_manager, +) -> None: + runner_called = threading.Event() + + def runner(task, progress_cb, cancel_event): + runner_called.set() + return {"status": "succeeded"} + + manager = make_manager(runner) + existing = manager.store.create( + {"factor_names": ["value"]}, + "data-v1", + run_id="weekly_claim_2026_33", + ) + + reused = manager.start( + {"factor_names": ["value"]}, + "data-v1", + run_id="weekly_claim_2026_33", + ) + + assert reused == existing + assert not runner_called.wait(0.05) + + +def test_start_reuses_active_and_success_but_force_creates_new_run(make_manager) -> None: + started = threading.Event() + release = threading.Event() + tasks: list[dict[str, Any]] = [] + + def runner(task, progress_cb, cancel_event): + tasks.append(task) + started.set() + assert release.wait(2) + return {"status": "succeeded", "candidate_count": 1} + + manager = make_manager(runner) + request = {"factor_names": ["value"]} + first = manager.start(request, "data-v1") + assert started.wait(1) + + active_reuse = manager.start(request, "data-v1") + assert active_reuse["run_id"] == first["run_id"] + assert len(tasks) == 1 + + release.set() + _wait_for_status(manager, first["run_id"], "succeeded") + success_reuse = manager.start(request, "data-v1") + assert success_reuse["run_id"] == first["run_id"] + assert len(tasks) == 1 + + forced = manager.start(request, "data-v1", force=True) + assert forced["run_id"] != first["run_id"] + _wait_for_status(manager, forced["run_id"], "succeeded") + assert len(tasks) == 2 + assert len(manager.store.list_runs()) == 2 + + +def test_cancel_while_waiting_for_capacity_never_calls_runner( + make_manager, + isolated_limiter: HeavyJobLimiter, +) -> None: + runner_called = threading.Event() + + def runner(task, progress_cb, cancel_event): + runner_called.set() + return {"status": "succeeded"} + + assert isolated_limiter.acquire("mining", timeout=0) + try: + manager = make_manager(runner) + created = manager.start({"factor_names": ["quality"]}, "data-v1") + run_id = created["run_id"] + assert manager.store.get(run_id)["status"] == "queued" # type: ignore[index] + + cancelling = manager.cancel(run_id) + assert cancelling["status"] == "cancelling" + _wait_for_status(manager, run_id, "cancelled") + assert not runner_called.is_set() + assert [event["type"] for event in manager.store.read_events(run_id)] == [ + "queued", + "cancelling", + "cancelled", + ] + finally: + isolated_limiter.release("mining") + + +def test_cancel_running_job_wins_over_worker_success(make_manager) -> None: + runner_started = threading.Event() + + def runner(task, progress_cb, cancel_event): + runner_started.set() + assert cancel_event.wait(2) + return {"status": "succeeded", "candidate_count": 9} + + manager = make_manager(runner) + created = manager.start({"factor_names": ["growth"]}, "data-v1") + run_id = created["run_id"] + assert runner_started.wait(1) + + cancelling = manager.cancel(run_id) + assert cancelling["status"] == "cancelling" + _wait_for_status(manager, run_id, "cancelled") + event_types = [event["type"] for event in manager.store.read_events(run_id)] + assert event_types == ["queued", "running", "cancelling", "cancelled"] + assert manager.store.read_summary(run_id) == {} + + +def test_runner_exception_marks_failed_and_appends_error_event(make_manager) -> None: + def runner(task, progress_cb, cancel_event): + raise RuntimeError("mining exploded") + + manager = make_manager(runner) + created = manager.start({"factor_names": ["size"]}, "data-v1") + run_id = created["run_id"] + + failed = _wait_for_status(manager, run_id, "failed") + assert failed["error"] == "mining exploded" + events = manager.store.read_events(run_id) + assert [event["type"] for event in events] == ["queued", "running", "error"] + assert events[-1]["payload"] == { + "status": "failed", + "message": "mining exploded", + } + + +def test_non_dict_worker_result_is_rejected(make_manager) -> None: + def runner(task, progress_cb, cancel_event): + return ["full", "result"] + + manager = make_manager(runner) + created = manager.start({"factor_names": ["liquidity"]}, "data-v1") + + failed = _wait_for_status(manager, created["run_id"], "failed") + assert failed["error"] == "mining worker result must be a compact dict" + + +def test_budget_exhausted_result_uses_distinct_success_status(make_manager) -> None: + result = { + "status": "succeeded_with_budget_exhausted", + "candidate_count": 2, + "budget_exhausted": True, + } + + def runner(task, progress_cb, cancel_event): + return result + + manager = make_manager(runner) + created = manager.start({"factor_names": ["volatility"]}, "data-v1") + run_id = created["run_id"] + + _wait_for_status(manager, run_id, "succeeded_with_budget_exhausted") + assert manager.store.read_summary(run_id) == result + assert manager.store.read_events(run_id)[-1]["type"] == ("succeeded_with_budget_exhausted") + + +def test_recover_interrupted_delegates_to_store(make_manager) -> None: + def runner(task, progress_cb, cancel_event): + return {"status": "succeeded"} + + manager = make_manager(runner) + manager.store.create({}, "v1", run_id="running_before_restart") + manager.store.transition_status("running_before_restart", "running") + manager.store.create({}, "v1", run_id="cancelling_before_restart") + manager.store.transition_status("cancelling_before_restart", "cancelling") + manager.store.create({}, "v1", run_id="queued_before_restart") + + assert manager.recover_interrupted() == 3 + assert manager.store.get("running_before_restart")["status"] == "interrupted" # type: ignore[index] + assert manager.store.get("cancelling_before_restart")["status"] == "interrupted" # type: ignore[index] + assert manager.store.get("queued_before_restart")["status"] == "interrupted" # type: ignore[index] + + +def test_shutdown_sets_cancel_uses_bounded_join_and_keeps_history( + make_manager, + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner_started = threading.Event() + release_runner = threading.Event() + + def runner(task, progress_cb, cancel_event): + runner_started.set() + assert release_runner.wait(2) + return {"status": "succeeded"} + + monkeypatch.setattr(mining_manager_module, "_SHUTDOWN_JOIN_SECONDS", 0.02) + manager = make_manager(runner) + created = manager.start({"factor_names": ["reversal"]}, "data-v1") + run_id = created["run_id"] + assert runner_started.wait(1) + + started = time.monotonic() + manager.shutdown() + elapsed = time.monotonic() - started + assert elapsed < 0.2 + assert manager.store.get(run_id)["status"] == "cancelling" # type: ignore[index] + + release_runner.set() + _wait_for_status(manager, run_id, "cancelled") + assert manager.store.get(run_id) is not None + with pytest.raises(RuntimeError, match="shut down"): + manager.start({"factor_names": ["new"]}, "data-v1") diff --git a/backend/tests/test_mining_process_lock.py b/backend/tests/test_mining_process_lock.py new file mode 100644 index 0000000..4d15cee --- /dev/null +++ b/backend/tests/test_mining_process_lock.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import multiprocessing +import os +from pathlib import Path +from queue import Empty + +from app.services.mining_process_lock import ( + MiningProcessLock, + MiningProcessLockError, +) + + +def _acquire_in_spawned_process(data_dir: str, result_queue) -> None: + lock = MiningProcessLock(Path(data_dir)) + try: + lock.acquire() + except MiningProcessLockError as exc: + result_queue.put(("blocked", str(exc))) + return + try: + result_queue.put(("acquired", None)) + finally: + lock.release() + + +def _spawn_lock_attempt(data_dir: Path) -> tuple[str, str | None]: + context = multiprocessing.get_context("spawn") + result_queue = context.Queue() + process = context.Process( + target=_acquire_in_spawned_process, + args=(str(data_dir), result_queue), + ) + process.start() + process.join(timeout=10) + if process.is_alive(): + process.terminate() + process.join(timeout=5) + raise AssertionError("spawned lock process did not exit") + assert process.exitcode == 0 + try: + return result_queue.get(timeout=2) + except Empty as exc: + raise AssertionError("spawned lock process returned no result") from exc + finally: + result_queue.close() + result_queue.join_thread() + + +def test_process_lock_rejects_contention_and_allows_acquire_after_release( + tmp_path, +) -> None: + owner = MiningProcessLock(tmp_path) + owner.acquire() + + blocked, message = _spawn_lock_attempt(tmp_path) + assert blocked == "blocked" + assert message is not None and "already owns mining" in message + + owner.release() + acquired, message = _spawn_lock_attempt(tmp_path) + assert acquired == "acquired" + assert message is None + + +def test_process_lock_handle_is_not_inheritable_and_release_is_idempotent( + tmp_path, +) -> None: + lock = MiningProcessLock(tmp_path) + lock.acquire() + + stream = vars(lock)["_stream"] + assert stream is not None + assert not os.get_inheritable(stream.fileno()) + + lock.release() + lock.release() diff --git a/backend/tests/test_mining_schedule.py b/backend/tests/test_mining_schedule.py new file mode 100644 index 0000000..a661d01 --- /dev/null +++ b/backend/tests/test_mining_schedule.py @@ -0,0 +1,427 @@ +from __future__ import annotations + +from datetime import date, datetime, timedelta +from pathlib import Path +from types import SimpleNamespace +from zoneinfo import ZoneInfo + +import polars as pl +import pytest + +from app.jobs import daily_pipeline +from app.services import mining_schedule, preferences +from app.services.mining_jobs import MiningRunStore + + +class FakeRepo: + def __init__(self, data_dir: Path, *, latest: date = date(2026, 8, 14)) -> None: + self.store = SimpleNamespace(data_dir=data_dir) + self.latest = latest + self.generation = "generation-1" + + def latest_enriched_date(self, asset_type: str = "stock") -> date | None: + assert asset_type == "stock" + return self.latest + + def get_matrix_data_generation(self, asset_type: str = "stock") -> str: + assert asset_type == "stock" + return self.generation + + def get_instruments_asset(self, asset_type: str = "stock") -> pl.DataFrame: + assert asset_type == "stock" + return pl.DataFrame({ + "symbol": ["000001.SZ"], + "name": ["示例"], + "total_shares": [1_000_000.0], + "float_shares": [800_000.0], + }) + + +class FakeManager: + def __init__(self, data_dir: Path) -> None: + self.store = MiningRunStore(data_dir) + self.calls: list[dict] = [] + + def start(self, request, fingerprint, *, force: bool, source: str, run_id: str): + call = { + "request": request, + "fingerprint": fingerprint, + "force": force, + "source": source, + "run_id": run_id, + } + self.calls.append(call) + manifest = self.store.create(request, fingerprint, run_id=run_id) + return {"run_id": manifest["run_id"]} + + +@pytest.fixture +def scheduled_state(tmp_path: Path, monkeypatch): + repo = FakeRepo(tmp_path) + manager = FakeManager(tmp_path) + state = SimpleNamespace(repo=repo, mining_manager=manager, strategy_engine=None) + monkeypatch.setattr( + preferences, + "get_mining_schedule", + lambda: { + "mining_schedule_enabled": True, + "mining_schedule_weekday": 4, + "mining_budget_profile": "balanced", + }, + ) + _write_prerequisites(tmp_path, repo.latest, days=1200) + return state + + +def _friday(week_offset: int = 0) -> datetime: + return datetime(2026, 8, 14, 16, tzinfo=ZoneInfo("Asia/Shanghai")) + timedelta( + weeks=week_offset + ) + + +def _write_prerequisites(data_dir: Path, latest: date, *, days: int) -> None: + enriched = data_dir / "kline_daily_enriched" + trading_dates: list[date] = [] + for offset in range(days): + day = latest - timedelta(days=offset) + if day.weekday() >= 5: + continue + trading_dates.append(day) + partition = enriched / f"date={day.isoformat()}" + partition.mkdir(parents=True, exist_ok=True) + (partition / "part.parquet").write_bytes(b"enriched") + regime = data_dir / "regime_history" / "part.parquet" + regime.parent.mkdir(parents=True, exist_ok=True) + pl.DataFrame({ + "date": sorted(trading_dates), + "state": ["range"] * len(trading_dates), + }).write_parquet(regime) + + +def test_beijing_date_and_iso_week_use_china_timezone(): + utc = ZoneInfo("UTC") + instant = datetime(2026, 8, 13, 16, 30, tzinfo=utc) + + assert mining_schedule.beijing_date(instant) == date(2026, 8, 14) + assert mining_schedule.iso_week(date(2027, 1, 1)) == (2026, 53) + + +def test_fingerprint_retries_generation_change_and_returns_stable_token( + tmp_path, + monkeypatch, +) -> None: + repo = FakeRepo(tmp_path) + state = SimpleNamespace(strategy_engine=None) + generations = iter([ + "generation-1", + "generation-2", + "generation-2", + "generation-2", + ]) + monkeypatch.setattr(repo, "get_matrix_data_generation", lambda _asset: next(generations)) + + fingerprint = mining_schedule.build_data_fingerprint( + repo, + state, + {"asset_type": "stock", "strategy_ids": []}, + ) + + assert fingerprint["generation"] == "generation-2" + + +def test_implementation_metadata_is_recursive_content_based_and_root_independent( + tmp_path, +) -> None: + roots = [tmp_path / "first" / "app", tmp_path / "second" / "app"] + for root in roots: + nested = root / "backtest" + nested.mkdir(parents=True) + (root / "main.py").write_text("VALUE = 1\n", encoding="utf-8") + (nested / "runtime.py").write_text("RESULT = 1\n", encoding="utf-8") + (nested / "ignored.txt").write_text("ignored\n", encoding="utf-8") + + first = mining_schedule._implementation_metadata(roots[0]) + second = mining_schedule._implementation_metadata(roots[1]) + (roots[1] / "backtest" / "runtime.py").write_text("RESULT = 2\n", encoding="utf-8") + changed = mining_schedule._implementation_metadata(roots[1]) + + assert first == second + assert first["file_count"] == 2 + assert str(tmp_path) not in str(first) + assert first["digest"] != changed["digest"] + + +def test_fingerprint_covers_result_implementation_digest( + tmp_path, + monkeypatch, +) -> None: + repo = FakeRepo(tmp_path) + state = SimpleNamespace(strategy_engine=None) + first = mining_schedule.build_data_fingerprint( + repo, + state, + {"asset_type": "stock", "strategy_ids": []}, + ) + monkeypatch.setattr( + mining_schedule, + "_implementation_metadata", + lambda _root: {"file_count": 1, "digest": "changed-runtime"}, + ) + second = mining_schedule.build_data_fingerprint( + repo, + state, + {"asset_type": "stock", "strategy_ids": []}, + ) + + assert first["implementation"] != second["implementation"] + assert first["digest"] != second["digest"] + + +def test_selected_strategy_metadata_changes_with_same_size_source_edit(tmp_path) -> None: + source = tmp_path / "strategies" / "custom" / "demo.py" + source.parent.mkdir(parents=True) + source.write_text("VALUE = 1\n", encoding="utf-8") + strategy = SimpleNamespace(execution_backend="matrix_native", file_path=source) + state = SimpleNamespace( + strategy_engine=SimpleNamespace(get=lambda _strategy_id: strategy) + ) + + first = mining_schedule._selected_strategy_metadata( + state, + ["demo"], + tmp_path, + ) + source.write_text("VALUE = 2\n", encoding="utf-8") + second = mining_schedule._selected_strategy_metadata( + state, + ["demo"], + tmp_path, + ) + + assert first[0]["source"]["size"] == second[0]["source"]["size"] + assert first[0]["source"]["sha256"] != second[0]["source"]["sha256"] + assert first[0]["source_tree"]["digest"] != second[0]["source_tree"]["digest"] + + +def test_fingerprint_rejects_continuously_changing_generation( + tmp_path, + monkeypatch, +) -> None: + repo = FakeRepo(tmp_path) + state = SimpleNamespace(strategy_engine=None) + generations = iter(["a", "b", "c", "d"]) + monkeypatch.setattr(repo, "get_matrix_data_generation", lambda _asset: next(generations)) + + with pytest.raises(ValueError, match="changed"): + mining_schedule.build_data_fingerprint( + repo, + state, + {"asset_type": "stock", "strategy_ids": []}, + ) + + +def test_disabled_and_before_scheduled_weekday_do_not_enqueue( + scheduled_state, + monkeypatch, +): + state = scheduled_state + monkeypatch.setattr( + preferences, + "get_mining_schedule", + lambda: { + "mining_schedule_enabled": False, + "mining_schedule_weekday": 4, + "mining_budget_profile": "balanced", + }, + ) + assert mining_schedule.run_weekly_mining(state, now=_friday())["status"] == "disabled" + + monkeypatch.setattr( + preferences, + "get_mining_schedule", + lambda: { + "mining_schedule_enabled": True, + "mining_schedule_weekday": 4, + "mining_budget_profile": "balanced", + }, + ) + thursday = _friday() - timedelta(days=1) + assert mining_schedule.run_weekly_mining(state, now=thursday)["status"] == "weekday_mismatch" + assert state.mining_manager.calls == [] + + +def test_later_workday_catches_up_once_in_same_iso_week(scheduled_state, monkeypatch): + monkeypatch.setattr( + preferences, + "get_mining_schedule", + lambda: { + "mining_schedule_enabled": True, + "mining_schedule_weekday": 3, + "mining_budget_profile": "balanced", + }, + ) + + first = mining_schedule.run_weekly_mining(scheduled_state, now=_friday()) + second = mining_schedule.run_weekly_mining(scheduled_state, now=_friday()) + + assert first["status"] == "enqueued" + assert second == {"status": "already_claimed", "run_id": first["run_id"]} + assert len(scheduled_state.mining_manager.calls) == 1 + + +def test_same_week_and_fingerprint_enqueue_once(scheduled_state): + first = mining_schedule.run_weekly_mining(scheduled_state, now=_friday()) + second = mining_schedule.run_weekly_mining(scheduled_state, now=_friday()) + + assert first["status"] == "enqueued" + assert second == {"status": "already_claimed", "run_id": first["run_id"]} + assert len(scheduled_state.mining_manager.calls) == 1 + call = scheduled_state.mining_manager.calls[0] + assert call["force"] is False + assert call["source"] == "scheduled" + assert call["run_id"] == first["run_id"] + assert call["run_id"] == call["fingerprint"]["source_claim"] + assert call["request"]["asset_type"] == "stock" + assert call["request"]["symbols"] is None + assert call["request"]["strategy_ids"] == [] + assert call["request"]["require_regime"] is True + assert call["request"]["end"] == "2026-08-14" + assert len(call["request"]["factor_names"]) <= 48 + + +def test_profile_change_cannot_bypass_same_week_claim(scheduled_state, monkeypatch): + first = mining_schedule.run_weekly_mining(scheduled_state, now=_friday()) + scheduled_state.mining_manager.store.transition_status( + first["run_id"], "failed", error="worker failed" + ) + monkeypatch.setattr( + preferences, + "get_mining_schedule", + lambda: { + "mining_schedule_enabled": True, + "mining_schedule_weekday": 4, + "mining_budget_profile": "strict", + }, + ) + + second = mining_schedule.run_weekly_mining(scheduled_state, now=_friday()) + + assert second == {"status": "already_claimed", "run_id": first["run_id"]} + assert len(scheduled_state.mining_manager.calls) == 1 + + +def test_new_week_creates_new_claim_but_same_week_metadata_change_does_not( + scheduled_state, +): + manager = scheduled_state.mining_manager + first = mining_schedule.run_weekly_mining(scheduled_state, now=_friday()) + next_week = mining_schedule.run_weekly_mining(scheduled_state, now=_friday(1)) + + latest_file = ( + scheduled_state.repo.store.data_dir + / "kline_daily_enriched" + / "date=2026-08-14" + / "part.parquet" + ) + latest_file.write_bytes(b"changed-enriched-metadata") + changed = mining_schedule.run_weekly_mining(scheduled_state, now=_friday()) + + assert first["run_id"] != next_week["run_id"] + assert changed == {"status": "already_claimed", "run_id": first["run_id"]} + assert len(manager.calls) == 2 + + +def test_missing_regime_records_visible_skipped_prerequisite(scheduled_state): + regime = scheduled_state.repo.store.data_dir / "regime_history" / "part.parquet" + regime.unlink() + + result = mining_schedule.run_weekly_mining(scheduled_state, now=_friday()) + manifest = scheduled_state.mining_manager.store.get(result["run_id"]) + + assert result["status"] == "skipped_prerequisite" + assert manifest is not None + assert manifest["status"] == "skipped_prerequisite" + assert "regime" in manifest["error"] + assert scheduled_state.mining_manager.calls == [] + + +def test_incomplete_regime_coverage_records_visible_skip(scheduled_state): + regime_path = ( + scheduled_state.repo.store.data_dir + / "regime_history" + / "part.parquet" + ) + history = pl.read_parquet(regime_path).sort("date") + history.filter(pl.col("date") != history["date"][-2]).write_parquet(regime_path) + + result = mining_schedule.run_weekly_mining(scheduled_state, now=_friday()) + manifest = scheduled_state.mining_manager.store.get(result["run_id"]) + + assert result["status"] == "skipped_prerequisite" + assert manifest is not None + assert "T-1" in manifest["error"] + assert scheduled_state.mining_manager.calls == [] + + +def test_early_regime_gap_records_visible_skipped_prerequisite(scheduled_state): + data_dir = scheduled_state.repo.store.data_dir + regime_path = data_dir / "regime_history" / "part.parquet" + history = pl.read_parquet(regime_path).sort("date") + history.slice(1).write_parquet(regime_path) + + result = mining_schedule.run_weekly_mining(scheduled_state, now=_friday()) + manifest = scheduled_state.mining_manager.store.get(result["run_id"]) + + assert result["status"] == "skipped_prerequisite" + assert manifest is not None + assert "T-1" in manifest["error"] + assert scheduled_state.mining_manager.calls == [] + + +def test_insufficient_data_records_visible_skipped_prerequisite(tmp_path, monkeypatch): + repo = FakeRepo(tmp_path) + manager = FakeManager(tmp_path) + state = SimpleNamespace(repo=repo, mining_manager=manager, strategy_engine=None) + monkeypatch.setattr( + preferences, + "get_mining_schedule", + lambda: { + "mining_schedule_enabled": True, + "mining_schedule_weekday": 4, + "mining_budget_profile": "strict", + }, + ) + _write_prerequisites(tmp_path, repo.latest, days=30) + + result = mining_schedule.run_weekly_mining(state, now=_friday()) + manifest = manager.store.get(result["run_id"]) + + assert result["status"] == "skipped_prerequisite" + assert manifest is not None + assert manifest["status"] == "skipped_prerequisite" + assert "insufficient" in manifest["error"] + assert manager.calls == [] + + +def test_pipeline_failure_does_not_trigger_mining(monkeypatch): + mining_calls = [] + monkeypatch.setattr(daily_pipeline, "_run_tracked", lambda *_args: False) + monkeypatch.setattr( + "app.services.mining_schedule.run_weekly_mining", + lambda state: mining_calls.append(state), + ) + + daily_pipeline._scheduled_pipeline_task(lambda: None) + + assert mining_calls == [] + + +def test_enqueue_failure_does_not_escape_successful_pipeline(monkeypatch): + monkeypatch.setattr(daily_pipeline, "_run_tracked", lambda *_args: True) + + def fail_enqueue(_state): + raise RuntimeError("queue unavailable") + + monkeypatch.setattr("app.services.mining_schedule.run_weekly_mining", fail_enqueue) + + daily_pipeline._scheduled_pipeline_task(lambda: None) diff --git a/backend/tests/test_minute_range_api.py b/backend/tests/test_minute_range_api.py new file mode 100644 index 0000000..c7c53d3 --- /dev/null +++ b/backend/tests/test_minute_range_api.py @@ -0,0 +1,113 @@ +"""多日分时 API 契约。""" + +import asyncio +from datetime import date, datetime +from types import SimpleNamespace +from unittest.mock import MagicMock + +import polars as pl +import pytest +from fastapi import HTTPException + +from app.api import kline as kline_api + + +def _request(repo=None, capset=None): + return SimpleNamespace( + app=SimpleNamespace( + state=SimpleNamespace( + repo=repo or MagicMock(), + capabilities=capset or MagicMock(), + ) + ) + ) + + +def test_minute_range_returns_latest_sessions_with_previous_closes(): + repo = MagicMock() + repo.resolve_asset_type.return_value = "stock" + # _get_stock_info 走 instruments 内存缓存 (不再走 execute_one DuckDB 查询) + repo.get_instruments.return_value = pl.DataFrame({ + "symbol": ["600000.SH"], + "name": ["浦发银行"], + "total_shares": [1.0], + "float_shares": [1.0], + }) + repo.get_minute_range.return_value = pl.DataFrame({ + "symbol": ["600000.SH"] * 3, + "datetime": [ + datetime(2026, 8, 5, 1, 30), + datetime(2026, 8, 6, 1, 30), + datetime(2026, 8, 7, 1, 30), + ], + "open": [10.0, 11.0, 12.0], + "high": [10.2, 11.2, 12.2], + "low": [9.8, 10.8, 11.8], + "close": [10.1, 11.1, 12.1], + "volume": [100.0, 110.0, 120.0], + "amount": [101_000.0, 122_100.0, 145_200.0], + }) + repo.get_daily_asset.return_value = pl.DataFrame({ + "date": [ + date(2026, 8, 4), + date(2026, 8, 5), + date(2026, 8, 6), + date(2026, 8, 7), + ], + "close": [9.9, 10.1, 11.1, 12.1], + }) + + result = kline_api.get_minute_range(_request(repo), "600000.SH", 2) + + assert result["name"] == "浦发银行" + assert result["requested_days"] == 2 + assert result["source"] == "local" + assert [session["date"] for session in result["sessions"]] == [ + "2026-08-06", + "2026-08-07", + ] + assert [session["prev_close"] for session in result["sessions"]] == [ + 10.1, + 11.1, + ] + assert result["sessions"][0]["rows"][0]["close"] == 11.1 + + +def test_minute_range_does_not_read_stock_store_for_index(): + repo = MagicMock() + repo.resolve_asset_type.return_value = "index" + repo.get_instruments_asset.return_value = pl.DataFrame() + + result = kline_api.get_minute_range(_request(repo), "000001.SH", 10) + + assert result["asset_type"] == "index" + assert result["sessions"] == [] + repo.get_minute_range.assert_not_called() + + +def test_sync_minute_single_uses_requested_days(monkeypatch): + repo = MagicMock() + repo.resolve_asset_type.return_value = "stock" + capset = MagicMock() + sync = MagicMock(return_value=2400) + refresh = MagicMock() + monkeypatch.setattr(kline_api, "_minute_allowed", lambda _: True) + monkeypatch.setattr(kline_api.kline_sync, "sync_and_persist_minute", sync) + monkeypatch.setattr("app.jobs.daily_pipeline._refresh_single_view", refresh) + + result = asyncio.run(kline_api.sync_minute_single( + _request(repo, capset), + {"symbol": "600000.SH", "days": 10}, + )) + + assert result["rows"] == 2400 + sync.assert_called_once_with(["600000.SH"], repo, capset, days=10, force_full_days=True) + refresh.assert_called_once_with(repo, "kline_minute") + + +def test_sync_minute_single_rejects_invalid_days(): + with pytest.raises(HTTPException, match="days 必须在 1 到 30 之间"): + asyncio.run(kline_api.sync_minute_single( + _request(), + {"symbol": "600000.SH", "days": 0}, + )) diff --git a/backend/tests/test_minute_routing.py b/backend/tests/test_minute_routing.py index 6792d35..1e30e27 100644 --- a/backend/tests/test_minute_routing.py +++ b/backend/tests/test_minute_routing.py @@ -12,6 +12,7 @@ mock 范式沿用 test_stocksdk_provider.py (monkeypatch 模块属性)。 from __future__ import annotations from datetime import date, datetime +from threading import Lock from unittest.mock import MagicMock import httpx @@ -411,6 +412,38 @@ def test_sync_and_persist_minute_custom_persists(monkeypatch, tmp_path): get_client_spy.assert_not_called() +def test_sync_and_persist_minute_holds_repository_write_lock(monkeypatch, tmp_path): + expected_df = _mock_minute_df() + mock_provider = MagicMock() + mock_provider.get_minute.return_value = expected_df + _setup_custom_provider(monkeypatch, mock_provider, has_dataset=True) + + monkeypatch.setattr(kline_sync, "_cleanup_null_datetime_minute", lambda repo: None) + monkeypatch.setattr(kline_sync, "_migrate_symbol_to_date_partition", lambda repo: None) + monkeypatch.setattr(kline_sync, "_latest_minute_datetime", lambda repo: None) + monkeypatch.setattr(kline_sync, "resolve_limit", lambda *a, **kw: MagicMock(batch=100, rpm=30)) + monkeypatch.setattr(kline_sync.preferences, "get_minute_sync_segment_days", lambda: 20) + + write_lock = Lock() + + def assert_locked(df, minute_dir): + assert not write_lock.acquire(blocking=False) + return df.height + + monkeypatch.setattr(kline_sync, "_write_minute_partition", assert_locked) + + mock_repo = MagicMock() + mock_repo.store.data_dir = tmp_path + mock_repo.db.execute = MagicMock() + mock_repo._write_lock = write_lock + + written = kline_sync.sync_and_persist_minute( + ["600519.SH"], mock_repo, MagicMock(), + ) + + assert written == expected_df.height + + # ---------- 测试 13: get_provider 异常时 fall through TickFlow (Issue 2) ---------- def test_get_provider_exception_falls_back_to_tickflow(monkeypatch): diff --git a/backend/tests/test_monitor_group_scope.py b/backend/tests/test_monitor_group_scope.py new file mode 100644 index 0000000..941b594 --- /dev/null +++ b/backend/tests/test_monitor_group_scope.py @@ -0,0 +1,183 @@ +"""监控规则 scope=watchlist_group — 自选分组动态作用域。 + +覆盖: 规则校验/normalize、引擎按分组当前成员过滤 (分组增删自选后无需改规则, +下一轮评估自动生效)、分组删除 fail-closed、异动规则分组过滤、API 保存时 +分组存在性校验与列表 runtime_warning。 +""" +from types import SimpleNamespace +from unittest.mock import MagicMock + +import polars as pl +import pytest +from fastapi import HTTPException + +from app.api import monitor_rules as monitor_rules_api +from app.config import settings +from app.services import watchlist +from app.strategy import monitor_rules +from app.strategy.monitor import MonitorRuleEngine + + +def _group_rule(rid="r_grp", group_id="g1", **overrides): + rule = { + "id": rid, "name": rid, "type": "signal", "asset_type": "stock", + "scope": "watchlist_group", "group_id": group_id, "symbols": [], + "logic": "or", + "conditions": [{"field": "rsi_14", "op": "<", "value": 100}], + "cooldown_seconds": 0, "enabled": True, + } + rule.update(overrides) + return rule + + +def _stock_df(): + return pl.DataFrame({ + "symbol": ["600000.SH", "000001.SZ", "300750.SZ"], + "name": ["浦发银行", "平安银行", "宁德时代"], + "close": [10.0, 12.0, 200.0], + "change_pct": [1.0, 2.0, 3.0], + "rsi_14": [40.0, 50.0, 60.0], + }) + + +# ── 校验与 normalize ───────────────────────────────────── + +def test_group_scope_validation(): + with pytest.raises(ValueError, match="自选分组"): + monitor_rules.validate(_group_rule(group_id=None)) + with pytest.raises(ValueError, match="自选分组"): + monitor_rules.validate(_group_rule(group_id=" ")) + with pytest.raises(ValueError, match="仅支持个股"): + monitor_rules.validate(_group_rule(asset_type="etf")) + # 分时穿越信号仅支持指定标的 (沿用既有限制) + with pytest.raises(ValueError, match="分时穿越"): + monitor_rules.validate(_group_rule( + conditions=[{"field": "signal_intraday_avg_cross_up", "op": "truth"}], + )) + monitor_rules.validate(_group_rule()) # 合法 + + +def test_normalize_group_scope_fields(): + # 分组作用域: 保留 group_id, 清掉 symbols (成员动态来自分组) + r = monitor_rules.normalize(_group_rule(symbols=["600000.SH"])) + assert r["group_id"] == "g1" + assert r["symbols"] == [] + # 非分组作用域: 清掉残留 group_id + r = monitor_rules.normalize(_group_rule(scope="all")) + assert r["group_id"] is None + + +# ── 引擎: 动态成员过滤 ─────────────────────────────────── + +def test_engine_group_scope_dynamic_members(monkeypatch, tmp_path): + """分组内后续加入的标的, 无需修改规则即自动进入监控范围。""" + monkeypatch.setattr(settings, "data_dir", tmp_path) + _, group = watchlist.create_group("核心池") + gid = group["id"] + watchlist.add("600000.SH", group_id=gid) + watchlist.add("000001.SZ", group_id=gid) + + eng = MonitorRuleEngine() + eng.set_rules([_group_rule(group_id=gid)]) + df = _stock_df() + + events = eng.evaluate(df) + assert {e["symbol"] for e in events} == {"600000.SH", "000001.SZ"} + + # 分组新增宁德时代 → 同一条规则下一轮自动覆盖 (版本号缓存立即失效) + watchlist.add("300750.SZ", group_id=gid) + events = eng.evaluate(df) + assert "300750.SZ" in {e["symbol"] for e in events} + + # 移出分组 → 自动退出监控范围 + watchlist.remove_from_group("300750.SZ", gid) + events = eng.evaluate(df) + assert "300750.SZ" not in {e["symbol"] for e in events} + + +def test_engine_group_scope_missing_group_fail_closed(monkeypatch, tmp_path): + """分组已删除: 不崩、不触发、绝不退化为全市场。""" + monkeypatch.setattr(settings, "data_dir", tmp_path) + watchlist.create_group("核心池") # 让分组文件存在, 但规则绑定的 id 不在其中 + + eng = MonitorRuleEngine() + eng.set_rules([_group_rule(group_id="ghost")]) + assert eng.evaluate(_stock_df()) == [] + + +def test_engine_group_scope_empty_group(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "data_dir", tmp_path) + _, group = watchlist.create_group("空组") + + eng = MonitorRuleEngine() + eng.set_rules([_group_rule(group_id=group["id"])]) + assert eng.evaluate(_stock_df()) == [] + + +def test_abnormal_group_scope_filtering(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "data_dir", tmp_path) + _, group = watchlist.create_group("异动池") + gid = group["id"] + watchlist.add("600000.SH", group_id=gid) + + def _row(symbol, dev_3d): + return { + "symbol": symbol, "name": symbol, "board": "主板", "st": False, + "close": 10.0, "rt_pct": 1.0, + "windows": {"3d": {"value": dev_3d, "threshold": 0.2, "closeness": abs(dev_3d) / 0.2}}, + } + + eng = MonitorRuleEngine() + eng.set_rules([_group_rule( + rid="r_ab", group_id=gid, type="abnormal", + scope="watchlist_group", threshold_pct=70, direction="both", + conditions=[], symbols=[], + )]) + high_rows = [_row("600000.SH", 0.16), _row("000001.SZ", 0.18), _row("300750.SZ", 0.19)] + low_rows = [_row("600000.SH", 0.10), _row("000001.SZ", 0.05), _row("300750.SZ", 0.05)] + # 边缘触发: 首轮观测不告警, 回落置 False 后再次上穿才触发 + eng.evaluate_abnormal(low_rows) + events = eng.evaluate_abnormal(high_rows) + # 只有分组内的 600000.SH 触发; 组外两只偏离更高也不会告警 + assert [e["symbol"] for e in events] == ["600000.SH"] + + +# ── API: 保存校验 + 列表警告 ──────────────────────────── + +def _fake_request(tmp_path): + repo = MagicMock() + repo.store.data_dir = tmp_path + repo.resolve_asset_type.return_value = "stock" + return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(repo=repo))) + + +def test_api_save_rejects_missing_group(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "data_dir", tmp_path) + watchlist.create_group("真实分组") + + req = _fake_request(tmp_path) + model = monitor_rules_api.RuleModel(**_group_rule(group_id="ghost")) + with pytest.raises(HTTPException) as exc_info: + monitor_rules_api.save_rule(model, req) + assert exc_info.value.status_code == 400 + + +def test_api_save_and_list_group_rule(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "data_dir", tmp_path) + _, group = watchlist.create_group("核心池") + + req = _fake_request(tmp_path) + model = monitor_rules_api.RuleModel(**_group_rule(group_id=group["id"])) + resp = monitor_rules_api.save_rule(model, req) + assert resp["ok"] is True + assert resp["rule"]["group_id"] == group["id"] + + listed = monitor_rules_api.list_rules(req) + rule = next(r for r in listed["rules"] if r["id"] == "r_grp") + assert "runtime_warning" not in rule + + # 分组删除后: 列表标注警告 (引擎侧同轮已 fail-closed) + watchlist.delete_group(group["id"]) + listed = monitor_rules_api.list_rules(req) + rule = next(r for r in listed["rules"] if r["id"] == "r_grp") + assert "已删除" in rule["runtime_warning"] diff --git a/backend/tests/test_override_signature_cache.py b/backend/tests/test_override_signature_cache.py new file mode 100644 index 0000000..a5e9bbc --- /dev/null +++ b/backend/tests/test_override_signature_cache.py @@ -0,0 +1,97 @@ +"""策略 override mtime 签名缓存测试 — 读盘去重, 写入/删除后立即可见, 返回深拷贝。""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from app.strategy import config as strat_config + + +@pytest.fixture(autouse=True) +def _clean_cache(): + strat_config._override_cache.clear() + strat_config._override_cache_sig.clear() + yield + strat_config._override_cache.clear() + strat_config._override_cache_sig.clear() + + +def _patched_loads(monkeypatch, counter: dict): + real_loads = json.loads + + def _counting(text): + counter["loads"] += 1 + return real_loads(text) + + monkeypatch.setattr(strat_config.json, "loads", _counting) + + +def test_second_load_hits_cache_without_disk_parse(tmp_path, monkeypatch): + strat_config.save_override(tmp_path, "s1", {"params": {"p": 1}}) + counter = {"loads": 0} + _patched_loads(monkeypatch, counter) + + assert strat_config.load_override(tmp_path, "s1")["params"] == {"p": 1} + assert strat_config.load_override(tmp_path, "s1")["params"] == {"p": 1} + assert counter["loads"] == 0 or counter["loads"] == 1, "save 后首次 load 允许一次 parse" + before = counter["loads"] + strat_config.load_override(tmp_path, "s1") + assert counter["loads"] == before, "签名未变时不得重复读盘+parse" + + +def test_external_file_change_visible(tmp_path): + strat_config.save_override(tmp_path, "s1", {"params": {"p": 1}}) + assert strat_config.load_override(tmp_path, "s1")["params"]["p"] == 1 + + p: Path = tmp_path / "user_data" / "strategy_overrides" / "s1.json" + p.write_text(json.dumps({"params": {"p": 2}}), encoding="utf-8") + st = p.stat() + os.utime(p, ns=(st.st_atime_ns, st.st_mtime_ns + 1_000_000)) + + assert strat_config.load_override(tmp_path, "s1")["params"]["p"] == 2 + + +def test_save_override_invalidates_cache(tmp_path): + strat_config.save_override(tmp_path, "s1", {"params": {"p": 1}}) + assert strat_config.load_override(tmp_path, "s1")["params"]["p"] == 1 + + strat_config.save_override(tmp_path, "s1", {"params": {"p": 9}}) + assert strat_config.load_override(tmp_path, "s1")["params"]["p"] == 9 + + +def test_delete_override_invalidates_cache(tmp_path): + strat_config.save_override(tmp_path, "s1", {"params": {"p": 1}}) + assert strat_config.load_override(tmp_path, "s1") != {} + + strat_config.delete_override(tmp_path, "s1") + assert strat_config.load_override(tmp_path, "s1") == {} + assert strat_config.load_override(tmp_path, "s1") == {} + + +def test_load_returns_deep_copy_not_cached_object(tmp_path): + strat_config.save_override(tmp_path, "s1", {"params": {"p": 1}, "basic_filter": {"a": 1}}) + first = strat_config.load_override(tmp_path, "s1") + first["params"]["p"] = 999 + first["extra"] = True + + again = strat_config.load_override(tmp_path, "s1") + assert again["params"]["p"] == 1 + assert "extra" not in again + + +def test_basic_filter_cleaning_preserved(tmp_path): + strat_config.save_override( + tmp_path, "s1", {"basic_filter": {"keep": 1, "drop": None}}, + ) + data = strat_config.load_override(tmp_path, "s1") + assert data["basic_filter"] == {"keep": 1} + + strat_config.save_override(tmp_path, "s2", {"basic_filter": {"drop": None}}) + assert "basic_filter" not in strat_config.load_override(tmp_path, "s2") + + +def test_load_missing_override_returns_empty(tmp_path): + assert strat_config.load_override(tmp_path, "never_saved") == {} diff --git a/backend/tests/test_pipeline_and_monitor_fixes.py b/backend/tests/test_pipeline_and_monitor_fixes.py index a3dd75e..3b3615c 100644 --- a/backend/tests/test_pipeline_and_monitor_fixes.py +++ b/backend/tests/test_pipeline_and_monitor_fixes.py @@ -8,7 +8,7 @@ import polars as pl import pytest from app.jobs import daily_pipeline -from app.services import pipeline_jobs, quote_service +from app.services import pipeline_jobs, preferences, quote_service from app.services.pipeline_jobs import JobStore from app.services.quote_service import QuoteService from app.strategy import monitor_rules @@ -16,12 +16,14 @@ from app.strategy.monitor import MonitorRuleEngine # ── JobStore 单飞 ──────────────────────────────────────────────────────── -def test_create_singleflight_dedupes_pending_window(tmp_path): +def test_create_singleflight_dedupes_pending_window(monkeypatch, tmp_path): """两次快速 create() 在 pending 窗口内应复用同一 job(is_new=False)。""" + monkeypatch.setattr(preferences, "load", lambda: {"data_source_job_timeout_s": 3600}) store = JobStore(store_dir=tmp_path / "jobs") jid1, new1 = store.create() assert new1 is True + assert store.get(jid1)["timeout_s"] == 3600 # 尚未 start(), job 仍是 pending —— 旧实现会在此另起新 job(并发双跑根因) jid2, new2 = store.create() @@ -35,10 +37,12 @@ def test_create_singleflight_dedupes_pending_window(tmp_path): assert new3 is False -def test_create_new_after_terminal(tmp_path): +def test_create_new_after_terminal(monkeypatch, tmp_path): """job 终态(succeed/fail)后, create() 应给出新 job。""" + monkeypatch.setattr(preferences, "load", lambda: {"data_source_long_job_timeout_s": 5400}) store = JobStore(store_dir=tmp_path / "jobs") - jid1, _ = store.create() + jid1, _ = store.create(long_running=True) + assert store.get(jid1)["timeout_s"] == 5400 store.start(jid1) store.succeed(jid1, {"ok": True}) diff --git a/backend/tests/test_preferences_cache.py b/backend/tests/test_preferences_cache.py new file mode 100644 index 0000000..634a42d --- /dev/null +++ b/backend/tests/test_preferences_cache.py @@ -0,0 +1,123 @@ +"""preferences mtime 缓存测试 — 读盘去重, 且外部修改/自身写入后立即可见。""" +from __future__ import annotations + +import json +import os + +import pytest + +from app.services import preferences + + +@pytest.fixture(autouse=True) +def _isolated(tmp_path, monkeypatch): + path = tmp_path / "preferences.json" + monkeypatch.setattr(preferences, "_path", lambda: path) + preferences._invalidate_cache() + yield path + preferences._invalidate_cache() + + +def _patched_loads(monkeypatch, counter: dict): + real_loads = json.loads + + def _counting(text): + counter["loads"] += 1 + return real_loads(text) + + monkeypatch.setattr(preferences.json, "loads", _counting) + + +def test_second_load_hits_cache_without_disk_parse(_isolated, monkeypatch): + _isolated.write_text(json.dumps({"realtime_quotes_enabled": True}), encoding="utf-8") + counter = {"loads": 0} + _patched_loads(monkeypatch, counter) + + assert preferences.load()["realtime_quotes_enabled"] is True + assert preferences.load()["realtime_quotes_enabled"] is True + assert counter["loads"] == 1, "签名未变时第二次 load 不得重复读盘+parse" + + +def test_external_file_change_invalidates_cache(_isolated, monkeypatch): + _isolated.write_text(json.dumps({"realtime_quote_interval": 6.0}), encoding="utf-8") + assert preferences.load()["realtime_quote_interval"] == 6.0 + + _isolated.write_text(json.dumps({"realtime_quote_interval": 3.0}), encoding="utf-8") + # 同尺寸修改且 mtime 粒度可能不变时, 显式推进 mtime 模拟真实场景 + st = _isolated.stat() + os.utime(_isolated, ns=(st.st_atime_ns, st.st_mtime_ns + 1_000_000)) + + assert preferences.load()["realtime_quote_interval"] == 3.0 + + +def test_save_then_load_sees_merged_values(_isolated): + _isolated.write_text(json.dumps({"a": 1}), encoding="utf-8") + out = preferences.save({"b": 2}) + assert out == {"a": 1, "b": 2} + assert preferences.load() == {"a": 1, "b": 2} + + +def test_interval_setter_invalidates_cache(_isolated): + preferences.set_realtime_quote_interval(2.0) + assert preferences.load()["realtime_quote_interval"] == 2.0 + + +def test_load_returns_copy_not_cached_object(_isolated): + _isolated.write_text(json.dumps({"k": [1, 2]}), encoding="utf-8") + first = preferences.load() + first["k"].append(3) + first["extra"] = True + again = preferences.load() + assert again == {"k": [1, 2]} + + +def test_mining_schedule_defaults_are_disabled(_isolated): + assert preferences.get_mining_schedule() == { + "mining_schedule_enabled": False, + "mining_schedule_weekday": 4, + "mining_budget_profile": "balanced", + } + + +def test_mining_schedule_invalid_stored_values_fail_closed(_isolated): + _isolated.write_text( + json.dumps( + { + "mining_schedule_enabled": "false", + "mining_schedule_weekday": True, + "mining_budget_profile": None, + } + ), + encoding="utf-8", + ) + + assert preferences.get_mining_schedule() == { + "mining_schedule_enabled": False, + "mining_schedule_weekday": 4, + "mining_budget_profile": "balanced", + } + + +def test_mining_schedule_setter_saves_group_once(monkeypatch): + calls = [] + monkeypatch.setattr(preferences, "save", lambda updates: calls.append(updates) or updates) + + result = preferences.set_mining_schedule(True, 2, "strict") + + assert result == { + "mining_schedule_enabled": True, + "mining_schedule_weekday": 2, + "mining_budget_profile": "strict", + } + assert calls == [result] + + +@pytest.mark.parametrize("weekday", [-1, 5, True]) +def test_mining_schedule_setter_rejects_invalid_weekday(weekday): + with pytest.raises(ValueError, match="weekday"): + preferences.set_mining_schedule(True, weekday, "balanced") + + +def test_mining_schedule_setter_rejects_invalid_profile(): + with pytest.raises(ValueError, match="profile"): + preferences.set_mining_schedule(True, 4, "exploratory") diff --git a/backend/tests/test_price_limits.py b/backend/tests/test_price_limits.py index 218bf55..8857621 100644 --- a/backend/tests/test_price_limits.py +++ b/backend/tests/test_price_limits.py @@ -200,6 +200,7 @@ def test_realtime_limit_prices_ignore_stale_instrument_date(): "close": [9.10], "raw_close": [9.10], "raw_high": [9.10], + "raw_low": [9.10], "_prev_close_raw": [10.0], "volume": [1000.0], }) @@ -215,3 +216,41 @@ def test_realtime_limit_prices_ignore_stale_instrument_date(): assert result["signal_limit_down"][0] is False assert "_instrument_as_of" not in result.columns + + +def test_limit_down_recovery_uses_raw_low_under_later_ex_div(): + """除权事件之后重算历史时, 跌停翘板"曾触及跌停"必须用原始价 low 判断。 + + day2 (历史日): 原始 low 9.30 未触及跌停价 9.00, 不应触发翘板; + 但 day3 除权 (ex_factor=2) 使 day2 前复权 low 变为 4.65, + 若误用复权 low 对比原始口径跌停价会误报翘板。 + day3 (除权日, 最新日不复权): 涨跌停基准切换为前复权昨收 4.825 → 跌停价 4.34, + 原始 low 4.34 触及且收阳未封死 → 真翘板。 + """ + raw = pl.DataFrame({ + "symbol": ["600001.SH"] * 3, + "date": [date(2024, 1, 2), date(2024, 1, 3), date(2024, 1, 4)], + "open": [10.00, 9.60, 4.30], + "high": [10.10, 9.70, 4.45], + "low": [9.90, 9.30, 4.34], + "close": [10.00, 9.65, 4.42], + "volume": [10000.0, 10000.0, 10000.0], + "amount": [1.0e7, 1.0e7, 1.0e7], + }) + factors = pl.DataFrame({ + "symbol": ["600001.SH"], + "trade_date": [date(2024, 1, 4)], + "ex_factor": [2.0], + }) + instruments = pl.DataFrame({ + "symbol": ["600001.SH"], + "name": ["普通股"], + "float_shares": [1.0e8], + }) + + df = pipeline.compute_enriched(raw, factors=factors, instruments=instruments) + + day2 = df.filter(pl.col("date") == date(2024, 1, 3)) + assert day2["signal_limit_down_recovery"][0] is False + day3 = df.filter(pl.col("date") == date(2024, 1, 4)) + assert day3["signal_limit_down_recovery"][0] is True diff --git a/backend/tests/test_quote_name_map_reuse.py b/backend/tests/test_quote_name_map_reuse.py new file mode 100644 index 0000000..37d0931 --- /dev/null +++ b/backend/tests/test_quote_name_map_reuse.py @@ -0,0 +1,57 @@ +"""监控 name_map 测试 — 走 repo.get_name_map() memo, 过滤空名称与旧行为一致。""" +from __future__ import annotations + +from types import SimpleNamespace + +from app.services.quote_service import _monitor_name_map + + +class _FakeRepo: + def __init__(self, mapping: dict[str, str]) -> None: + self._mapping = mapping + self.calls = 0 + + def get_name_map(self) -> dict[str, str]: + self.calls += 1 + return dict(self._mapping) + + +def test_filters_falsy_names_like_legacy_build(): + repo = _FakeRepo({ + "600000.SH": "浦发银行", + "000001.SZ": "", # 空名称: 旧 iter_rows 构建会跳过 + "399001.SZ": None, # None 名称: 同上 + "510300.SH": "沪深300ETF", + }) + assert _monitor_name_map(repo) == { + "600000.SH": "浦发银行", + "510300.SH": "沪深300ETF", + } + + +def test_merges_all_asset_types_from_repo_map(): + # get_name_map 已合并股票 + ETF + 指数 (股票优先), 监控回填无需再分表构建 + repo = _FakeRepo({"600000.SH": "股票", "510300.SH": "ETF", "000001.SH": "指数"}) + assert _monitor_name_map(repo) == repo._mapping + + +def test_delegates_to_repo_each_call(): + repo = _FakeRepo({"600000.SH": "浦发银行"}) + _monitor_name_map(repo) + _monitor_name_map(repo) + assert repo.calls == 2 + + +def test_empty_repo_map_returns_empty(): + assert _monitor_name_map(_FakeRepo({})) == {} + + +def test_survives_repo_failure(): + # 与旧行为一致: name_map 构建失败不影响监控主流程 (调用方捕获) + repo = SimpleNamespace() + try: + _monitor_name_map(repo) # type: ignore[arg-type] + raised = False + except AttributeError: + raised = True + assert raised, "repo 无 get_name_map 时应抛出, 由调用方 try 兜底" diff --git a/backend/tests/test_realtime_turnover_rate.py b/backend/tests/test_realtime_turnover_rate.py index c4ad75a..bb0a5aa 100644 --- a/backend/tests/test_realtime_turnover_rate.py +++ b/backend/tests/test_realtime_turnover_rate.py @@ -16,6 +16,7 @@ def _today_rows(turnover_rate: float | None = None) -> pl.DataFrame: "close": 10.0, "raw_close": 10.0, "raw_high": 10.0, + "raw_low": 10.0, "volume": 8000.0, } if turnover_rate is not None: diff --git a/backend/tests/test_regime_builder.py b/backend/tests/test_regime_builder.py index 947a349..45641f2 100644 --- a/backend/tests/test_regime_builder.py +++ b/backend/tests/test_regime_builder.py @@ -13,6 +13,7 @@ import time from datetime import date import polars as pl +import pytest from app.services import regime_builder @@ -145,6 +146,43 @@ def test_aggregate_empty_returns_empty(): assert regime_builder._aggregate_daily(pl.DataFrame()).is_empty() +def test_run_regime_batch_excludes_st(tmp_path, monkeypatch): + """run_regime_batch 默认剔除风险警示股: A(*ST) 的涨停/涨幅不进入统计。 + + 宽度类指标(涨跌家数)同样只统计非 ST 标的 — 同一 df 统一过滤。 + 关闭开关(preferences)后恢复全市场口径。 + """ + from app.services import market_mainline, preferences + + instruments = tmp_path / "instruments" / "part.parquet" + instruments.parent.mkdir(parents=True, exist_ok=True) + pl.DataFrame({"symbol": ["A", "B"], "name": ["*ST甲", "正常乙"]}).write_parquet(instruments) + monkeypatch.setattr(market_mainline, "_ST_SYMBOLS_CACHE", None) + monkeypatch.setattr(preferences, "get_sentiment_exclude_st", lambda: True) + monkeypatch.setattr(regime_builder, "_load_index_pct", lambda *a, **k: {}) + + class _FakeRepo: + class store: + data_dir = tmp_path + + def get_enriched_range(self, start, end): + return _enriched_df() + + out = regime_builder.run_regime_batch(_FakeRepo(), date(2026, 1, 2), date(2026, 1, 3)) + r1 = out.filter(pl.col("date") == date(2026, 1, 2)).row(0, named=True) + r2 = out.filter(pl.col("date") == date(2026, 1, 3)).row(0, named=True) + assert r1["limit_up"] == 0 # A(ST) 涨停被剔除 + assert r1["up_count"] == 1 # 剩 B/C/D 中仅 C 上涨 + assert r1["down_count"] == 2 + assert r2["max_consecutive"] == 1 # A 的 2板不计, C=1板 + + monkeypatch.setattr(preferences, "get_sentiment_exclude_st", lambda: False) + out_all = regime_builder.run_regime_batch(_FakeRepo(), date(2026, 1, 2), date(2026, 1, 3)) + r1_all = out_all.filter(pl.col("date") == date(2026, 1, 2)).row(0, named=True) + assert r1_all["limit_up"] == 1 # A 计入 + assert r1_all["up_count"] == 2 # A,C 上涨 + + # ───────────────────────── 持久化(upsert) ───────────────────────── @@ -312,28 +350,58 @@ def test_build_regime_mask_none_when_no_filter(): assert StrategyBacktestService._build_regime_mask(("2026-01-01",), None, None) is None -def test_build_regime_mask_none_when_no_data(tmp_path): - """无 regime 历史数据 → 返回 None(不阻断回测)。""" +def test_build_regime_mask_fails_when_no_data(tmp_path): + """启用过滤但无 regime 历史数据时必须阻止回测。""" from app.backtest.strategy import StrategyBacktestService - mask = StrategyBacktestService._build_regime_mask( - ("2026-01-01", "2026-01-02"), {"states": ["strong"]}, tmp_path, - ) - assert mask is None + with pytest.raises(ValueError, match="市场环境数据为空"): + StrategyBacktestService._build_regime_mask( + ("2026-01-01", "2026-01-02"), {"states": ["strong"]}, tmp_path, + ) -def test_build_regime_mask_first_day_allowed(tmp_path): - """首日无前一日环境数据 → 默认允许(不阻断)。""" +def test_build_regime_mask_fails_when_required_t1_date_is_missing(tmp_path): + """正式区间内任一入场日缺少 T-1 环境时必须阻止回测。""" from app.backtest.strategy import StrategyBacktestService regime_builder.upsert_regime_history(tmp_path, pl.DataFrame({ "date": [date(2026, 1, 1)], - "state": ["weak"], "score": [10], + "state": ["strong"], + "score": [85], })) - labels = ("2026-01-01", "2026-01-02") - mask = StrategyBacktestService._build_regime_mask( - labels, {"states": ["strong"]}, tmp_path, - ) - # 1/1 首日 → True; 1/2 由 1/1(weak) → False - assert mask.tolist() == [True, False] + with pytest.raises(ValueError, match="缺少前一交易日环境"): + StrategyBacktestService._build_regime_mask( + ("2026-01-01", "2026-01-02", "2026-01-03"), + {"states": ["strong"]}, + tmp_path, + ) + + +def test_build_regime_mask_first_formal_day_requires_warmup_predecessor(tmp_path): + """正式首日缺少前一交易标签时必须阻断; warmup 前缀可安全对齐。""" + from app.backtest.strategy import StrategyBacktestService + + regime_builder.upsert_regime_history(tmp_path, pl.DataFrame({ + "date": [date(2026, 1, 1), date(2026, 1, 2)], + "state": ["weak", "strong"], + "score": [10, 85], + })) + with pytest.raises(ValueError, match="正式首日"): + StrategyBacktestService._build_regime_mask( + ("2026-01-01", "2026-01-02"), + {"states": ["strong"]}, + tmp_path, + required_start=date(2026, 1, 1), + required_end=date(2026, 1, 2), + ) + + mask = StrategyBacktestService._build_regime_mask( + ("2026-01-01", "2026-01-02", "2026-01-03"), + {"states": ["strong"]}, + tmp_path, + required_start=date(2026, 1, 2), + required_end=date(2026, 1, 3), + ) + assert mask is not None + assert mask.tolist() == [True, False, True] diff --git a/backend/tests/test_repository_index.py b/backend/tests/test_repository_index.py index 28f12ef..56c0450 100644 --- a/backend/tests/test_repository_index.py +++ b/backend/tests/test_repository_index.py @@ -1,4 +1,6 @@ """指数资产路由 — repository 层测试。""" +import os + import polars as pl import pytest @@ -44,9 +46,65 @@ def test_name_map_stock_beats_index(repo): assert repo.get_name_map(["600000.SH"]).get("600000.SH") == "浦发银行" +def _write_stock_instruments(repo, symbols, names): + pl.DataFrame({ + "symbol": symbols, "name": names, "code": [s[:6] for s in symbols], + "exchange": ["SH"] * len(symbols), "region": ["CN"] * len(symbols), + "type": ["stock"] * len(symbols), + "listing_date": [None] * len(symbols), "total_shares": [None] * len(symbols), + "float_shares": [None] * len(symbols), "tick_size": [None] * len(symbols), + "limit_up": [None] * len(symbols), "limit_down": [None] * len(symbols), + "as_of": ["2026-08-14"] * len(symbols), + }).write_parquet(repo.store.data_dir / "instruments" / "instruments.parquet") + repo._refresh_instruments() + + +def test_name_map_partial_query_does_not_poison_cache(repo): + """带 symbols 的部分查询不能把残缺映射写入缓存 (自选新加股票无名称的回归). + + 旧 bug: 首次 get_name_map(["600000.SH"]) 把只含 600000 的映射缓存住, + 之后自选加入 000001.SZ 再查名称命中残缺缓存 → name=None。 + """ + _write_stock_instruments(repo, ["600000.SH", "000001.SZ"], ["浦发银行", "平安银行"]) + first = repo.get_name_map(["600000.SH"]) + assert first == {"600000.SH": "浦发银行"} + # 缓存必须是全量: 后续其他 symbols 查询仍能命中 + second = repo.get_name_map(["000001.SZ"]) + assert second == {"000001.SZ": "平安银行"} + full = repo.get_name_map() + assert full == {"600000.SH": "浦发银行", "000001.SZ": "平安银行"} + + +def test_name_map_cache_invalidated_on_instruments_refresh(repo): + """维表刷新后缓存必须失效: 新收录的股票能立刻查到名称。""" + _write_stock_instruments(repo, ["600000.SH"], ["浦发银行"]) + assert repo.get_name_map(["600000.SH"]) == {"600000.SH": "浦发银行"} + _write_stock_instruments(repo, ["600000.SH", "301999.SZ"], ["浦发银行", "新股股份"]) + assert repo.get_name_map(["301999.SZ"]) == {"301999.SZ": "新股股份"} + + import datetime as _dt +def test_execute_one_releases_parquet_file(repo): + minute_dir = repo.store.data_dir / "kline_minute" / "date=2026-07-23" + minute_dir.mkdir(parents=True, exist_ok=True) + part = minute_dir / "part.parquet" + replacement = minute_dir / "part.parquet.tmp" + minute = pl.DataFrame({ + "symbol": ["600000.SH"], + "datetime": [_dt.datetime(2026, 7, 23, 9, 30)], + "close": [10.0], + }) + minute.write_parquet(part) + repo.rebuild_views() + + assert repo.execute_one("SELECT max(datetime) FROM kline_minute")[0] == _dt.datetime(2026, 7, 23, 9, 30) + + minute.write_parquet(replacement) + os.replace(replacement, part) + + def _write_index_enriched(repo, dates_rows): for ds, rows in dates_rows.items(): d = repo.store.data_dir / "kline_index_enriched" / f"date={ds}" diff --git a/backend/tests/test_screener_builtin_params.py b/backend/tests/test_screener_builtin_params.py index 00e0d98..660d9d2 100644 --- a/backend/tests/test_screener_builtin_params.py +++ b/backend/tests/test_screener_builtin_params.py @@ -44,6 +44,11 @@ class _CapturingStrategyEngine: def has(self, strategy_id): return strategy_id == "builtin_strategy" + def get(self, strategy_id): + if not self.has(strategy_id): + raise ValueError(f"unknown strategy: {strategy_id}") + return types.SimpleNamespace(meta={"id": strategy_id}) + def run(self, strategy_id, context, *, pool=None, params=None, overrides=None): self.calls.append({ "kind": "run", diff --git a/backend/tests/test_settings_mining_schedule.py b/backend/tests/test_settings_mining_schedule.py new file mode 100644 index 0000000..3b7e78f --- /dev/null +++ b/backend/tests/test_settings_mining_schedule.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from app.api import settings + + +def test_mining_schedule_model_is_strict_and_forbids_extra_fields(): + valid = settings.MiningSchedulePrefs( + mining_schedule_enabled=True, + mining_schedule_weekday=4, + mining_budget_profile="strict", + ) + assert valid.model_dump() == { + "mining_schedule_enabled": True, + "mining_schedule_weekday": 4, + "mining_budget_profile": "strict", + } + + invalid_payloads = [ + { + "mining_schedule_enabled": "true", + "mining_schedule_weekday": 4, + "mining_budget_profile": "balanced", + }, + { + "mining_schedule_enabled": True, + "mining_schedule_weekday": 5, + "mining_budget_profile": "balanced", + }, + { + "mining_schedule_enabled": True, + "mining_schedule_weekday": 4, + "mining_budget_profile": "exploratory", + }, + { + "mining_schedule_enabled": True, + "mining_schedule_weekday": 4, + "mining_budget_profile": "balanced", + "unknown": True, + }, + ] + for payload in invalid_payloads: + with pytest.raises(ValidationError): + settings.MiningSchedulePrefs.model_validate(payload) + + +def test_update_mining_schedule_calls_group_setter_once(monkeypatch): + calls = [] + monkeypatch.setattr( + "app.services.preferences.set_mining_schedule", + lambda enabled, weekday, profile: ( + calls.append((enabled, weekday, profile)) + or { + "mining_schedule_enabled": enabled, + "mining_schedule_weekday": weekday, + "mining_budget_profile": profile, + } + ), + ) + request = settings.MiningSchedulePrefs( + mining_schedule_enabled=True, + mining_schedule_weekday=1, + mining_budget_profile="strict", + ) + + result = settings.update_mining_schedule(request) + + assert calls == [(True, 1, "strict")] + assert result["mining_budget_profile"] == "strict" diff --git a/backend/tests/test_st_limit_and_sharpe.py b/backend/tests/test_st_limit_and_sharpe.py index 75402b5..bb82bbd 100644 --- a/backend/tests/test_st_limit_and_sharpe.py +++ b/backend/tests/test_st_limit_and_sharpe.py @@ -55,6 +55,7 @@ def _two_day( "raw_close": [prev_close, today_close], "close": [prev_close, today_close], "raw_high": [prev_close, today_close], + "raw_low": [prev_close, today_close], "open": [prev_close, today_close], "high": [prev_close, today_close], "low": [prev_close, today_close], diff --git a/backend/tests/test_strategy_build_stream.py b/backend/tests/test_strategy_build_stream.py index 7d1b5dc..24e76ae 100644 --- a/backend/tests/test_strategy_build_stream.py +++ b/backend/tests/test_strategy_build_stream.py @@ -23,7 +23,6 @@ ENTRY_SIGNALS = [] EXIT_SIGNALS = [] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 20 -ALERTS = [] RULES = """ 1. 测试规则一 diff --git a/backend/tests/test_strategy_code_save.py b/backend/tests/test_strategy_code_save.py index 1816220..e27ef61 100644 --- a/backend/tests/test_strategy_code_save.py +++ b/backend/tests/test_strategy_code_save.py @@ -30,7 +30,6 @@ ENTRY_SIGNALS = [] EXIT_SIGNALS = [] STOP_LOSS = -0.05 MAX_HOLD_DAYS = 20 -ALERTS = [] RULES = """ 1. 测试规则一 diff --git a/backend/tests/test_strategy_detail_signals.py b/backend/tests/test_strategy_detail_signals.py index 110846c..c89674f 100644 --- a/backend/tests/test_strategy_detail_signals.py +++ b/backend/tests/test_strategy_detail_signals.py @@ -28,7 +28,6 @@ def _make_strategy( trailing_take_profit_activate=None, trailing_take_profit_drawdown=None, max_hold_days=10, - alerts=[], filter_fn=None, filter_history_fn=None, lookback_days=60, @@ -43,6 +42,7 @@ def test_no_overrides_returns_default_signals(): exit_signals=["signal_ma20_breakdown"], ) detail = _strategy_detail(s, overrides=None) + assert "alerts" not in detail assert detail["entry_signals"] == ["signal_ma20_breakout", "signal_n_day_high"] assert detail["exit_signals"] == ["signal_ma20_breakdown"] diff --git a/backend/tests/test_strategy_monitor_events.py b/backend/tests/test_strategy_monitor_events.py index 6c6af6f..4c917d2 100644 --- a/backend/tests/test_strategy_monitor_events.py +++ b/backend/tests/test_strategy_monitor_events.py @@ -55,12 +55,23 @@ def _result( pool: tuple[str, ...] = (), buys: tuple[str, ...] = (), sells: tuple[str, ...] = (), + scores: dict[str, float] | None = None, ) -> StrategyResult: + scores = scores or {} return StrategyResult( as_of=as_of, strategy_id="demo", - rows=[{"symbol": symbol, "close": 10.0, "change_pct": 0.01} for symbol in pool], + rows=[ + { + "symbol": symbol, + "close": 10.0, + "change_pct": 0.01, + **({"score": scores[symbol]} if symbol in scores else {}), + } + for symbol in pool + ], total=len(pool), + scores=scores, entry_signal_hits=[{"symbol": symbol, "signals": ["signal_buy"]} for symbol in buys], exit_signal_hits=[{"symbol": symbol, "signals": ["signal_sell"]} for symbol in sells], ) @@ -84,15 +95,75 @@ def test_strategy_rule_compatibility_and_validation(tmp_path): loaded = monitor_rules.load_one(tmp_path, "legacy") assert loaded is not None assert loaded["notify_events"] == ["pool_entry", "pool_exit"] + assert loaded["score_min"] is None + assert loaded["score_max"] is None assert monitor_rules.load_all(tmp_path)[0]["notify_events"] == ["pool_entry", "pool_exit"] with pytest.raises(ValueError, match="至少选择一个通知事件"): monitor_rules.validate(_rule()) with pytest.raises(ValueError, match="非法事件"): monitor_rules.validate(_rule("unknown")) + with pytest.raises(ValueError, match="0 到 100"): + monitor_rules.validate(_rule("pool_entry", score_min=-1)) + with pytest.raises(ValueError, match="不能大于"): + monitor_rules.validate(_rule("pool_entry", score_min=90, score_max=70)) monitor_rules.validate(_rule("buy_signal", "pool_exit")) +def test_strategy_score_range_filters_pool_and_buy_signals_but_not_sell_signals(): + day = date(2026, 7, 24) + engine = MonitorRuleEngine() + engine.set_strategy_engine(_SequenceStrategyEngine([ + _result( + day, + pool=("A", "B", "D"), + buys=("A", "B", "D"), + scores={"A": 69, "B": 80}, + ), + _result( + day, + pool=("A", "B", "C", "D"), + buys=("A", "B", "C", "D"), + sells=("B",), + scores={"A": 70, "B": 91, "C": 90}, + ), + ])) + engine.set_rules([_rule( + "buy_signal", "sell_signal", "pool_entry", "pool_exit", + score_min=70, + score_max=90, + )]) + + with patch("app.strategy.monitor.time.time", side_effect=[100, 101]): + assert engine.evaluate(_quotes()) == [] + events = engine.evaluate(_quotes()) + + assert {(event["type"], event["symbol"]) for event in events} == { + ("buy_signal", "A"), + ("buy_signal", "C"), + ("sell_signal", "B"), + ("pool_entry", "A"), + ("pool_entry", "C"), + ("pool_exit", "B"), + } + + +def test_strategy_score_range_edit_resets_pool_baseline(): + day = date(2026, 7, 24) + engine = MonitorRuleEngine() + engine.set_strategy_engine(_SequenceStrategyEngine([ + _result(day, pool=("A",), scores={"A": 80}), + _result(day, pool=("A",), scores={"A": 80}), + ])) + rule = _rule("pool_exit", score_min=70) + engine.set_rules([rule]) + assert engine.evaluate(_quotes()) == [] + + engine.set_rules([{**rule, "score_min": 90}]) + + assert engine.evaluate(_quotes()) == [] + + def test_strategy_events_baseline_dedupe_and_next_day_replay(): day1 = date(2026, 7, 24) day2 = date(2026, 7, 25) @@ -223,7 +294,6 @@ def test_matrix_strategy_pool_masks_rows_and_both_signal_directions(): trailing_take_profit_activate=None, trailing_take_profit_drawdown=None, max_hold_days=None, - alerts=[], filter_fn=None, filter_history_fn=None, lookback_days=1, @@ -270,7 +340,6 @@ def test_ordinary_strategy_uses_signal_overrides_and_ignores_malformed_values(): trailing_take_profit_activate=None, trailing_take_profit_drawdown=None, max_hold_days=None, - alerts=[], filter_fn=None, filter_history_fn=None, lookback_days=1, diff --git a/backend/tests/test_strategy_param_normalize.py b/backend/tests/test_strategy_param_normalize.py index 92a202e..6ac2ed5 100644 --- a/backend/tests/test_strategy_param_normalize.py +++ b/backend/tests/test_strategy_param_normalize.py @@ -113,7 +113,7 @@ def _make_strategy_with_params(params) -> StrategyDef: entry_signals=[], exit_signals=[], stop_loss=None, trailing_stop=None, trailing_take_profit_activate=None, trailing_take_profit_drawdown=None, - max_hold_days=None, alerts=[], + max_hold_days=None, filter_fn=None, filter_history_fn=None, lookback_days=60, source="custom", ) diff --git a/backend/tests/test_strategy_registry.py b/backend/tests/test_strategy_registry.py index 3799aa8..b930b53 100644 --- a/backend/tests/test_strategy_registry.py +++ b/backend/tests/test_strategy_registry.py @@ -118,3 +118,17 @@ def test_builtin_custom_and_ai_files_share_one_registry_and_run_path(tmp_path): results = engine.run_all(context, overrides_map=overrides) assert set(results) == set(strategy_ids.values()) assert all(result.total == 1 for result in results.values()) + + +def test_legacy_alerts_global_is_ignored(tmp_path): + path = tmp_path / "legacy_alerts.py" + path.write_text( + _strategy_code("legacy_alerts") + + '\nALERTS = [{"field": "rsi_14", "op": "<", "value": 25}]\n', + encoding="utf-8", + ) + + engine = StrategyEngine(strategy_dirs=[tmp_path]) + + assert engine.has("legacy_alerts") + assert not hasattr(engine.get("legacy_alerts"), "alerts") diff --git a/backend/tests/test_strategy_saved_params.py b/backend/tests/test_strategy_saved_params.py index e2ccf89..ee39ec7 100644 --- a/backend/tests/test_strategy_saved_params.py +++ b/backend/tests/test_strategy_saved_params.py @@ -1,7 +1,12 @@ from datetime import date +from types import SimpleNamespace import polars as pl +import pytest +from fastapi import HTTPException +from app.api import strategy as strategy_api +from app.strategy import config as strategy_config from app.strategy.engine import StrategyDataContext, StrategyDef, StrategyEngine @@ -18,7 +23,6 @@ def _make_engine() -> tuple[StrategyEngine, StrategyDataContext]: trailing_take_profit_activate=None, trailing_take_profit_drawdown=None, max_hold_days=None, - alerts=[], filter_fn=lambda _df, params: pl.col("value") >= params.get("min_value", 1), filter_history_fn=None, lookback_days=1, @@ -53,3 +57,44 @@ def test_explicit_params_override_saved_strategy_params(): ) assert [row["symbol"] for row in result.rows] == ["C"] + + +def test_patch_config_preserves_other_user_overrides(tmp_path): + engine, _ = _make_engine() + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace( + strategy_engine=engine, + repo=SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path)), + ))) + strategy_config.save_override(tmp_path, "saved_params", { + "params": {"min_value": 2}, + "stop_loss": -0.05, + }) + + strategy_api.patch_config(strategy_api.SaveConfigRequest( + strategy_id="saved_params", + overrides={ + "scoring": {"rsi_14": 1.0}, + "scoring_directions": {"rsi_14": "low"}, + "scoring_replace": True, + }, + ), request) + + saved = strategy_config.load_override(tmp_path, "saved_params") + assert saved["params"] == {"min_value": 2} + assert saved["stop_loss"] == -0.05 + assert saved["scoring"] == {"rsi_14": 1.0} + assert saved["scoring_directions"] == {"rsi_14": "low"} + + +def test_save_config_rejects_invalid_scoring_direction(tmp_path): + engine, _ = _make_engine() + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace( + strategy_engine=engine, + repo=SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path)), + ))) + + with pytest.raises(HTTPException, match="方向无效"): + strategy_api.save_config(strategy_api.SaveConfigRequest( + strategy_id="saved_params", + overrides={"scoring_directions": {"rsi_14": "sideways"}}, + ), request) diff --git a/backend/tests/test_strategy_scoring.py b/backend/tests/test_strategy_scoring.py index b565b05..a81fcff 100644 --- a/backend/tests/test_strategy_scoring.py +++ b/backend/tests/test_strategy_scoring.py @@ -1,4 +1,4 @@ -from datetime import date +from datetime import date, timedelta from types import SimpleNamespace import polars as pl @@ -6,6 +6,7 @@ import pytest from app.backtest.strategy import StrategyBacktestService from app.strategy.engine import StrategyEngine +from app.strategy.scoring import effective_scoring def _candidates() -> pl.DataFrame: @@ -37,3 +38,75 @@ def test_scoring_reweights_only_available_fields(): ) assert scored["score"].to_list() == pytest.approx([100.0, 0.0]) + + +def test_scoring_can_prefer_lower_factor_values(): + scored = StrategyEngine._apply_scoring( + _candidates(), + {"ma20_bias": 0.6, "vol_ratio_5d": 0.4}, + {"ma20_bias": "low"}, + ) + + assert scored["score"].to_list() == pytest.approx([100.0, 0.0]) + + +def test_backtest_scoring_uses_saved_direction_and_replacement(): + strategy = SimpleNamespace(meta={ + "scoring": {"ma20_bias": 1.0}, + "order_by": "score", + "descending": True, + }) + + scored = StrategyBacktestService._apply_score( + _candidates(), + strategy, + { + "scoring": {"vol_ratio_5d": 1.0}, + "scoring_directions": {"vol_ratio_5d": "low"}, + "scoring_replace": True, + }, + ) + + assert scored["score"].to_list() == pytest.approx([0.0, 100.0]) + + +def test_realtime_scoring_materializes_rolling_factor_from_history(): + start = date(2024, 1, 1) + history = pl.DataFrame({ + "symbol": [symbol for offset in range(11) for symbol in ("A", "B")], + "date": [start + timedelta(days=offset) for offset in range(11) for _ in range(2)], + "volume": [ + 20.0 if symbol == "A" and offset == 10 else 10.0 + for offset in range(11) + for symbol in ("A", "B") + ], + }) + current = history.filter(pl.col("date") == start + timedelta(days=10)) + + scored_current, scored_history = StrategyEngine._materialize_scoring_frames( + current, + history, + {"vol_ratio_10d": 1.0}, + ) + + assert scored_current is not None + assert scored_history is not None + assert scored_current.sort("symbol")["vol_ratio_10d"].to_list() == pytest.approx([2.0, 1.0]) + assert scored_history["vol_ratio_10d"].drop_nulls().len() == 2 + + +def test_effective_scoring_keeps_legacy_merge_and_supports_full_replace(): + defaults = {"momentum_20d": 0.6, "vol_ratio_5d": 0.4} + + assert effective_scoring(defaults, {"scoring": {"vol_ratio_5d": 0.8}}) == { + "momentum_20d": 0.6, + "vol_ratio_5d": 0.8, + } + assert effective_scoring(defaults, { + "scoring": {"rsi_14": 1.0}, + "scoring_replace": True, + }) == {"rsi_14": 1.0} + assert effective_scoring(defaults, { + "scoring": {}, + "scoring_replace": True, + }) == {} diff --git a/backend/tests/test_watchlist_groups.py b/backend/tests/test_watchlist_groups.py new file mode 100644 index 0000000..ab797d7 --- /dev/null +++ b/backend/tests/test_watchlist_groups.py @@ -0,0 +1,280 @@ +"""自选分组持久化与 API 契约。""" +from types import SimpleNamespace +from unittest.mock import MagicMock + +import polars as pl +import pytest +from fastapi import HTTPException + +from app.api import watchlist as watchlist_api +from app.config import settings +from app.services import watchlist + + +def _request(): + repo = MagicMock() + repo.get_name_map.return_value = {} + return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(repo=repo))) + + +def test_historical_watchlist_is_read_as_ungrouped(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "data_dir", tmp_path) + path = tmp_path / "user_data" / "watchlist.parquet" + path.parent.mkdir(parents=True) + pl.DataFrame({ + "symbol": ["600000.SH"], + "added_at": ["2026-08-08T10:00:00"], + "note": [""], + }).write_parquet(path) + + assert watchlist.list_symbols()[0]["group_ids"] == [] + + +def test_group_lifecycle_preserves_watchlist_entries(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "data_dir", tmp_path) + groups, created = watchlist.create_group(" 短线 ", "orange") + assert groups == [{"id": created["id"], "name": "短线", "color": "orange"}] + + watchlist.add("600000.SH", group_id=created["id"]) + watchlist.add("000001.SZ") + assert watchlist.list_symbols()[1]["group_ids"] == [created["id"]] + + renamed = watchlist.rename_group(created["id"], "观察", "fuchsia") + assert renamed[0]["name"] == "观察" + assert renamed[0]["color"] == "fuchsia" + + remaining, rows = watchlist.delete_group(created["id"]) + assert remaining == [] + assert {row["symbol"] for row in rows} == {"600000.SH", "000001.SZ"} + assert all(row["group_ids"] == [] for row in rows) + + +def test_group_validation_and_assignment_errors(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "data_dir", tmp_path) + _, created = watchlist.create_group("核心") + watchlist.add("600000.SH") + + with pytest.raises(ValueError, match="已存在"): + watchlist.create_group("核心") + with pytest.raises(ValueError, match="颜色"): + watchlist.create_group("无效颜色", "black") + with pytest.raises(ValueError, match="颜色"): + watchlist.rename_group(created["id"], "核心", "black") + with pytest.raises(ValueError, match="不存在"): + watchlist.set_group("600000.SH", "missing") + with pytest.raises(KeyError): + watchlist.set_group("000001.SZ", created["id"]) + + rows = watchlist.set_group("600000.SH", created["id"]) + assert rows[0]["group_ids"] == [created["id"]] + rows = watchlist.set_group("600000.SH", None) + assert rows[0]["group_ids"] == [] + + +def test_reorder_groups(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "data_dir", tmp_path) + _, first = watchlist.create_group("一") + _, second = watchlist.create_group("二") + _, third = watchlist.create_group("三") + + reordered = watchlist.reorder_groups([third["id"], first["id"], second["id"]]) + assert [group["name"] for group in reordered] == ["三", "一", "二"] + assert [group["name"] for group in watchlist.list_groups()] == ["三", "一", "二"] + + # ids 与现有分组不一致 (缺失 / 多余 / 重复) 均拒绝 + with pytest.raises(ValueError, match="不一致"): + watchlist.reorder_groups([first["id"], second["id"]]) + with pytest.raises(ValueError, match="不一致"): + watchlist.reorder_groups([first["id"], second["id"], third["id"], "missing"]) + with pytest.raises(ValueError, match="不一致"): + watchlist.reorder_groups([first["id"], first["id"], second["id"], third["id"]]) + # 失败请求不改变现有顺序 + assert [group["name"] for group in watchlist.list_groups()] == ["三", "一", "二"] + + +def test_reorder_groups_api(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "data_dir", tmp_path) + _, first = watchlist.create_group("一") + _, second = watchlist.create_group("二") + + result = watchlist_api.reorder_groups( + watchlist_api.GroupReorderRequest(ordered_ids=[second["id"], first["id"]]) + ) + assert [group["name"] for group in result["groups"]] == ["二", "一"] + + with pytest.raises(HTTPException) as exc_info: + watchlist_api.reorder_groups( + watchlist_api.GroupReorderRequest(ordered_ids=["missing"]) + ) + assert exc_info.value.status_code == 400 + + +def test_group_api_contract(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "data_dir", tmp_path) + request = _request() + created = watchlist_api.create_group( + watchlist_api.GroupNameRequest(name="中线", color="teal") + ) + group_id = created["group"]["id"] + assert created["group"]["color"] == "teal" + + added = watchlist_api.add_one( + watchlist_api.AddRequest(symbol="600000.SH", group_id=group_id), + request, + ) + assert added["symbols"][0]["group_ids"] == [group_id] + + moved = watchlist_api.assign_group( + "600000.SH", + watchlist_api.GroupAssignRequest(group_id=None), + request, + ) + assert moved["symbols"][0]["group_ids"] == [] + + with pytest.raises(HTTPException) as exc_info: + watchlist_api.rename_group("missing", watchlist_api.GroupNameRequest(name="无效")) + assert exc_info.value.status_code == 404 + + +def test_historical_groups_default_to_sky(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "data_dir", tmp_path) + path = tmp_path / "user_data" / "watchlist_groups.json" + path.parent.mkdir(parents=True) + path.write_text( + '[{"id":"legacy","name":"旧分组"},' + '{"id":"invalid","name":"未知颜色","color":"black"}]', + encoding="utf-8", + ) + + assert watchlist.list_groups() == [ + {"id": "legacy", "name": "旧分组", "color": "sky"}, + {"id": "invalid", "name": "未知颜色", "color": "sky"}, + ] + + +def test_clear_group_moves_members_to_ungrouped(monkeypatch, tmp_path): + """清空分组:成员变未分组,分组定义保留。""" + monkeypatch.setattr(settings, "data_dir", tmp_path) + _, group = watchlist.create_group("芯片") + watchlist.add("600000.SH", group_id=group["id"]) + watchlist.add("000001.SZ", group_id=group["id"]) + watchlist.add("300750.SZ") # 不在任何分组 + + rows = watchlist.clear_group(group["id"]) + # 3 只都还在,group_id 全部为 None + assert len(rows) == 3 + assert all(r["group_ids"] == [] for r in rows) + # 分组定义仍在 + assert any(g["id"] == group["id"] for g in watchlist.list_groups()) + + # 清空不存在的分组 → KeyError + with pytest.raises(KeyError): + watchlist.clear_group("missing") + + +def test_clear_group_api(monkeypatch, tmp_path): + monkeypatch.setattr(settings, "data_dir", tmp_path) + request = _request() + created = watchlist_api.create_group( + watchlist_api.GroupNameRequest(name="中线", color="teal") + ) + group_id = created["group"]["id"] + watchlist.add("600000.SH", group_id=group_id) + watchlist.add("000001.SZ", group_id=group_id) + + result = watchlist_api.clear_group(group_id, request) + assert all(s["group_ids"] == [] for s in result["symbols"]) + + # 不存在的分组 → 404 + with pytest.raises(HTTPException) as exc: + watchlist_api.clear_group("missing", request) + assert exc.value.status_code == 404 + + +# ── 多组成员关系 (M:N) ────────────────────────────────────── + + +def test_multi_group_membership(monkeypatch, tmp_path): + """一股可同时属于多个分组; 移出一个不影响其他。""" + monkeypatch.setattr(settings, "data_dir", tmp_path) + _, ga = watchlist.create_group("半导体") + _, gb = watchlist.create_group("反包备选") + watchlist.add("600118.SH", group_id=ga["id"]) + + rows = watchlist.add_to_group("600118.SH", gb["id"]) + got = next(r for r in rows if r["symbol"] == "600118.SH") + assert got["group_ids"] == [ga["id"], gb["id"]] # 两组并存 + + # 重复加入幂等 + rows = watchlist.add_to_group("600118.SH", gb["id"]) + got = next(r for r in rows if r["symbol"] == "600118.SH") + assert got["group_ids"] == [ga["id"], gb["id"]] + + # 移出一个, 另一个保留 + rows = watchlist.remove_from_group("600118.SH", ga["id"]) + got = next(r for r in rows if r["symbol"] == "600118.SH") + assert got["group_ids"] == [gb["id"]] + + # 移出最后一个 → 未分组(仍在自选) + rows = watchlist.remove_from_group("600118.SH", gb["id"]) + got = next(r for r in rows if r["symbol"] == "600118.SH") + assert got["group_ids"] == [] + assert any(r["symbol"] == "600118.SH" for r in rows) + + # 不存在的分组/标的 + with pytest.raises(ValueError, match="不存在"): + watchlist.add_to_group("600118.SH", "missing") + with pytest.raises(KeyError): + watchlist.remove_from_group("000001.SZ", ga["id"]) + + +def test_set_group_exclusive_keeps_only_one(monkeypatch, tmp_path): + """互斥设定: 已在多组的标的被 set 后只保留指定组。""" + monkeypatch.setattr(settings, "data_dir", tmp_path) + _, ga = watchlist.create_group("一") + _, gb = watchlist.create_group("二") + watchlist.add("600000.SH", group_id=ga["id"]) + watchlist.add_to_group("600000.SH", gb["id"]) + + rows = watchlist.set_group("600000.SH", ga["id"]) + assert rows[0]["group_ids"] == [ga["id"]] + + +def test_clear_group_strips_only_that_group(monkeypatch, tmp_path): + """清空分组只摘该组标签, 其他组成员关系保留。""" + monkeypatch.setattr(settings, "data_dir", tmp_path) + _, ga = watchlist.create_group("一") + _, gb = watchlist.create_group("二") + watchlist.add("600000.SH", group_id=ga["id"]) + watchlist.add_to_group("600000.SH", gb["id"]) + watchlist.add("000001.SZ", group_id=ga["id"]) + + rows = watchlist.clear_group(ga["id"]) + a = next(r for r in rows if r["symbol"] == "600000.SH") + b = next(r for r in rows if r["symbol"] == "000001.SZ") + assert a["group_ids"] == [gb["id"]] # 二组保留 + assert b["group_ids"] == [] + + +def test_legacy_single_group_id_migration(monkeypatch, tmp_path): + """旧 schema(单值 group_id 列)读取迁移 + 首次写回前自动备份 .bak。""" + monkeypatch.setattr(settings, "data_dir", tmp_path) + path = tmp_path / "user_data" / "watchlist.parquet" + path.parent.mkdir(parents=True) + pl.DataFrame({ + "symbol": ["600000.SH", "000001.SZ"], + "added_at": ["2026-08-08T10:00:00"] * 2, + "note": ["", ""], + "group_id": ["g1", None], + }).write_parquet(path) + + rows = watchlist.list_symbols() + assert rows[0]["group_ids"] == ["g1"] + assert rows[1]["group_ids"] == [] + assert not (tmp_path / "user_data" / "watchlist.parquet.bak").exists() # 只读不备份 + + # 触发写入 → 备份生成, 文件落新 schema + watchlist.add("300750.SZ") + assert (tmp_path / "user_data" / "watchlist.parquet.bak").exists() + df = pl.read_parquet(path) + assert "group_ids" in df.columns and "group_id" not in df.columns diff --git a/backend/uv.lock b/backend/uv.lock index f38c899..e90c8ec 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -16,452 +16,452 @@ resolution-markers = [ [[package]] name = "annotated-doc" version = "0.0.4" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] [[package]] name = "annotated-types" version = "0.7.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] [[package]] name = "anyio" version = "4.13.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] name = "apscheduler" version = "3.11.2" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzlocal" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, + { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, ] [[package]] name = "ast-serialize" version = "0.5.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, + { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, ] [[package]] name = "asttokens" version = "3.0.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, ] [[package]] name = "bottle" version = "0.13.4" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7a/71/cca6167c06d00c81375fd668719df245864076d284f7cb46a694cbeb5454/bottle-0.13.4.tar.gz", hash = "sha256:787e78327e12b227938de02248333d788cfe45987edca735f8f88e03472c3f47", size = 98717, upload-time = "2025-06-15T10:08:59.439Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/71/cca6167c06d00c81375fd668719df245864076d284f7cb46a694cbeb5454/bottle-0.13.4.tar.gz", hash = "sha256:787e78327e12b227938de02248333d788cfe45987edca735f8f88e03472c3f47", size = 98717, upload-time = "2025-06-15T10:08:59.439Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/83/f6/b55ec74cfe68c6584163faa311503c20b0da4c09883a41e8e00d6726c954/bottle-0.13.4-py2.py3-none-any.whl", hash = "sha256:045684fbd2764eac9cdeb824861d1551d113e8b683d8d26e296898d3dd99a12e", size = 103807, upload-time = "2025-06-15T10:08:57.691Z" }, + { url = "https://files.pythonhosted.org/packages/83/f6/b55ec74cfe68c6584163faa311503c20b0da4c09883a41e8e00d6726c954/bottle-0.13.4-py2.py3-none-any.whl", hash = "sha256:045684fbd2764eac9cdeb824861d1551d113e8b683d8d26e296898d3dd99a12e", size = 103807, upload-time = "2025-06-15T10:08:57.691Z" }, ] [[package]] name = "certifi" version = "2026.5.20" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] [[package]] name = "cffi" version = "2.0.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy' and sys_platform == 'win32'" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.7" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] name = "click" version = "8.4.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, ] [[package]] name = "clr-loader" version = "0.3.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/46/7eea92b6aa2d68af78e049cbecec5f757f1aad44ecdecdc16bbad7eead51/clr_loader-0.3.1.tar.gz", hash = "sha256:2e073e9aaf49d1ae2f56ecba27987ad5fb68be4bcd9dd34a5bed8f0e4e128366", size = 86805, upload-time = "2026-04-18T17:49:44.287Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/46/7eea92b6aa2d68af78e049cbecec5f757f1aad44ecdecdc16bbad7eead51/clr_loader-0.3.1.tar.gz", hash = "sha256:2e073e9aaf49d1ae2f56ecba27987ad5fb68be4bcd9dd34a5bed8f0e4e128366", size = 86805, upload-time = "2026-04-18T17:49:44.287Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/da/ec1a6e36624000b6df0dd61183c42342ee5814c073315e802cadaad04d2f/clr_loader-0.3.1-py3-none-any.whl", hash = "sha256:cbad189de20d202a7d621956b0fc38049e13c9bf7ca2923441eff725cd121aa1", size = 55730, upload-time = "2026-04-18T17:49:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/5e/da/ec1a6e36624000b6df0dd61183c42342ee5814c073315e802cadaad04d2f/clr_loader-0.3.1-py3-none-any.whl", hash = "sha256:cbad189de20d202a7d621956b0fc38049e13c9bf7ca2923441eff725cd121aa1", size = 55730, upload-time = "2026-04-18T17:49:42.99Z" }, ] [[package]] name = "colorama" version = "0.4.6" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "comm" version = "0.2.3" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, ] [[package]] name = "contourpy" version = "1.3.3" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, ] [[package]] name = "cycler" version = "0.12.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30" }, + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] [[package]] name = "dateparser" version = "1.4.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, { name = "pytz" }, { name = "regex" }, { name = "tzlocal" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/2d/a0ccdb78788064fa0dc901b8524e50615c42be1d78b78d646d0b28d09180/dateparser-1.4.0.tar.gz", hash = "sha256:97a21840d5ecdf7630c584f673338a5afac5dfe84f647baf4d7e8df98f9354a4", size = 321512, upload-time = "2026-03-26T09:56:10.292Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/2d/a0ccdb78788064fa0dc901b8524e50615c42be1d78b78d646d0b28d09180/dateparser-1.4.0.tar.gz", hash = "sha256:97a21840d5ecdf7630c584f673338a5afac5dfe84f647baf4d7e8df98f9354a4", size = 321512, upload-time = "2026-03-26T09:56:10.292Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/0b/3c3bb7cbe757279e693a0be6049048012f794d01f81099609ecd53b899f0/dateparser-1.4.0-py3-none-any.whl", hash = "sha256:7902b8e85d603494bf70a5a0b1decdddb2270b9c6e6b2bc8a57b93476c0df378", size = 300379, upload-time = "2026-03-26T09:56:08.409Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0b/3c3bb7cbe757279e693a0be6049048012f794d01f81099609ecd53b899f0/dateparser-1.4.0-py3-none-any.whl", hash = "sha256:7902b8e85d603494bf70a5a0b1decdddb2270b9c6e6b2bc8a57b93476c0df378", size = 300379, upload-time = "2026-03-26T09:56:08.409Z" }, ] [[package]] name = "decorator" version = "5.3.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, ] [[package]] name = "dill" version = "0.4.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, ] [[package]] name = "distro" version = "1.9.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] [[package]] name = "duckdb" version = "1.5.3" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/00/d579dcb2a536b6ea3a2563cdad6844f77d81a9b2d4b22a858097f2468acf/duckdb-1.5.3.tar.gz", hash = "sha256:df39428eb130faa35ae96fd35245bdeae6ecf43936250b116b5fead568eb9f16", size = 18026640, upload-time = "2026-05-20T11:55:31.901Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/00/d579dcb2a536b6ea3a2563cdad6844f77d81a9b2d4b22a858097f2468acf/duckdb-1.5.3.tar.gz", hash = "sha256:df39428eb130faa35ae96fd35245bdeae6ecf43936250b116b5fead568eb9f16", size = 18026640, upload-time = "2026-05-20T11:55:31.901Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/fc/a8a89c6c73f31c2b58c6abbc2f543e0b736042dd5ef7cc1784c24ec31428/duckdb-1.5.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:341a2672e2551ba51c95c1898f0ade983e76675e79038ccb16342c3d6cfb82d7", size = 32583465, upload-time = "2026-05-20T11:54:13.132Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/f1/3423a2f523dd034e505d4a5dd8e210ae577212e152598dc13b6a5e736e1b/duckdb-1.5.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c9e8fa408705081160ede7ead238d16e73a36b8561b700f2bf2d650ae48e7b92", size = 17278520, upload-time = "2026-05-20T11:54:16.368Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e1/1a/7bf5ba1b7ea520557e6b2dbee1c85abab016bdac0c1779d9d0ef76c87300/duckdb-1.5.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:70a18f932cf6d87bd0e554613657a515c1443a1724aacfc7ec5137dd28698b03", size = 15424794, upload-time = "2026-05-20T11:54:19.891Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ad/16/ce4b1e386e45fab0268edbf1b85bace20e9437589e9edb2bd5f9a226fa44/duckdb-1.5.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e80eb4d0fb59869cb2c7d7ef494c07fb92014fe8e77d96c170cd1ebc1488a708", size = 19306666, upload-time = "2026-05-20T11:54:22.77Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/1f/651f8453f26931e8061b7e27b3090f868868185814ecb9216d0bd71ec8ef/duckdb-1.5.3-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3248b49cd835ea322574bc6aac0ae7a83be85547f49d4f5f5777cb380ee6627f", size = 21418306, upload-time = "2026-05-20T11:54:25.616Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bc/64/e1ffebf010b1631a6fef8d1508f46d4eab3e97c18729af986bb796fa8452/duckdb-1.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:f4eff89c12c3a362efa012262e57b7b4ab904a7f79bad9178fe365510077abe8", size = 13101423, upload-time = "2026-05-20T11:54:28.107Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/42/b1d4e34f9658cc0e13d7aae581ab82643f50a548d5aee8767f0c587cc3a4/duckdb-1.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:75d13308c9da3ee431d1e72b8ab720aa74a1b3e9159d4124cb62435924496334", size = 13951740, upload-time = "2026-05-20T11:54:30.886Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/c4/2e34929b16c8d544ef664fad8f7f3a2a9db05746aae1e7c8c4ee3a8b23e4/duckdb-1.5.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ff11a457258148337ef9a392148a8cdbd1069b6c27c21958816c7b67fe6c542d", size = 32626494, upload-time = "2026-05-20T11:54:33.738Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3a/53/3af681793d03771365ae3e2215331151c196a3ac8193f613344840694671/duckdb-1.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fd25f533cb1b6b2c84cc767a9a9bab7769bb1aa44571a2a0bfc91ac3e4a38ac", size = 17301121, upload-time = "2026-05-20T11:54:36.928Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/e2/c80af1eac2ab5d35fc2c372ef0a84668842e549fbbf7799277b3fccf3e39/duckdb-1.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10960400ed60cdf0fe05bab2086fa8eb733889cb0ceca18d07ff9a00c0e0be7b", size = 15449283, upload-time = "2026-05-20T11:54:39.777Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/9a/c63af233c9f761bf5178a5210437e1bc6bcb30fa8a9073de6398cfb12c03/duckdb-1.5.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5f18e7561403054433706c187589e86629a7af09a7efc23a06a8b308e6acc68", size = 19332762, upload-time = "2026-05-20T11:54:42.51Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/21/cc/2d77af4fff86012f334ef82e6d54a995a86c8745e58074f1218ed7d25171/duckdb-1.5.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9fb7516255a8764545e30f7efacea408cc847764a3027b3b0b3e7d1a7bebbc5c", size = 21453290, upload-time = "2026-05-20T11:54:45.272Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8d/5e/9bc4817a98feb4dab83e56f2245cd3a30d00ee646d4dec7926464e2b3f28/duckdb-1.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:8001eccbc28be244dfd04d708526f34ddd6460b47a8aeb5d0e39d6f7f9e3fe15", size = 13118308, upload-time = "2026-05-20T11:54:48.058Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/81/35/e3f32e4e53e2450ddb1db8312a17d1ce455d60cc4941b6ad2cfc908794b0/duckdb-1.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:6d2835e39bb6af73891f73c0f8d4324f98afe00d0b00c6d34b2a582c2256cbb0", size = 13927187, upload-time = "2026-05-20T11:54:50.584Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/9c/a528eb09d8be51954c485864bd06753e616939a080cbc3dd4417e8c94a57/duckdb-1.5.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e75a6122c12579a99848517f6f00a4e342aebda3590c30fe9b5cc5f39d5e6afc", size = 32626254, upload-time = "2026-05-20T11:54:53.65Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ec/3c/1534c0a6db347c05eb7d0f6ecfb7aefbe74cbff398e4892a8fd1903a20e8/duckdb-1.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fd3963c1cb9d9567777f4a898a9dbe388a2fe9724681801b1e7d6d93eecf1b76", size = 17300917, upload-time = "2026-05-20T11:54:56.628Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/fa/beafb91e6e152d2161c4a9cbc472334c87607eb61ad7104b5a7fa8d8d7b1/duckdb-1.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3d5db8c0b55e072cf437948ebb5d7e23d7b9d03d905fa5f9145583e65aa447f7", size = 15449411, upload-time = "2026-05-20T11:54:59.089Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/0a/49b6fe04e2fcd63729eb607dadd44818dde77342a4f5ce086c6c92f1dd4d/duckdb-1.5.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ce80aed7a538422129a57eaca9141e3afb51f8bf562b1908b1576c9725b5b22", size = 19333120, upload-time = "2026-05-20T11:55:01.727Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/4c/0907c3f76adb9dd90e67610b31e0304a35814e65c4c41a354a262c09b885/duckdb-1.5.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787df63824f07bf18022dbc3b8ca4b2bfab0ebe616464f55c6e8cd0f59ea762e", size = 21453266, upload-time = "2026-05-20T11:55:04.5Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6d/9c/d2f23a7803ddbbd9413f7572ecf66a15120ed5ced7ce5c73e698c1406b76/duckdb-1.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:bb5bb5dcdd09d62ee60f0ddbbef918e71cce304ffe28428b1131949d39ffaabf", size = 13118640, upload-time = "2026-05-20T11:55:07.389Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/27/d5/7ba2316415bcdab6edd765bbbe35c2ca8a3800f2fe695cd70e3cdb997f09/duckdb-1.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:2fa17ecdd5d3db122836cb71bb93601c2106a3be883c17dffddc02fbf3fa7888", size = 13926409, upload-time = "2026-05-20T11:55:10.166Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a5/c2/d4b6f8a5e4d3bc25773be6da76a99d9661ebbf3552c007c460d2dd59dbf8/duckdb-1.5.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4bfa9a4dadf71e83e2c4eaca2f9421c82a54defecc1b0b4c0be95e2389dec4fe", size = 32636685, upload-time = "2026-05-20T11:55:13.158Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/58/e835c8298979d29db7a62cb5acc29e9b57aeaca7cdde2fcd3ac980f5cb18/duckdb-1.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aea7baf67ad7e1829ac76f67d7dcbd7fb1f57c3eb179d55ac30952df4709ae30", size = 17308134, upload-time = "2026-05-20T11:55:16.194Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c9/46/617b51363f5613418c8b224b3cce16b58e6dde80904566bec232579c1d4e/duckdb-1.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b0b4f088a65d77e1217ce5d7eff889e63fedc44281200d899ff47c84d8ff836", size = 15449891, upload-time = "2026-05-20T11:55:18.687Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/72/354146656e8d9ba3853d3a5ee80a481b8c5f70edfc3d5ae80a8c4479c967/duckdb-1.5.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe8d0c1f6a120aa03fa6e0d03897c71a1842e6cf7afd31d181348391f7108fe1", size = 19338499, upload-time = "2026-05-20T11:55:21.34Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/56/8f/65fc623b51448f2bfba1a9ec6ab3debb4664c0876c0113a5e782600b53ac/duckdb-1.5.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0405eae18ec6e8210a471c97dbfe87a7e4d605274b7fe572a1f276e92158f13", size = 21455828, upload-time = "2026-05-20T11:55:23.847Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/db/d0274cbe9f5fe219f77c0bdf900ac77103569e83c102a4225ce04cbc607d/duckdb-1.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:33ae08b3e818d7613d8936744b67718c2062c2f530376895bfd89efb51b81538", size = 13640011, upload-time = "2026-05-20T11:55:26.276Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/5d/8f1899b8bef291caf953992fcd6c24df9f29387a35645e58c2504a5ca473/duckdb-1.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:746433e49bbc667b4df283153415fbe37e9083e0eff6c3cd6e54de7536869cd4", size = 14411554, upload-time = "2026-05-20T11:55:29.037Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fc/a8a89c6c73f31c2b58c6abbc2f543e0b736042dd5ef7cc1784c24ec31428/duckdb-1.5.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:341a2672e2551ba51c95c1898f0ade983e76675e79038ccb16342c3d6cfb82d7", size = 32583465, upload-time = "2026-05-20T11:54:13.132Z" }, + { url = "https://files.pythonhosted.org/packages/63/f1/3423a2f523dd034e505d4a5dd8e210ae577212e152598dc13b6a5e736e1b/duckdb-1.5.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c9e8fa408705081160ede7ead238d16e73a36b8561b700f2bf2d650ae48e7b92", size = 17278520, upload-time = "2026-05-20T11:54:16.368Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1a/7bf5ba1b7ea520557e6b2dbee1c85abab016bdac0c1779d9d0ef76c87300/duckdb-1.5.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:70a18f932cf6d87bd0e554613657a515c1443a1724aacfc7ec5137dd28698b03", size = 15424794, upload-time = "2026-05-20T11:54:19.891Z" }, + { url = "https://files.pythonhosted.org/packages/ad/16/ce4b1e386e45fab0268edbf1b85bace20e9437589e9edb2bd5f9a226fa44/duckdb-1.5.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e80eb4d0fb59869cb2c7d7ef494c07fb92014fe8e77d96c170cd1ebc1488a708", size = 19306666, upload-time = "2026-05-20T11:54:22.77Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/651f8453f26931e8061b7e27b3090f868868185814ecb9216d0bd71ec8ef/duckdb-1.5.3-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3248b49cd835ea322574bc6aac0ae7a83be85547f49d4f5f5777cb380ee6627f", size = 21418306, upload-time = "2026-05-20T11:54:25.616Z" }, + { url = "https://files.pythonhosted.org/packages/bc/64/e1ffebf010b1631a6fef8d1508f46d4eab3e97c18729af986bb796fa8452/duckdb-1.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:f4eff89c12c3a362efa012262e57b7b4ab904a7f79bad9178fe365510077abe8", size = 13101423, upload-time = "2026-05-20T11:54:28.107Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/b1d4e34f9658cc0e13d7aae581ab82643f50a548d5aee8767f0c587cc3a4/duckdb-1.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:75d13308c9da3ee431d1e72b8ab720aa74a1b3e9159d4124cb62435924496334", size = 13951740, upload-time = "2026-05-20T11:54:30.886Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/2e34929b16c8d544ef664fad8f7f3a2a9db05746aae1e7c8c4ee3a8b23e4/duckdb-1.5.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ff11a457258148337ef9a392148a8cdbd1069b6c27c21958816c7b67fe6c542d", size = 32626494, upload-time = "2026-05-20T11:54:33.738Z" }, + { url = "https://files.pythonhosted.org/packages/3a/53/3af681793d03771365ae3e2215331151c196a3ac8193f613344840694671/duckdb-1.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fd25f533cb1b6b2c84cc767a9a9bab7769bb1aa44571a2a0bfc91ac3e4a38ac", size = 17301121, upload-time = "2026-05-20T11:54:36.928Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/c80af1eac2ab5d35fc2c372ef0a84668842e549fbbf7799277b3fccf3e39/duckdb-1.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10960400ed60cdf0fe05bab2086fa8eb733889cb0ceca18d07ff9a00c0e0be7b", size = 15449283, upload-time = "2026-05-20T11:54:39.777Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/c63af233c9f761bf5178a5210437e1bc6bcb30fa8a9073de6398cfb12c03/duckdb-1.5.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5f18e7561403054433706c187589e86629a7af09a7efc23a06a8b308e6acc68", size = 19332762, upload-time = "2026-05-20T11:54:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/21/cc/2d77af4fff86012f334ef82e6d54a995a86c8745e58074f1218ed7d25171/duckdb-1.5.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9fb7516255a8764545e30f7efacea408cc847764a3027b3b0b3e7d1a7bebbc5c", size = 21453290, upload-time = "2026-05-20T11:54:45.272Z" }, + { url = "https://files.pythonhosted.org/packages/8d/5e/9bc4817a98feb4dab83e56f2245cd3a30d00ee646d4dec7926464e2b3f28/duckdb-1.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:8001eccbc28be244dfd04d708526f34ddd6460b47a8aeb5d0e39d6f7f9e3fe15", size = 13118308, upload-time = "2026-05-20T11:54:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/e3f32e4e53e2450ddb1db8312a17d1ce455d60cc4941b6ad2cfc908794b0/duckdb-1.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:6d2835e39bb6af73891f73c0f8d4324f98afe00d0b00c6d34b2a582c2256cbb0", size = 13927187, upload-time = "2026-05-20T11:54:50.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/a528eb09d8be51954c485864bd06753e616939a080cbc3dd4417e8c94a57/duckdb-1.5.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e75a6122c12579a99848517f6f00a4e342aebda3590c30fe9b5cc5f39d5e6afc", size = 32626254, upload-time = "2026-05-20T11:54:53.65Z" }, + { url = "https://files.pythonhosted.org/packages/ec/3c/1534c0a6db347c05eb7d0f6ecfb7aefbe74cbff398e4892a8fd1903a20e8/duckdb-1.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fd3963c1cb9d9567777f4a898a9dbe388a2fe9724681801b1e7d6d93eecf1b76", size = 17300917, upload-time = "2026-05-20T11:54:56.628Z" }, + { url = "https://files.pythonhosted.org/packages/23/fa/beafb91e6e152d2161c4a9cbc472334c87607eb61ad7104b5a7fa8d8d7b1/duckdb-1.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3d5db8c0b55e072cf437948ebb5d7e23d7b9d03d905fa5f9145583e65aa447f7", size = 15449411, upload-time = "2026-05-20T11:54:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/50/0a/49b6fe04e2fcd63729eb607dadd44818dde77342a4f5ce086c6c92f1dd4d/duckdb-1.5.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ce80aed7a538422129a57eaca9141e3afb51f8bf562b1908b1576c9725b5b22", size = 19333120, upload-time = "2026-05-20T11:55:01.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/4c/0907c3f76adb9dd90e67610b31e0304a35814e65c4c41a354a262c09b885/duckdb-1.5.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787df63824f07bf18022dbc3b8ca4b2bfab0ebe616464f55c6e8cd0f59ea762e", size = 21453266, upload-time = "2026-05-20T11:55:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/6d/9c/d2f23a7803ddbbd9413f7572ecf66a15120ed5ced7ce5c73e698c1406b76/duckdb-1.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:bb5bb5dcdd09d62ee60f0ddbbef918e71cce304ffe28428b1131949d39ffaabf", size = 13118640, upload-time = "2026-05-20T11:55:07.389Z" }, + { url = "https://files.pythonhosted.org/packages/27/d5/7ba2316415bcdab6edd765bbbe35c2ca8a3800f2fe695cd70e3cdb997f09/duckdb-1.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:2fa17ecdd5d3db122836cb71bb93601c2106a3be883c17dffddc02fbf3fa7888", size = 13926409, upload-time = "2026-05-20T11:55:10.166Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c2/d4b6f8a5e4d3bc25773be6da76a99d9661ebbf3552c007c460d2dd59dbf8/duckdb-1.5.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4bfa9a4dadf71e83e2c4eaca2f9421c82a54defecc1b0b4c0be95e2389dec4fe", size = 32636685, upload-time = "2026-05-20T11:55:13.158Z" }, + { url = "https://files.pythonhosted.org/packages/42/58/e835c8298979d29db7a62cb5acc29e9b57aeaca7cdde2fcd3ac980f5cb18/duckdb-1.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aea7baf67ad7e1829ac76f67d7dcbd7fb1f57c3eb179d55ac30952df4709ae30", size = 17308134, upload-time = "2026-05-20T11:55:16.194Z" }, + { url = "https://files.pythonhosted.org/packages/c9/46/617b51363f5613418c8b224b3cce16b58e6dde80904566bec232579c1d4e/duckdb-1.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b0b4f088a65d77e1217ce5d7eff889e63fedc44281200d899ff47c84d8ff836", size = 15449891, upload-time = "2026-05-20T11:55:18.687Z" }, + { url = "https://files.pythonhosted.org/packages/b3/72/354146656e8d9ba3853d3a5ee80a481b8c5f70edfc3d5ae80a8c4479c967/duckdb-1.5.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe8d0c1f6a120aa03fa6e0d03897c71a1842e6cf7afd31d181348391f7108fe1", size = 19338499, upload-time = "2026-05-20T11:55:21.34Z" }, + { url = "https://files.pythonhosted.org/packages/56/8f/65fc623b51448f2bfba1a9ec6ab3debb4664c0876c0113a5e782600b53ac/duckdb-1.5.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0405eae18ec6e8210a471c97dbfe87a7e4d605274b7fe572a1f276e92158f13", size = 21455828, upload-time = "2026-05-20T11:55:23.847Z" }, + { url = "https://files.pythonhosted.org/packages/2b/db/d0274cbe9f5fe219f77c0bdf900ac77103569e83c102a4225ce04cbc607d/duckdb-1.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:33ae08b3e818d7613d8936744b67718c2062c2f530376895bfd89efb51b81538", size = 13640011, upload-time = "2026-05-20T11:55:26.276Z" }, + { url = "https://files.pythonhosted.org/packages/07/5d/8f1899b8bef291caf953992fcd6c24df9f29387a35645e58c2504a5ca473/duckdb-1.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:746433e49bbc667b4df283153415fbe37e9083e0eff6c3cd6e54de7536869cd4", size = 14411554, upload-time = "2026-05-20T11:55:29.037Z" }, ] [[package]] name = "executing" version = "2.2.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] [[package]] name = "fastapi" version = "0.136.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, { name = "pydantic" }, @@ -469,191 +469,191 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, ] [[package]] name = "fastexcel" version = "0.20.2" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/96/96/24314765a3204081a55ecc96e83dfdbd2ef82aa80a521ac3005b2c91e941/fastexcel-0.20.2.tar.gz", hash = "sha256:a4be10eb0f4dd819d2b36950b3f26bf87260b53e54150ac02b78e524c7063902", size = 60862, upload-time = "2026-05-04T12:28:36.01Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/96/24314765a3204081a55ecc96e83dfdbd2ef82aa80a521ac3005b2c91e941/fastexcel-0.20.2.tar.gz", hash = "sha256:a4be10eb0f4dd819d2b36950b3f26bf87260b53e54150ac02b78e524c7063902", size = 60862, upload-time = "2026-05-04T12:28:36.01Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/29/29/acc5b2eb95fb4eaeb3486b6f034f2d9754dcc0cc0b5dbc79b2fa9a31f66b/fastexcel-0.20.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2a240b9f4b6af42cafc247c1b8c956cf32f5393a3956ad0ed49e07b2b5bf0e09", size = 3432179, upload-time = "2026-05-04T12:28:24.611Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3f/2b/f78a2df2d93ec4a23d7fc94ed967bf004d5115e1f4d2da389bb888d2cc32/fastexcel-0.20.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:6c7e58cf83c873743c18006e3866d5c2b69c34df7a4a253a72e81169853ddf0e", size = 3260476, upload-time = "2026-05-04T12:28:26.191Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/9c/a8f45e5c195a70a408893b348595bae4c113091dddba7d1b029e9c980b8e/fastexcel-0.20.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdab3cc439c18c4ee14c4ec2b321e1d927cae84f557525f390a3c28517f6e721", size = 3625132, upload-time = "2026-05-04T12:28:12.173Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/01/da0ae9b3ee86daca5ac9f0a7b05477cf0c1624321d95f1e52492a772dba5/fastexcel-0.20.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15d41bcc4433bb3ab778426ecaeaf3a5f38d949555c631a83e958c57bb0a116e", size = 3789901, upload-time = "2026-05-04T12:28:13.884Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/15/80c614c8b07c631c7c2acd015e7f2e1cd683cd04279e5615b39c36a0e43b/fastexcel-0.20.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:441f9b7e97b4fd1c9487e5c0a0d9cad7aa4097a39e65fcd494551f5ec3e5d02f", size = 3792388, upload-time = "2026-05-04T12:28:15.448Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/80/4548305ce4d70a0ecf38205988e5caeb9347dae7e5d06bcd2b939586fc52/fastexcel-0.20.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b15f21baef3e4914071e34f176d8692dd79e3464c10f8373047aab1384cfaae", size = 3960316, upload-time = "2026-05-04T12:28:16.821Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/22/b3/7de6b49406fda63c9fee817d5fc70fe3f52c24431605f1de2f7b0f3a0938/fastexcel-0.20.2-cp310-abi3-win_amd64.whl", hash = "sha256:ce28748390ae6a7d286ba29d8ee2badcfb6478d32ed700db6c2030de32a16e7b", size = 3275637, upload-time = "2026-05-04T12:28:30.45Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/3b/c251f38596d630e87280c191f53803e7409e7723e95e53a477f6760c7298/fastexcel-0.20.2-cp310-abi3-win_arm64.whl", hash = "sha256:c3e58f5053bc05b0ee8fa735056cea7af6e1c41c39fc4b2b06cc86c1025d140b", size = 2990952, upload-time = "2026-05-04T12:28:32.434Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/d3/fcc1ba752415327f0fd5a1b52060e66f1c0bf8b7b90bbba781b91750ce7e/fastexcel-0.20.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e5b677d669cc7c099998ef27d9442b78d7190916d3b40418bda619cdc4ebae2c", size = 3427401, upload-time = "2026-05-04T12:28:27.741Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/bc/e492c343c096e93e269c157fc11def297430047f1ee09db4f8a39bcdc1a0/fastexcel-0.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9692ac135ede4d6fc45b25cc22f1c6bd1eb8750bda14097b7f319450c33d3124", size = 3253915, upload-time = "2026-05-04T12:28:29.053Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dd/ef/bd7b98149b50e01fdaa3b146b025ce50b8e983f35522f8e69bd94d2711a4/fastexcel-0.20.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:675d7eab2110bd8d5520b98d5eb3f3e15173b69a5f64b43412f917e9717e9aa0", size = 3621364, upload-time = "2026-05-04T12:28:18.457Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4c/e0/f67911db6515df326900e8ac1ae17cc08233ca5fb8f71c633f57a50cf786/fastexcel-0.20.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db049d51e5323459f534ce9a65869f997bebd4dde75ef1d3b3af53dd64b8b9ca", size = 3784924, upload-time = "2026-05-04T12:28:19.955Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/34/82/fabe2d1de28e89a90bb2c884c37d862b5d85063fd41fffcb56b2980d0b51/fastexcel-0.20.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5b23f69898f6a32c2f279d8f72eb8ac93c336bf7f453a9b8930cab9dc494520", size = 3791264, upload-time = "2026-05-04T12:28:21.612Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f7/34/b3ed819dd6d35d71c6640ff857c6362d42f19e8b15354e64b06f355df3a4/fastexcel-0.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c3058d034e75967b6f01e0c82767adeb40cbf4533b6bc54e3b93f77f5f80dec4", size = 3953234, upload-time = "2026-05-04T12:28:23.224Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3a/51/1d9a72473cc213d5647d8cf7c2393ef995b3453ac5e4b544ba652d1428dd/fastexcel-0.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d71e2a1fce006cb8bc1ae06da6ff5e5fe83e6496be5aef01b623b15aa0d0f82d", size = 3271344, upload-time = "2026-05-04T12:28:34.443Z" }, + { url = "https://files.pythonhosted.org/packages/29/29/acc5b2eb95fb4eaeb3486b6f034f2d9754dcc0cc0b5dbc79b2fa9a31f66b/fastexcel-0.20.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2a240b9f4b6af42cafc247c1b8c956cf32f5393a3956ad0ed49e07b2b5bf0e09", size = 3432179, upload-time = "2026-05-04T12:28:24.611Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2b/f78a2df2d93ec4a23d7fc94ed967bf004d5115e1f4d2da389bb888d2cc32/fastexcel-0.20.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:6c7e58cf83c873743c18006e3866d5c2b69c34df7a4a253a72e81169853ddf0e", size = 3260476, upload-time = "2026-05-04T12:28:26.191Z" }, + { url = "https://files.pythonhosted.org/packages/36/9c/a8f45e5c195a70a408893b348595bae4c113091dddba7d1b029e9c980b8e/fastexcel-0.20.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdab3cc439c18c4ee14c4ec2b321e1d927cae84f557525f390a3c28517f6e721", size = 3625132, upload-time = "2026-05-04T12:28:12.173Z" }, + { url = "https://files.pythonhosted.org/packages/b4/01/da0ae9b3ee86daca5ac9f0a7b05477cf0c1624321d95f1e52492a772dba5/fastexcel-0.20.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15d41bcc4433bb3ab778426ecaeaf3a5f38d949555c631a83e958c57bb0a116e", size = 3789901, upload-time = "2026-05-04T12:28:13.884Z" }, + { url = "https://files.pythonhosted.org/packages/c5/15/80c614c8b07c631c7c2acd015e7f2e1cd683cd04279e5615b39c36a0e43b/fastexcel-0.20.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:441f9b7e97b4fd1c9487e5c0a0d9cad7aa4097a39e65fcd494551f5ec3e5d02f", size = 3792388, upload-time = "2026-05-04T12:28:15.448Z" }, + { url = "https://files.pythonhosted.org/packages/c0/80/4548305ce4d70a0ecf38205988e5caeb9347dae7e5d06bcd2b939586fc52/fastexcel-0.20.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b15f21baef3e4914071e34f176d8692dd79e3464c10f8373047aab1384cfaae", size = 3960316, upload-time = "2026-05-04T12:28:16.821Z" }, + { url = "https://files.pythonhosted.org/packages/22/b3/7de6b49406fda63c9fee817d5fc70fe3f52c24431605f1de2f7b0f3a0938/fastexcel-0.20.2-cp310-abi3-win_amd64.whl", hash = "sha256:ce28748390ae6a7d286ba29d8ee2badcfb6478d32ed700db6c2030de32a16e7b", size = 3275637, upload-time = "2026-05-04T12:28:30.45Z" }, + { url = "https://files.pythonhosted.org/packages/d8/3b/c251f38596d630e87280c191f53803e7409e7723e95e53a477f6760c7298/fastexcel-0.20.2-cp310-abi3-win_arm64.whl", hash = "sha256:c3e58f5053bc05b0ee8fa735056cea7af6e1c41c39fc4b2b06cc86c1025d140b", size = 2990952, upload-time = "2026-05-04T12:28:32.434Z" }, + { url = "https://files.pythonhosted.org/packages/37/d3/fcc1ba752415327f0fd5a1b52060e66f1c0bf8b7b90bbba781b91750ce7e/fastexcel-0.20.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e5b677d669cc7c099998ef27d9442b78d7190916d3b40418bda619cdc4ebae2c", size = 3427401, upload-time = "2026-05-04T12:28:27.741Z" }, + { url = "https://files.pythonhosted.org/packages/80/bc/e492c343c096e93e269c157fc11def297430047f1ee09db4f8a39bcdc1a0/fastexcel-0.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9692ac135ede4d6fc45b25cc22f1c6bd1eb8750bda14097b7f319450c33d3124", size = 3253915, upload-time = "2026-05-04T12:28:29.053Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ef/bd7b98149b50e01fdaa3b146b025ce50b8e983f35522f8e69bd94d2711a4/fastexcel-0.20.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:675d7eab2110bd8d5520b98d5eb3f3e15173b69a5f64b43412f917e9717e9aa0", size = 3621364, upload-time = "2026-05-04T12:28:18.457Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e0/f67911db6515df326900e8ac1ae17cc08233ca5fb8f71c633f57a50cf786/fastexcel-0.20.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db049d51e5323459f534ce9a65869f997bebd4dde75ef1d3b3af53dd64b8b9ca", size = 3784924, upload-time = "2026-05-04T12:28:19.955Z" }, + { url = "https://files.pythonhosted.org/packages/34/82/fabe2d1de28e89a90bb2c884c37d862b5d85063fd41fffcb56b2980d0b51/fastexcel-0.20.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5b23f69898f6a32c2f279d8f72eb8ac93c336bf7f453a9b8930cab9dc494520", size = 3791264, upload-time = "2026-05-04T12:28:21.612Z" }, + { url = "https://files.pythonhosted.org/packages/f7/34/b3ed819dd6d35d71c6640ff857c6362d42f19e8b15354e64b06f355df3a4/fastexcel-0.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c3058d034e75967b6f01e0c82767adeb40cbf4533b6bc54e3b93f77f5f80dec4", size = 3953234, upload-time = "2026-05-04T12:28:23.224Z" }, + { url = "https://files.pythonhosted.org/packages/3a/51/1d9a72473cc213d5647d8cf7c2393ef995b3453ac5e4b544ba652d1428dd/fastexcel-0.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d71e2a1fce006cb8bc1ae06da6ff5e5fe83e6496be5aef01b623b15aa0d0f82d", size = 3271344, upload-time = "2026-05-04T12:28:34.443Z" }, ] [[package]] name = "fonttools" version = "4.63.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, + { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, + { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, + { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, + { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, ] [[package]] name = "h11" version = "0.16.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] name = "httpcore" version = "1.0.9" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] name = "httptools" version = "0.7.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, + { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, ] [[package]] name = "httpx" version = "0.28.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "certifi" }, { name = "httpcore" }, { name = "idna" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [[package]] name = "idna" version = "3.16" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, ] [[package]] name = "imageio" version = "2.37.3" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "pillow" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/84/93bcd1300216ea50811cee96873b84a1bebf8d0489ffaf7f2a3756bab866/imageio-2.37.3.tar.gz", hash = "sha256:bbb37efbfc4c400fcd534b367b91fcd66d5da639aaa138034431a1c5e0a41451", size = 389673, upload-time = "2026-03-09T11:31:12.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/84/93bcd1300216ea50811cee96873b84a1bebf8d0489ffaf7f2a3756bab866/imageio-2.37.3.tar.gz", hash = "sha256:bbb37efbfc4c400fcd534b367b91fcd66d5da639aaa138034431a1c5e0a41451", size = 389673, upload-time = "2026-03-09T11:31:12.573Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl", hash = "sha256:46f5bb8522cd421c0f5ae104d8268f569d856b29eb1a13b92829d1970f32c9f0", size = 317646, upload-time = "2026-03-09T11:31:10.771Z" }, + { url = "https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl", hash = "sha256:46f5bb8522cd421c0f5ae104d8268f569d856b29eb1a13b92829d1970f32c9f0", size = 317646, upload-time = "2026-03-09T11:31:10.771Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] name = "ipython" version = "9.13.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "decorator" }, @@ -668,27 +668,27 @@ dependencies = [ { name = "traitlets" }, { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cd/c4/87cda5842cf5c31837c06ddb588e11c3c35d8ece89b7a0108c06b8c9b00a/ipython-9.13.0.tar.gz", hash = "sha256:7e834b6afc99f020e3f05966ced34792f40267d64cb1ea9043886dab0dde5967", size = 4430549, upload-time = "2026-04-24T12:24:55.221Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/87cda5842cf5c31837c06ddb588e11c3c35d8ece89b7a0108c06b8c9b00a/ipython-9.13.0.tar.gz", hash = "sha256:7e834b6afc99f020e3f05966ced34792f40267d64cb1ea9043886dab0dde5967", size = 4430549, upload-time = "2026-04-24T12:24:55.221Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b9/86/3060e8029b7cc505cce9a0137431dda81d0a3fde93a8f0f50ee0bf37a795/ipython-9.13.0-py3-none-any.whl", hash = "sha256:57f9d4639e20818d328d287c7b549af3d05f12486ea8f2e7f73e52a36ec4d201", size = 627274, upload-time = "2026-04-24T12:24:53.038Z" }, + { url = "https://files.pythonhosted.org/packages/b9/86/3060e8029b7cc505cce9a0137431dda81d0a3fde93a8f0f50ee0bf37a795/ipython-9.13.0-py3-none-any.whl", hash = "sha256:57f9d4639e20818d328d287c7b549af3d05f12486ea8f2e7f73e52a36ec4d201", size = 627274, upload-time = "2026-04-24T12:24:53.038Z" }, ] [[package]] name = "ipython-pygments-lexers" version = "1.1.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pygments" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, ] [[package]] name = "ipywidgets" version = "8.1.8" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "comm" }, { name = "ipython" }, @@ -696,342 +696,342 @@ dependencies = [ { name = "traitlets" }, { name = "widgetsnbextension" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4c/ae/c5ce1edc1afe042eadb445e95b0671b03cee61895264357956e61c0d2ac0/ipywidgets-8.1.8.tar.gz", hash = "sha256:61f969306b95f85fba6b6986b7fe45d73124d1d9e3023a8068710d47a22ea668", size = 116739, upload-time = "2025-11-01T21:18:12.393Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/ae/c5ce1edc1afe042eadb445e95b0671b03cee61895264357956e61c0d2ac0/ipywidgets-8.1.8.tar.gz", hash = "sha256:61f969306b95f85fba6b6986b7fe45d73124d1d9e3023a8068710d47a22ea668", size = 116739, upload-time = "2025-11-01T21:18:12.393Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl", hash = "sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e", size = 139808, upload-time = "2025-11-01T21:18:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl", hash = "sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e", size = 139808, upload-time = "2025-11-01T21:18:10.956Z" }, ] [[package]] name = "jedi" version = "0.20.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "parso" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, ] [[package]] name = "jiter" version = "0.15.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/b5/55f06bb281d92fb3cc86d14e1def2bd908bb77693183e7cb1f5a3c388b0c/jiter-0.15.0.tar.gz", hash = "sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76", size = 166640, upload-time = "2026-05-19T10:09:48.361Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/b5/55f06bb281d92fb3cc86d14e1def2bd908bb77693183e7cb1f5a3c388b0c/jiter-0.15.0.tar.gz", hash = "sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76", size = 166640, upload-time = "2026-05-19T10:09:48.361Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/13/daa722f5765c393576f466378f9dfd29d77c9bed939e0688f96afa3601ea/jiter-0.15.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0f862193b8696249d22ec433e85fd2ab0ad9596bc3e45e6c0bc55e8aeba97be2", size = 310899, upload-time = "2026-05-19T10:07:12.89Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7f/82/2d2551829b082f4b6d82b9f939b031fb808a10aab1ec0664f82e150bb9a2/jiter-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1303d4d68a9b051ea90502402063ecf3807da00ad2affa19ca1ae3b90b3c5f67", size = 314963, upload-time = "2026-05-19T10:07:14.539Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/0a/8b1a51466f7fe9f31dbe4bc7e0ca848674f9825e0f737b929b97e8c60aa7/jiter-0.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:392b8ab019e5502d08aff85c6272209c24bc2cbe706ea82a56368f524236614a", size = 341730, upload-time = "2026-05-19T10:07:15.869Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/2a/e71dea19822e2e404e83992a08c1d6b9b617bb944f28c9c2fbd85d02c91e/jiter-0.15.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:773b6eb282ce11ee19f05f6b2d4404fa308e5bbd353b0b80a0262caad6db2cd7", size = 366214, upload-time = "2026-05-19T10:07:17.259Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/59/97e1fa539d124a509a00ab7f669289d1c1d236ecabf12948a18f16c91082/jiter-0.15.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2c0c44d569ce0f2850f5c926f8caeb5f245fbc84475aeb36efccc2103e6dbd", size = 459527, upload-time = "2026-05-19T10:07:18.741Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/7a/4a68d331aef8cf2e2393c14a3aacb635c62aa86071b0229899fb5baaa907/jiter-0.15.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:032396229564bca02440396bd327710719f724f5e7b7e9f7a8eb3faa4a2c2281", size = 375451, upload-time = "2026-05-19T10:07:20.208Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/7e/1c445c2b6f0e30a274dc8082e0c3c7825411cce80d726bccd697c98cc8d3/jiter-0.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3d37768fce7f88dd2a8c6091f2325dea27d30d30d5c6e7a1c0f0af77723b708", size = 349428, upload-time = "2026-05-19T10:07:22.372Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/00/94/e20d38984fc17a636371bffd2ae0f698124fdc8e75ef969cd2da6ba7cea7/jiter-0.15.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2c9cb907439d20bd0c7d7565ca01ee52234203208433749bae5b516907526928", size = 355405, upload-time = "2026-05-19T10:07:23.916Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/fa/4d09f814779d0ea80a28ed8e4c6662ec9a4a8ecef0ac52190ebac6262d14/jiter-0.15.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9100ddbec09741cc66feb0fc6773f8bdbd0e3c345689368f260082ff85dcc0cd", size = 393688, upload-time = "2026-05-19T10:07:25.854Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/9d/8eb5d4fb8bf7e93a75964a5da71a75c67c864baf7fa3f98598187b3c7e57/jiter-0.15.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ae1b0d82ac2d987f9ea512b1c9adfcc71a28de3dea3a6039b54d76cffda9901e", size = 520853, upload-time = "2026-05-19T10:07:27.303Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/2c/5e07874e59e623a943a0acf1552a80d05b70f31b402287a8fc6d7ec634c7/jiter-0.15.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8020c99ec13a7db2b6f96cbe82ef4721c88b426a4892f27478044af0284615ef", size = 551016, upload-time = "2026-05-19T10:07:28.846Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/22/ed/d2d34422143474cadc15b60d482b1c35683dbc5c63c24346ddd0df09bcaf/jiter-0.15.0-cp311-cp311-win32.whl", hash = "sha256:42bfb257930800cf43e7c62c832402c704ab60797c992faf88d20e903eac8f32", size = 209518, upload-time = "2026-05-19T10:07:30.431Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1d/7d/52778b930e5cc3e52a37d950b1c10494244308b4329b25a0ff0d88303a81/jiter-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:860a74063284a2ae9bfedd694f299cc2c68e2696c5f3d440cc9d18bb81b9dd04", size = 200565, upload-time = "2026-05-19T10:07:32.125Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3b/4f/d9b4067feb69b3fa6eb0488e1b59e2ad5b463fe39f59e527eab2aca00bb0/jiter-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:37a10c377ce3a4a85f4a67f28b7afe093154cde77eaf248a72e856aa08b4d865", size = 195488, upload-time = "2026-05-19T10:07:33.846Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/44/53/4f6bddbcde3c71e56d0aa1337ec95950f3d27dd4153e25aadf0feac71751/jiter-0.15.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0e90a1c315a0226ec822d973817967f9223b7701546c8c2a7913e7ab0926294d", size = 308793, upload-time = "2026-05-19T10:07:35.25Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/84/c01099b59a285a1ebba64ae93f62bfa036675340fd1b0045ae65890a0442/jiter-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8c9004af7c8d67cce7f1aae1026fb55607f4aa600710d08ede3a3ce4aeefe7e0", size = 309570, upload-time = "2026-05-19T10:07:36.919Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/64/8fb7f9d45bb98190355454cd04dad8d8f27223d6bd52f83af07f637168a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c210f8b35dc6f30aafd4b4365ca89b9d1189f21ab49b8e68fa6322a847aef138", size = 336783, upload-time = "2026-05-19T10:07:38.694Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/b6/f5739011d009b3a30f6a53c5240979030ba29ae46a8c67e3a15759f7c37d/jiter-0.15.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f30bae8bc1c2d613e28e5af3e8cceb09b742f1c8a8a5f839fb67afaffc03b61", size = 363555, upload-time = "2026-05-19T10:07:40.832Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/12/98a9d9f766665e8a3b6252454e17cb0c464606a28cf2fa09399b003345fa/jiter-0.15.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e71b6d10cfc284c9bf36bd885e8d44c46f688ce50aa91b5edd90181dea687", size = 452255, upload-time = "2026-05-19T10:07:42.62Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/d5/60f972840f79c5e7544fce567c56f1e4e50468f996baba3e78d823dd62a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ab068bce62a45aa3e7367eceaffb5dde60b7eb853be8dece45132e3d0ff4879", size = 373559, upload-time = "2026-05-19T10:07:44.201Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ee/cf/d46ef1234ba335aabc2f013210db8e0821a22f5e644a2e9449df199ecc23/jiter-0.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa248c9eb220197d363f688818dac2fd4b2f0cd7d843ca7105d652034823427d", size = 346055, upload-time = "2026-05-19T10:07:46.005Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f0/63/4d2749d8d54d230bad9b3a6b0d00cc28c6ff6b2fdffc26a8ccf76cc5a974/jiter-0.15.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2a77aadd57cac1682e4401a72724d2796d89a4ba129b1a5812aa94ee480826eb", size = 351406, upload-time = "2026-05-19T10:07:47.855Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/b9/9965b990035d8773328e0a8c8b457a87bf2b19f6c4126d9d99296be5d16a/jiter-0.15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2ae901f3a55bfafdde31d289590fa25e3245735a2b1e8c7cc15871710a002871", size = 389357, upload-time = "2026-05-19T10:07:49.665Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/55/9ddf903deda1413e87fed792f416b7123daee5b8efbad6a202a7421c36a5/jiter-0.15.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f0b271b462769543716f92d3a4f90527df6ef5ed05ee95ec4137f513e21e1b77", size = 517263, upload-time = "2026-05-19T10:07:51.537Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/76/a0c40ad064d3a20a4fde231e35d56e9a01ce82164278180e82d5daf85469/jiter-0.15.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2fb6a5d26af81fc0f00f9360a891e05cf755e149bba391c4d563adc54812973d", size = 548646, upload-time = "2026-05-19T10:07:53.196Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/4f/eca9b954942916ba2f453891b8593ab444cd872396fe66a3936616f236f3/jiter-0.15.0-cp312-cp312-win32.whl", hash = "sha256:c2f6bb8b5216ab9e7873bc08b5d7bef2b8abbb578a3069bf1cd14a45d71d771d", size = 206427, upload-time = "2026-05-19T10:07:55.307Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/bf/8ead82a87495149542748e828d153fd232a512a22c83b02c4815c1a9c7d8/jiter-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:40b2c7e92c44a84d748d21706c68dc6ff8161d80b59c99d774721a0d2317d7c7", size = 197300, upload-time = "2026-05-19T10:07:56.651Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/e4/9b8a78fb2d894471bc344e37f1949bdd784bd914d031dba0ba3a40c71dd7/jiter-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:cc0bc345cf2df9d1c00ac443f50d543c1ccfa8b0422cb85b1ab70d681c0b255b", size = 192702, upload-time = "2026-05-19T10:07:58.307Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/f4/f708c900ecee41b2025ef8413d5351e5649eb2125c506f6720cc69b06f5c/jiter-0.15.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1c11465f97e2abf45a014b83b730222f8f1c5335e802c7055a67d50de6f1f4e3", size = 307829, upload-time = "2026-05-19T10:07:59.704Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/59/db537c0949e83668c38481d426b9f2fd5ab758c4ee53a811dd0a510626a0/jiter-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e7b1776f0797956c509e123d0952d10d293a9492dea9f288ab9570ec01d1a5", size = 308445, upload-time = "2026-05-19T10:08:01.184Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/38/ea0e13b18c30ef951da0d47d39e7fa9edb82a93a62990ffbd7cea9b622d4/jiter-0.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:351a341c2105aa430b7047e30f1bf7975f6313b00165d3fc07be2edaf741f279", size = 336181, upload-time = "2026-05-19T10:08:02.688Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/fc/2303901b16c4ba05865588990a420c0b4156270b44379c20931544a1d962/jiter-0.15.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ab395feec8d249ec4044e228e98a7033f043426a265df439dc3698823f0a4e4", size = 362985, upload-time = "2026-05-19T10:08:04.394Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5b/6f/11bace093c52e7d4d26c8e606ccd7ae8c972189622469ec0d9e28161e28b/jiter-0.15.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2a438005b6f22d0273413484d6094d7c2c5d10ec1b3a3bf128e0d1d3ba53258", size = 453292, upload-time = "2026-05-19T10:08:05.967Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/22/db/987f2f086ca4d7a6582eb4ccd513f9b26b42d9e4243a087609a3137a8fc7/jiter-0.15.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f18f85e4218d1b40f000f42a92239a7a61a902cd42c65e6c360dbd17dcb20894", size = 373501, upload-time = "2026-05-19T10:08:07.857Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/7c/89fbcabb2739b7a5b8dc959a1b6c5761f6484f5fed3486854b3c789bb1de/jiter-0.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1aa62e277fc1cbd80e6deacae6f4d983b41b3d7728e0645c5d741a6149bba45", size = 344683, upload-time = "2026-05-19T10:08:09.431Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/6f/6cca7692e7dddfec6d8d76c54dc97f2af2a41df4ac0674b999df1f09a5f3/jiter-0.15.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:6550fa135c7deb8ead6af49ed7ff648532ea8334a1447fe34a36315ef79c5c29", size = 350892, upload-time = "2026-05-19T10:08:11.352Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/39/14/0338d6190cb8e6d22e677ab1d4eabd4117f67cca70c54cd04b82ff64e068/jiter-0.15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:066f8f33f18b2419cd8213b2436fa7fbc9c499f315971cfa3ce1f9820c001b1b", size = 388723, upload-time = "2026-05-19T10:08:12.912Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/31/cc19f4a1bdb6afb09ce6a2f2615aa8d44d994eba0d8e6105ed1af920e736/jiter-0.15.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:75e8a04e91432dde9f1838373cf93d23726c79d3e908d319acf0e796f85592e7", size = 516648, upload-time = "2026-05-19T10:08:14.808Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/9f/833c541512cd091b63c10c0381973dfe11bc7a503a818c16384417e0c81e/jiter-0.15.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a97261f1fccb8e50ecd2890a96e46efdc3f57c80a197324c6777827231eca712", size = 547382, upload-time = "2026-05-19T10:08:16.927Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d2/11/e7b70e91f90bc4477e8eee9e8a5f7cf3cb41b4525d6394dc98a714eb8f7f/jiter-0.15.0-cp313-cp313-win32.whl", hash = "sha256:c77496cb10bd7549690fbbab3e5ec05857b83e49276f4a9423a766ddd2afcd4c", size = 205845, upload-time = "2026-05-19T10:08:18.401Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/23/5c20d9ad6f02c493e4023e5d2d09e1c1f15fe2753c9102c544aff068a88e/jiter-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b15741f501469009ae0ae90b7147958a664a7dede40aa7ff174a8a4645f546d0", size = 196842, upload-time = "2026-05-19T10:08:20.131Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/11/1eb400ef248e8c925fd883fbe325daf5e42cd1b0d308539dd332bd4f7ffc/jiter-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d6a60072b44c3c2b797a7ddcbcbbf2b34ea3cfd4721580fbfd2a09d9d9b84ba", size = 192212, upload-time = "2026-05-19T10:08:21.807Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/60/2fd8d7c79da8acf9b7b277c7616847773779356b92acfc9bb158452174da/jiter-0.15.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ef1fd24d9413f6209e00d3d5a453e67acfe004a25cc6c8e8484faed4311ab9e8", size = 315065, upload-time = "2026-05-19T10:08:23.218Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/f4/008fb7d65e8ac2abf00811651a661e025c4ba80bbc6f378450384ddd3aed/jiter-0.15.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:144f8e72cb53dab146347b91cceac01f5481237f2b93b4a339a1ee8f8878b67c", size = 339444, upload-time = "2026-05-19T10:08:24.701Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/00/55/90b0c7b9c6896c0f2a591dd36d36b71d22e09674bfef178fa03ba3f81499/jiter-0.15.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553fcac2ef2cb990877f9fc0833b8b629a3e6a5670b6b5fd58219b41a653ddc4", size = 347779, upload-time = "2026-05-19T10:08:26.408Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/51/6b/69666cec5000fd57734c118437394516c749ae8dbeea9fb66d6fef9c4775/jiter-0.15.0-cp313-cp313t-win_amd64.whl", hash = "sha256:774f93f65031856bf14ad9f59bdcab8b8cad501e5ceabd51ba3525f76937a25b", size = 200395, upload-time = "2026-05-19T10:08:28.055Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/39/04/a6aa62cd27e8149b0d28df5561f10f6cceaf7935a9ccf3f1c5a05f9a0cd8/jiter-0.15.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f1e1754960f38ec40613a07e5e372df67acb3b890fb383b6fb3de3e49ddbf3c7", size = 190516, upload-time = "2026-05-19T10:08:29.35Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/d2/079f350ebf7859d081de30aa890f9e3be68516f754f3ba32366ffff4dcee/jiter-0.15.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ac0d9ddea4350974be7a221fc25895f251a8fee748c889bdced2141c0fec1a49", size = 308884, upload-time = "2026-05-19T10:08:31.667Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/4e/a2c30a7f69b48c03b20935d647479106fe932f6e63f75faf53937197e05d/jiter-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01a8222cf05ab1128e239421156c207949808acaaea2bdfd33130ae666786e86", size = 310028, upload-time = "2026-05-19T10:08:33.304Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/90/2e7cdfd3cf8ca967be38c48f5cf474d79f089efaf559a40f15984a77ae69/jiter-0.15.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:182226cbc930c9fab81bc2e41a4da672f89539906dadb05e75670ac07b94f71f", size = 337485, upload-time = "2026-05-19T10:08:35.259Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9b/11/15a1aa28b120b8ee5b4f1fb894c125046225f09847738bd64233d3b84883/jiter-0.15.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:71683c38c825452999b5717fcae07ea708e8c93003e808be4319c1b02e3d176e", size = 364223, upload-time = "2026-05-19T10:08:36.694Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/25/f442e8af5f3d0dcf47b39e83a0efd9ee45ea946aa6d04625dc3181eae3b6/jiter-0.15.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30f2218e6a9e5c18bc10fe6d41ac189c442c88eacf11bad9f28ef95a9bef00e6", size = 456387, upload-time = "2026-05-19T10:08:38.143Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/f4/37f2d2c9f64f49af7da652ed7532bb5a2372e588e6927c3fdd76f911db65/jiter-0.15.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5157de9f76eb4bc5ea74a1219366a25f945ad305641d74e04f59c54087091aa9", size = 374461, upload-time = "2026-05-19T10:08:39.869Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/28/edcfbbbf0cb15436f36664a8908a0df47ab9006298d4cd937dc08ea932d6/jiter-0.15.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c5db5527c221249a876160663ab891ace358c17f7b9c93ec1478b7f0550e5c", size = 345924, upload-time = "2026-05-19T10:08:41.668Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/13/89fba6398dab7f202b7278c4b4aac122399d2c0183971c4a57a3b7088df5/jiter-0.15.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:3e4540b8e74e4268811ac05db226a6a128ff572e7e0ce3f1163b693cadb184cd", size = 352283, upload-time = "2026-05-19T10:08:43.091Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1b/da/0f6af8cef2c565a1ab44d970f268c43ccaa72707386ea6388e6fe2b6cd26/jiter-0.15.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:62ebd14e47e9aed9df4472afcb2663668ce4d74891cd54f86bf6e44029d6dc89", size = 389985, upload-time = "2026-05-19T10:08:44.915Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a1/ec/b9cb7d6d29e24ee14910266157d2a279d7a8f60ee0df7fa840882976ba64/jiter-0.15.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0be6f5ad41a809f303f416d17cec92a7a725902fb9b4f3de3d19362ac0ef8554", size = 517695, upload-time = "2026-05-19T10:08:46.486Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/5e/6d1bda880723aae0ad86b4b763f044362448efe31e3e819635d41cb03451/jiter-0.15.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:813dfbb17d65328bf86e5f0905dd277ba2265d3ca20556e86c0c7035b7182e5a", size = 548868, upload-time = "2026-05-19T10:08:48.026Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/72/7de501cf38dcacaf35098796f3a50e0f2e338baba18a58946c618544b809/jiter-0.15.0-cp314-cp314-win32.whl", hash = "sha256:50e51156192722a9c58db112837d3f8ef96fb3c5ecc14e95f409134b08b158ec", size = 206380, upload-time = "2026-05-19T10:08:49.738Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/a9/e19addf4b0c1bdce52c6da12351e6bc42c340c45e7c09e2158e46d293ccc/jiter-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:30ce1a5d16b5641dc935d50ef775af6a0871e3d14ab05d6fc54dff371b78e558", size = 197687, upload-time = "2026-05-19T10:08:51.088Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/c9/776b1db01db25fc6c1d58d1979a37b0a9fe787e5f5b1d062d2eaacb77923/jiter-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:510c8b3c17a0ed9ac69850c0438dada3c9b82d9c4d589fcb62002a5a9cf3a866", size = 192571, upload-time = "2026-05-19T10:08:52.451Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/f6/45bb4670bacf300fd2c7abadbfb3af376e5f1b6ae75fd9bc069891d15870/jiter-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7553333dd0930c104a5a0db8df72bf7219fe663d731383b576bb6ed6351c984d", size = 317151, upload-time = "2026-05-19T10:08:53.867Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d7/68/ed635ad5acd7b73e454283083bbb7c8205ad10e88b0d9d7d793b09fe8226/jiter-0.15.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2143ab06181d2b029eedcb6af3cebe95f11bbac62441781860f98ee9330a6a6", size = 341243, upload-time = "2026-05-19T10:08:55.383Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/db/3ff4176b817b8ea33879e71e13d8bc2b0d481a7ed3fe9e080f333d415c16/jiter-0.15.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eac374c5c975709b69c10f09afd199df74150172156ad10c8d4fd785b7da995", size = 363629, upload-time = "2026-05-19T10:08:56.928Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ab/24/5f8270e0ba9c883582f96f722f8a0b58015c7ce1f8c6d4571cf394e99b6b/jiter-0.15.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b3b3b775e33d3bfaec9899edc526ae97b0da0bf9d071a46124ba419149a414f8", size = 456198, upload-time = "2026-05-19T10:08:58.618Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/5b/76fc02b0b5c54c3d18c60653156e2f76fde1816f9b4722db68d6ee2c897e/jiter-0.15.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3071db3346334beae1360b46da4606da57bf3528c167b3c38533afaf9f2c5", size = 373710, upload-time = "2026-05-19T10:09:00.151Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/52/4310821b0ea9277994d3e1f49fc6a4b34e4800caebacb2c0af81da59a454/jiter-0.15.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6694a173ecabc12eb60efbc0b474464ead1951ff65cd8b1e72100715c64512b", size = 349901, upload-time = "2026-05-19T10:09:01.621Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/93/fe/67648c35b3594fba8854ac64cc8a826d8bcd18324bbdb53d77697c60b6ef/jiter-0.15.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a254e10b593624d230c365b6d616b22ca0ad65e63a16e6631c2b3466022e6ba8", size = 352438, upload-time = "2026-05-19T10:09:03.216Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/28/0a1879d07ad6b3e025a2750027363452ced93c2d16d1c9d4b153ffd51c91/jiter-0.15.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d8d2955167274e15d79a7a020afdd9b39c990eb80b2d89fca695d92dcfdd38ec", size = 388152, upload-time = "2026-05-19T10:09:04.741Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/78/46c6f6b56ba85c90021f4afd72ed42f691f8f84daacb5fe27277070e3858/jiter-0.15.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:acf4ee4d1fc55917239fe72972fb292dd773055d05eb040d36f4326e02cc2c0e", size = 517707, upload-time = "2026-05-19T10:09:06.231Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ca/cb/720662d4c88fcad606e826fef5424365527ba43ce4868a479aed8f8c507e/jiter-0.15.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e7196e56f1cd69af1dbb07dff02dcfb260a50b45a82d409d92a06fedb32473b5", size = 548241, upload-time = "2026-05-19T10:09:08.093Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/e3/935b8034fd143f21125c87d51404a9e0e1449186a494405721ff5d1d695e/jiter-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:7f6163c0f10b055245f814dcc59f4818da60dfe72f3e72ab89fc24b6bd5e9c52", size = 207950, upload-time = "2026-05-19T10:09:09.616Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/93/59/984fd9ece895953dad3e0880a650e766f5a2da2c5514f0eafdaaabbeb5f9/jiter-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c256edb05b78a111b99c4de3b1d32e31634b867fd1fc2cf726e7b7bba9854", size = 200055, upload-time = "2026-05-19T10:09:11.367Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0e/a4/cf8d779feb133a27a2e3bc833bccb9e13aa332cdf820497ebf72c10ce8c3/jiter-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:66b1880df2d01e206e8339769d1c7c1753bcb653efd6289e203f6f24ebada0c0", size = 191244, upload-time = "2026-05-19T10:09:12.74Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/65/43/1fc62172aa98b50a7de9a25554060db510f85c89cfbed0dfe13e1907a139/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:411fa4dfa5a7ae3d11491027ffb9beadec3996010a986862db70d91abba1c750", size = 305585, upload-time = "2026-05-19T10:09:35.995Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/c4/dd58fcd9e2df83666e5c1c1347bef58ce919cd8efc3ffa38aeea62ce493b/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:2b0074e2f56eb2dacca1689760fd2852a068f85a0547a157b82cb4cafeb6768b", size = 306936, upload-time = "2026-05-19T10:09:37.435Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/39/86/b695e16f1180c07f43ea98e73ecd21cf63fa2e1b0c1103739013784d11ae/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:913d02d29c9606643418d9ccfc3b72492ab25a6bf7889934e09a3490f8d3438b", size = 342453, upload-time = "2026-05-19T10:09:39.294Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/34/56/55d76614af37fe3f22a3347d1e410d2a15da581997cb2da499a625000bb5/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b15d3ec9b0449c40e85319bdb4caa8b77ab526e74f5532ed94bec15e2f66822c", size = 345606, upload-time = "2026-05-19T10:09:40.727Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/73/38/505941b2b092fd5bbbd60a52a880db1173f1690ae6751bed3af1c9ddcb4e/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:631f13a3d04e97d4e083993b10f4b99530e3a10d953e2eb5e196b7dc7f812ce0", size = 303769, upload-time = "2026-05-19T10:09:42.203Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/95/a06692b29e77473f286e1ec1f426d3ca44d7b5843be8ad21d7a5f3fcdcc0/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b6c0ffae686c39bf3737be60793783267628783ea42545632c10b291105aee45", size = 305128, upload-time = "2026-05-19T10:09:43.657Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/85/7270d7ad41d6061a25b950c6bf91d638bd9aacb113200a8c8d57a055fd67/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d54fb5b31dea401a41af3f8a7d2512e9b6a6a005491e6166c7e4ffab9639a9c", size = 340459, upload-time = "2026-05-19T10:09:45.452Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c8/8d/302cb2057b7513327b4d575cff6b1d066ee6431a5357fc3f8867cd684406/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a", size = 344469, upload-time = "2026-05-19T10:09:46.864Z" }, + { url = "https://files.pythonhosted.org/packages/e4/13/daa722f5765c393576f466378f9dfd29d77c9bed939e0688f96afa3601ea/jiter-0.15.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0f862193b8696249d22ec433e85fd2ab0ad9596bc3e45e6c0bc55e8aeba97be2", size = 310899, upload-time = "2026-05-19T10:07:12.89Z" }, + { url = "https://files.pythonhosted.org/packages/7f/82/2d2551829b082f4b6d82b9f939b031fb808a10aab1ec0664f82e150bb9a2/jiter-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1303d4d68a9b051ea90502402063ecf3807da00ad2affa19ca1ae3b90b3c5f67", size = 314963, upload-time = "2026-05-19T10:07:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0a/8b1a51466f7fe9f31dbe4bc7e0ca848674f9825e0f737b929b97e8c60aa7/jiter-0.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:392b8ab019e5502d08aff85c6272209c24bc2cbe706ea82a56368f524236614a", size = 341730, upload-time = "2026-05-19T10:07:15.869Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2a/e71dea19822e2e404e83992a08c1d6b9b617bb944f28c9c2fbd85d02c91e/jiter-0.15.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:773b6eb282ce11ee19f05f6b2d4404fa308e5bbd353b0b80a0262caad6db2cd7", size = 366214, upload-time = "2026-05-19T10:07:17.259Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/97e1fa539d124a509a00ab7f669289d1c1d236ecabf12948a18f16c91082/jiter-0.15.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2c0c44d569ce0f2850f5c926f8caeb5f245fbc84475aeb36efccc2103e6dbd", size = 459527, upload-time = "2026-05-19T10:07:18.741Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7a/4a68d331aef8cf2e2393c14a3aacb635c62aa86071b0229899fb5baaa907/jiter-0.15.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:032396229564bca02440396bd327710719f724f5e7b7e9f7a8eb3faa4a2c2281", size = 375451, upload-time = "2026-05-19T10:07:20.208Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/1c445c2b6f0e30a274dc8082e0c3c7825411cce80d726bccd697c98cc8d3/jiter-0.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3d37768fce7f88dd2a8c6091f2325dea27d30d30d5c6e7a1c0f0af77723b708", size = 349428, upload-time = "2026-05-19T10:07:22.372Z" }, + { url = "https://files.pythonhosted.org/packages/00/94/e20d38984fc17a636371bffd2ae0f698124fdc8e75ef969cd2da6ba7cea7/jiter-0.15.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2c9cb907439d20bd0c7d7565ca01ee52234203208433749bae5b516907526928", size = 355405, upload-time = "2026-05-19T10:07:23.916Z" }, + { url = "https://files.pythonhosted.org/packages/94/fa/4d09f814779d0ea80a28ed8e4c6662ec9a4a8ecef0ac52190ebac6262d14/jiter-0.15.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9100ddbec09741cc66feb0fc6773f8bdbd0e3c345689368f260082ff85dcc0cd", size = 393688, upload-time = "2026-05-19T10:07:25.854Z" }, + { url = "https://files.pythonhosted.org/packages/54/9d/8eb5d4fb8bf7e93a75964a5da71a75c67c864baf7fa3f98598187b3c7e57/jiter-0.15.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ae1b0d82ac2d987f9ea512b1c9adfcc71a28de3dea3a6039b54d76cffda9901e", size = 520853, upload-time = "2026-05-19T10:07:27.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/5e07874e59e623a943a0acf1552a80d05b70f31b402287a8fc6d7ec634c7/jiter-0.15.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8020c99ec13a7db2b6f96cbe82ef4721c88b426a4892f27478044af0284615ef", size = 551016, upload-time = "2026-05-19T10:07:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/d2d34422143474cadc15b60d482b1c35683dbc5c63c24346ddd0df09bcaf/jiter-0.15.0-cp311-cp311-win32.whl", hash = "sha256:42bfb257930800cf43e7c62c832402c704ab60797c992faf88d20e903eac8f32", size = 209518, upload-time = "2026-05-19T10:07:30.431Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7d/52778b930e5cc3e52a37d950b1c10494244308b4329b25a0ff0d88303a81/jiter-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:860a74063284a2ae9bfedd694f299cc2c68e2696c5f3d440cc9d18bb81b9dd04", size = 200565, upload-time = "2026-05-19T10:07:32.125Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4f/d9b4067feb69b3fa6eb0488e1b59e2ad5b463fe39f59e527eab2aca00bb0/jiter-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:37a10c377ce3a4a85f4a67f28b7afe093154cde77eaf248a72e856aa08b4d865", size = 195488, upload-time = "2026-05-19T10:07:33.846Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/4f6bddbcde3c71e56d0aa1337ec95950f3d27dd4153e25aadf0feac71751/jiter-0.15.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0e90a1c315a0226ec822d973817967f9223b7701546c8c2a7913e7ab0926294d", size = 308793, upload-time = "2026-05-19T10:07:35.25Z" }, + { url = "https://files.pythonhosted.org/packages/01/84/c01099b59a285a1ebba64ae93f62bfa036675340fd1b0045ae65890a0442/jiter-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8c9004af7c8d67cce7f1aae1026fb55607f4aa600710d08ede3a3ce4aeefe7e0", size = 309570, upload-time = "2026-05-19T10:07:36.919Z" }, + { url = "https://files.pythonhosted.org/packages/58/64/8fb7f9d45bb98190355454cd04dad8d8f27223d6bd52f83af07f637168a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c210f8b35dc6f30aafd4b4365ca89b9d1189f21ab49b8e68fa6322a847aef138", size = 336783, upload-time = "2026-05-19T10:07:38.694Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b6/f5739011d009b3a30f6a53c5240979030ba29ae46a8c67e3a15759f7c37d/jiter-0.15.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f30bae8bc1c2d613e28e5af3e8cceb09b742f1c8a8a5f839fb67afaffc03b61", size = 363555, upload-time = "2026-05-19T10:07:40.832Z" }, + { url = "https://files.pythonhosted.org/packages/e5/12/98a9d9f766665e8a3b6252454e17cb0c464606a28cf2fa09399b003345fa/jiter-0.15.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e71b6d10cfc284c9bf36bd885e8d44c46f688ce50aa91b5edd90181dea687", size = 452255, upload-time = "2026-05-19T10:07:42.62Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d5/60f972840f79c5e7544fce567c56f1e4e50468f996baba3e78d823dd62a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ab068bce62a45aa3e7367eceaffb5dde60b7eb853be8dece45132e3d0ff4879", size = 373559, upload-time = "2026-05-19T10:07:44.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cf/d46ef1234ba335aabc2f013210db8e0821a22f5e644a2e9449df199ecc23/jiter-0.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa248c9eb220197d363f688818dac2fd4b2f0cd7d843ca7105d652034823427d", size = 346055, upload-time = "2026-05-19T10:07:46.005Z" }, + { url = "https://files.pythonhosted.org/packages/f0/63/4d2749d8d54d230bad9b3a6b0d00cc28c6ff6b2fdffc26a8ccf76cc5a974/jiter-0.15.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2a77aadd57cac1682e4401a72724d2796d89a4ba129b1a5812aa94ee480826eb", size = 351406, upload-time = "2026-05-19T10:07:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b9/9965b990035d8773328e0a8c8b457a87bf2b19f6c4126d9d99296be5d16a/jiter-0.15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2ae901f3a55bfafdde31d289590fa25e3245735a2b1e8c7cc15871710a002871", size = 389357, upload-time = "2026-05-19T10:07:49.665Z" }, + { url = "https://files.pythonhosted.org/packages/2d/55/9ddf903deda1413e87fed792f416b7123daee5b8efbad6a202a7421c36a5/jiter-0.15.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f0b271b462769543716f92d3a4f90527df6ef5ed05ee95ec4137f513e21e1b77", size = 517263, upload-time = "2026-05-19T10:07:51.537Z" }, + { url = "https://files.pythonhosted.org/packages/e8/76/a0c40ad064d3a20a4fde231e35d56e9a01ce82164278180e82d5daf85469/jiter-0.15.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2fb6a5d26af81fc0f00f9360a891e05cf755e149bba391c4d563adc54812973d", size = 548646, upload-time = "2026-05-19T10:07:53.196Z" }, + { url = "https://files.pythonhosted.org/packages/23/4f/eca9b954942916ba2f453891b8593ab444cd872396fe66a3936616f236f3/jiter-0.15.0-cp312-cp312-win32.whl", hash = "sha256:c2f6bb8b5216ab9e7873bc08b5d7bef2b8abbb578a3069bf1cd14a45d71d771d", size = 206427, upload-time = "2026-05-19T10:07:55.307Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/8ead82a87495149542748e828d153fd232a512a22c83b02c4815c1a9c7d8/jiter-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:40b2c7e92c44a84d748d21706c68dc6ff8161d80b59c99d774721a0d2317d7c7", size = 197300, upload-time = "2026-05-19T10:07:56.651Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e4/9b8a78fb2d894471bc344e37f1949bdd784bd914d031dba0ba3a40c71dd7/jiter-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:cc0bc345cf2df9d1c00ac443f50d543c1ccfa8b0422cb85b1ab70d681c0b255b", size = 192702, upload-time = "2026-05-19T10:07:58.307Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f4/f708c900ecee41b2025ef8413d5351e5649eb2125c506f6720cc69b06f5c/jiter-0.15.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1c11465f97e2abf45a014b83b730222f8f1c5335e802c7055a67d50de6f1f4e3", size = 307829, upload-time = "2026-05-19T10:07:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/86/59/db537c0949e83668c38481d426b9f2fd5ab758c4ee53a811dd0a510626a0/jiter-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e7b1776f0797956c509e123d0952d10d293a9492dea9f288ab9570ec01d1a5", size = 308445, upload-time = "2026-05-19T10:08:01.184Z" }, + { url = "https://files.pythonhosted.org/packages/37/38/ea0e13b18c30ef951da0d47d39e7fa9edb82a93a62990ffbd7cea9b622d4/jiter-0.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:351a341c2105aa430b7047e30f1bf7975f6313b00165d3fc07be2edaf741f279", size = 336181, upload-time = "2026-05-19T10:08:02.688Z" }, + { url = "https://files.pythonhosted.org/packages/58/fc/2303901b16c4ba05865588990a420c0b4156270b44379c20931544a1d962/jiter-0.15.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ab395feec8d249ec4044e228e98a7033f043426a265df439dc3698823f0a4e4", size = 362985, upload-time = "2026-05-19T10:08:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/11bace093c52e7d4d26c8e606ccd7ae8c972189622469ec0d9e28161e28b/jiter-0.15.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2a438005b6f22d0273413484d6094d7c2c5d10ec1b3a3bf128e0d1d3ba53258", size = 453292, upload-time = "2026-05-19T10:08:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/987f2f086ca4d7a6582eb4ccd513f9b26b42d9e4243a087609a3137a8fc7/jiter-0.15.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f18f85e4218d1b40f000f42a92239a7a61a902cd42c65e6c360dbd17dcb20894", size = 373501, upload-time = "2026-05-19T10:08:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/89fbcabb2739b7a5b8dc959a1b6c5761f6484f5fed3486854b3c789bb1de/jiter-0.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1aa62e277fc1cbd80e6deacae6f4d983b41b3d7728e0645c5d741a6149bba45", size = 344683, upload-time = "2026-05-19T10:08:09.431Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/6cca7692e7dddfec6d8d76c54dc97f2af2a41df4ac0674b999df1f09a5f3/jiter-0.15.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:6550fa135c7deb8ead6af49ed7ff648532ea8334a1447fe34a36315ef79c5c29", size = 350892, upload-time = "2026-05-19T10:08:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/39/14/0338d6190cb8e6d22e677ab1d4eabd4117f67cca70c54cd04b82ff64e068/jiter-0.15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:066f8f33f18b2419cd8213b2436fa7fbc9c499f315971cfa3ce1f9820c001b1b", size = 388723, upload-time = "2026-05-19T10:08:12.912Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/cc19f4a1bdb6afb09ce6a2f2615aa8d44d994eba0d8e6105ed1af920e736/jiter-0.15.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:75e8a04e91432dde9f1838373cf93d23726c79d3e908d319acf0e796f85592e7", size = 516648, upload-time = "2026-05-19T10:08:14.808Z" }, + { url = "https://files.pythonhosted.org/packages/49/9f/833c541512cd091b63c10c0381973dfe11bc7a503a818c16384417e0c81e/jiter-0.15.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a97261f1fccb8e50ecd2890a96e46efdc3f57c80a197324c6777827231eca712", size = 547382, upload-time = "2026-05-19T10:08:16.927Z" }, + { url = "https://files.pythonhosted.org/packages/d2/11/e7b70e91f90bc4477e8eee9e8a5f7cf3cb41b4525d6394dc98a714eb8f7f/jiter-0.15.0-cp313-cp313-win32.whl", hash = "sha256:c77496cb10bd7549690fbbab3e5ec05857b83e49276f4a9423a766ddd2afcd4c", size = 205845, upload-time = "2026-05-19T10:08:18.401Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/5c20d9ad6f02c493e4023e5d2d09e1c1f15fe2753c9102c544aff068a88e/jiter-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b15741f501469009ae0ae90b7147958a664a7dede40aa7ff174a8a4645f546d0", size = 196842, upload-time = "2026-05-19T10:08:20.131Z" }, + { url = "https://files.pythonhosted.org/packages/6b/11/1eb400ef248e8c925fd883fbe325daf5e42cd1b0d308539dd332bd4f7ffc/jiter-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d6a60072b44c3c2b797a7ddcbcbbf2b34ea3cfd4721580fbfd2a09d9d9b84ba", size = 192212, upload-time = "2026-05-19T10:08:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/8a/60/2fd8d7c79da8acf9b7b277c7616847773779356b92acfc9bb158452174da/jiter-0.15.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ef1fd24d9413f6209e00d3d5a453e67acfe004a25cc6c8e8484faed4311ab9e8", size = 315065, upload-time = "2026-05-19T10:08:23.218Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/008fb7d65e8ac2abf00811651a661e025c4ba80bbc6f378450384ddd3aed/jiter-0.15.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:144f8e72cb53dab146347b91cceac01f5481237f2b93b4a339a1ee8f8878b67c", size = 339444, upload-time = "2026-05-19T10:08:24.701Z" }, + { url = "https://files.pythonhosted.org/packages/00/55/90b0c7b9c6896c0f2a591dd36d36b71d22e09674bfef178fa03ba3f81499/jiter-0.15.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553fcac2ef2cb990877f9fc0833b8b629a3e6a5670b6b5fd58219b41a653ddc4", size = 347779, upload-time = "2026-05-19T10:08:26.408Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/69666cec5000fd57734c118437394516c749ae8dbeea9fb66d6fef9c4775/jiter-0.15.0-cp313-cp313t-win_amd64.whl", hash = "sha256:774f93f65031856bf14ad9f59bdcab8b8cad501e5ceabd51ba3525f76937a25b", size = 200395, upload-time = "2026-05-19T10:08:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/39/04/a6aa62cd27e8149b0d28df5561f10f6cceaf7935a9ccf3f1c5a05f9a0cd8/jiter-0.15.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f1e1754960f38ec40613a07e5e372df67acb3b890fb383b6fb3de3e49ddbf3c7", size = 190516, upload-time = "2026-05-19T10:08:29.35Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/079f350ebf7859d081de30aa890f9e3be68516f754f3ba32366ffff4dcee/jiter-0.15.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ac0d9ddea4350974be7a221fc25895f251a8fee748c889bdced2141c0fec1a49", size = 308884, upload-time = "2026-05-19T10:08:31.667Z" }, + { url = "https://files.pythonhosted.org/packages/04/4e/a2c30a7f69b48c03b20935d647479106fe932f6e63f75faf53937197e05d/jiter-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01a8222cf05ab1128e239421156c207949808acaaea2bdfd33130ae666786e86", size = 310028, upload-time = "2026-05-19T10:08:33.304Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/2e7cdfd3cf8ca967be38c48f5cf474d79f089efaf559a40f15984a77ae69/jiter-0.15.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:182226cbc930c9fab81bc2e41a4da672f89539906dadb05e75670ac07b94f71f", size = 337485, upload-time = "2026-05-19T10:08:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/15a1aa28b120b8ee5b4f1fb894c125046225f09847738bd64233d3b84883/jiter-0.15.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:71683c38c825452999b5717fcae07ea708e8c93003e808be4319c1b02e3d176e", size = 364223, upload-time = "2026-05-19T10:08:36.694Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/f442e8af5f3d0dcf47b39e83a0efd9ee45ea946aa6d04625dc3181eae3b6/jiter-0.15.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30f2218e6a9e5c18bc10fe6d41ac189c442c88eacf11bad9f28ef95a9bef00e6", size = 456387, upload-time = "2026-05-19T10:08:38.143Z" }, + { url = "https://files.pythonhosted.org/packages/da/f4/37f2d2c9f64f49af7da652ed7532bb5a2372e588e6927c3fdd76f911db65/jiter-0.15.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5157de9f76eb4bc5ea74a1219366a25f945ad305641d74e04f59c54087091aa9", size = 374461, upload-time = "2026-05-19T10:08:39.869Z" }, + { url = "https://files.pythonhosted.org/packages/60/28/edcfbbbf0cb15436f36664a8908a0df47ab9006298d4cd937dc08ea932d6/jiter-0.15.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c5db5527c221249a876160663ab891ace358c17f7b9c93ec1478b7f0550e5c", size = 345924, upload-time = "2026-05-19T10:08:41.668Z" }, + { url = "https://files.pythonhosted.org/packages/47/13/89fba6398dab7f202b7278c4b4aac122399d2c0183971c4a57a3b7088df5/jiter-0.15.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:3e4540b8e74e4268811ac05db226a6a128ff572e7e0ce3f1163b693cadb184cd", size = 352283, upload-time = "2026-05-19T10:08:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/1b/da/0f6af8cef2c565a1ab44d970f268c43ccaa72707386ea6388e6fe2b6cd26/jiter-0.15.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:62ebd14e47e9aed9df4472afcb2663668ce4d74891cd54f86bf6e44029d6dc89", size = 389985, upload-time = "2026-05-19T10:08:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ec/b9cb7d6d29e24ee14910266157d2a279d7a8f60ee0df7fa840882976ba64/jiter-0.15.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0be6f5ad41a809f303f416d17cec92a7a725902fb9b4f3de3d19362ac0ef8554", size = 517695, upload-time = "2026-05-19T10:08:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/64/5e/6d1bda880723aae0ad86b4b763f044362448efe31e3e819635d41cb03451/jiter-0.15.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:813dfbb17d65328bf86e5f0905dd277ba2265d3ca20556e86c0c7035b7182e5a", size = 548868, upload-time = "2026-05-19T10:08:48.026Z" }, + { url = "https://files.pythonhosted.org/packages/0c/72/7de501cf38dcacaf35098796f3a50e0f2e338baba18a58946c618544b809/jiter-0.15.0-cp314-cp314-win32.whl", hash = "sha256:50e51156192722a9c58db112837d3f8ef96fb3c5ecc14e95f409134b08b158ec", size = 206380, upload-time = "2026-05-19T10:08:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/1e/a9/e19addf4b0c1bdce52c6da12351e6bc42c340c45e7c09e2158e46d293ccc/jiter-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:30ce1a5d16b5641dc935d50ef775af6a0871e3d14ab05d6fc54dff371b78e558", size = 197687, upload-time = "2026-05-19T10:08:51.088Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c9/776b1db01db25fc6c1d58d1979a37b0a9fe787e5f5b1d062d2eaacb77923/jiter-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:510c8b3c17a0ed9ac69850c0438dada3c9b82d9c4d589fcb62002a5a9cf3a866", size = 192571, upload-time = "2026-05-19T10:08:52.451Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f6/45bb4670bacf300fd2c7abadbfb3af376e5f1b6ae75fd9bc069891d15870/jiter-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7553333dd0930c104a5a0db8df72bf7219fe663d731383b576bb6ed6351c984d", size = 317151, upload-time = "2026-05-19T10:08:53.867Z" }, + { url = "https://files.pythonhosted.org/packages/d7/68/ed635ad5acd7b73e454283083bbb7c8205ad10e88b0d9d7d793b09fe8226/jiter-0.15.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2143ab06181d2b029eedcb6af3cebe95f11bbac62441781860f98ee9330a6a6", size = 341243, upload-time = "2026-05-19T10:08:55.383Z" }, + { url = "https://files.pythonhosted.org/packages/5d/db/3ff4176b817b8ea33879e71e13d8bc2b0d481a7ed3fe9e080f333d415c16/jiter-0.15.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eac374c5c975709b69c10f09afd199df74150172156ad10c8d4fd785b7da995", size = 363629, upload-time = "2026-05-19T10:08:56.928Z" }, + { url = "https://files.pythonhosted.org/packages/ab/24/5f8270e0ba9c883582f96f722f8a0b58015c7ce1f8c6d4571cf394e99b6b/jiter-0.15.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b3b3b775e33d3bfaec9899edc526ae97b0da0bf9d071a46124ba419149a414f8", size = 456198, upload-time = "2026-05-19T10:08:58.618Z" }, + { url = "https://files.pythonhosted.org/packages/45/5b/76fc02b0b5c54c3d18c60653156e2f76fde1816f9b4722db68d6ee2c897e/jiter-0.15.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3071db3346334beae1360b46da4606da57bf3528c167b3c38533afaf9f2c5", size = 373710, upload-time = "2026-05-19T10:09:00.151Z" }, + { url = "https://files.pythonhosted.org/packages/c4/52/4310821b0ea9277994d3e1f49fc6a4b34e4800caebacb2c0af81da59a454/jiter-0.15.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6694a173ecabc12eb60efbc0b474464ead1951ff65cd8b1e72100715c64512b", size = 349901, upload-time = "2026-05-19T10:09:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/93/fe/67648c35b3594fba8854ac64cc8a826d8bcd18324bbdb53d77697c60b6ef/jiter-0.15.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a254e10b593624d230c365b6d616b22ca0ad65e63a16e6631c2b3466022e6ba8", size = 352438, upload-time = "2026-05-19T10:09:03.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/28/0a1879d07ad6b3e025a2750027363452ced93c2d16d1c9d4b153ffd51c91/jiter-0.15.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d8d2955167274e15d79a7a020afdd9b39c990eb80b2d89fca695d92dcfdd38ec", size = 388152, upload-time = "2026-05-19T10:09:04.741Z" }, + { url = "https://files.pythonhosted.org/packages/c1/78/46c6f6b56ba85c90021f4afd72ed42f691f8f84daacb5fe27277070e3858/jiter-0.15.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:acf4ee4d1fc55917239fe72972fb292dd773055d05eb040d36f4326e02cc2c0e", size = 517707, upload-time = "2026-05-19T10:09:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/ca/cb/720662d4c88fcad606e826fef5424365527ba43ce4868a479aed8f8c507e/jiter-0.15.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e7196e56f1cd69af1dbb07dff02dcfb260a50b45a82d409d92a06fedb32473b5", size = 548241, upload-time = "2026-05-19T10:09:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/60/e3/935b8034fd143f21125c87d51404a9e0e1449186a494405721ff5d1d695e/jiter-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:7f6163c0f10b055245f814dcc59f4818da60dfe72f3e72ab89fc24b6bd5e9c52", size = 207950, upload-time = "2026-05-19T10:09:09.616Z" }, + { url = "https://files.pythonhosted.org/packages/93/59/984fd9ece895953dad3e0880a650e766f5a2da2c5514f0eafdaaabbeb5f9/jiter-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c256edb05b78a111b99c4de3b1d32e31634b867fd1fc2cf726e7b7bba9854", size = 200055, upload-time = "2026-05-19T10:09:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/cf8d779feb133a27a2e3bc833bccb9e13aa332cdf820497ebf72c10ce8c3/jiter-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:66b1880df2d01e206e8339769d1c7c1753bcb653efd6289e203f6f24ebada0c0", size = 191244, upload-time = "2026-05-19T10:09:12.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/43/1fc62172aa98b50a7de9a25554060db510f85c89cfbed0dfe13e1907a139/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:411fa4dfa5a7ae3d11491027ffb9beadec3996010a986862db70d91abba1c750", size = 305585, upload-time = "2026-05-19T10:09:35.995Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c4/dd58fcd9e2df83666e5c1c1347bef58ce919cd8efc3ffa38aeea62ce493b/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:2b0074e2f56eb2dacca1689760fd2852a068f85a0547a157b82cb4cafeb6768b", size = 306936, upload-time = "2026-05-19T10:09:37.435Z" }, + { url = "https://files.pythonhosted.org/packages/39/86/b695e16f1180c07f43ea98e73ecd21cf63fa2e1b0c1103739013784d11ae/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:913d02d29c9606643418d9ccfc3b72492ab25a6bf7889934e09a3490f8d3438b", size = 342453, upload-time = "2026-05-19T10:09:39.294Z" }, + { url = "https://files.pythonhosted.org/packages/34/56/55d76614af37fe3f22a3347d1e410d2a15da581997cb2da499a625000bb5/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b15d3ec9b0449c40e85319bdb4caa8b77ab526e74f5532ed94bec15e2f66822c", size = 345606, upload-time = "2026-05-19T10:09:40.727Z" }, + { url = "https://files.pythonhosted.org/packages/73/38/505941b2b092fd5bbbd60a52a880db1173f1690ae6751bed3af1c9ddcb4e/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:631f13a3d04e97d4e083993b10f4b99530e3a10d953e2eb5e196b7dc7f812ce0", size = 303769, upload-time = "2026-05-19T10:09:42.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/a06692b29e77473f286e1ec1f426d3ca44d7b5843be8ad21d7a5f3fcdcc0/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b6c0ffae686c39bf3737be60793783267628783ea42545632c10b291105aee45", size = 305128, upload-time = "2026-05-19T10:09:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/7270d7ad41d6061a25b950c6bf91d638bd9aacb113200a8c8d57a055fd67/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d54fb5b31dea401a41af3f8a7d2512e9b6a6a005491e6166c7e4ffab9639a9c", size = 340459, upload-time = "2026-05-19T10:09:45.452Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8d/302cb2057b7513327b4d575cff6b1d066ee6431a5357fc3f8867cd684406/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a", size = 344469, upload-time = "2026-05-19T10:09:46.864Z" }, ] [[package]] name = "joblib" version = "1.5.3" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, ] [[package]] name = "jupyterlab-widgets" version = "3.0.16" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/2d/ef58fed122b268c69c0aa099da20bc67657cdfb2e222688d5731bd5b971d/jupyterlab_widgets-3.0.16.tar.gz", hash = "sha256:423da05071d55cf27a9e602216d35a3a65a3e41cdf9c5d3b643b814ce38c19e0", size = 897423, upload-time = "2025-11-01T21:11:29.724Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/2d/ef58fed122b268c69c0aa099da20bc67657cdfb2e222688d5731bd5b971d/jupyterlab_widgets-3.0.16.tar.gz", hash = "sha256:423da05071d55cf27a9e602216d35a3a65a3e41cdf9c5d3b643b814ce38c19e0", size = 897423, upload-time = "2025-11-01T21:11:29.724Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8", size = 914926, upload-time = "2025-11-01T21:11:28.008Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8", size = 914926, upload-time = "2025-11-01T21:11:28.008Z" }, ] [[package]] name = "kiwisolver" version = "1.5.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, ] [[package]] name = "librt" version = "0.11.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, + { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, + { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] [[package]] name = "llvmlite" version = "0.47.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/88/a8952b6d5c21e74cbf158515b779666f692846502623e9e3c39d8e8ba25f/llvmlite-0.47.0.tar.gz", hash = "sha256:62031ce968ec74e95092184d4b0e857e444f8fdff0b8f9213707699570c33ccc", size = 193614, upload-time = "2026-03-31T18:29:53.497Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/88/a8952b6d5c21e74cbf158515b779666f692846502623e9e3c39d8e8ba25f/llvmlite-0.47.0.tar.gz", hash = "sha256:62031ce968ec74e95092184d4b0e857e444f8fdff0b8f9213707699570c33ccc", size = 193614, upload-time = "2026-03-31T18:29:53.497Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/34/0b/b9d1911cfefa61399821dfb37f486d83e0f42630a8d12f7194270c417002/llvmlite-0.47.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:74090f0dcfd6f24ebbef3f21f11e38111c4d7e6919b54c4416e1e357c3446b07", size = 37232770, upload-time = "2026-03-31T18:28:26.765Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/27/5799b020e4cdfb25a7c951c06a96397c135efcdc21b78d853bbd9c814c7d/llvmlite-0.47.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ca14f02e29134e837982497959a8e2193d6035235de1cb41a9cb2bd6da4eedbb", size = 56275177, upload-time = "2026-03-31T18:28:31.01Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/51/48a53fedf01cb1f3f43ef200be17ebf83c8d9a04018d3783c1a226c342c2/llvmlite-0.47.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12a69d4bb05f402f30477e21eeabe81911e7c251cecb192bed82cd83c9db10d8", size = 55128631, upload-time = "2026-03-31T18:28:36.046Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/50/59227d06bdc96e23322713c381af4e77420949d8cd8a042c79e0043096cc/llvmlite-0.47.0-cp311-cp311-win_amd64.whl", hash = "sha256:c37d6eb7aaabfa83ab9c2ff5b5cdb95a5e6830403937b2c588b7490724e05327", size = 38138400, upload-time = "2026-03-31T18:28:40.076Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/48/4b7fe0e34c169fa2f12532916133e0b219d2823b540733651b34fdac509a/llvmlite-0.47.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:306a265f408c259067257a732c8e159284334018b4083a9e35f67d19792b164f", size = 37232769, upload-time = "2026-03-31T18:28:43.735Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e6/4b/e3f2cd17822cf772a4a51a0a8080b0032e6d37b2dbe8cfb724eac4e31c52/llvmlite-0.47.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5853bf26160857c0c2573415ff4efe01c4c651e59e2c55c2a088740acfee51cd", size = 56275178, upload-time = "2026-03-31T18:28:48.342Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b6/55/a3b4a543185305a9bdf3d9759d53646ed96e55e7dfd43f53e7a421b8fbae/llvmlite-0.47.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:003bcf7fa579e14db59c1a1e113f93ab8a06b56a4be31c7f08264d1d4072d077", size = 55128632, upload-time = "2026-03-31T18:28:52.901Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2f/f5/d281ae0f79378a5a91f308ea9fdb9f9cc068fddd09629edc0725a5a8fde1/llvmlite-0.47.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3079f25bdc24cd9d27c4b2b5e68f5f60c4fdb7e8ad5ee2b9b006007558f9df7", size = 38138692, upload-time = "2026-03-31T18:28:57.147Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/77/6f/4615353e016799f80fa52ccb270a843c413b22361fadda2589b2922fb9b0/llvmlite-0.47.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a3c6a735d4e1041808434f9d440faa3d78d9b4af2ee64d05a66f351883b6ceec", size = 37232771, upload-time = "2026-03-31T18:29:01.324Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/31/b8/69f5565f1a280d032525878a86511eebed0645818492feeb169dfb20ae8e/llvmlite-0.47.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2699a74321189e812d476a43d6d7f652f51811e7b5aad9d9bba842a1c7927acb", size = 56275178, upload-time = "2026-03-31T18:29:05.748Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/da/b32cafcb926fb0ce2aa25553bf32cb8764af31438f40e2481df08884c947/llvmlite-0.47.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c6951e2b29930227963e53ee152441f0e14be92e9d4231852102d986c761e40", size = 55128632, upload-time = "2026-03-31T18:29:11.235Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/9f/4898b44e4042c60fafcb1162dfb7014f6f15b1ec19bf29cfea6bf26df90d/llvmlite-0.47.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2e9adf8698d813a9a5efb2d4370caf344dbc1e145019851fee6a6f319ba760e", size = 38138695, upload-time = "2026-03-31T18:29:15.43Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1c/d4/33c8af00f0bf6f552d74f3a054f648af2c5bc6bece97972f3bfadce4f5ec/llvmlite-0.47.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:de966c626c35c9dff5ae7bf12db25637738d0df83fc370cf793bc94d43d92d14", size = 37232773, upload-time = "2026-03-31T18:29:19.453Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/1d/a760e993e0c0ba6db38d46b9f48f6c7dceb8ac838824997fb9e25f97bc04/llvmlite-0.47.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ddbccff2aeaff8670368340a158abefc032fe9b3ccf7d9c496639263d00151aa", size = 56275176, upload-time = "2026-03-31T18:29:24.149Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/3b/e679bc3b29127182a7f4aa2d2e9e5bea42adb93fb840484147d59c236299/llvmlite-0.47.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a7b778a2e144fc64468fb9bf509ac1226c9813a00b4d7afea5d988c4e22fca", size = 55128631, upload-time = "2026-03-31T18:29:29.536Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/f7/19e2a09c62809c9e63bbd14ce71fb92c6ff7b7b3045741bb00c781efc3c9/llvmlite-0.47.0-cp314-cp314-win_amd64.whl", hash = "sha256:694e3c2cdc472ed2bd8bd4555ca002eec4310961dd58ef791d508f57b5cc4c94", size = 39153826, upload-time = "2026-03-31T18:29:33.681Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/a1/581a8c707b5e80efdbbe1dd94527404d33fe50bceb71f39d5a7e11bd57b7/llvmlite-0.47.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:92ec8a169a20b473c1c54d4695e371bde36489fc1efa3688e11e99beba0abf9c", size = 37232772, upload-time = "2026-03-31T18:29:37.952Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/11/03/16090dd6f74ba2b8b922276047f15962fbeea0a75d5601607edb301ba945/llvmlite-0.47.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbd800edd3b20bc141521f7fd45a6185a5b84109aa6855134e81397ffe72b", size = 56275178, upload-time = "2026-03-31T18:29:42.58Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/cb/0abf1dd4c5286a95ffe0c1d8c67aec06b515894a0dd2ac97f5e27b82ab0b/llvmlite-0.47.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6725179b89f03b17dabe236ff3422cb8291b4c1bf40af152826dfd34e350ae8", size = 55128632, upload-time = "2026-03-31T18:29:46.939Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4f/79/d3bbab197e86e0ff4f9c07122895b66a3e0d024247fcff7f12c473cb36d9/llvmlite-0.47.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6842cf6f707ec4be3d985a385ad03f72b2d724439e118fcbe99b2929964f0453", size = 39153839, upload-time = "2026-03-31T18:29:51.004Z" }, + { url = "https://files.pythonhosted.org/packages/34/0b/b9d1911cfefa61399821dfb37f486d83e0f42630a8d12f7194270c417002/llvmlite-0.47.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:74090f0dcfd6f24ebbef3f21f11e38111c4d7e6919b54c4416e1e357c3446b07", size = 37232770, upload-time = "2026-03-31T18:28:26.765Z" }, + { url = "https://files.pythonhosted.org/packages/46/27/5799b020e4cdfb25a7c951c06a96397c135efcdc21b78d853bbd9c814c7d/llvmlite-0.47.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ca14f02e29134e837982497959a8e2193d6035235de1cb41a9cb2bd6da4eedbb", size = 56275177, upload-time = "2026-03-31T18:28:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/7e/51/48a53fedf01cb1f3f43ef200be17ebf83c8d9a04018d3783c1a226c342c2/llvmlite-0.47.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12a69d4bb05f402f30477e21eeabe81911e7c251cecb192bed82cd83c9db10d8", size = 55128631, upload-time = "2026-03-31T18:28:36.046Z" }, + { url = "https://files.pythonhosted.org/packages/a2/50/59227d06bdc96e23322713c381af4e77420949d8cd8a042c79e0043096cc/llvmlite-0.47.0-cp311-cp311-win_amd64.whl", hash = "sha256:c37d6eb7aaabfa83ab9c2ff5b5cdb95a5e6830403937b2c588b7490724e05327", size = 38138400, upload-time = "2026-03-31T18:28:40.076Z" }, + { url = "https://files.pythonhosted.org/packages/fa/48/4b7fe0e34c169fa2f12532916133e0b219d2823b540733651b34fdac509a/llvmlite-0.47.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:306a265f408c259067257a732c8e159284334018b4083a9e35f67d19792b164f", size = 37232769, upload-time = "2026-03-31T18:28:43.735Z" }, + { url = "https://files.pythonhosted.org/packages/e6/4b/e3f2cd17822cf772a4a51a0a8080b0032e6d37b2dbe8cfb724eac4e31c52/llvmlite-0.47.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5853bf26160857c0c2573415ff4efe01c4c651e59e2c55c2a088740acfee51cd", size = 56275178, upload-time = "2026-03-31T18:28:48.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a3b4a543185305a9bdf3d9759d53646ed96e55e7dfd43f53e7a421b8fbae/llvmlite-0.47.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:003bcf7fa579e14db59c1a1e113f93ab8a06b56a4be31c7f08264d1d4072d077", size = 55128632, upload-time = "2026-03-31T18:28:52.901Z" }, + { url = "https://files.pythonhosted.org/packages/2f/f5/d281ae0f79378a5a91f308ea9fdb9f9cc068fddd09629edc0725a5a8fde1/llvmlite-0.47.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3079f25bdc24cd9d27c4b2b5e68f5f60c4fdb7e8ad5ee2b9b006007558f9df7", size = 38138692, upload-time = "2026-03-31T18:28:57.147Z" }, + { url = "https://files.pythonhosted.org/packages/77/6f/4615353e016799f80fa52ccb270a843c413b22361fadda2589b2922fb9b0/llvmlite-0.47.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a3c6a735d4e1041808434f9d440faa3d78d9b4af2ee64d05a66f351883b6ceec", size = 37232771, upload-time = "2026-03-31T18:29:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/31/b8/69f5565f1a280d032525878a86511eebed0645818492feeb169dfb20ae8e/llvmlite-0.47.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2699a74321189e812d476a43d6d7f652f51811e7b5aad9d9bba842a1c7927acb", size = 56275178, upload-time = "2026-03-31T18:29:05.748Z" }, + { url = "https://files.pythonhosted.org/packages/d6/da/b32cafcb926fb0ce2aa25553bf32cb8764af31438f40e2481df08884c947/llvmlite-0.47.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c6951e2b29930227963e53ee152441f0e14be92e9d4231852102d986c761e40", size = 55128632, upload-time = "2026-03-31T18:29:11.235Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/4898b44e4042c60fafcb1162dfb7014f6f15b1ec19bf29cfea6bf26df90d/llvmlite-0.47.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2e9adf8698d813a9a5efb2d4370caf344dbc1e145019851fee6a6f319ba760e", size = 38138695, upload-time = "2026-03-31T18:29:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d4/33c8af00f0bf6f552d74f3a054f648af2c5bc6bece97972f3bfadce4f5ec/llvmlite-0.47.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:de966c626c35c9dff5ae7bf12db25637738d0df83fc370cf793bc94d43d92d14", size = 37232773, upload-time = "2026-03-31T18:29:19.453Z" }, + { url = "https://files.pythonhosted.org/packages/64/1d/a760e993e0c0ba6db38d46b9f48f6c7dceb8ac838824997fb9e25f97bc04/llvmlite-0.47.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ddbccff2aeaff8670368340a158abefc032fe9b3ccf7d9c496639263d00151aa", size = 56275176, upload-time = "2026-03-31T18:29:24.149Z" }, + { url = "https://files.pythonhosted.org/packages/84/3b/e679bc3b29127182a7f4aa2d2e9e5bea42adb93fb840484147d59c236299/llvmlite-0.47.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a7b778a2e144fc64468fb9bf509ac1226c9813a00b4d7afea5d988c4e22fca", size = 55128631, upload-time = "2026-03-31T18:29:29.536Z" }, + { url = "https://files.pythonhosted.org/packages/be/f7/19e2a09c62809c9e63bbd14ce71fb92c6ff7b7b3045741bb00c781efc3c9/llvmlite-0.47.0-cp314-cp314-win_amd64.whl", hash = "sha256:694e3c2cdc472ed2bd8bd4555ca002eec4310961dd58ef791d508f57b5cc4c94", size = 39153826, upload-time = "2026-03-31T18:29:33.681Z" }, + { url = "https://files.pythonhosted.org/packages/40/a1/581a8c707b5e80efdbbe1dd94527404d33fe50bceb71f39d5a7e11bd57b7/llvmlite-0.47.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:92ec8a169a20b473c1c54d4695e371bde36489fc1efa3688e11e99beba0abf9c", size = 37232772, upload-time = "2026-03-31T18:29:37.952Z" }, + { url = "https://files.pythonhosted.org/packages/11/03/16090dd6f74ba2b8b922276047f15962fbeea0a75d5601607edb301ba945/llvmlite-0.47.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbd800edd3b20bc141521f7fd45a6185a5b84109aa6855134e81397ffe72b", size = 56275178, upload-time = "2026-03-31T18:29:42.58Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cb/0abf1dd4c5286a95ffe0c1d8c67aec06b515894a0dd2ac97f5e27b82ab0b/llvmlite-0.47.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6725179b89f03b17dabe236ff3422cb8291b4c1bf40af152826dfd34e350ae8", size = 55128632, upload-time = "2026-03-31T18:29:46.939Z" }, + { url = "https://files.pythonhosted.org/packages/4f/79/d3bbab197e86e0ff4f9c07122895b66a3e0d024247fcff7f12c473cb36d9/llvmlite-0.47.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6842cf6f707ec4be3d985a385ad03f72b2d724439e118fcbe99b2929964f0453", size = 39153839, upload-time = "2026-03-31T18:29:51.004Z" }, ] [[package]] name = "matplotlib" version = "3.10.9" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "contourpy" }, { name = "cycler" }, @@ -1043,71 +1043,71 @@ dependencies = [ { name = "pyparsing" }, { name = "python-dateutil" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } +sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4c/8c/290f021104741fea63769c31494f5324c0cd249bf536a65a4350767b1f22/matplotlib-3.10.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:68cfdcede415f7c8f5577b03303dd94526cdb6d11036cecdc205e08733b2d2bb", size = 8306860, upload-time = "2026-04-24T00:12:01.207Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/51/18/325cd32ece1120d1da51cc4e4294c6580190699490183fc2fe8cb6d61ec5/matplotlib-3.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfca0129678bd56379db26c52b5d77ed7de314c047492fbdc763aa7501710cfb", size = 8199254, upload-time = "2026-04-24T00:12:04.239Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/79/db/e28c1b83e3680740aa78925f5fb2ae4d16207207419ad75ea9fe604f8676/matplotlib-3.10.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e436d155fa8a3399dc62683f8f5d0e2e50d25d0144a73edd73f82eec8f4abfb", size = 8777092, upload-time = "2026-04-24T00:12:06.793Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/55/fa/3ce7adfe9ba101748f465211660d9c6374c876b671bdb8c2bb6d347e8b94/matplotlib-3.10.9-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56fc0bd271b00025c6edfdc7c2dcd247372c8e1544971d62e1dc7c17367e8bf9", size = 9595691, upload-time = "2026-04-24T00:12:09.706Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/c4/6960a76686ed668f2c60f84e9799ba4c0d56abdb36b1577b60c1d061d1ec/matplotlib-3.10.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5a6104ed666402ba5106d7f36e0e0cdca4e8d7fa4d39708ca88019e2835a2eb", size = 9659771, upload-time = "2026-04-24T00:12:12.766Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/0d/271aace3342157c64700c9ff4c59c7b392f3dbab393692e8db6fbe7ab96c/matplotlib-3.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:d730e984eddf56974c3e72b6129c7ca462ac38dc624338f4b0b23eb23ecba00f", size = 8205112, upload-time = "2026-04-24T00:12:15.773Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e2/ee/cb57ad4754f3e7b9174ce6ce66d9205fb827067e48a9f58ac09d7e7d6b77/matplotlib-3.10.9-cp311-cp311-win_arm64.whl", hash = "sha256:51bf0ddbdc598e060d46c16b5590708f81a1624cefbaaf62f6a81bf9285b8c80", size = 8132310, upload-time = "2026-04-24T00:12:18.645Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/35/c6/5581e26c72233ebb2a2a6fed2d24fb7c66b4700120b813f51b0555acf0b6/matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1", size = 8319908, upload-time = "2026-04-24T00:12:21.323Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320", size = 8216016, upload-time = "2026-04-24T00:12:23.4Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", size = 8789336, upload-time = "2026-04-24T00:12:26.096Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5c/04/030a2f61ef2158f5e4c259487a92ac877732499fb33d871585d89e03c42d/matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2", size = 9604602, upload-time = "2026-04-24T00:12:29.052Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/c2/541e4d09d87bb6b5830fc28b4c887a9a8cf4e1c6cee698a8c05552ae2003/matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", size = 9670966, upload-time = "2026-04-24T00:12:32.131Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6", size = 8217462, upload-time = "2026-04-24T00:12:35.226Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/d0/2269edb12aa30c13c8bcc9382892e39943ce1d28aab4ec296e0381798e81/matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42", size = 8136688, upload-time = "2026-04-24T00:12:37.442Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/d3/8d4f6afbecb49fc04e060a57c0fce39ea51cc163a6bd87303ccd698e4fa6/matplotlib-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f", size = 8320331, upload-time = "2026-04-24T00:12:39.688Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/d9/9e14bc7564bf92d5ffa801ae5fac819ce74b925dfb55e3ebde61a3bbad3e/matplotlib-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e", size = 8216461, upload-time = "2026-04-24T00:12:42.494Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/17/4402d0d14ccf1dfc70932600b68097fbbf9c898a4871d2cbbe79c7801a32/matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f", size = 8790091, upload-time = "2026-04-24T00:12:44.789Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/0b/322aeec06dd9b91411f92028b37d447342770a24392aa4813e317064dad5/matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838", size = 9605027, upload-time = "2026-04-24T00:12:47.583Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/88/5f13482f55e7b00bcfc09838b093c2456e1379978d2a146844aae05350ad/matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2", size = 9671269, upload-time = "2026-04-24T00:12:50.878Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/e0/0840fd2f93da988ec660b8ad1984abe9f25d2aed22a5e394ff1c68c88307/matplotlib-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921", size = 8217588, upload-time = "2026-04-24T00:12:53.784Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/b9/d706d06dd605c49b9f83a2aed8c13e3e5db70697d7a80b7e3d7915de6b17/matplotlib-3.10.9-cp313-cp313-win_arm64.whl", hash = "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8", size = 8136913, upload-time = "2026-04-24T00:12:56.501Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9b/45/6e32d96978264c8ca8c4b1010adb955a1a49cfaf314e212bbc8908f04a61/matplotlib-3.10.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9", size = 8368019, upload-time = "2026-04-24T00:12:58.896Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/0a/c8e3d3bba245f0f7fc424937f8ff7ef77291a36af3edb97ccd78aa93d84f/matplotlib-3.10.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4", size = 8264645, upload-time = "2026-04-24T00:13:01.406Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/aa/5bf5a14fe4fed73a4209a155606f8096ff797aad89c6c35179026571133e/matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc", size = 8802194, upload-time = "2026-04-24T00:13:03.702Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dd/5e/b4be852d6bba6fd15893fadf91ff26ae49cb91aac789e95dde9d342e664f/matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99", size = 9622684, upload-time = "2026-04-24T00:13:06.647Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4c/3d/ed428c971139112ef730f62770654d609467346d09d4b62617e1afd68a5a/matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d", size = 9680790, upload-time = "2026-04-24T00:13:10.009Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/09/052e884aaf2b985c63cb79f715f1d5b6a3eaa7de78f6a52b9dbc077d5b53/matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8", size = 8287571, upload-time = "2026-04-24T00:13:13.087Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/38/ae27288e788c35a4250491422f3db7750366fc8c97d6f36fbdecfc1f5518/matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38", size = 8188292, upload-time = "2026-04-24T00:13:15.546Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/e6/3bd8afd04949f02eabc1c17115ea5255e19cacd4d06fc5abdde4eeb0052c/matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d", size = 8321276, upload-time = "2026-04-24T00:13:18.318Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f", size = 8218218, upload-time = "2026-04-24T00:13:20.974Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/85/8f/becc9722cafc64f5d2eb0b7c1bf5f585271c618a45dbd8fabeb021f898b6/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b", size = 9608145, upload-time = "2026-04-24T00:13:23.228Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2", size = 9885085, upload-time = "2026-04-24T00:13:25.849Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a5/fd/fa69f2221534e80cc5772ac2b7d222011a2acafc2ec7216d5dd174c864ae/matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716", size = 9672358, upload-time = "2026-04-24T00:13:28.906Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f", size = 8349970, upload-time = "2026-04-24T00:13:31.904Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/dc/95d60ecaefe30680a154b52ea96ab4b0dab547f1fd6aa12f5fb655e89cae/matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456", size = 8272785, upload-time = "2026-04-24T00:13:34.511Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/a0/005d68bc8b8418300ce6591f18586910a8526806e2ab663933d9f20a41e9/matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe", size = 8367999, upload-time = "2026-04-24T00:13:36.962Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/22/05/1236cc9290be70b2498af20ca348add76e3fffe7f67b477db5133a84f3ea/matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6", size = 8264543, upload-time = "2026-04-24T00:13:39.851Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cd/c2/071f5a5ff6c5bd63aaaf2f45c811d9bf2ced94bde188d9e1a519e21d0cba/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c", size = 9622800, upload-time = "2026-04-24T00:13:42.296Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/57/da7d1f10a85624b9e7db68e069dd94e58dc41dbf9463c5921632ecbe3661/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4", size = 9888561, upload-time = "2026-04-24T00:13:45.026Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/b2/ef8d6bb59b0edb6c16c968b70f548aa13b54348972def5aa6ac85df67145/matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", size = 9680884, upload-time = "2026-04-24T00:13:48.066Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/61/1c/d21bfeb9931881ebe96bcfcff27c7ae4b160ae0ec291a714c42641a56d75/matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39", size = 8432333, upload-time = "2026-04-24T00:13:51.008Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785, upload-time = "2026-04-24T00:13:53.633Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/e2/9f66ca6a651a52abfe0d4964ce01439ed34f3f1e119de10ff3a07f403043/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:42fb814efabe95c06c1994d8ab5a8385f43a249e23badd3ba931d4308e5bca20", size = 8304420, upload-time = "2026-04-24T00:14:04.57Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/e8/467c03568218792906aa87b5e7bb379b605e056ed0c74fe00c051786d925/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f76e640a5268850bfda54b5131b1b1941cc685e42c5fa98ed9f2d64038308cba", size = 8197981, upload-time = "2026-04-24T00:14:07.233Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6f/87/afead29192170917537934c6aff4b008c805fff7b1ccea0c79120d96beda/matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4", size = 8774002, upload-time = "2026-04-24T00:14:09.816Z" }, + { url = "https://files.pythonhosted.org/packages/4c/8c/290f021104741fea63769c31494f5324c0cd249bf536a65a4350767b1f22/matplotlib-3.10.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:68cfdcede415f7c8f5577b03303dd94526cdb6d11036cecdc205e08733b2d2bb", size = 8306860, upload-time = "2026-04-24T00:12:01.207Z" }, + { url = "https://files.pythonhosted.org/packages/51/18/325cd32ece1120d1da51cc4e4294c6580190699490183fc2fe8cb6d61ec5/matplotlib-3.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfca0129678bd56379db26c52b5d77ed7de314c047492fbdc763aa7501710cfb", size = 8199254, upload-time = "2026-04-24T00:12:04.239Z" }, + { url = "https://files.pythonhosted.org/packages/79/db/e28c1b83e3680740aa78925f5fb2ae4d16207207419ad75ea9fe604f8676/matplotlib-3.10.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e436d155fa8a3399dc62683f8f5d0e2e50d25d0144a73edd73f82eec8f4abfb", size = 8777092, upload-time = "2026-04-24T00:12:06.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/fa/3ce7adfe9ba101748f465211660d9c6374c876b671bdb8c2bb6d347e8b94/matplotlib-3.10.9-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56fc0bd271b00025c6edfdc7c2dcd247372c8e1544971d62e1dc7c17367e8bf9", size = 9595691, upload-time = "2026-04-24T00:12:09.706Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/6960a76686ed668f2c60f84e9799ba4c0d56abdb36b1577b60c1d061d1ec/matplotlib-3.10.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5a6104ed666402ba5106d7f36e0e0cdca4e8d7fa4d39708ca88019e2835a2eb", size = 9659771, upload-time = "2026-04-24T00:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0d/271aace3342157c64700c9ff4c59c7b392f3dbab393692e8db6fbe7ab96c/matplotlib-3.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:d730e984eddf56974c3e72b6129c7ca462ac38dc624338f4b0b23eb23ecba00f", size = 8205112, upload-time = "2026-04-24T00:12:15.773Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ee/cb57ad4754f3e7b9174ce6ce66d9205fb827067e48a9f58ac09d7e7d6b77/matplotlib-3.10.9-cp311-cp311-win_arm64.whl", hash = "sha256:51bf0ddbdc598e060d46c16b5590708f81a1624cefbaaf62f6a81bf9285b8c80", size = 8132310, upload-time = "2026-04-24T00:12:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/35/c6/5581e26c72233ebb2a2a6fed2d24fb7c66b4700120b813f51b0555acf0b6/matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1", size = 8319908, upload-time = "2026-04-24T00:12:21.323Z" }, + { url = "https://files.pythonhosted.org/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320", size = 8216016, upload-time = "2026-04-24T00:12:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", size = 8789336, upload-time = "2026-04-24T00:12:26.096Z" }, + { url = "https://files.pythonhosted.org/packages/5c/04/030a2f61ef2158f5e4c259487a92ac877732499fb33d871585d89e03c42d/matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2", size = 9604602, upload-time = "2026-04-24T00:12:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/541e4d09d87bb6b5830fc28b4c887a9a8cf4e1c6cee698a8c05552ae2003/matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", size = 9670966, upload-time = "2026-04-24T00:12:32.131Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6", size = 8217462, upload-time = "2026-04-24T00:12:35.226Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d0/2269edb12aa30c13c8bcc9382892e39943ce1d28aab4ec296e0381798e81/matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42", size = 8136688, upload-time = "2026-04-24T00:12:37.442Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d3/8d4f6afbecb49fc04e060a57c0fce39ea51cc163a6bd87303ccd698e4fa6/matplotlib-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f", size = 8320331, upload-time = "2026-04-24T00:12:39.688Z" }, + { url = "https://files.pythonhosted.org/packages/63/d9/9e14bc7564bf92d5ffa801ae5fac819ce74b925dfb55e3ebde61a3bbad3e/matplotlib-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e", size = 8216461, upload-time = "2026-04-24T00:12:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/8a/17/4402d0d14ccf1dfc70932600b68097fbbf9c898a4871d2cbbe79c7801a32/matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f", size = 8790091, upload-time = "2026-04-24T00:12:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0b/322aeec06dd9b91411f92028b37d447342770a24392aa4813e317064dad5/matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838", size = 9605027, upload-time = "2026-04-24T00:12:47.583Z" }, + { url = "https://files.pythonhosted.org/packages/74/88/5f13482f55e7b00bcfc09838b093c2456e1379978d2a146844aae05350ad/matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2", size = 9671269, upload-time = "2026-04-24T00:12:50.878Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/0840fd2f93da988ec660b8ad1984abe9f25d2aed22a5e394ff1c68c88307/matplotlib-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921", size = 8217588, upload-time = "2026-04-24T00:12:53.784Z" }, + { url = "https://files.pythonhosted.org/packages/47/b9/d706d06dd605c49b9f83a2aed8c13e3e5db70697d7a80b7e3d7915de6b17/matplotlib-3.10.9-cp313-cp313-win_arm64.whl", hash = "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8", size = 8136913, upload-time = "2026-04-24T00:12:56.501Z" }, + { url = "https://files.pythonhosted.org/packages/9b/45/6e32d96978264c8ca8c4b1010adb955a1a49cfaf314e212bbc8908f04a61/matplotlib-3.10.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9", size = 8368019, upload-time = "2026-04-24T00:12:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/0a/c8e3d3bba245f0f7fc424937f8ff7ef77291a36af3edb97ccd78aa93d84f/matplotlib-3.10.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4", size = 8264645, upload-time = "2026-04-24T00:13:01.406Z" }, + { url = "https://files.pythonhosted.org/packages/3d/aa/5bf5a14fe4fed73a4209a155606f8096ff797aad89c6c35179026571133e/matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc", size = 8802194, upload-time = "2026-04-24T00:13:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5e/b4be852d6bba6fd15893fadf91ff26ae49cb91aac789e95dde9d342e664f/matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99", size = 9622684, upload-time = "2026-04-24T00:13:06.647Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/ed428c971139112ef730f62770654d609467346d09d4b62617e1afd68a5a/matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d", size = 9680790, upload-time = "2026-04-24T00:13:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/e7/09/052e884aaf2b985c63cb79f715f1d5b6a3eaa7de78f6a52b9dbc077d5b53/matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8", size = 8287571, upload-time = "2026-04-24T00:13:13.087Z" }, + { url = "https://files.pythonhosted.org/packages/f4/38/ae27288e788c35a4250491422f3db7750366fc8c97d6f36fbdecfc1f5518/matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38", size = 8188292, upload-time = "2026-04-24T00:13:15.546Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e6/3bd8afd04949f02eabc1c17115ea5255e19cacd4d06fc5abdde4eeb0052c/matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d", size = 8321276, upload-time = "2026-04-24T00:13:18.318Z" }, + { url = "https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f", size = 8218218, upload-time = "2026-04-24T00:13:20.974Z" }, + { url = "https://files.pythonhosted.org/packages/85/8f/becc9722cafc64f5d2eb0b7c1bf5f585271c618a45dbd8fabeb021f898b6/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b", size = 9608145, upload-time = "2026-04-24T00:13:23.228Z" }, + { url = "https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2", size = 9885085, upload-time = "2026-04-24T00:13:25.849Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fd/fa69f2221534e80cc5772ac2b7d222011a2acafc2ec7216d5dd174c864ae/matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716", size = 9672358, upload-time = "2026-04-24T00:13:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f", size = 8349970, upload-time = "2026-04-24T00:13:31.904Z" }, + { url = "https://files.pythonhosted.org/packages/64/dc/95d60ecaefe30680a154b52ea96ab4b0dab547f1fd6aa12f5fb655e89cae/matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456", size = 8272785, upload-time = "2026-04-24T00:13:34.511Z" }, + { url = "https://files.pythonhosted.org/packages/70/a0/005d68bc8b8418300ce6591f18586910a8526806e2ab663933d9f20a41e9/matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe", size = 8367999, upload-time = "2026-04-24T00:13:36.962Z" }, + { url = "https://files.pythonhosted.org/packages/22/05/1236cc9290be70b2498af20ca348add76e3fffe7f67b477db5133a84f3ea/matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6", size = 8264543, upload-time = "2026-04-24T00:13:39.851Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c2/071f5a5ff6c5bd63aaaf2f45c811d9bf2ced94bde188d9e1a519e21d0cba/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c", size = 9622800, upload-time = "2026-04-24T00:13:42.296Z" }, + { url = "https://files.pythonhosted.org/packages/95/57/da7d1f10a85624b9e7db68e069dd94e58dc41dbf9463c5921632ecbe3661/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4", size = 9888561, upload-time = "2026-04-24T00:13:45.026Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/ef8d6bb59b0edb6c16c968b70f548aa13b54348972def5aa6ac85df67145/matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", size = 9680884, upload-time = "2026-04-24T00:13:48.066Z" }, + { url = "https://files.pythonhosted.org/packages/61/1c/d21bfeb9931881ebe96bcfcff27c7ae4b160ae0ec291a714c42641a56d75/matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39", size = 8432333, upload-time = "2026-04-24T00:13:51.008Z" }, + { url = "https://files.pythonhosted.org/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785, upload-time = "2026-04-24T00:13:53.633Z" }, + { url = "https://files.pythonhosted.org/packages/63/e2/9f66ca6a651a52abfe0d4964ce01439ed34f3f1e119de10ff3a07f403043/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:42fb814efabe95c06c1994d8ab5a8385f43a249e23badd3ba931d4308e5bca20", size = 8304420, upload-time = "2026-04-24T00:14:04.57Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e8/467c03568218792906aa87b5e7bb379b605e056ed0c74fe00c051786d925/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f76e640a5268850bfda54b5131b1b1941cc685e42c5fa98ed9f2d64038308cba", size = 8197981, upload-time = "2026-04-24T00:14:07.233Z" }, + { url = "https://files.pythonhosted.org/packages/6f/87/afead29192170917537934c6aff4b008c805fff7b1ccea0c79120d96beda/matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4", size = 8774002, upload-time = "2026-04-24T00:14:09.816Z" }, ] [[package]] name = "matplotlib-inline" version = "0.2.2" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "traitlets" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, ] [[package]] name = "mypy" version = "2.1.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ast-serialize" }, { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, @@ -1115,179 +1115,179 @@ dependencies = [ { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, + { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, + { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, ] [[package]] name = "mypy-extensions" version = "1.1.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] name = "narwhals" version = "2.21.2" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cf/a0/6198c56d42ef2f3c6ed0c42ba30dbcefdc86a91262d7d449010770ae085b/narwhals-2.21.2.tar.gz", hash = "sha256:5c5b2d0b47aef7c73ea412cfcbcd467f2f2d5be73e3c2ab19d78f4a97718790a", size = 632176, upload-time = "2026-05-16T08:49:08.314Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/a0/6198c56d42ef2f3c6ed0c42ba30dbcefdc86a91262d7d449010770ae085b/narwhals-2.21.2.tar.gz", hash = "sha256:5c5b2d0b47aef7c73ea412cfcbcd467f2f2d5be73e3c2ab19d78f4a97718790a", size = 632176, upload-time = "2026-05-16T08:49:08.314Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1d/77/928ea2e70641ca177a11140062cc5840d421795f2e82749d408d0cce900a/narwhals-2.21.2-py3-none-any.whl", hash = "sha256:7bb57c3700486039215455b9bf2d64261915cc0fd845cc30272d631df696b251", size = 451201, upload-time = "2026-05-16T08:49:05.536Z" }, + { url = "https://files.pythonhosted.org/packages/1d/77/928ea2e70641ca177a11140062cc5840d421795f2e82749d408d0cce900a/narwhals-2.21.2-py3-none-any.whl", hash = "sha256:7bb57c3700486039215455b9bf2d64261915cc0fd845cc30272d631df696b251", size = 451201, upload-time = "2026-05-16T08:49:05.536Z" }, ] [[package]] name = "numba" version = "0.65.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "llvmlite" }, { name = "numpy" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz", hash = "sha256:19357146c32fe9ed25059ab915e8465fb13951cf6b0aace3826b76886373ab23", size = 2765600, upload-time = "2026-04-24T02:02:56.551Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz", hash = "sha256:19357146c32fe9ed25059ab915e8465fb13951cf6b0aace3826b76886373ab23", size = 2765600, upload-time = "2026-04-24T02:02:56.551Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/96/b3/650500c2eab4534d98e9166f4298e0f3c69c742afdf24e6eabccd1f16ad8/numba-0.65.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:7020d74b19cdb8cff16506542fdd510756e28c5e7f3bd0b7f574f0f42272fcd9", size = 2680563, upload-time = "2026-04-24T02:02:18.414Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/44/0b/0615dbedb98f5b32a35a53290fbdc6e22306968109278d7e58df82d7a9f6/numba-0.65.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f80ed83774b5173abd6581cd8d2165d1d38e13d2e5c8155c0c0b421784745420", size = 3745018, upload-time = "2026-04-24T02:02:20.252Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/aa/4361698f35bf63bff67dfe6c90493731177f48ede954f77b0588731537bc/numba-0.65.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ed425a43b0a5f9772f2f4e2dd0bbd12eabecae1af0b24efcfd4e053f012aac6", size = 3450962, upload-time = "2026-04-24T02:02:22.449Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/9a/af61ec03b3116c161fd7a06b9e8a265729a8718458333e8ffbb06d9a3978/numba-0.65.1-cp311-cp311-win_amd64.whl", hash = "sha256:df40a5028a975b9ea66f6a2a3f7abbdbd541a863070e34ed367aff21141248e4", size = 2747417, upload-time = "2026-04-24T02:02:24.43Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/bc/76f8f8c5cf9adee47fdb7bbb03be8900f76f902d451d7477cf12b845e1de/numba-0.65.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ac3f1e77c352dd0ea9712732c2d8f9ca507717435eec5b5013bf138ac33c4a08", size = 2681371, upload-time = "2026-04-24T02:02:26.105Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/47/a415af0283e4db0398104c6d1c11c9861a98dc67a7aa442a7769ed5d6196/numba-0.65.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:52bc6f3ceb8fcaff9b2ae26b4c6b1e9fee39db8d355534c0fe4f39a901246b84", size = 3802467, upload-time = "2026-04-24T02:02:27.712Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/36/246f73ec99cfeab2f2cb2ce7d4218766cc36a2da418901223f4f4da9c813/numba-0.65.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90ca10b3463bae0bd70589726fe3c77d01d6b5fc86bee54bcdf9fb6b47c28977", size = 3502628, upload-time = "2026-04-24T02:02:29.763Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/db/9e/3c679b2ee078425b9e99a91e44f8d132a6830d8ccce5227bc5e9181aeed8/numba-0.65.1-cp312-cp312-win_amd64.whl", hash = "sha256:5971c632be2a2351500431f46213821dba8d02b18a9f7d02fd36bd2743e41a6a", size = 2750611, upload-time = "2026-04-24T02:02:31.477Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/79/37/14a4579049c1eb673afd0de0cb4842982acd55b9ce2643e763db858bcea0/numba-0.65.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1735c15c1134a5108b4d6a5c77fc0947924ea066a738dc09a52008c13df9cad3", size = 2681344, upload-time = "2026-04-24T02:02:33.65Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/22/b8d873f6466b20aa563fc9b33acd48dec89a07803ddaa2f1c8ca1cd33126/numba-0.65.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c09f49117ef255e1f1c6dad0c7a1ed39868243862a73be5706793241a3755f1b", size = 3810619, upload-time = "2026-04-24T02:02:36.041Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/08/e16a8b5d9a018962ebb5c66be662317cde32b9f5dab08441f90bed5522fb/numba-0.65.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:594a8680b3fadac99e97e489b1fd89007177e5336713745c3b769528c635a464", size = 3509783, upload-time = "2026-04-24T02:02:38.245Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/a5/03c970d57f4c1741354837353ce39fb5206952ae1dba8922d29c86f64805/numba-0.65.1-cp313-cp313-win_amd64.whl", hash = "sha256:85be74c0d036842699a30058f82fb88fc5ffdc59f7615cab5792ea92914c9b62", size = 2750534, upload-time = "2026-04-24T02:02:39.903Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4f/2e/8aed9b726d9ba5f11ad287645fd479e88278db3060a25cb1225d730eb2b7/numba-0.65.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:33f5eb68eb1c843511615d14663ce60258525d6a4c65ab040e2c2b0c4cf17450", size = 2681554, upload-time = "2026-04-24T02:02:41.812Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/87/96/f3eb235fafa82a34e2ab5dd7dc9ffff998ebf5f0bbc23fa56a96aeb44da6/numba-0.65.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71e73029bf53a62cc6afcf96be4bd942290d8b4c55f0a454fb536158115790f7", size = 3779602, upload-time = "2026-04-24T02:02:43.726Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/09/90/b0f09b48752d23640b8284f22aa597737e8adaddc7fbfacc4708b7f73a4c/numba-0.65.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a07635e0be926b9bdbffb09137c230fb13f6ec0e564914ba937cee12ce3eb35", size = 3479532, upload-time = "2026-04-24T02:02:45.427Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/56/46/3f7fc04fb853559e74b210e0b62c19974ec844cefec611f9e535f4da3761/numba-0.65.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a20fcdabdefbdacf88d85caf70c3b18c4bcb7ebb8f82e6a19486383dd26ab63", size = 2752637, upload-time = "2026-04-24T02:02:47.664Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/81/7b/c1a341a9067367778f4152a5f01061cf281fb09582c92c510ec4918cabf6/numba-0.65.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:548dd4b3a4508d5062768d1514b2cd7b015f9a25ec7af651c50dee243965e652", size = 2684600, upload-time = "2026-04-24T02:02:49.653Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/03/36/98ddbcf3e4f04a6dd07e1c67249955920579ba4af6bb6868e3088f4ed282/numba-0.65.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:78abc28feff2c2ff8307fff3975b6438352759c9acb797ecd6b1fb6e7e39e31d", size = 3817198, upload-time = "2026-04-24T02:02:51.266Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/83/0dad21057ece5a835599f5d24099b091703995e23dbbf894f259e91c010b/numba-0.65.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee7676cb389555805f9b9a1840cbcd1ea6c8bd5376ab6918e3a29c5ea1dbda20", size = 3533862, upload-time = "2026-04-24T02:02:52.987Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/32/36/8be7118ffd4c8440881046eac3d0982cc5ab42909508cf5d67024d62a2e4/numba-0.65.1-cp314-cp314t-win_amd64.whl", hash = "sha256:20609346e3bd75204950dcbbfe383a8d7dbf4902f442aedbf00f97fef4aa8f38", size = 2758237, upload-time = "2026-04-24T02:02:54.612Z" }, + { url = "https://files.pythonhosted.org/packages/96/b3/650500c2eab4534d98e9166f4298e0f3c69c742afdf24e6eabccd1f16ad8/numba-0.65.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:7020d74b19cdb8cff16506542fdd510756e28c5e7f3bd0b7f574f0f42272fcd9", size = 2680563, upload-time = "2026-04-24T02:02:18.414Z" }, + { url = "https://files.pythonhosted.org/packages/44/0b/0615dbedb98f5b32a35a53290fbdc6e22306968109278d7e58df82d7a9f6/numba-0.65.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f80ed83774b5173abd6581cd8d2165d1d38e13d2e5c8155c0c0b421784745420", size = 3745018, upload-time = "2026-04-24T02:02:20.252Z" }, + { url = "https://files.pythonhosted.org/packages/49/aa/4361698f35bf63bff67dfe6c90493731177f48ede954f77b0588731537bc/numba-0.65.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ed425a43b0a5f9772f2f4e2dd0bbd12eabecae1af0b24efcfd4e053f012aac6", size = 3450962, upload-time = "2026-04-24T02:02:22.449Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9a/af61ec03b3116c161fd7a06b9e8a265729a8718458333e8ffbb06d9a3978/numba-0.65.1-cp311-cp311-win_amd64.whl", hash = "sha256:df40a5028a975b9ea66f6a2a3f7abbdbd541a863070e34ed367aff21141248e4", size = 2747417, upload-time = "2026-04-24T02:02:24.43Z" }, + { url = "https://files.pythonhosted.org/packages/57/bc/76f8f8c5cf9adee47fdb7bbb03be8900f76f902d451d7477cf12b845e1de/numba-0.65.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ac3f1e77c352dd0ea9712732c2d8f9ca507717435eec5b5013bf138ac33c4a08", size = 2681371, upload-time = "2026-04-24T02:02:26.105Z" }, + { url = "https://files.pythonhosted.org/packages/69/47/a415af0283e4db0398104c6d1c11c9861a98dc67a7aa442a7769ed5d6196/numba-0.65.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:52bc6f3ceb8fcaff9b2ae26b4c6b1e9fee39db8d355534c0fe4f39a901246b84", size = 3802467, upload-time = "2026-04-24T02:02:27.712Z" }, + { url = "https://files.pythonhosted.org/packages/46/36/246f73ec99cfeab2f2cb2ce7d4218766cc36a2da418901223f4f4da9c813/numba-0.65.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90ca10b3463bae0bd70589726fe3c77d01d6b5fc86bee54bcdf9fb6b47c28977", size = 3502628, upload-time = "2026-04-24T02:02:29.763Z" }, + { url = "https://files.pythonhosted.org/packages/db/9e/3c679b2ee078425b9e99a91e44f8d132a6830d8ccce5227bc5e9181aeed8/numba-0.65.1-cp312-cp312-win_amd64.whl", hash = "sha256:5971c632be2a2351500431f46213821dba8d02b18a9f7d02fd36bd2743e41a6a", size = 2750611, upload-time = "2026-04-24T02:02:31.477Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/14a4579049c1eb673afd0de0cb4842982acd55b9ce2643e763db858bcea0/numba-0.65.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1735c15c1134a5108b4d6a5c77fc0947924ea066a738dc09a52008c13df9cad3", size = 2681344, upload-time = "2026-04-24T02:02:33.65Z" }, + { url = "https://files.pythonhosted.org/packages/a0/22/b8d873f6466b20aa563fc9b33acd48dec89a07803ddaa2f1c8ca1cd33126/numba-0.65.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c09f49117ef255e1f1c6dad0c7a1ed39868243862a73be5706793241a3755f1b", size = 3810619, upload-time = "2026-04-24T02:02:36.041Z" }, + { url = "https://files.pythonhosted.org/packages/62/08/e16a8b5d9a018962ebb5c66be662317cde32b9f5dab08441f90bed5522fb/numba-0.65.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:594a8680b3fadac99e97e489b1fd89007177e5336713745c3b769528c635a464", size = 3509783, upload-time = "2026-04-24T02:02:38.245Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/03c970d57f4c1741354837353ce39fb5206952ae1dba8922d29c86f64805/numba-0.65.1-cp313-cp313-win_amd64.whl", hash = "sha256:85be74c0d036842699a30058f82fb88fc5ffdc59f7615cab5792ea92914c9b62", size = 2750534, upload-time = "2026-04-24T02:02:39.903Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2e/8aed9b726d9ba5f11ad287645fd479e88278db3060a25cb1225d730eb2b7/numba-0.65.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:33f5eb68eb1c843511615d14663ce60258525d6a4c65ab040e2c2b0c4cf17450", size = 2681554, upload-time = "2026-04-24T02:02:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/87/96/f3eb235fafa82a34e2ab5dd7dc9ffff998ebf5f0bbc23fa56a96aeb44da6/numba-0.65.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71e73029bf53a62cc6afcf96be4bd942290d8b4c55f0a454fb536158115790f7", size = 3779602, upload-time = "2026-04-24T02:02:43.726Z" }, + { url = "https://files.pythonhosted.org/packages/09/90/b0f09b48752d23640b8284f22aa597737e8adaddc7fbfacc4708b7f73a4c/numba-0.65.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a07635e0be926b9bdbffb09137c230fb13f6ec0e564914ba937cee12ce3eb35", size = 3479532, upload-time = "2026-04-24T02:02:45.427Z" }, + { url = "https://files.pythonhosted.org/packages/56/46/3f7fc04fb853559e74b210e0b62c19974ec844cefec611f9e535f4da3761/numba-0.65.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a20fcdabdefbdacf88d85caf70c3b18c4bcb7ebb8f82e6a19486383dd26ab63", size = 2752637, upload-time = "2026-04-24T02:02:47.664Z" }, + { url = "https://files.pythonhosted.org/packages/81/7b/c1a341a9067367778f4152a5f01061cf281fb09582c92c510ec4918cabf6/numba-0.65.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:548dd4b3a4508d5062768d1514b2cd7b015f9a25ec7af651c50dee243965e652", size = 2684600, upload-time = "2026-04-24T02:02:49.653Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/98ddbcf3e4f04a6dd07e1c67249955920579ba4af6bb6868e3088f4ed282/numba-0.65.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:78abc28feff2c2ff8307fff3975b6438352759c9acb797ecd6b1fb6e7e39e31d", size = 3817198, upload-time = "2026-04-24T02:02:51.266Z" }, + { url = "https://files.pythonhosted.org/packages/a3/83/0dad21057ece5a835599f5d24099b091703995e23dbbf894f259e91c010b/numba-0.65.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee7676cb389555805f9b9a1840cbcd1ea6c8bd5376ab6918e3a29c5ea1dbda20", size = 3533862, upload-time = "2026-04-24T02:02:52.987Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/8be7118ffd4c8440881046eac3d0982cc5ab42909508cf5d67024d62a2e4/numba-0.65.1-cp314-cp314t-win_amd64.whl", hash = "sha256:20609346e3bd75204950dcbbfe383a8d7dbf4902f442aedbf00f97fef4aa8f38", size = 2758237, upload-time = "2026-04-24T02:02:54.612Z" }, ] [[package]] name = "numpy" version = "2.4.6" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, ] [[package]] name = "openai" version = "2.38.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "distro" }, @@ -1298,247 +1298,247 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/12/cfa322c5f5dd8fa21aab9a7a8e979e7a11123800f86ca8d82eb68a83d213/openai-2.38.0.tar.gz", hash = "sha256:798694c6cf74145541fda94325b6f8f72d8e1fd0262cc137c8d728177a6a4ce3", size = 772764, upload-time = "2026-05-21T21:23:42.105Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/12/cfa322c5f5dd8fa21aab9a7a8e979e7a11123800f86ca8d82eb68a83d213/openai-2.38.0.tar.gz", hash = "sha256:798694c6cf74145541fda94325b6f8f72d8e1fd0262cc137c8d728177a6a4ce3", size = 772764, upload-time = "2026-05-21T21:23:42.105Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/bf/ccff9be562e24207716d04ef9dc931c76aff0c89a7265da43e2104d7fe06/openai-2.38.0-py3-none-any.whl", hash = "sha256:ec6661c57b2dcc47414a767e6e3335c7ed3d19c9696999283a3c82e95c756a3c", size = 1344910, upload-time = "2026-05-21T21:23:39.636Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/ccff9be562e24207716d04ef9dc931c76aff0c89a7265da43e2104d7fe06/openai-2.38.0-py3-none-any.whl", hash = "sha256:ec6661c57b2dcc47414a767e6e3335c7ed3d19c9696999283a3c82e95c756a3c", size = 1344910, upload-time = "2026-05-21T21:23:39.636Z" }, ] [[package]] name = "packaging" version = "26.2" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] name = "pandas" version = "3.0.3" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "python-dateutil" }, { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/16/b5c76b838fd9bf6ce84d3a53346b8874ec05c5f0040d75ef2c320100cd2a/pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98", size = 10338495, upload-time = "2026-05-11T18:52:11.558Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/b0/a4ffc4ae74d2d822200dcc46898987d8eb6032d1e2b219cae39da6f5cbcc/pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639", size = 9938250, upload-time = "2026-05-11T18:52:17.005Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558, upload-time = "2026-05-11T18:52:19.865Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611, upload-time = "2026-05-11T18:52:22.622Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670, upload-time = "2026-05-11T18:52:25.4Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708, upload-time = "2026-05-11T18:52:28.139Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/62/c321f13b5ba1819fc8dca456c7fce578da2dcfecff1abbf0eaddf8406c0f/pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea", size = 9907609, upload-time = "2026-05-11T18:52:30.982Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/53/85/1b7f563ebc6357c27233a02a96b589bcce1fa9c6eb89fb4f0e56421d277e/pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a", size = 9165596, upload-time = "2026-05-11T18:52:33.334Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, + { url = "https://files.pythonhosted.org/packages/42/16/b5c76b838fd9bf6ce84d3a53346b8874ec05c5f0040d75ef2c320100cd2a/pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98", size = 10338495, upload-time = "2026-05-11T18:52:11.558Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b0/a4ffc4ae74d2d822200dcc46898987d8eb6032d1e2b219cae39da6f5cbcc/pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639", size = 9938250, upload-time = "2026-05-11T18:52:17.005Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558, upload-time = "2026-05-11T18:52:19.865Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611, upload-time = "2026-05-11T18:52:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670, upload-time = "2026-05-11T18:52:25.4Z" }, + { url = "https://files.pythonhosted.org/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708, upload-time = "2026-05-11T18:52:28.139Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/c321f13b5ba1819fc8dca456c7fce578da2dcfecff1abbf0eaddf8406c0f/pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea", size = 9907609, upload-time = "2026-05-11T18:52:30.982Z" }, + { url = "https://files.pythonhosted.org/packages/53/85/1b7f563ebc6357c27233a02a96b589bcce1fa9c6eb89fb4f0e56421d277e/pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a", size = 9165596, upload-time = "2026-05-11T18:52:33.334Z" }, + { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, ] [[package]] name = "parso" version = "0.8.7" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, ] [[package]] name = "pathspec" version = "1.1.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] name = "pexpect" version = "4.9.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, ] [[package]] name = "pillow" version = "12.2.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, ] [[package]] name = "platformdirs" version = "4.10.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] [[package]] name = "plotly" version = "6.7.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "narwhals" }, { name = "packaging" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3a/7f/0f100df1172aadf88a929a9dbb902656b0880ba4b960fe5224867159d8f4/plotly-6.7.0.tar.gz", hash = "sha256:45eea0ff27e2a23ccd62776f77eb43aa1ca03df4192b76036e380bb479b892c6", size = 6911286, upload-time = "2026-04-09T20:36:45.738Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/7f/0f100df1172aadf88a929a9dbb902656b0880ba4b960fe5224867159d8f4/plotly-6.7.0.tar.gz", hash = "sha256:45eea0ff27e2a23ccd62776f77eb43aa1ca03df4192b76036e380bb479b892c6", size = 6911286, upload-time = "2026-04-09T20:36:45.738Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl", hash = "sha256:ac8aca1c25c663a59b5b9140a549264a5badde2e057d79b8c772ae2920e32ff0", size = 9898444, upload-time = "2026-04-09T20:36:39.812Z" }, + { url = "https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl", hash = "sha256:ac8aca1c25c663a59b5b9140a549264a5badde2e057d79b8c772ae2920e32ff0", size = 9898444, upload-time = "2026-04-09T20:36:39.812Z" }, ] [[package]] name = "pluggy" version = "1.6.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "plyer" version = "2.1.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/20/85/f61425aa9be1f9108eec1c13861c1e11c9a04eb786eb4832a8f7188317df/plyer-2.1.0.tar.gz", hash = "sha256:65b7dfb7e11e07af37a8487eb2aa69524276ef70dad500b07228ce64736baa61", size = 121371, upload-time = "2022-11-12T13:36:48.978Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/85/f61425aa9be1f9108eec1c13861c1e11c9a04eb786eb4832a8f7188317df/plyer-2.1.0.tar.gz", hash = "sha256:65b7dfb7e11e07af37a8487eb2aa69524276ef70dad500b07228ce64736baa61", size = 121371, upload-time = "2022-11-12T13:36:48.978Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/89/a41c2643fc8eabeb84791acb9d0e4d139b1e4b53473cc4dae947b5fa33ed/plyer-2.1.0-py2.py3-none-any.whl", hash = "sha256:1b1772060df8b3045ed4f08231690ec8f7de30f5a004aa1724665a9074eed113", size = 142266, upload-time = "2022-11-12T13:36:47.181Z" }, + { url = "https://files.pythonhosted.org/packages/d3/89/a41c2643fc8eabeb84791acb9d0e4d139b1e4b53473cc4dae947b5fa33ed/plyer-2.1.0-py2.py3-none-any.whl", hash = "sha256:1b1772060df8b3045ed4f08231690ec8f7de30f5a004aa1724665a9074eed113", size = 142266, upload-time = "2022-11-12T13:36:47.181Z" }, ] [[package]] name = "polars" version = "1.40.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "polars-runtime-32" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/8c/bc9bc948058348ed43117cecc3007cd608f395915dae8a00974579a5dab1/polars-1.40.1.tar.gz", hash = "sha256:ab2694134b137596b5a59bfd7b4c54ebbc9b59f9403127f18e32d363777552e8", size = 733574, upload-time = "2026-04-22T19:15:55.507Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8c/bc9bc948058348ed43117cecc3007cd608f395915dae8a00974579a5dab1/polars-1.40.1.tar.gz", hash = "sha256:ab2694134b137596b5a59bfd7b4c54ebbc9b59f9403127f18e32d363777552e8", size = 733574, upload-time = "2026-04-22T19:15:55.507Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ea/91/74fc60d94488685a92ac9d49d7ec55f3e91fe9b77942a6235a5fa7f249c3/polars-1.40.1-py3-none-any.whl", hash = "sha256:c0f861219d1319cdea45c4ce4d30355a47176b8f98dcedf95ea8269f131b8abd", size = 828723, upload-time = "2026-04-22T19:14:25.452Z" }, + { url = "https://files.pythonhosted.org/packages/ea/91/74fc60d94488685a92ac9d49d7ec55f3e91fe9b77942a6235a5fa7f249c3/polars-1.40.1-py3-none-any.whl", hash = "sha256:c0f861219d1319cdea45c4ce4d30355a47176b8f98dcedf95ea8269f131b8abd", size = 828723, upload-time = "2026-04-22T19:14:25.452Z" }, ] [package.optional-dependencies] @@ -1549,432 +1549,441 @@ rtcompat = [ [[package]] name = "polars-runtime-32" version = "1.40.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/ba/26d40f039be9f552b5fd7365a621bdfc0f8e912ef77094ae4693491b0bae/polars_runtime_32-1.40.1.tar.gz", hash = "sha256:37f3065615d1bf90d03b5326222df4c5c1f8a5d33e50470aa588e3465e6eb814", size = 2935843, upload-time = "2026-04-22T19:15:57.26Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ba/26d40f039be9f552b5fd7365a621bdfc0f8e912ef77094ae4693491b0bae/polars_runtime_32-1.40.1.tar.gz", hash = "sha256:37f3065615d1bf90d03b5326222df4c5c1f8a5d33e50470aa588e3465e6eb814", size = 2935843, upload-time = "2026-04-22T19:15:57.26Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/46/22c8af5eed68ac2eeb556e0fa3ca8a7b798e984ceff4450888f3b5ac61fd/polars_runtime_32-1.40.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b748ef652270cc49e9e69f99a035e0eb4d5f856d42bcd6ac4d9d80a40142aa1e", size = 52098755, upload-time = "2026-04-22T19:14:28.555Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c6/3e/48599a38009ca60ff82a6f38c8a621ce3c0286aa7397c7d79e741bd9060e/polars_runtime_32-1.40.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d249b3743e05986060cec0a7aaa542d020df6c6b876e556023a310efd581f9be", size = 46367542, upload-time = "2026-04-22T19:14:32.433Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/e9/384bc069367a1a36ee31c13782c178dbd039b2b873b772d4a0fc23a2373d/polars_runtime_32-1.40.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5987b30e7aa1059d069498496e8dda35afd592b0ac3d46ed87e3ff8df1ad652c", size = 50252104, upload-time = "2026-04-22T19:14:35.945Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/ef/7d57ceb0651af74194e97ed6583e148d352f03d696090221b8059cdfc90b/polars_runtime_32-1.40.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d7f42a8b3f16fc66002cc0f6516f7dd7653396886ae0ed362ab95c0b3408b59", size = 56250788, upload-time = "2026-04-22T19:14:39.743Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/0f/e4b3ffc748827a14a474ec9c42e45c066050e440fec57e914091d9adda75/polars_runtime_32-1.40.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e5f7becc237a7ec9d9a10878dc8e54b73bbf4e2d94a2991c37d7a0b38590d8f9", size = 50432590, upload-time = "2026-04-22T19:14:43.388Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/0b/b8d95fbed869fa4caabe9c400e4210374913b376e925e96fdcfa9be6416b/polars_runtime_32-1.40.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:992d14cf191dde043d36fbdbc98a65e43fbc7e9a5024cecd45f838ac4988c1ee", size = 54155564, upload-time = "2026-04-22T19:14:47.239Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/d9/d091d8fb5cbed5e9536adfed955c4c89987a4cc3b8e73ae4532402b91c74/polars_runtime_32-1.40.1-cp310-abi3-win_amd64.whl", hash = "sha256:f78bb2abd00101cbb23cc0cb068f7e36e081057a15d2ec2dde3dda280709f030", size = 51829755, upload-time = "2026-04-22T19:14:50.85Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/65/ad/b33c3022a394f3eb55c3310597cec615412a8a33880055eee191d154a628/polars_runtime_32-1.40.1-cp310-abi3-win_arm64.whl", hash = "sha256:b5cbfaf6b085b420b4bfcbe24e8f665076d1cccfdb80c0484c02a023ce205537", size = 45822104, upload-time = "2026-04-22T19:14:54.192Z" }, + { url = "https://files.pythonhosted.org/packages/7d/46/22c8af5eed68ac2eeb556e0fa3ca8a7b798e984ceff4450888f3b5ac61fd/polars_runtime_32-1.40.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b748ef652270cc49e9e69f99a035e0eb4d5f856d42bcd6ac4d9d80a40142aa1e", size = 52098755, upload-time = "2026-04-22T19:14:28.555Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/48599a38009ca60ff82a6f38c8a621ce3c0286aa7397c7d79e741bd9060e/polars_runtime_32-1.40.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d249b3743e05986060cec0a7aaa542d020df6c6b876e556023a310efd581f9be", size = 46367542, upload-time = "2026-04-22T19:14:32.433Z" }, + { url = "https://files.pythonhosted.org/packages/43/e9/384bc069367a1a36ee31c13782c178dbd039b2b873b772d4a0fc23a2373d/polars_runtime_32-1.40.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5987b30e7aa1059d069498496e8dda35afd592b0ac3d46ed87e3ff8df1ad652c", size = 50252104, upload-time = "2026-04-22T19:14:35.945Z" }, + { url = "https://files.pythonhosted.org/packages/15/ef/7d57ceb0651af74194e97ed6583e148d352f03d696090221b8059cdfc90b/polars_runtime_32-1.40.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d7f42a8b3f16fc66002cc0f6516f7dd7653396886ae0ed362ab95c0b3408b59", size = 56250788, upload-time = "2026-04-22T19:14:39.743Z" }, + { url = "https://files.pythonhosted.org/packages/10/0f/e4b3ffc748827a14a474ec9c42e45c066050e440fec57e914091d9adda75/polars_runtime_32-1.40.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e5f7becc237a7ec9d9a10878dc8e54b73bbf4e2d94a2991c37d7a0b38590d8f9", size = 50432590, upload-time = "2026-04-22T19:14:43.388Z" }, + { url = "https://files.pythonhosted.org/packages/d9/0b/b8d95fbed869fa4caabe9c400e4210374913b376e925e96fdcfa9be6416b/polars_runtime_32-1.40.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:992d14cf191dde043d36fbdbc98a65e43fbc7e9a5024cecd45f838ac4988c1ee", size = 54155564, upload-time = "2026-04-22T19:14:47.239Z" }, + { url = "https://files.pythonhosted.org/packages/06/d9/d091d8fb5cbed5e9536adfed955c4c89987a4cc3b8e73ae4532402b91c74/polars_runtime_32-1.40.1-cp310-abi3-win_amd64.whl", hash = "sha256:f78bb2abd00101cbb23cc0cb068f7e36e081057a15d2ec2dde3dda280709f030", size = 51829755, upload-time = "2026-04-22T19:14:50.85Z" }, + { url = "https://files.pythonhosted.org/packages/65/ad/b33c3022a394f3eb55c3310597cec615412a8a33880055eee191d154a628/polars_runtime_32-1.40.1-cp310-abi3-win_arm64.whl", hash = "sha256:b5cbfaf6b085b420b4bfcbe24e8f665076d1cccfdb80c0484c02a023ce205537", size = 45822104, upload-time = "2026-04-22T19:14:54.192Z" }, ] [[package]] name = "polars-runtime-compat" version = "1.40.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4c/60/f8340030f94a45231ad02abff171c8c10f45df0984e5a4fd0f39a48500f4/polars_runtime_compat-1.40.1.tar.gz", hash = "sha256:6149aa764439cec26a5f883fb9921a50f61a0f6c4549df51c735626701a73a18", size = 2935443, upload-time = "2026-04-22T19:16:00.906Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/f8340030f94a45231ad02abff171c8c10f45df0984e5a4fd0f39a48500f4/polars_runtime_compat-1.40.1.tar.gz", hash = "sha256:6149aa764439cec26a5f883fb9921a50f61a0f6c4549df51c735626701a73a18", size = 2935443, upload-time = "2026-04-22T19:16:00.906Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/c7/978481a2a2f5b50636d485bc176e16084fa1bb3b1d7e7e34f580cef240dc/polars_runtime_compat-1.40.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a683d287237f1dcc3dee95b5b2b6408cec318abbbb412ca49206492513be862f", size = 52016973, upload-time = "2026-04-22T19:15:25.228Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/c9/e308b0bbb331330a762f5f495597d34e9933c965ca6d4026a2eb481ef4ad/polars_runtime_compat-1.40.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c83750f5593cec088134614fbf783fd10c5a92030d71b35f3dea0fff682d92c6", size = 46246682, upload-time = "2026-04-22T19:15:28.666Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1d/54/eaecb4747695e2e200ed6d13ce40dd7b0cdc7499d0b9af1e64e83af0e46d/polars_runtime_compat-1.40.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffa15c4a8b7f67911412c849dc3d3aac313f682d834eb3f374d0f6cb7e080efc", size = 50123217, upload-time = "2026-04-22T19:15:32.321Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/20/87/0a881905d94341111d38e6a7ae94674511e6797cf3be2500998f06963edf/polars_runtime_compat-1.40.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0373dabf942135d13b453d50be7a84b14420b495bd1242570545f58866396e7", size = 55869626, upload-time = "2026-04-22T19:15:37.15Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/6e/ac57e39bb94ebd63d6714895881ee0461e39039daa208916e5cad93174f9/polars_runtime_compat-1.40.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:bcdc488472d2c57dd3315a897456b47cdac222a1520fcf3d8d38410a5bf47ec1", size = 50287716, upload-time = "2026-04-22T19:15:40.829Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e1/f3/b75afc9f79cd4cadff52bdc4566119892b378f739a295573bf6bd0190a6c/polars_runtime_compat-1.40.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:34d03962169fc7c0ef6f6efd324d0a51350e7b5abf3b0cd118eb7a32e4d947ec", size = 53796312, upload-time = "2026-04-22T19:15:44.68Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/68/0df61011d894d1c94c915b39423c9aab79b3f7b18b836031ed32987227e0/polars_runtime_compat-1.40.1-cp310-abi3-win_amd64.whl", hash = "sha256:d9ccfe58eeb776567cf9556a83b980b9face9f0b1bb629bf2cd2334f4d9daf57", size = 51696665, upload-time = "2026-04-22T19:15:48.65Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/01/1bc7e868d3e4055b1ae756ad8ccc2a5d2bce57d7c88c3b4427babd43450b/polars_runtime_compat-1.40.1-cp310-abi3-win_arm64.whl", hash = "sha256:0c662e6acf5d4e3784eee8e8a1ec6eb185132ac90689cb84034132b5787903b1", size = 45701223, upload-time = "2026-04-22T19:15:52.189Z" }, + { url = "https://files.pythonhosted.org/packages/07/c7/978481a2a2f5b50636d485bc176e16084fa1bb3b1d7e7e34f580cef240dc/polars_runtime_compat-1.40.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a683d287237f1dcc3dee95b5b2b6408cec318abbbb412ca49206492513be862f", size = 52016973, upload-time = "2026-04-22T19:15:25.228Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c9/e308b0bbb331330a762f5f495597d34e9933c965ca6d4026a2eb481ef4ad/polars_runtime_compat-1.40.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c83750f5593cec088134614fbf783fd10c5a92030d71b35f3dea0fff682d92c6", size = 46246682, upload-time = "2026-04-22T19:15:28.666Z" }, + { url = "https://files.pythonhosted.org/packages/1d/54/eaecb4747695e2e200ed6d13ce40dd7b0cdc7499d0b9af1e64e83af0e46d/polars_runtime_compat-1.40.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffa15c4a8b7f67911412c849dc3d3aac313f682d834eb3f374d0f6cb7e080efc", size = 50123217, upload-time = "2026-04-22T19:15:32.321Z" }, + { url = "https://files.pythonhosted.org/packages/20/87/0a881905d94341111d38e6a7ae94674511e6797cf3be2500998f06963edf/polars_runtime_compat-1.40.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0373dabf942135d13b453d50be7a84b14420b495bd1242570545f58866396e7", size = 55869626, upload-time = "2026-04-22T19:15:37.15Z" }, + { url = "https://files.pythonhosted.org/packages/be/6e/ac57e39bb94ebd63d6714895881ee0461e39039daa208916e5cad93174f9/polars_runtime_compat-1.40.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:bcdc488472d2c57dd3315a897456b47cdac222a1520fcf3d8d38410a5bf47ec1", size = 50287716, upload-time = "2026-04-22T19:15:40.829Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f3/b75afc9f79cd4cadff52bdc4566119892b378f739a295573bf6bd0190a6c/polars_runtime_compat-1.40.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:34d03962169fc7c0ef6f6efd324d0a51350e7b5abf3b0cd118eb7a32e4d947ec", size = 53796312, upload-time = "2026-04-22T19:15:44.68Z" }, + { url = "https://files.pythonhosted.org/packages/67/68/0df61011d894d1c94c915b39423c9aab79b3f7b18b836031ed32987227e0/polars_runtime_compat-1.40.1-cp310-abi3-win_amd64.whl", hash = "sha256:d9ccfe58eeb776567cf9556a83b980b9face9f0b1bb629bf2cd2334f4d9daf57", size = 51696665, upload-time = "2026-04-22T19:15:48.65Z" }, + { url = "https://files.pythonhosted.org/packages/45/01/1bc7e868d3e4055b1ae756ad8ccc2a5d2bce57d7c88c3b4427babd43450b/polars_runtime_compat-1.40.1-cp310-abi3-win_arm64.whl", hash = "sha256:0c662e6acf5d4e3784eee8e8a1ec6eb185132ac90689cb84034132b5787903b1", size = 45701223, upload-time = "2026-04-22T19:15:52.189Z" }, ] [[package]] name = "prompt-toolkit" version = "3.0.52" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wcwidth" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] [[package]] name = "proxy-tools" version = "0.1.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/cf/77d3e19b7fabd03895caca7857ef51e4c409e0ca6b37ee6e9f7daa50b642/proxy_tools-0.1.0.tar.gz", hash = "sha256:ccb3751f529c047e2d8a58440d86b205303cf0fe8146f784d1cbcd94f0a28010", size = 2978, upload-time = "2014-05-05T21:02:24.606Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/cf/77d3e19b7fabd03895caca7857ef51e4c409e0ca6b37ee6e9f7daa50b642/proxy_tools-0.1.0.tar.gz", hash = "sha256:ccb3751f529c047e2d8a58440d86b205303cf0fe8146f784d1cbcd94f0a28010", size = 2978, upload-time = "2014-05-05T21:02:24.606Z" } [[package]] name = "psutil" version = "7.2.2" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] [[package]] name = "ptyprocess" version = "0.7.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, ] [[package]] name = "pure-eval" version = "0.2.3" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] [[package]] name = "pyarrow" version = "24.0.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/c9/a47ab7ece0d86cbe6678418a0fbd1ac4bb493b9184a3891dfa0e7f287ae0/pyarrow-24.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74", size = 35068898, upload-time = "2026-04-21T10:46:36.599Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/bc/8db86617a9a58008acf8913d6fed68ea2a46acb6de928db28d724c891a68/pyarrow-24.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3", size = 36679915, upload-time = "2026-04-21T10:46:42.602Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/8e/fb178720400ef69db251eb4a9c3ccf4af269bc1feb5055529b8fc87170d1/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868", size = 45697931, upload-time = "2026-04-21T10:46:48.403Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/27/99c42abe8e21b44f4917f62631f3aa31404882a2c41d8a4cd5c110e13d52/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e", size = 48837449, upload-time = "2026-04-21T10:46:55.329Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/b6/333749e2666e9032891125bf9c691146e92901bece62030ac1430e2e7c88/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57", size = 49395949, upload-time = "2026-04-21T10:47:01.869Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/17/25/c5201706a2dd374e8ba6ee3fd7a8c89fb7ffc16eed5217a91fd2bd7f7626/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c", size = 51912986, upload-time = "2026-04-21T10:47:09.872Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/d2/4d1bbba65320b21a49678d6fbdc6ff7c649251359fdcfc03568c4136231d/pyarrow-24.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981", size = 27255371, upload-time = "2026-04-21T10:47:15.943Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, + { url = "https://files.pythonhosted.org/packages/62/c9/a47ab7ece0d86cbe6678418a0fbd1ac4bb493b9184a3891dfa0e7f287ae0/pyarrow-24.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74", size = 35068898, upload-time = "2026-04-21T10:46:36.599Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bc/8db86617a9a58008acf8913d6fed68ea2a46acb6de928db28d724c891a68/pyarrow-24.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3", size = 36679915, upload-time = "2026-04-21T10:46:42.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8e/fb178720400ef69db251eb4a9c3ccf4af269bc1feb5055529b8fc87170d1/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868", size = 45697931, upload-time = "2026-04-21T10:46:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/f3/27/99c42abe8e21b44f4917f62631f3aa31404882a2c41d8a4cd5c110e13d52/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e", size = 48837449, upload-time = "2026-04-21T10:46:55.329Z" }, + { url = "https://files.pythonhosted.org/packages/36/b6/333749e2666e9032891125bf9c691146e92901bece62030ac1430e2e7c88/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57", size = 49395949, upload-time = "2026-04-21T10:47:01.869Z" }, + { url = "https://files.pythonhosted.org/packages/17/25/c5201706a2dd374e8ba6ee3fd7a8c89fb7ffc16eed5217a91fd2bd7f7626/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c", size = 51912986, upload-time = "2026-04-21T10:47:09.872Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d2/4d1bbba65320b21a49678d6fbdc6ff7c649251359fdcfc03568c4136231d/pyarrow-24.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981", size = 27255371, upload-time = "2026-04-21T10:47:15.943Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" }, + { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" }, + { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" }, + { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" }, + { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" }, + { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" }, + { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" }, + { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, ] [[package]] name = "pycparser" version = "3.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +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://pypi.tuna.tsinghua.edu.cn/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, + { 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" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] name = "pydantic-core" version = "2.46.4" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] [[package]] name = "pydantic-settings" version = "2.14.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, ] [[package]] name = "pygments" version = "2.20.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pyobjc-core" version = "12.2.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/b1/729f7458a63758bd21716648a8abcd9a0c8f2d2e9897763c8a1a1c7fd31b/pyobjc_core-12.2.1.tar.gz", hash = "sha256:7a7b9b018402342cf32bf1956366896350fbe5c0478cb3ef59778f77abed7f07", size = 1063383, upload-time = "2026-06-19T16:19:39.357Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/b1/729f7458a63758bd21716648a8abcd9a0c8f2d2e9897763c8a1a1c7fd31b/pyobjc_core-12.2.1.tar.gz", hash = "sha256:7a7b9b018402342cf32bf1956366896350fbe5c0478cb3ef59778f77abed7f07", size = 1063383, upload-time = "2026-06-19T16:19:39.357Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/87/16564ef5e4568ee0edd9e712d8111dc8b67621d6bb6ff430646ee2d637dd/pyobjc_core-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:24b76a63caf0b5369d4a377c7c0438cd70df81539057af3db839bfaa3579e04a", size = 6484662, upload-time = "2026-06-19T16:04:44.979Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/88/300ad283bed0c971c52dcac6f70113e138169d4ce6d856ddd03d16081e51/pyobjc_core-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b", size = 6433347, upload-time = "2026-06-19T16:04:49.341Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/1e/b9b0ddffae66996b8779f1f7958adc9f21c13a0448cd3be8d7fe589b5b0f/pyobjc_core-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:af101222762665a4125157906cb4b23f5d5a63d3851d5e0504f72a1eaaa2cfd2", size = 6436004, upload-time = "2026-06-19T16:04:53.257Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/26/bd309ede07784c6e5fac4b440c90a5f72a66da7859ed303a9392fe8a5f3f/pyobjc_core-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:efe465e3ecc6fc73f7c7622620345d134a8d34564ab1c29d8247e45f4ed55071", size = 6687044, upload-time = "2026-06-19T16:04:57.42Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/8a/cfa4f56939d554dbb342ec6e5226a441e2f552bc2002a0ddf7705bb11bef/pyobjc_core-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2b8fc0531c27277325e113ac00b8a72a82e6145f0a88175b9425d8de814ff69a", size = 6429289, upload-time = "2026-06-19T16:05:02.191Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/74/446c89bc18103aaa4a00d1fb85ff8acace9a0dc3f362d9678ebf7571e275/pyobjc_core-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9bef500f979e22d54f9da3aaebf6a48f873234b324858bd69256055a318955c7", size = 6690181, upload-time = "2026-06-19T16:05:06.201Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/c7/0121ee4c616af07ad2de8cd1a286f6978dc9a227eb58b7c2e875cb68a1df/pyobjc_core-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:047c226eeb58a2993ace5e8904e71cc9426ee20d064c617f8fbf32717d37093e", size = 6487078, upload-time = "2026-06-19T16:05:10.093Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b5/a8/cb9fcc150f97d0bf22a2028f88b24cc35949beb1bcc7b8bc5c17d4401677/pyobjc_core-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1188613805336270279570467e4455b74cb6c0f60913ac74c917ee1c37cfaecb", size = 6733064, upload-time = "2026-06-19T16:05:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/92/87/16564ef5e4568ee0edd9e712d8111dc8b67621d6bb6ff430646ee2d637dd/pyobjc_core-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:24b76a63caf0b5369d4a377c7c0438cd70df81539057af3db839bfaa3579e04a", size = 6484662, upload-time = "2026-06-19T16:04:44.979Z" }, + { url = "https://files.pythonhosted.org/packages/8c/88/300ad283bed0c971c52dcac6f70113e138169d4ce6d856ddd03d16081e51/pyobjc_core-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b", size = 6433347, upload-time = "2026-06-19T16:04:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/b9b0ddffae66996b8779f1f7958adc9f21c13a0448cd3be8d7fe589b5b0f/pyobjc_core-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:af101222762665a4125157906cb4b23f5d5a63d3851d5e0504f72a1eaaa2cfd2", size = 6436004, upload-time = "2026-06-19T16:04:53.257Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/bd309ede07784c6e5fac4b440c90a5f72a66da7859ed303a9392fe8a5f3f/pyobjc_core-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:efe465e3ecc6fc73f7c7622620345d134a8d34564ab1c29d8247e45f4ed55071", size = 6687044, upload-time = "2026-06-19T16:04:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8a/cfa4f56939d554dbb342ec6e5226a441e2f552bc2002a0ddf7705bb11bef/pyobjc_core-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2b8fc0531c27277325e113ac00b8a72a82e6145f0a88175b9425d8de814ff69a", size = 6429289, upload-time = "2026-06-19T16:05:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/42/74/446c89bc18103aaa4a00d1fb85ff8acace9a0dc3f362d9678ebf7571e275/pyobjc_core-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9bef500f979e22d54f9da3aaebf6a48f873234b324858bd69256055a318955c7", size = 6690181, upload-time = "2026-06-19T16:05:06.201Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/0121ee4c616af07ad2de8cd1a286f6978dc9a227eb58b7c2e875cb68a1df/pyobjc_core-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:047c226eeb58a2993ace5e8904e71cc9426ee20d064c617f8fbf32717d37093e", size = 6487078, upload-time = "2026-06-19T16:05:10.093Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a8/cb9fcc150f97d0bf22a2028f88b24cc35949beb1bcc7b8bc5c17d4401677/pyobjc_core-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1188613805336270279570467e4455b74cb6c0f60913ac74c917ee1c37cfaecb", size = 6733064, upload-time = "2026-06-19T16:05:14.313Z" }, ] [[package]] name = "pyobjc-framework-cocoa" version = "12.2.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/d6/dc66ea8519a0475efbccf73f82cc28066339bb300a27f5e1bf91ab1d7002/pyobjc_framework_cocoa-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dc6da84f4fc62cc25463bbb85e77a57b8d5ac6caf9a60702daf2edb601332f15", size = 387298, upload-time = "2026-06-19T16:07:37.412Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f7/cf/1b3b32b2f28f66cc053c3438ef4e6df36a1591945bf05e7399da18d74553/pyobjc_framework_cocoa-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:28b9b8bab1c36efb94744786918752d0c1842f5fbb67e7d5ca97b5f736512080", size = 388113, upload-time = "2026-06-19T16:07:38.9Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/46/68e8e4d926a2f70fed0437047bc3f9fe08af8fe620d94d80656ebc3cfa9b/pyobjc_framework_cocoa-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3b74a78fa7803e547b32e5e8ec1b49987b52fe318383e793bc6cd49b80efbd9f", size = 388183, upload-time = "2026-06-19T16:07:40.483Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2e/f3/dfc9af4c9eb2e5389c860ad5ef252be9fe456db09f39d537555dc5057aa1/pyobjc_framework_cocoa-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:dc2eaca2f13c7bcd8e41e51a372e47825dea9dd3126108760eed7ba883d2945c", size = 392275, upload-time = "2026-06-19T16:07:42.078Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ec/c8/b90baa8f3592eded79b4be98fb59d2b8dc16b62361e34292bd95806ebd9f/pyobjc_framework_cocoa-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b386c324d64ae565c1f6b7dfb77be68f640a1c7c23caa6966ab661131f519561", size = 388357, upload-time = "2026-06-19T16:07:43.364Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/d8/64a94651b9294702d55e748d94de30e25bc59d0784526be7643f4467eccd/pyobjc_framework_cocoa-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a6c584e2af0813cb2f6103b184e632665a26f58c1bd5b08ffd6e95a19c617f7b", size = 392404, upload-time = "2026-06-19T16:07:44.955Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5c/cc/26e8a7bf1f5e8caa38b7f80d486296f9fd3c97e71ad7e5444ef22e802758/pyobjc_framework_cocoa-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:b6023657b8d6cc049a21bd6b4752425f2f53c42f9f0b02d64c7608cc484bf103", size = 388589, upload-time = "2026-06-19T16:07:46.276Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b6/f3/eedf743a303ea742b8e082afe3613fb4d6618bc1a48cf2568b004ce906f7/pyobjc_framework_cocoa-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c685ccd8e266a07cf912a2c5a13b1f2eff2a868a1aff163b4801b4687bd425e1", size = 392691, upload-time = "2026-06-19T16:07:47.477Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d6/dc66ea8519a0475efbccf73f82cc28066339bb300a27f5e1bf91ab1d7002/pyobjc_framework_cocoa-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dc6da84f4fc62cc25463bbb85e77a57b8d5ac6caf9a60702daf2edb601332f15", size = 387298, upload-time = "2026-06-19T16:07:37.412Z" }, + { url = "https://files.pythonhosted.org/packages/f7/cf/1b3b32b2f28f66cc053c3438ef4e6df36a1591945bf05e7399da18d74553/pyobjc_framework_cocoa-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:28b9b8bab1c36efb94744786918752d0c1842f5fbb67e7d5ca97b5f736512080", size = 388113, upload-time = "2026-06-19T16:07:38.9Z" }, + { url = "https://files.pythonhosted.org/packages/cc/46/68e8e4d926a2f70fed0437047bc3f9fe08af8fe620d94d80656ebc3cfa9b/pyobjc_framework_cocoa-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3b74a78fa7803e547b32e5e8ec1b49987b52fe318383e793bc6cd49b80efbd9f", size = 388183, upload-time = "2026-06-19T16:07:40.483Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f3/dfc9af4c9eb2e5389c860ad5ef252be9fe456db09f39d537555dc5057aa1/pyobjc_framework_cocoa-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:dc2eaca2f13c7bcd8e41e51a372e47825dea9dd3126108760eed7ba883d2945c", size = 392275, upload-time = "2026-06-19T16:07:42.078Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c8/b90baa8f3592eded79b4be98fb59d2b8dc16b62361e34292bd95806ebd9f/pyobjc_framework_cocoa-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b386c324d64ae565c1f6b7dfb77be68f640a1c7c23caa6966ab661131f519561", size = 388357, upload-time = "2026-06-19T16:07:43.364Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/64a94651b9294702d55e748d94de30e25bc59d0784526be7643f4467eccd/pyobjc_framework_cocoa-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a6c584e2af0813cb2f6103b184e632665a26f58c1bd5b08ffd6e95a19c617f7b", size = 392404, upload-time = "2026-06-19T16:07:44.955Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cc/26e8a7bf1f5e8caa38b7f80d486296f9fd3c97e71ad7e5444ef22e802758/pyobjc_framework_cocoa-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:b6023657b8d6cc049a21bd6b4752425f2f53c42f9f0b02d64c7608cc484bf103", size = 388589, upload-time = "2026-06-19T16:07:46.276Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/eedf743a303ea742b8e082afe3613fb4d6618bc1a48cf2568b004ce906f7/pyobjc_framework_cocoa-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c685ccd8e266a07cf912a2c5a13b1f2eff2a868a1aff163b4801b4687bd425e1", size = 392691, upload-time = "2026-06-19T16:07:47.477Z" }, ] [[package]] name = "pyobjc-framework-quartz" version = "12.2.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3b/f6/2a8b84dbf1fe7c04dd96ea73d991678d4e09a909f51971ecc51629bb2ab4/pyobjc_framework_quartz-12.2.1.tar.gz", hash = "sha256:b3b8b6f71e66147f8ff9e6213864cc8527e3a0b1ee90835b93ce221f4802d9b0", size = 3215521, upload-time = "2026-06-19T16:21:30.199Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/f6/2a8b84dbf1fe7c04dd96ea73d991678d4e09a909f51971ecc51629bb2ab4/pyobjc_framework_quartz-12.2.1.tar.gz", hash = "sha256:b3b8b6f71e66147f8ff9e6213864cc8527e3a0b1ee90835b93ce221f4802d9b0", size = 3215521, upload-time = "2026-06-19T16:21:30.199Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b9/08/527d1ff856e2f2446b5887be01989cc08f9adaf3de7d4eb13d07826c362f/pyobjc_framework_quartz-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60f29408b4f9ed5391a29c6b63e2aa56ddfb8b66b3fb47962930427981e14462", size = 217998, upload-time = "2026-06-19T16:16:02.978Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/14/fc/d7c7b3134cdbd1a487f3f77b5be125d87a6c9e7d9411035739d99335cc0c/pyobjc_framework_quartz-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:de9c8cca7e95290c8d540466af11c7cdfe3a5458e6f56c34006d5b45243f9ed9", size = 219000, upload-time = "2026-06-19T16:16:04.29Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/4b/861f91a1565d3189ee899e177b915551fb9a7e2ca25414025a8974f04e74/pyobjc_framework_quartz-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54c9bc7f507192691841ee4eba5bf36990b259df83ac728efed2d7ea1cd021e4", size = 219403, upload-time = "2026-06-19T16:16:05.645Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/b5/b27010d2f288737f627f74be6d5549f49c841542365c84b9a3011fe39ce7/pyobjc_framework_quartz-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:bfc0d2badd819823d21df8069dcf9544ce360ed747a8895c51bdb25d8d125f45", size = 224458, upload-time = "2026-06-19T16:16:07.252Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/5d/85ffd9d433989205d572a50d625c63b29c05e0c5235a725f15ae1023672c/pyobjc_framework_quartz-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ceb56939c337b36d9d81185ade31f77dc52c85cf79bb16e53e9b32f54b6bb3f5", size = 219769, upload-time = "2026-06-19T16:16:08.814Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e2/d6/b917e4b63d72ea84a27121076f3033f23f6497c0e6ce8d304766c899897f/pyobjc_framework_quartz-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8105c98b798f2bf81c05c54bddeeadbf62f0b5dfec13bd6e719dd2cdf7e1cddf", size = 224717, upload-time = "2026-06-19T16:16:10.215Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/e2/f3c1ed3228f7430ef5ade23db6f1fcbae99290f177ce5653348fd9e05f4d/pyobjc_framework_quartz-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:bbc214f1a216b5d3651bc832d0ac4589f029f3f37cd6cbb370aac12a7c77942c", size = 219825, upload-time = "2026-06-19T16:16:11.433Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/2a/2c99a5ad2fe0a11600ea123b8e9a08ff138fcb2ad1e13e376f4bd4aa1d96/pyobjc_framework_quartz-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:ca61624a0b0e6286d8a0f97f47eb9011e4e81e9a339db436d48af527e7065bb1", size = 224770, upload-time = "2026-06-19T16:16:13.035Z" }, + { url = "https://files.pythonhosted.org/packages/b9/08/527d1ff856e2f2446b5887be01989cc08f9adaf3de7d4eb13d07826c362f/pyobjc_framework_quartz-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60f29408b4f9ed5391a29c6b63e2aa56ddfb8b66b3fb47962930427981e14462", size = 217998, upload-time = "2026-06-19T16:16:02.978Z" }, + { url = "https://files.pythonhosted.org/packages/14/fc/d7c7b3134cdbd1a487f3f77b5be125d87a6c9e7d9411035739d99335cc0c/pyobjc_framework_quartz-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:de9c8cca7e95290c8d540466af11c7cdfe3a5458e6f56c34006d5b45243f9ed9", size = 219000, upload-time = "2026-06-19T16:16:04.29Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4b/861f91a1565d3189ee899e177b915551fb9a7e2ca25414025a8974f04e74/pyobjc_framework_quartz-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54c9bc7f507192691841ee4eba5bf36990b259df83ac728efed2d7ea1cd021e4", size = 219403, upload-time = "2026-06-19T16:16:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b5/b27010d2f288737f627f74be6d5549f49c841542365c84b9a3011fe39ce7/pyobjc_framework_quartz-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:bfc0d2badd819823d21df8069dcf9544ce360ed747a8895c51bdb25d8d125f45", size = 224458, upload-time = "2026-06-19T16:16:07.252Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5d/85ffd9d433989205d572a50d625c63b29c05e0c5235a725f15ae1023672c/pyobjc_framework_quartz-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ceb56939c337b36d9d81185ade31f77dc52c85cf79bb16e53e9b32f54b6bb3f5", size = 219769, upload-time = "2026-06-19T16:16:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/e2/d6/b917e4b63d72ea84a27121076f3033f23f6497c0e6ce8d304766c899897f/pyobjc_framework_quartz-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8105c98b798f2bf81c05c54bddeeadbf62f0b5dfec13bd6e719dd2cdf7e1cddf", size = 224717, upload-time = "2026-06-19T16:16:10.215Z" }, + { url = "https://files.pythonhosted.org/packages/04/e2/f3c1ed3228f7430ef5ade23db6f1fcbae99290f177ce5653348fd9e05f4d/pyobjc_framework_quartz-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:bbc214f1a216b5d3651bc832d0ac4589f029f3f37cd6cbb370aac12a7c77942c", size = 219825, upload-time = "2026-06-19T16:16:11.433Z" }, + { url = "https://files.pythonhosted.org/packages/66/2a/2c99a5ad2fe0a11600ea123b8e9a08ff138fcb2ad1e13e376f4bd4aa1d96/pyobjc_framework_quartz-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:ca61624a0b0e6286d8a0f97f47eb9011e4e81e9a339db436d48af527e7065bb1", size = 224770, upload-time = "2026-06-19T16:16:13.035Z" }, ] [[package]] name = "pyobjc-framework-security" version = "12.2.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/44/b8/4267b802d8dba6de468e7d0765b05cc4e146fa376ed9f55e0b6461016bef/pyobjc_framework_security-12.2.1.tar.gz", hash = "sha256:d7831b1537f4346892e7f2f0e2b09d79bee98919b0767f4061278d0e03028f2d", size = 181065, upload-time = "2026-06-19T16:21:40.151Z" } +sdist = { url = "https://files.pythonhosted.org/packages/44/b8/4267b802d8dba6de468e7d0765b05cc4e146fa376ed9f55e0b6461016bef/pyobjc_framework_security-12.2.1.tar.gz", hash = "sha256:d7831b1537f4346892e7f2f0e2b09d79bee98919b0767f4061278d0e03028f2d", size = 181065, upload-time = "2026-06-19T16:21:40.151Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/ac/f2ff946edfaf16b4ce5e31afac5e519f83705c0f4842fd25134ecb8f2f4a/pyobjc_framework_security-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ce461296b003b2ba17c8b65f6339f9d2fd5dcfa2b3b52ddc0a696334cc8974c5", size = 41306, upload-time = "2026-06-19T16:17:16.816Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4e/5b/2719bc4062e6c27083191fd20e365ae02d0bf1c22f4d1a88211e3d96b369/pyobjc_framework_security-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76ff6e44e62d3e15651540493879bf16687d862c4f10f3cadade757811c8b8d0", size = 41300, upload-time = "2026-06-19T16:17:17.702Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/90/dccd4cd6877ef208957dc1f3675287d8614a4dcd2a3ee0a5e56f5fb5a1ba/pyobjc_framework_security-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:990013baba29d6f985d8950b23701129b2597b3d16f628b785fe97596d8a8de3", size = 41299, upload-time = "2026-06-19T16:17:18.511Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/af/f9e8040e0c3ef6a50392a46ad1df482a666aa615180d40730b00282ff81f/pyobjc_framework_security-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:066a3e5e9d368e7a6ba8dd52be2077a634ef12a54fbfcc78b3b8154a8f988a1d", size = 42179, upload-time = "2026-06-19T16:17:19.48Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c9/3c/76e2a8bb8d5fe48f0e8e25c6abec1609f3667cc39935017badfe9e9603f2/pyobjc_framework_security-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:5319ae49b8874363ab51c6ff4d85d4ea0cfa6d836fe0306e901ba9ae560b880d", size = 41370, upload-time = "2026-06-19T16:17:20.501Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/14/6e/7120956e9833b2c70757eec1f65f57c191e00662cf74c4545d88315643fa/pyobjc_framework_security-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:21618431e0dbfbd3d4029445e3118af88e5d7e52ddecf9a2d17c759c51628d85", size = 42926, upload-time = "2026-06-19T16:17:21.425Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/ff/0bafc557523e5755f74dd5363386a1e9b03f611e2e36df0737a508cd5ab4/pyobjc_framework_security-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:fa192e9df479375e6242adcadb9a44f32907dd7fe1207608710cd3af65fe3c84", size = 41376, upload-time = "2026-06-19T16:17:22.337Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/33/33d266117e46fef148caa4f986b3d896cb9bfd76bef48bd761cb60c758ee/pyobjc_framework_security-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:07cd044a7996f9a897040c49055fa3bdf565acac4a25b834a72e60602376146d", size = 42944, upload-time = "2026-06-19T16:17:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/be/ac/f2ff946edfaf16b4ce5e31afac5e519f83705c0f4842fd25134ecb8f2f4a/pyobjc_framework_security-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ce461296b003b2ba17c8b65f6339f9d2fd5dcfa2b3b52ddc0a696334cc8974c5", size = 41306, upload-time = "2026-06-19T16:17:16.816Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5b/2719bc4062e6c27083191fd20e365ae02d0bf1c22f4d1a88211e3d96b369/pyobjc_framework_security-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76ff6e44e62d3e15651540493879bf16687d862c4f10f3cadade757811c8b8d0", size = 41300, upload-time = "2026-06-19T16:17:17.702Z" }, + { url = "https://files.pythonhosted.org/packages/15/90/dccd4cd6877ef208957dc1f3675287d8614a4dcd2a3ee0a5e56f5fb5a1ba/pyobjc_framework_security-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:990013baba29d6f985d8950b23701129b2597b3d16f628b785fe97596d8a8de3", size = 41299, upload-time = "2026-06-19T16:17:18.511Z" }, + { url = "https://files.pythonhosted.org/packages/ce/af/f9e8040e0c3ef6a50392a46ad1df482a666aa615180d40730b00282ff81f/pyobjc_framework_security-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:066a3e5e9d368e7a6ba8dd52be2077a634ef12a54fbfcc78b3b8154a8f988a1d", size = 42179, upload-time = "2026-06-19T16:17:19.48Z" }, + { url = "https://files.pythonhosted.org/packages/c9/3c/76e2a8bb8d5fe48f0e8e25c6abec1609f3667cc39935017badfe9e9603f2/pyobjc_framework_security-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:5319ae49b8874363ab51c6ff4d85d4ea0cfa6d836fe0306e901ba9ae560b880d", size = 41370, upload-time = "2026-06-19T16:17:20.501Z" }, + { url = "https://files.pythonhosted.org/packages/14/6e/7120956e9833b2c70757eec1f65f57c191e00662cf74c4545d88315643fa/pyobjc_framework_security-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:21618431e0dbfbd3d4029445e3118af88e5d7e52ddecf9a2d17c759c51628d85", size = 42926, upload-time = "2026-06-19T16:17:21.425Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/0bafc557523e5755f74dd5363386a1e9b03f611e2e36df0737a508cd5ab4/pyobjc_framework_security-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:fa192e9df479375e6242adcadb9a44f32907dd7fe1207608710cd3af65fe3c84", size = 41376, upload-time = "2026-06-19T16:17:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/47/33/33d266117e46fef148caa4f986b3d896cb9bfd76bef48bd761cb60c758ee/pyobjc_framework_security-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:07cd044a7996f9a897040c49055fa3bdf565acac4a25b834a72e60602376146d", size = 42944, upload-time = "2026-06-19T16:17:23.371Z" }, ] [[package]] name = "pyobjc-framework-uniformtypeidentifiers" version = "12.2.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/a1/108fa1e5a3dd8aff626f98fb97de370323b290404b04ffa2ef9420665ed3/pyobjc_framework_uniformtypeidentifiers-12.2.1.tar.gz", hash = "sha256:1fb89d13aa3c2df8e6d6536f6df3493fe5a6caefd2a5adebf17c5af3b29ed4a2", size = 20679, upload-time = "2026-06-19T16:21:55.739Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/a1/108fa1e5a3dd8aff626f98fb97de370323b290404b04ffa2ef9420665ed3/pyobjc_framework_uniformtypeidentifiers-12.2.1.tar.gz", hash = "sha256:1fb89d13aa3c2df8e6d6536f6df3493fe5a6caefd2a5adebf17c5af3b29ed4a2", size = 20679, upload-time = "2026-06-19T16:21:55.739Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e3/44/18a7b3c3b4f9f6784fddf64ed5a2c148577d0300705a50e8ab81da8fc71d/pyobjc_framework_uniformtypeidentifiers-12.2.1-py2.py3-none-any.whl", hash = "sha256:ea08413ad895a7dfea13670e26548bcf5b00154084cdfb5d8f96603320e77cf3", size = 5042, upload-time = "2026-06-19T16:18:54.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/44/18a7b3c3b4f9f6784fddf64ed5a2c148577d0300705a50e8ab81da8fc71d/pyobjc_framework_uniformtypeidentifiers-12.2.1-py2.py3-none-any.whl", hash = "sha256:ea08413ad895a7dfea13670e26548bcf5b00154084cdfb5d8f96603320e77cf3", size = 5042, upload-time = "2026-06-19T16:18:54.085Z" }, ] [[package]] name = "pyobjc-framework-webkit" version = "12.2.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/11/d2/b230c594f70ecb970b4cef67bae2648d1bfa5b381e9b7e3710bf24ec8887/pyobjc_framework_webkit-12.2.1.tar.gz", hash = "sha256:a56acae55b50d549b20dff2921ad1099add8fbc377d0de09ddc2ba50957f7def", size = 332374, upload-time = "2026-06-19T16:22:01.988Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/d2/b230c594f70ecb970b4cef67bae2648d1bfa5b381e9b7e3710bf24ec8887/pyobjc_framework_webkit-12.2.1.tar.gz", hash = "sha256:a56acae55b50d549b20dff2921ad1099add8fbc377d0de09ddc2ba50957f7def", size = 332374, upload-time = "2026-06-19T16:22:01.988Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f0/d3/2ab99d3975dd4624dd943e5a7c8d37e40258d3c9fcf4f26baf09a24e6c9b/pyobjc_framework_webkit-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:af5c4ccdf03845adac082823a3b4341b5b2fe62d2d664550afa705b5286a06fc", size = 50264, upload-time = "2026-06-19T16:19:30.607Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/47/7a2099eb2e062c6230a9440f1795cf34056ca5e16ef25c8aad7c059b8734/pyobjc_framework_webkit-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7e04dcc08cdc59380113ea1232af75a0a04c2426418ebe967b4c0045c973f776", size = 50372, upload-time = "2026-06-19T16:19:31.581Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/a4/202ec288808011d3f459d000d593e88b1118f2d1d5a4dfaaf5232f2c2ac2/pyobjc_framework_webkit-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:23bee8bf7077f91da4e3ae54a00c7f5e4414319e15f98be8584dbd67c4043fae", size = 50387, upload-time = "2026-06-19T16:19:32.522Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/a4/f796e94b43a66704b6ae17c747c7b97fd4b79348f1cfa9bef7b008aaa718/pyobjc_framework_webkit-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:00ffb254f97e9ffdd0a82c1faa61a07f6072ba900fa8aba70c83c21198b52e4e", size = 50853, upload-time = "2026-06-19T16:19:33.43Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/f6/d24716fef19ccc3d880e99029458803f0174c05df310d991eb97ea3a0799/pyobjc_framework_webkit-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:67030258c3cd66e8495ccfccef3d2d58010ff0209284c5115e5afdb0e9fd6de1", size = 50499, upload-time = "2026-06-19T16:19:34.45Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a8/6c/817119a52efcc229a30ceff56a0641005a431806a1f555e0571626ba313a/pyobjc_framework_webkit-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5d91527c9950c79269dd0d70f2bb8668c298dd06930637c1c063ce5f274a87e5", size = 50967, upload-time = "2026-06-19T16:19:35.474Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/59/5fac0754d53b2a72aed6f424dfc72e5fa245f83cb57c2e00d02e45390fca/pyobjc_framework_webkit-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:657825081484c9920c50b76b469b9583f116225b0449c9d95c46cbc8c640adc8", size = 50498, upload-time = "2026-06-19T16:19:36.397Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/0c/e997e33d99d4ad91da2cf70f0e51ac39b03c58ba210548e9e944bbb421be/pyobjc_framework_webkit-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:f46adcc6227873f2b14d74b2e789c937f227722274ab59b9fa3c04c6ecb46dd5", size = 50958, upload-time = "2026-06-19T16:19:37.424Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/2ab99d3975dd4624dd943e5a7c8d37e40258d3c9fcf4f26baf09a24e6c9b/pyobjc_framework_webkit-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:af5c4ccdf03845adac082823a3b4341b5b2fe62d2d664550afa705b5286a06fc", size = 50264, upload-time = "2026-06-19T16:19:30.607Z" }, + { url = "https://files.pythonhosted.org/packages/84/47/7a2099eb2e062c6230a9440f1795cf34056ca5e16ef25c8aad7c059b8734/pyobjc_framework_webkit-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7e04dcc08cdc59380113ea1232af75a0a04c2426418ebe967b4c0045c973f776", size = 50372, upload-time = "2026-06-19T16:19:31.581Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a4/202ec288808011d3f459d000d593e88b1118f2d1d5a4dfaaf5232f2c2ac2/pyobjc_framework_webkit-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:23bee8bf7077f91da4e3ae54a00c7f5e4414319e15f98be8584dbd67c4043fae", size = 50387, upload-time = "2026-06-19T16:19:32.522Z" }, + { url = "https://files.pythonhosted.org/packages/95/a4/f796e94b43a66704b6ae17c747c7b97fd4b79348f1cfa9bef7b008aaa718/pyobjc_framework_webkit-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:00ffb254f97e9ffdd0a82c1faa61a07f6072ba900fa8aba70c83c21198b52e4e", size = 50853, upload-time = "2026-06-19T16:19:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f6/d24716fef19ccc3d880e99029458803f0174c05df310d991eb97ea3a0799/pyobjc_framework_webkit-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:67030258c3cd66e8495ccfccef3d2d58010ff0209284c5115e5afdb0e9fd6de1", size = 50499, upload-time = "2026-06-19T16:19:34.45Z" }, + { url = "https://files.pythonhosted.org/packages/a8/6c/817119a52efcc229a30ceff56a0641005a431806a1f555e0571626ba313a/pyobjc_framework_webkit-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5d91527c9950c79269dd0d70f2bb8668c298dd06930637c1c063ce5f274a87e5", size = 50967, upload-time = "2026-06-19T16:19:35.474Z" }, + { url = "https://files.pythonhosted.org/packages/2d/59/5fac0754d53b2a72aed6f424dfc72e5fa245f83cb57c2e00d02e45390fca/pyobjc_framework_webkit-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:657825081484c9920c50b76b469b9583f116225b0449c9d95c46cbc8c640adc8", size = 50498, upload-time = "2026-06-19T16:19:36.397Z" }, + { url = "https://files.pythonhosted.org/packages/da/0c/e997e33d99d4ad91da2cf70f0e51ac39b03c58ba210548e9e944bbb421be/pyobjc_framework_webkit-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:f46adcc6227873f2b14d74b2e789c937f227722274ab59b9fa3c04c6ecb46dd5", size = 50958, upload-time = "2026-06-19T16:19:37.424Z" }, ] [[package]] name = "pyparsing" version = "3.3.2" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pypinyin" +version = "0.55.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/a4/784cf98c09e0dc22776b0d7d8a4a5b761218bcae4608c2416ce1e167c8af/pypinyin-0.55.0.tar.gz", hash = "sha256:b5711b3a0c6f76e67408ec6b2e3c4987a3a806b7c528076e7c7b86fcf0eaa66b", size = 839836, upload-time = "2025-07-20T12:01:50.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/7b/4cabc76fcc21c3c7d5c671d8783984d30ac9d3bb387c4ba784fca3cdfa3a/pypinyin-0.55.0-py2.py3-none-any.whl", hash = "sha256:d53b1e8ad2cdb815fb2cb604ed3123372f5a28c6f447571244aca36fc62a286f", size = 840203, upload-time = "2025-07-20T12:01:48.535Z" }, ] [[package]] name = "pytesseract" version = "0.3.13" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, { name = "pillow" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/a6/7d679b83c285974a7cb94d739b461fa7e7a9b17a3abfd7bf6cbc5c2394b0/pytesseract-0.3.13.tar.gz", hash = "sha256:4bf5f880c99406f52a3cfc2633e42d9dc67615e69d8a509d74867d3baddb5db9", size = 17689, upload-time = "2024-08-16T02:33:56.762Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/a6/7d679b83c285974a7cb94d739b461fa7e7a9b17a3abfd7bf6cbc5c2394b0/pytesseract-0.3.13.tar.gz", hash = "sha256:4bf5f880c99406f52a3cfc2633e42d9dc67615e69d8a509d74867d3baddb5db9", size = 17689, upload-time = "2024-08-16T02:33:56.762Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7a/33/8312d7ce74670c9d39a532b2c246a853861120486be9443eebf048043637/pytesseract-0.3.13-py3-none-any.whl", hash = "sha256:7a99c6c2ac598360693d83a416e36e0b33a67638bb9d77fdcac094a3589d4b34", size = 14705, upload-time = "2024-08-16T02:36:10.09Z" }, + { url = "https://files.pythonhosted.org/packages/7a/33/8312d7ce74670c9d39a532b2c246a853861120486be9443eebf048043637/pytesseract-0.3.13-py3-none-any.whl", hash = "sha256:7a99c6c2ac598360693d83a416e36e0b33a67638bb9d77fdcac094a3589d4b34", size = 14705, upload-time = "2024-08-16T02:36:10.09Z" }, ] [[package]] name = "pytest" version = "9.0.3" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, @@ -1982,80 +1991,80 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] name = "pytest-asyncio" version = "1.3.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] [[package]] name = "python-dateutil" version = "2.9.0.post0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] name = "python-dotenv" version = "1.2.2" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] name = "python-multipart" version = "0.0.30" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/82/c8cd43a6e0719bf5a3b034f6726dd701f75829c08944c83d4b95d02ed0e8/python_multipart-0.0.30.tar.gz", hash = "sha256:0edfe0475c1f46ddd3ff7785a626f6118af32bdcf359bb21260367313bb32118", size = 46316, upload-time = "2026-05-31T19:24:55.198Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/82/c8cd43a6e0719bf5a3b034f6726dd701f75829c08944c83d4b95d02ed0e8/python_multipart-0.0.30.tar.gz", hash = "sha256:0edfe0475c1f46ddd3ff7785a626f6118af32bdcf359bb21260367313bb32118", size = 46316, upload-time = "2026-05-31T19:24:55.198Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1c/fd/0318007beb234790993d3ec5afd051d1dbceb733e81e3afe2b981ece3f37/python_multipart-0.0.30-py3-none-any.whl", hash = "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b", size = 29730, upload-time = "2026-05-31T19:24:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/0318007beb234790993d3ec5afd051d1dbceb733e81e3afe2b981ece3f37/python_multipart-0.0.30-py3-none-any.whl", hash = "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b", size = 29730, upload-time = "2026-05-31T19:24:53.814Z" }, ] [[package]] name = "pythonnet" version = "3.1.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "clr-loader", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/57/da1992e44663b71365c6e842c8d7fa453d4ec45fb99a68cfee5b7e944d3c/pythonnet-3.1.0.tar.gz", hash = "sha256:7b34c382905d10a371509ffafd64cae0416305c28817738a9cd138336f4e9991", size = 250599, upload-time = "2026-05-23T20:30:21.578Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/57/da1992e44663b71365c6e842c8d7fa453d4ec45fb99a68cfee5b7e944d3c/pythonnet-3.1.0.tar.gz", hash = "sha256:7b34c382905d10a371509ffafd64cae0416305c28817738a9cd138336f4e9991", size = 250599, upload-time = "2026-05-23T20:30:21.578Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ac/4b/52414f442624d2589f5374a48c08d5ae94f24bea67fc13a20a752884e5b7/pythonnet-3.1.0-cp310.cp311.cp312.cp313.cp314-none-any.whl", hash = "sha256:698dd88edc198819ad63b624a6ebe76208c7b46e4fe13626f65e484f0358d6ba", size = 217578, upload-time = "2026-05-23T20:30:19.527Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/db/67/031124fdcb937c266a3265118525bbf6dc13b8c79786d6a7290aecb6e7bb/pythonnet-3.1.0-cp310.cp311.cp312.cp313.cp314-none-win32.win_amd64.whl", hash = "sha256:7bdd4de03df3547a48122a3989265c8b31d5be0d19dadffa009eec7df8085e0b", size = 1644898, upload-time = "2026-05-23T20:30:16.213Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4b/52414f442624d2589f5374a48c08d5ae94f24bea67fc13a20a752884e5b7/pythonnet-3.1.0-cp310.cp311.cp312.cp313.cp314-none-any.whl", hash = "sha256:698dd88edc198819ad63b624a6ebe76208c7b46e4fe13626f65e484f0358d6ba", size = 217578, upload-time = "2026-05-23T20:30:19.527Z" }, + { url = "https://files.pythonhosted.org/packages/db/67/031124fdcb937c266a3265118525bbf6dc13b8c79786d6a7290aecb6e7bb/pythonnet-3.1.0-cp310.cp311.cp312.cp313.cp314-none-win32.win_amd64.whl", hash = "sha256:7bdd4de03df3547a48122a3989265c8b31d5be0d19dadffa009eec7df8085e0b", size = 1644898, upload-time = "2026-05-23T20:30:16.213Z" }, ] [[package]] name = "pytz" version = "2026.2" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, ] [[package]] name = "pywebview" version = "6.2.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bottle" }, { name = "proxy-tools" }, @@ -2069,430 +2078,430 @@ dependencies = [ { name = "qtpy", marker = "sys_platform == 'openbsd6'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/59/4a/05307135dafba67778669d194bd1a3822a7685ec9ee8a6d7e70856c1a551/pywebview-6.2.1.tar.gz", hash = "sha256:71b7136752e40824655304d938efb62014218d1a90bd8e87e1cbdb1ce9c466af", size = 513126, upload-time = "2026-04-15T09:02:16.595Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/4a/05307135dafba67778669d194bd1a3822a7685ec9ee8a6d7e70856c1a551/pywebview-6.2.1.tar.gz", hash = "sha256:71b7136752e40824655304d938efb62014218d1a90bd8e87e1cbdb1ce9c466af", size = 513126, upload-time = "2026-04-15T09:02:16.595Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/25/9491695c22c4842c5b3903b4dc172e0eecf67a27c0af34a71512c9b76a0a/pywebview-6.2.1-py3-none-any.whl", hash = "sha256:9d07275f53894ab4d5e2e0e996227193e7187dec276d9b624dccbce029216b46", size = 525463, upload-time = "2026-04-15T09:02:10.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/25/9491695c22c4842c5b3903b4dc172e0eecf67a27c0af34a71512c9b76a0a/pywebview-6.2.1-py3-none-any.whl", hash = "sha256:9d07275f53894ab4d5e2e0e996227193e7187dec276d9b624dccbce029216b46", size = 525463, upload-time = "2026-04-15T09:02:10.186Z" }, ] [[package]] name = "pyyaml" version = "6.0.3" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] name = "qtpy" version = "2.4.3" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/01/392eba83c8e47b946b929d7c46e0f04b35e9671f8bb6fc36b6f7945b4de8/qtpy-2.4.3.tar.gz", hash = "sha256:db744f7832e6d3da90568ba6ccbca3ee2b3b4a890c3d6fbbc63142f6e4cdf5bb", size = 66982, upload-time = "2025-02-11T15:09:25.759Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/01/392eba83c8e47b946b929d7c46e0f04b35e9671f8bb6fc36b6f7945b4de8/qtpy-2.4.3.tar.gz", hash = "sha256:db744f7832e6d3da90568ba6ccbca3ee2b3b4a890c3d6fbbc63142f6e4cdf5bb", size = 66982, upload-time = "2025-02-11T15:09:25.759Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/76/37c0ccd5ab968a6a438f9c623aeecc84c202ab2fabc6a8fd927580c15b5a/QtPy-2.4.3-py3-none-any.whl", hash = "sha256:72095afe13673e017946cc258b8d5da43314197b741ed2890e563cf384b51aa1", size = 95045, upload-time = "2025-02-11T15:09:24.162Z" }, + { url = "https://files.pythonhosted.org/packages/69/76/37c0ccd5ab968a6a438f9c623aeecc84c202ab2fabc6a8fd927580c15b5a/QtPy-2.4.3-py3-none-any.whl", hash = "sha256:72095afe13673e017946cc258b8d5da43314197b741ed2890e563cf384b51aa1", size = 95045, upload-time = "2025-02-11T15:09:24.162Z" }, ] [[package]] name = "regex" version = "2026.5.9" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/dc/c1f2df4027e82fc54b5a473e4b250f5139faca49a0fbe29a48668d228f34/regex-2026.5.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ccf5249114cc3e772ecdd88a98a86eca0fd74c61ce32a94743758c083fc05d48", size = 489445, upload-time = "2026-05-09T23:12:06.111Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/03/d2/59f01110660081cce9c0bc30ebd0b5ee250dacf658e3248ed92f01e0e8ee/regex-2026.5.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46f1326ca6e65b0879d23ca302c0f2415aad42ff0309b9c818e7949fe19a41d8", size = 291271, upload-time = "2026-05-09T23:12:07.731Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/b6/14b2c84ff90ddb370c81d27503f4a0fcf071496416f4855f6cc8c5d81c35/regex-2026.5.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef31cbfe458e21c6122ba8150ff060e0c7789ed0d26eb423f25472584920b555", size = 289212, upload-time = "2026-05-09T23:12:09.266Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/03/d0/4db86529117320de0c84afd90e70bb47434625875e34fcef9d8c127c5b16/regex-2026.5.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:992604d02e6d9c6d786c24a706a71ecffe1020fc1ef264044474cd81fa2c3919", size = 792310, upload-time = "2026-05-09T23:12:11.416Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/78/fe4800cd322f862ecffd2d553409b20d80650e5ed71b9d178f853d020b82/regex-2026.5.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9411dd64ca95477225734a93dfc8583b51916b8d5942f99d6cac21e09965451", size = 861721, upload-time = "2026-05-09T23:12:13.681Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b5/d0/b3618a895dd8feb897c61bb2954edd265e1767d82a01d53065d5871127a3/regex-2026.5.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4a3ff360dfb836fecdb93a4598f9d6e2ac81e3e397125145c6221bf58cf4c", size = 906460, upload-time = "2026-05-09T23:12:15.443Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/33/6f/1481597e859ef19508b345eec4afd1416ed6e6b459c75a64026ef193aecf/regex-2026.5.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a661a7d270a61f7cf460caee8b9fa2d5ef9e5c681234bcb9e0fe14f488e7dfc", size = 799843, upload-time = "2026-05-09T23:12:16.892Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/73/59/955734c803f59108deccba3597ae440c76b62a652733c0006e6243758420/regex-2026.5.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f079e50a0d3cc3cd5091fa9ff45869a2e6b2cd35895731edafb0327901a8d86d", size = 773610, upload-time = "2026-05-09T23:12:19.127Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/68/8f/70c04a236d651c81881dac42ef8538bddda6121434509d0a22d9e601503b/regex-2026.5.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4ebe8f0b5ec5a5024dc4a4c59f444c4e9afc5f2abdbb8962065b75d27fb971f9", size = 781645, upload-time = "2026-05-09T23:12:20.806Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1d/96/05c7434d88185e5d27fe54aeb74df86bd77cd79f52f0b4eae54faa8fea70/regex-2026.5.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:97cf3bc1b7d7d2306772ec07366c80d9df00ff79e79cea32898883a646d2fae2", size = 854473, upload-time = "2026-05-09T23:12:22.465Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4e/c1/6e3d8202d981f3117004bf341ee74893ba4ba8a9fbaf4b94615846550a08/regex-2026.5.9-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0f9eede6a5cbdc02d4978090186390936e1776a7d1359b21e41014c609880bcf", size = 763311, upload-time = "2026-05-09T23:12:24.351Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/93/c7/e7737f1526b3fb32bd4c337fd6c71c3ebb5c8296fc34d11197e0955d2e35/regex-2026.5.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:01f0f5f55f4b64dacec85dc116d3c05fd23ad3ff037bbc73a2085775953c2611", size = 844593, upload-time = "2026-05-09T23:12:26.341Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a5/27/0daffb1a535bb39f422c3d200f4ab023c71110ad66a32b366bee708baba0/regex-2026.5.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1268eddd8486dc561d08eee1156e40aa3a8fe10f4bdec8fa653b455fcbffd12c", size = 789167, upload-time = "2026-05-09T23:12:27.975Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/fc/294fe4fac4f2ed67207b17471815870c1c45b3a489e08e0ac96daea16ef6/regex-2026.5.9-cp311-cp311-win32.whl", hash = "sha256:8676474c07469d6f33dd1085ca2cd45f65785f32518f2b20e36d9953ca07f994", size = 266249, upload-time = "2026-05-09T23:12:30.141Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d0/b0/8dce459f6245bcf8f6e9f23ac9569f1a0f15c131cc0745e82b43226204cf/regex-2026.5.9-cp311-cp311-win_amd64.whl", hash = "sha256:246de9d60aa3f8538b519834dd95cbf276ea263d6a7bd5a3666dc3fa0230505b", size = 278423, upload-time = "2026-05-09T23:12:31.676Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/db/8d/f9aeff6ad63a3ef720386f2907e6d34a35a510a6e498ebad28b0fb3f6ab6/regex-2026.5.9-cp311-cp311-win_arm64.whl", hash = "sha256:d726ca3f0d76969bf1e8e477d160d3d666bbf999f6860bd314889e5345782046", size = 270420, upload-time = "2026-05-09T23:12:33.194Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/9b/6550044bc44e17c84d312c031c2ec42fbdb6a4ec4e29093be3a172d08772/regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06", size = 490451, upload-time = "2026-05-09T23:12:34.72Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/95/fc7ba4303b5a0f92446a12ee6778ef2c6c799233f5060042a31bf390cfe9/regex-2026.5.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6", size = 292112, upload-time = "2026-05-09T23:12:36.285Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/4b/ee27938d1b2c443e89a9a10e00d2d19aa5ee300cd3d61140644e93bb083e/regex-2026.5.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225", size = 289599, upload-time = "2026-05-09T23:12:38.089Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/dd/ba103dc19614e25f3880800ca67ce093d6e21b325d72b8383c7bf906e9fa/regex-2026.5.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0", size = 796732, upload-time = "2026-05-09T23:12:40.062Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cf/e7/f035b4fd858b050b0080bf302968dc0f59ba34e391872d54936758e6844e/regex-2026.5.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107", size = 865440, upload-time = "2026-05-09T23:12:42.059Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/51/8cd301ecc899aea28124357f729f4272f44de7806fc7ca02490bfbe253e8/regex-2026.5.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309", size = 912329, upload-time = "2026-05-09T23:12:44.373Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/1e/3fbe2fa1e8cebd62f3bb7d3321cff1640aca2e240b51d9bd624aad949260/regex-2026.5.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8", size = 801239, upload-time = "2026-05-09T23:12:46.268Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/17/2f/6f6008682bf2cf98040a0d3153a8e557b6ab728d7713d045cee4ce544ab8/regex-2026.5.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66", size = 777054, upload-time = "2026-05-09T23:12:48.051Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/2b/eee0d20a6842ba04df4b8847a920b57ef56853f14ef85405473e586b605a/regex-2026.5.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026", size = 785098, upload-time = "2026-05-09T23:12:49.851Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4a/98/6fc1e6410feefb92159edaed5041992bfe390e8d26c721865434acbca558/regex-2026.5.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962", size = 860095, upload-time = "2026-05-09T23:12:51.666Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/a3/bd855e0f2cb1a978ecf6fa6bb69632dd9c3f6ea3b81cde62fde14c9daec7/regex-2026.5.9-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621", size = 765762, upload-time = "2026-05-09T23:12:53.413Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dc/66/0ae8c092e60b14c79d24f8e0b7f0aea5bfbffdcab00b5483d13404d3c3a5/regex-2026.5.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d", size = 852100, upload-time = "2026-05-09T23:12:55.256Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/21/de/8dfde60fc1b21c946a893ba273403b72617edb261370cb1087099a83f088/regex-2026.5.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce", size = 789479, upload-time = "2026-05-09T23:12:57.573Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/1c/bdcc98f9a4af4fdd166c74941174619ccff4726d3ce32faa8e9a2ecd38dd/regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e", size = 266699, upload-time = "2026-05-09T23:12:59.14Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/87/240d36864f9e48ace85f72e79ced97ceb7f27ce87739a947dcb834b4e6bc/regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e", size = 277783, upload-time = "2026-05-09T23:13:00.789Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4f/b5/7b30f312b0669dff5beebe5b0989dc2d1a312b1a44fab852199c387a5b96/regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070", size = 270513, upload-time = "2026-05-09T23:13:02.426Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/da/797e91ecec6f84135da778ddce78c20e0af5d2a15c26f87a81bc3eadb6db/regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb", size = 490303, upload-time = "2026-05-09T23:13:04.382Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/44/da/bf30abaaa737b58f4a4b8c4a03659e02fd92092c822e0197ed9e0daab917/regex-2026.5.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f", size = 292019, upload-time = "2026-05-09T23:13:06.022Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/e7/d0eaf5713828417b9e5648cf81fa9bacd4961f6ab98c380c2034f8716e35/regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c", size = 289468, upload-time = "2026-05-09T23:13:08.214Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/9b/b3fdd62b003baa1a9b593cd8c8699c9651c2e80cc21a5c715707983c42d7/regex-2026.5.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed", size = 796749, upload-time = "2026-05-09T23:13:10.573Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/30/66ab84588765f5b4b271a9ca09ef7ce2b87caa95176ec3d2ad65d7bc4902/regex-2026.5.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020", size = 865445, upload-time = "2026-05-09T23:13:12.523Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1a/89/f05169e8588aac365f35ffc7f3bc3184f095ef4cfded7cfaa3c7fd5dbd89/regex-2026.5.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2", size = 912322, upload-time = "2026-05-09T23:13:14.281Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/e1/c93444052cf41581f3c884ab3fb5823daf0992f11cd4388d4275ca610558/regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2", size = 801269, upload-time = "2026-05-09T23:13:16.569Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/fe/0cf96b882f540e62e8b9956599798203d599c44cf4c77917ca27400ff69b/regex-2026.5.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04", size = 777085, upload-time = "2026-05-09T23:13:18.675Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/5c/d78d4924e7fc875557b9e9b768423925fdfaac5549d06da7810019a9bd26/regex-2026.5.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c", size = 785153, upload-time = "2026-05-09T23:13:20.525Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/e0/5214774090e7b4524dcea3e3c4aa74141d43043f8beb49c1599db1c8b53a/regex-2026.5.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f", size = 860164, upload-time = "2026-05-09T23:13:22.263Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6e/e1/4a57a83350319b1271f0d7a249b8672513ed928b237a741631270de6caea/regex-2026.5.9-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8", size = 765731, upload-time = "2026-05-09T23:13:24.277Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/12/f4/499e74a20c156fc75836ee04a72a38d1a063978f600937f9760467beb1b0/regex-2026.5.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6", size = 852062, upload-time = "2026-05-09T23:13:26.125Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5b/92/7eebc0d0a01e78629695f342ba17e0deaff8fb45e79cc0d7b98287da6e3e/regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21", size = 789577, upload-time = "2026-05-09T23:13:27.814Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/a4/018e71f7d2ad48c1ebe6d3ae0026f9b7cb4802fd15c7cc02fdf724355102/regex-2026.5.9-cp313-cp313-win32.whl", hash = "sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127", size = 266691, upload-time = "2026-05-09T23:13:29.549Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e6/1d/861a93719fb9ee7dbfc3761b3797b7a3e112a5d42c6129459d2d741be9b5/regex-2026.5.9-cp313-cp313-win_amd64.whl", hash = "sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca", size = 277747, upload-time = "2026-05-09T23:13:31.859Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/c6/0a2436ae4da1ba76e51cb98943c6838a9a721faa40ebe2dce07694ae34e3/regex-2026.5.9-cp313-cp313-win_arm64.whl", hash = "sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6", size = 270500, upload-time = "2026-05-09T23:13:33.525Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/e9/d21346f7b60ed58789371358ed66b09d00f832e1bd7c06e55d9da5679882/regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3", size = 494172, upload-time = "2026-05-09T23:13:35.935Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/43/fd1177a2032037c681baecdb3422ee4e1424aec4e4f470ef47793d325274/regex-2026.5.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6", size = 293952, upload-time = "2026-05-09T23:13:38.307Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/7d/9fbf919768368d3f8a4f6c692cf2aa61e482b2b81ec6a298ace4cbf02480/regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff", size = 292314, upload-time = "2026-05-09T23:13:40.353Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e2/6c/e41bfeecb589716843e7c4df09ba46ff2a42961457afece19059d85caeef/regex-2026.5.9-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88", size = 811681, upload-time = "2026-05-09T23:13:42.543Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/87/83/a5c1c525fba0aa656e88ad0face0b1829788ef4c2fb6b26df58aa1151b84/regex-2026.5.9-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178", size = 871135, upload-time = "2026-05-09T23:13:44.326Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/d4/80882e799e440dd878b0979cbebf8fa4d54624a332c83037c7a701649e3f/regex-2026.5.9-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100", size = 917265, upload-time = "2026-05-09T23:13:47.295Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/ff/8db60211e2286e396aad7dc7725356c502bff0901ea05bd6cdc2e1a042b9/regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e", size = 816311, upload-time = "2026-05-09T23:13:49.885Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4c/47/742ef579c61730f8d268e5cf1f9ce0e37e2ea041ad0f5644724f2378e463/regex-2026.5.9-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2", size = 785498, upload-time = "2026-05-09T23:13:52.25Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7f/ab/cb0999802dcb0fb95b1ab005e8d4163d8afdd67efc2cb6b6630ac13f8cb1/regex-2026.5.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b", size = 801348, upload-time = "2026-05-09T23:13:54.127Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/62/8ca59a24c55bc34d166eefaf3717bd77772f329fdbf984d86581e0a3571c/regex-2026.5.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e", size = 866493, upload-time = "2026-05-09T23:13:56.067Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8d/3d/30f2ae62cef3278bb5bb821f467277a55fb73f01032cf85997e15e8289a8/regex-2026.5.9-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041", size = 772811, upload-time = "2026-05-09T23:13:57.867Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/ae/7d2089bcd78ad0c0161bc684339df50032acb438a7bd3305e7ddb1193cec/regex-2026.5.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0", size = 856584, upload-time = "2026-05-09T23:13:59.679Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a9/29/92ff47f75990131ea4f24ba17819e5a9d141e10819807e09addd73409af6/regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081", size = 803453, upload-time = "2026-05-09T23:14:01.978Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/99/eff29f1037dcab36702c9ee5d6858cf1ce2336ea8ea2987f64245b99ea5e/regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5", size = 269951, upload-time = "2026-05-09T23:14:03.661Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0e/9d/8870b8981d27b22cda77bb26a5ac7ebfa9c7d9e0dea195a834a82380e748/regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4", size = 281240, upload-time = "2026-05-09T23:14:05.56Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/b1/3379415e8f135c13ac551353397cc4fe97b4978f3cac73c5fcbcded548b8/regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de", size = 272383, upload-time = "2026-05-09T23:14:07.843Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/13/3e/9c3cd292d8808b3645a2ce517e200179b6d0e903f176300bd8b542e14de5/regex-2026.5.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a", size = 490376, upload-time = "2026-05-09T23:14:09.64Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/70/d43ee8a2ca0a8b68d167f21658b85520ac0574617c7f320367c5047f7556/regex-2026.5.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4", size = 291964, upload-time = "2026-05-09T23:14:11.424Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/21/91/9d50b433828d8e74196904e168a43abf1e6e88b2a15d47ed742456720c37/regex-2026.5.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c", size = 289682, upload-time = "2026-05-09T23:14:13.123Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/d2/b835e3cafbb9d977736912436259ff551d60919f7d7b3d37d46659c63564/regex-2026.5.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9", size = 796996, upload-time = "2026-05-09T23:14:14.923Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/a6/9f992d00019166b9de01c546dd4549bc679f2a68df11b877740b0760b7c2/regex-2026.5.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af", size = 866089, upload-time = "2026-05-09T23:14:17.757Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e0/08/4d32af657e049b19cb62b02e46e38fe1518797bfb2203ee93a510b21b0dc/regex-2026.5.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0", size = 911530, upload-time = "2026-05-09T23:14:20.353Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/27/2af43dd1dc201d1fecefda64a45f4ad0995855b92724f795a777b402ee69/regex-2026.5.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4", size = 800643, upload-time = "2026-05-09T23:14:22.265Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a4/dd/23a249047013b5321d4a60c4d2437462086f601b061776a525e5fba2a59f/regex-2026.5.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf", size = 777223, upload-time = "2026-05-09T23:14:24.179Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/6a/e85ed9538cd19586d0465076a4578a12e093ce776d15f3f8ce92733a8dd6/regex-2026.5.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346", size = 785760, upload-time = "2026-05-09T23:14:26.065Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/c4/f25473209438638e947c55f9156fd8f236f74169229028cc99116380868e/regex-2026.5.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676", size = 860891, upload-time = "2026-05-09T23:14:28.17Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/f7/f4f86e3c74419c37370e91f150ae0c2ef7d34b2e0e4cdd5da046a02e4022/regex-2026.5.9-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14", size = 765891, upload-time = "2026-05-09T23:14:30.06Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/70/704d8e13765939146b1cd0ef4e2feb71d7929727d2290f026eed10095955/regex-2026.5.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd", size = 851380, upload-time = "2026-05-09T23:14:32.123Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/29/1a13582a8460038edc38e49f64ceb0dd7c60f5caba77571f4bf6601965d9/regex-2026.5.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e", size = 789350, upload-time = "2026-05-09T23:14:34.799Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/73/56/3dcafe34fc72e271d62ad9a291801e88a1457bb251c132f15fcc2e5aad1a/regex-2026.5.9-cp314-cp314-win32.whl", hash = "sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad", size = 272130, upload-time = "2026-05-09T23:14:36.729Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d0/9c/02eebf0be95efe416c664db7fb8b6b05b7a0b06a7544f2884f2558b0526f/regex-2026.5.9-cp314-cp314-win_amd64.whl", hash = "sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763", size = 280999, upload-time = "2026-05-09T23:14:39.126Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/5a/1dd1abee76cb7a846a0bcf42fdc87e5720c3c33c24f3e37814310a513d9f/regex-2026.5.9-cp314-cp314-win_arm64.whl", hash = "sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372", size = 273500, upload-time = "2026-05-09T23:14:41.059Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/c1/c5f619b0057a7965cb78ec559c1d7a45ce8c99a35bea95483d64959a93d9/regex-2026.5.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499", size = 494269, upload-time = "2026-05-09T23:14:42.869Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/2c/5d01f1aee33de4bbe60c8452945bfc8477ca7c5ae4450f6bfe711036cb36/regex-2026.5.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1", size = 293954, upload-time = "2026-05-09T23:14:44.822Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7a/fe/e8988b2ae2108c6ef71bd4aa8d87fbe257976dd0810e826cd75f701c68b6/regex-2026.5.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d", size = 292405, upload-time = "2026-05-09T23:14:47.211Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/79/34/d2b0937faa7859263f7f0a3c6b103a1296306be6952dc173d0154e9a2f49/regex-2026.5.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c", size = 811855, upload-time = "2026-05-09T23:14:49.21Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/fe/daf53a47457a8486db66c66c01ceb9c2303eecee3f87197f1e77eb1a736d/regex-2026.5.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5", size = 871189, upload-time = "2026-05-09T23:14:51.555Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1c/75/058fc4470cbfbf57d800aff1a0022b929a3f9fa553ee10a0cdf2070eb31f/regex-2026.5.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20", size = 917485, upload-time = "2026-05-09T23:14:53.633Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/88/e7/179cfda3a28bc843b5c6cfe7f79f23489c791ed95f151083803660878432/regex-2026.5.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0", size = 816369, upload-time = "2026-05-09T23:14:56.198Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/90/6f0cc422071688266d344fca8462d787cba0a2c144acb25721f9a61ec265/regex-2026.5.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d", size = 785869, upload-time = "2026-05-09T23:14:58.602Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/02/67/a31f1760f09c27b251ef39e9beb541f462cf977381d067faa764c2c0e393/regex-2026.5.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b", size = 801427, upload-time = "2026-05-09T23:15:00.642Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e3/c4/1a80654597b6bc1e1ea0494824c31200e8a956abe290afae9b19a166a148/regex-2026.5.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a", size = 866482, upload-time = "2026-05-09T23:15:03.384Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/11/960724e06482c08466ff5611e242e86f80062949cdf6b4b9cc317b9dd93d/regex-2026.5.9-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415", size = 773022, upload-time = "2026-05-09T23:15:05.625Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/a8/a9979c3e7918280e93159ebcab5ef1a65116dd4f3bd6091be0eae4a126e8/regex-2026.5.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2", size = 856642, upload-time = "2026-05-09T23:15:07.966Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fe/d4/a9b732f2f0072c0ab12227483abb24fffcb9f73f8a2b203df0a6d0434735/regex-2026.5.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41", size = 803552, upload-time = "2026-05-09T23:15:10.215Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/fe/1b3113817447a1d4155e4ac76d2e072f42c0bcba2f43fa8a0e756ea2cd91/regex-2026.5.9-cp314-cp314t-win32.whl", hash = "sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58", size = 275746, upload-time = "2026-05-09T23:15:12.609Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/73/93d42045302636c91f2e5ef588b65b84b01428f28ec77de256b1dfdfbe5c/regex-2026.5.9-cp314-cp314t-win_amd64.whl", hash = "sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77", size = 285685, upload-time = "2026-05-09T23:15:15.086Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/80/35b4c33c804a165a7f55289afda3ea9e3eb6d15800341a2d66455c0f1f30/regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa", size = 275713, upload-time = "2026-05-09T23:15:16.98Z" }, + { url = "https://files.pythonhosted.org/packages/c2/dc/c1f2df4027e82fc54b5a473e4b250f5139faca49a0fbe29a48668d228f34/regex-2026.5.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ccf5249114cc3e772ecdd88a98a86eca0fd74c61ce32a94743758c083fc05d48", size = 489445, upload-time = "2026-05-09T23:12:06.111Z" }, + { url = "https://files.pythonhosted.org/packages/03/d2/59f01110660081cce9c0bc30ebd0b5ee250dacf658e3248ed92f01e0e8ee/regex-2026.5.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46f1326ca6e65b0879d23ca302c0f2415aad42ff0309b9c818e7949fe19a41d8", size = 291271, upload-time = "2026-05-09T23:12:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/58/b6/14b2c84ff90ddb370c81d27503f4a0fcf071496416f4855f6cc8c5d81c35/regex-2026.5.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef31cbfe458e21c6122ba8150ff060e0c7789ed0d26eb423f25472584920b555", size = 289212, upload-time = "2026-05-09T23:12:09.266Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/4db86529117320de0c84afd90e70bb47434625875e34fcef9d8c127c5b16/regex-2026.5.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:992604d02e6d9c6d786c24a706a71ecffe1020fc1ef264044474cd81fa2c3919", size = 792310, upload-time = "2026-05-09T23:12:11.416Z" }, + { url = "https://files.pythonhosted.org/packages/07/78/fe4800cd322f862ecffd2d553409b20d80650e5ed71b9d178f853d020b82/regex-2026.5.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9411dd64ca95477225734a93dfc8583b51916b8d5942f99d6cac21e09965451", size = 861721, upload-time = "2026-05-09T23:12:13.681Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d0/b3618a895dd8feb897c61bb2954edd265e1767d82a01d53065d5871127a3/regex-2026.5.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4a3ff360dfb836fecdb93a4598f9d6e2ac81e3e397125145c6221bf58cf4c", size = 906460, upload-time = "2026-05-09T23:12:15.443Z" }, + { url = "https://files.pythonhosted.org/packages/33/6f/1481597e859ef19508b345eec4afd1416ed6e6b459c75a64026ef193aecf/regex-2026.5.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a661a7d270a61f7cf460caee8b9fa2d5ef9e5c681234bcb9e0fe14f488e7dfc", size = 799843, upload-time = "2026-05-09T23:12:16.892Z" }, + { url = "https://files.pythonhosted.org/packages/73/59/955734c803f59108deccba3597ae440c76b62a652733c0006e6243758420/regex-2026.5.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f079e50a0d3cc3cd5091fa9ff45869a2e6b2cd35895731edafb0327901a8d86d", size = 773610, upload-time = "2026-05-09T23:12:19.127Z" }, + { url = "https://files.pythonhosted.org/packages/68/8f/70c04a236d651c81881dac42ef8538bddda6121434509d0a22d9e601503b/regex-2026.5.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4ebe8f0b5ec5a5024dc4a4c59f444c4e9afc5f2abdbb8962065b75d27fb971f9", size = 781645, upload-time = "2026-05-09T23:12:20.806Z" }, + { url = "https://files.pythonhosted.org/packages/1d/96/05c7434d88185e5d27fe54aeb74df86bd77cd79f52f0b4eae54faa8fea70/regex-2026.5.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:97cf3bc1b7d7d2306772ec07366c80d9df00ff79e79cea32898883a646d2fae2", size = 854473, upload-time = "2026-05-09T23:12:22.465Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/6e3d8202d981f3117004bf341ee74893ba4ba8a9fbaf4b94615846550a08/regex-2026.5.9-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0f9eede6a5cbdc02d4978090186390936e1776a7d1359b21e41014c609880bcf", size = 763311, upload-time = "2026-05-09T23:12:24.351Z" }, + { url = "https://files.pythonhosted.org/packages/93/c7/e7737f1526b3fb32bd4c337fd6c71c3ebb5c8296fc34d11197e0955d2e35/regex-2026.5.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:01f0f5f55f4b64dacec85dc116d3c05fd23ad3ff037bbc73a2085775953c2611", size = 844593, upload-time = "2026-05-09T23:12:26.341Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/0daffb1a535bb39f422c3d200f4ab023c71110ad66a32b366bee708baba0/regex-2026.5.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1268eddd8486dc561d08eee1156e40aa3a8fe10f4bdec8fa653b455fcbffd12c", size = 789167, upload-time = "2026-05-09T23:12:27.975Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fc/294fe4fac4f2ed67207b17471815870c1c45b3a489e08e0ac96daea16ef6/regex-2026.5.9-cp311-cp311-win32.whl", hash = "sha256:8676474c07469d6f33dd1085ca2cd45f65785f32518f2b20e36d9953ca07f994", size = 266249, upload-time = "2026-05-09T23:12:30.141Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b0/8dce459f6245bcf8f6e9f23ac9569f1a0f15c131cc0745e82b43226204cf/regex-2026.5.9-cp311-cp311-win_amd64.whl", hash = "sha256:246de9d60aa3f8538b519834dd95cbf276ea263d6a7bd5a3666dc3fa0230505b", size = 278423, upload-time = "2026-05-09T23:12:31.676Z" }, + { url = "https://files.pythonhosted.org/packages/db/8d/f9aeff6ad63a3ef720386f2907e6d34a35a510a6e498ebad28b0fb3f6ab6/regex-2026.5.9-cp311-cp311-win_arm64.whl", hash = "sha256:d726ca3f0d76969bf1e8e477d160d3d666bbf999f6860bd314889e5345782046", size = 270420, upload-time = "2026-05-09T23:12:33.194Z" }, + { url = "https://files.pythonhosted.org/packages/50/9b/6550044bc44e17c84d312c031c2ec42fbdb6a4ec4e29093be3a172d08772/regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06", size = 490451, upload-time = "2026-05-09T23:12:34.72Z" }, + { url = "https://files.pythonhosted.org/packages/1e/95/fc7ba4303b5a0f92446a12ee6778ef2c6c799233f5060042a31bf390cfe9/regex-2026.5.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6", size = 292112, upload-time = "2026-05-09T23:12:36.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/4b/ee27938d1b2c443e89a9a10e00d2d19aa5ee300cd3d61140644e93bb083e/regex-2026.5.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225", size = 289599, upload-time = "2026-05-09T23:12:38.089Z" }, + { url = "https://files.pythonhosted.org/packages/d8/dd/ba103dc19614e25f3880800ca67ce093d6e21b325d72b8383c7bf906e9fa/regex-2026.5.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0", size = 796732, upload-time = "2026-05-09T23:12:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e7/f035b4fd858b050b0080bf302968dc0f59ba34e391872d54936758e6844e/regex-2026.5.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107", size = 865440, upload-time = "2026-05-09T23:12:42.059Z" }, + { url = "https://files.pythonhosted.org/packages/0a/51/8cd301ecc899aea28124357f729f4272f44de7806fc7ca02490bfbe253e8/regex-2026.5.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309", size = 912329, upload-time = "2026-05-09T23:12:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/cc/1e/3fbe2fa1e8cebd62f3bb7d3321cff1640aca2e240b51d9bd624aad949260/regex-2026.5.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8", size = 801239, upload-time = "2026-05-09T23:12:46.268Z" }, + { url = "https://files.pythonhosted.org/packages/17/2f/6f6008682bf2cf98040a0d3153a8e557b6ab728d7713d045cee4ce544ab8/regex-2026.5.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66", size = 777054, upload-time = "2026-05-09T23:12:48.051Z" }, + { url = "https://files.pythonhosted.org/packages/19/2b/eee0d20a6842ba04df4b8847a920b57ef56853f14ef85405473e586b605a/regex-2026.5.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026", size = 785098, upload-time = "2026-05-09T23:12:49.851Z" }, + { url = "https://files.pythonhosted.org/packages/4a/98/6fc1e6410feefb92159edaed5041992bfe390e8d26c721865434acbca558/regex-2026.5.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962", size = 860095, upload-time = "2026-05-09T23:12:51.666Z" }, + { url = "https://files.pythonhosted.org/packages/18/a3/bd855e0f2cb1a978ecf6fa6bb69632dd9c3f6ea3b81cde62fde14c9daec7/regex-2026.5.9-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621", size = 765762, upload-time = "2026-05-09T23:12:53.413Z" }, + { url = "https://files.pythonhosted.org/packages/dc/66/0ae8c092e60b14c79d24f8e0b7f0aea5bfbffdcab00b5483d13404d3c3a5/regex-2026.5.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d", size = 852100, upload-time = "2026-05-09T23:12:55.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/8dfde60fc1b21c946a893ba273403b72617edb261370cb1087099a83f088/regex-2026.5.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce", size = 789479, upload-time = "2026-05-09T23:12:57.573Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1c/bdcc98f9a4af4fdd166c74941174619ccff4726d3ce32faa8e9a2ecd38dd/regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e", size = 266699, upload-time = "2026-05-09T23:12:59.14Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/240d36864f9e48ace85f72e79ced97ceb7f27ce87739a947dcb834b4e6bc/regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e", size = 277783, upload-time = "2026-05-09T23:13:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b5/7b30f312b0669dff5beebe5b0989dc2d1a312b1a44fab852199c387a5b96/regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070", size = 270513, upload-time = "2026-05-09T23:13:02.426Z" }, + { url = "https://files.pythonhosted.org/packages/aa/da/797e91ecec6f84135da778ddce78c20e0af5d2a15c26f87a81bc3eadb6db/regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb", size = 490303, upload-time = "2026-05-09T23:13:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/44/da/bf30abaaa737b58f4a4b8c4a03659e02fd92092c822e0197ed9e0daab917/regex-2026.5.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f", size = 292019, upload-time = "2026-05-09T23:13:06.022Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e7/d0eaf5713828417b9e5648cf81fa9bacd4961f6ab98c380c2034f8716e35/regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c", size = 289468, upload-time = "2026-05-09T23:13:08.214Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9b/b3fdd62b003baa1a9b593cd8c8699c9651c2e80cc21a5c715707983c42d7/regex-2026.5.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed", size = 796749, upload-time = "2026-05-09T23:13:10.573Z" }, + { url = "https://files.pythonhosted.org/packages/d4/30/66ab84588765f5b4b271a9ca09ef7ce2b87caa95176ec3d2ad65d7bc4902/regex-2026.5.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020", size = 865445, upload-time = "2026-05-09T23:13:12.523Z" }, + { url = "https://files.pythonhosted.org/packages/1a/89/f05169e8588aac365f35ffc7f3bc3184f095ef4cfded7cfaa3c7fd5dbd89/regex-2026.5.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2", size = 912322, upload-time = "2026-05-09T23:13:14.281Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/c93444052cf41581f3c884ab3fb5823daf0992f11cd4388d4275ca610558/regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2", size = 801269, upload-time = "2026-05-09T23:13:16.569Z" }, + { url = "https://files.pythonhosted.org/packages/50/fe/0cf96b882f540e62e8b9956599798203d599c44cf4c77917ca27400ff69b/regex-2026.5.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04", size = 777085, upload-time = "2026-05-09T23:13:18.675Z" }, + { url = "https://files.pythonhosted.org/packages/23/5c/d78d4924e7fc875557b9e9b768423925fdfaac5549d06da7810019a9bd26/regex-2026.5.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c", size = 785153, upload-time = "2026-05-09T23:13:20.525Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e0/5214774090e7b4524dcea3e3c4aa74141d43043f8beb49c1599db1c8b53a/regex-2026.5.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f", size = 860164, upload-time = "2026-05-09T23:13:22.263Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e1/4a57a83350319b1271f0d7a249b8672513ed928b237a741631270de6caea/regex-2026.5.9-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8", size = 765731, upload-time = "2026-05-09T23:13:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/12/f4/499e74a20c156fc75836ee04a72a38d1a063978f600937f9760467beb1b0/regex-2026.5.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6", size = 852062, upload-time = "2026-05-09T23:13:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/5b/92/7eebc0d0a01e78629695f342ba17e0deaff8fb45e79cc0d7b98287da6e3e/regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21", size = 789577, upload-time = "2026-05-09T23:13:27.814Z" }, + { url = "https://files.pythonhosted.org/packages/05/a4/018e71f7d2ad48c1ebe6d3ae0026f9b7cb4802fd15c7cc02fdf724355102/regex-2026.5.9-cp313-cp313-win32.whl", hash = "sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127", size = 266691, upload-time = "2026-05-09T23:13:29.549Z" }, + { url = "https://files.pythonhosted.org/packages/e6/1d/861a93719fb9ee7dbfc3761b3797b7a3e112a5d42c6129459d2d741be9b5/regex-2026.5.9-cp313-cp313-win_amd64.whl", hash = "sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca", size = 277747, upload-time = "2026-05-09T23:13:31.859Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c6/0a2436ae4da1ba76e51cb98943c6838a9a721faa40ebe2dce07694ae34e3/regex-2026.5.9-cp313-cp313-win_arm64.whl", hash = "sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6", size = 270500, upload-time = "2026-05-09T23:13:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e9/d21346f7b60ed58789371358ed66b09d00f832e1bd7c06e55d9da5679882/regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3", size = 494172, upload-time = "2026-05-09T23:13:35.935Z" }, + { url = "https://files.pythonhosted.org/packages/c4/43/fd1177a2032037c681baecdb3422ee4e1424aec4e4f470ef47793d325274/regex-2026.5.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6", size = 293952, upload-time = "2026-05-09T23:13:38.307Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/9fbf919768368d3f8a4f6c692cf2aa61e482b2b81ec6a298ace4cbf02480/regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff", size = 292314, upload-time = "2026-05-09T23:13:40.353Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6c/e41bfeecb589716843e7c4df09ba46ff2a42961457afece19059d85caeef/regex-2026.5.9-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88", size = 811681, upload-time = "2026-05-09T23:13:42.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/83/a5c1c525fba0aa656e88ad0face0b1829788ef4c2fb6b26df58aa1151b84/regex-2026.5.9-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178", size = 871135, upload-time = "2026-05-09T23:13:44.326Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/80882e799e440dd878b0979cbebf8fa4d54624a332c83037c7a701649e3f/regex-2026.5.9-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100", size = 917265, upload-time = "2026-05-09T23:13:47.295Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ff/8db60211e2286e396aad7dc7725356c502bff0901ea05bd6cdc2e1a042b9/regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e", size = 816311, upload-time = "2026-05-09T23:13:49.885Z" }, + { url = "https://files.pythonhosted.org/packages/4c/47/742ef579c61730f8d268e5cf1f9ce0e37e2ea041ad0f5644724f2378e463/regex-2026.5.9-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2", size = 785498, upload-time = "2026-05-09T23:13:52.25Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ab/cb0999802dcb0fb95b1ab005e8d4163d8afdd67efc2cb6b6630ac13f8cb1/regex-2026.5.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b", size = 801348, upload-time = "2026-05-09T23:13:54.127Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/8ca59a24c55bc34d166eefaf3717bd77772f329fdbf984d86581e0a3571c/regex-2026.5.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e", size = 866493, upload-time = "2026-05-09T23:13:56.067Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3d/30f2ae62cef3278bb5bb821f467277a55fb73f01032cf85997e15e8289a8/regex-2026.5.9-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041", size = 772811, upload-time = "2026-05-09T23:13:57.867Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ae/7d2089bcd78ad0c0161bc684339df50032acb438a7bd3305e7ddb1193cec/regex-2026.5.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0", size = 856584, upload-time = "2026-05-09T23:13:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/a9/29/92ff47f75990131ea4f24ba17819e5a9d141e10819807e09addd73409af6/regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081", size = 803453, upload-time = "2026-05-09T23:14:01.978Z" }, + { url = "https://files.pythonhosted.org/packages/04/99/eff29f1037dcab36702c9ee5d6858cf1ce2336ea8ea2987f64245b99ea5e/regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5", size = 269951, upload-time = "2026-05-09T23:14:03.661Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9d/8870b8981d27b22cda77bb26a5ac7ebfa9c7d9e0dea195a834a82380e748/regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4", size = 281240, upload-time = "2026-05-09T23:14:05.56Z" }, + { url = "https://files.pythonhosted.org/packages/72/b1/3379415e8f135c13ac551353397cc4fe97b4978f3cac73c5fcbcded548b8/regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de", size = 272383, upload-time = "2026-05-09T23:14:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/13/3e/9c3cd292d8808b3645a2ce517e200179b6d0e903f176300bd8b542e14de5/regex-2026.5.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a", size = 490376, upload-time = "2026-05-09T23:14:09.64Z" }, + { url = "https://files.pythonhosted.org/packages/60/70/d43ee8a2ca0a8b68d167f21658b85520ac0574617c7f320367c5047f7556/regex-2026.5.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4", size = 291964, upload-time = "2026-05-09T23:14:11.424Z" }, + { url = "https://files.pythonhosted.org/packages/21/91/9d50b433828d8e74196904e168a43abf1e6e88b2a15d47ed742456720c37/regex-2026.5.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c", size = 289682, upload-time = "2026-05-09T23:14:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/b835e3cafbb9d977736912436259ff551d60919f7d7b3d37d46659c63564/regex-2026.5.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9", size = 796996, upload-time = "2026-05-09T23:14:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a6/9f992d00019166b9de01c546dd4549bc679f2a68df11b877740b0760b7c2/regex-2026.5.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af", size = 866089, upload-time = "2026-05-09T23:14:17.757Z" }, + { url = "https://files.pythonhosted.org/packages/e0/08/4d32af657e049b19cb62b02e46e38fe1518797bfb2203ee93a510b21b0dc/regex-2026.5.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0", size = 911530, upload-time = "2026-05-09T23:14:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/d9/27/2af43dd1dc201d1fecefda64a45f4ad0995855b92724f795a777b402ee69/regex-2026.5.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4", size = 800643, upload-time = "2026-05-09T23:14:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/a4/dd/23a249047013b5321d4a60c4d2437462086f601b061776a525e5fba2a59f/regex-2026.5.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf", size = 777223, upload-time = "2026-05-09T23:14:24.179Z" }, + { url = "https://files.pythonhosted.org/packages/94/6a/e85ed9538cd19586d0465076a4578a12e093ce776d15f3f8ce92733a8dd6/regex-2026.5.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346", size = 785760, upload-time = "2026-05-09T23:14:26.065Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c4/f25473209438638e947c55f9156fd8f236f74169229028cc99116380868e/regex-2026.5.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676", size = 860891, upload-time = "2026-05-09T23:14:28.17Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f7/f4f86e3c74419c37370e91f150ae0c2ef7d34b2e0e4cdd5da046a02e4022/regex-2026.5.9-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14", size = 765891, upload-time = "2026-05-09T23:14:30.06Z" }, + { url = "https://files.pythonhosted.org/packages/26/70/704d8e13765939146b1cd0ef4e2feb71d7929727d2290f026eed10095955/regex-2026.5.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd", size = 851380, upload-time = "2026-05-09T23:14:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/1a13582a8460038edc38e49f64ceb0dd7c60f5caba77571f4bf6601965d9/regex-2026.5.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e", size = 789350, upload-time = "2026-05-09T23:14:34.799Z" }, + { url = "https://files.pythonhosted.org/packages/73/56/3dcafe34fc72e271d62ad9a291801e88a1457bb251c132f15fcc2e5aad1a/regex-2026.5.9-cp314-cp314-win32.whl", hash = "sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad", size = 272130, upload-time = "2026-05-09T23:14:36.729Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/02eebf0be95efe416c664db7fb8b6b05b7a0b06a7544f2884f2558b0526f/regex-2026.5.9-cp314-cp314-win_amd64.whl", hash = "sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763", size = 280999, upload-time = "2026-05-09T23:14:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/70/5a/1dd1abee76cb7a846a0bcf42fdc87e5720c3c33c24f3e37814310a513d9f/regex-2026.5.9-cp314-cp314-win_arm64.whl", hash = "sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372", size = 273500, upload-time = "2026-05-09T23:14:41.059Z" }, + { url = "https://files.pythonhosted.org/packages/86/c1/c5f619b0057a7965cb78ec559c1d7a45ce8c99a35bea95483d64959a93d9/regex-2026.5.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499", size = 494269, upload-time = "2026-05-09T23:14:42.869Z" }, + { url = "https://files.pythonhosted.org/packages/05/2c/5d01f1aee33de4bbe60c8452945bfc8477ca7c5ae4450f6bfe711036cb36/regex-2026.5.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1", size = 293954, upload-time = "2026-05-09T23:14:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/7a/fe/e8988b2ae2108c6ef71bd4aa8d87fbe257976dd0810e826cd75f701c68b6/regex-2026.5.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d", size = 292405, upload-time = "2026-05-09T23:14:47.211Z" }, + { url = "https://files.pythonhosted.org/packages/79/34/d2b0937faa7859263f7f0a3c6b103a1296306be6952dc173d0154e9a2f49/regex-2026.5.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c", size = 811855, upload-time = "2026-05-09T23:14:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/80/fe/daf53a47457a8486db66c66c01ceb9c2303eecee3f87197f1e77eb1a736d/regex-2026.5.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5", size = 871189, upload-time = "2026-05-09T23:14:51.555Z" }, + { url = "https://files.pythonhosted.org/packages/1c/75/058fc4470cbfbf57d800aff1a0022b929a3f9fa553ee10a0cdf2070eb31f/regex-2026.5.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20", size = 917485, upload-time = "2026-05-09T23:14:53.633Z" }, + { url = "https://files.pythonhosted.org/packages/88/e7/179cfda3a28bc843b5c6cfe7f79f23489c791ed95f151083803660878432/regex-2026.5.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0", size = 816369, upload-time = "2026-05-09T23:14:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/41/90/6f0cc422071688266d344fca8462d787cba0a2c144acb25721f9a61ec265/regex-2026.5.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d", size = 785869, upload-time = "2026-05-09T23:14:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/02/67/a31f1760f09c27b251ef39e9beb541f462cf977381d067faa764c2c0e393/regex-2026.5.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b", size = 801427, upload-time = "2026-05-09T23:15:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c4/1a80654597b6bc1e1ea0494824c31200e8a956abe290afae9b19a166a148/regex-2026.5.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a", size = 866482, upload-time = "2026-05-09T23:15:03.384Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/960724e06482c08466ff5611e242e86f80062949cdf6b4b9cc317b9dd93d/regex-2026.5.9-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415", size = 773022, upload-time = "2026-05-09T23:15:05.625Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/a9979c3e7918280e93159ebcab5ef1a65116dd4f3bd6091be0eae4a126e8/regex-2026.5.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2", size = 856642, upload-time = "2026-05-09T23:15:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d4/a9b732f2f0072c0ab12227483abb24fffcb9f73f8a2b203df0a6d0434735/regex-2026.5.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41", size = 803552, upload-time = "2026-05-09T23:15:10.215Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fe/1b3113817447a1d4155e4ac76d2e072f42c0bcba2f43fa8a0e756ea2cd91/regex-2026.5.9-cp314-cp314t-win32.whl", hash = "sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58", size = 275746, upload-time = "2026-05-09T23:15:12.609Z" }, + { url = "https://files.pythonhosted.org/packages/92/73/93d42045302636c91f2e5ef588b65b84b01428f28ec77de256b1dfdfbe5c/regex-2026.5.9-cp314-cp314t-win_amd64.whl", hash = "sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77", size = 285685, upload-time = "2026-05-09T23:15:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/da/80/35b4c33c804a165a7f55289afda3ea9e3eb6d15800341a2d66455c0f1f30/regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa", size = 275713, upload-time = "2026-05-09T23:15:16.98Z" }, ] [[package]] name = "requests" version = "2.34.2" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] name = "ruff" version = "0.15.14" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, + { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, + { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, + { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, + { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, ] [[package]] name = "schedule" version = "1.2.2" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/91/b525790063015759f34447d4cf9d2ccb52cdee0f1dd6ff8764e863bcb74c/schedule-1.2.2.tar.gz", hash = "sha256:15fe9c75fe5fd9b9627f3f19cc0ef1420508f9f9a46f45cd0769ef75ede5f0b7", size = 26452, upload-time = "2024-06-18T20:03:14.633Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/91/b525790063015759f34447d4cf9d2ccb52cdee0f1dd6ff8764e863bcb74c/schedule-1.2.2.tar.gz", hash = "sha256:15fe9c75fe5fd9b9627f3f19cc0ef1420508f9f9a46f45cd0769ef75ede5f0b7", size = 26452, upload-time = "2024-06-18T20:03:14.633Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/20/a7/84c96b61fd13205f2cafbe263cdb2745965974bdf3e0078f121dfeca5f02/schedule-1.2.2-py3-none-any.whl", hash = "sha256:5bef4a2a0183abf44046ae0d164cadcac21b1db011bdd8102e4a0c1e91e06a7d", size = 12220, upload-time = "2024-05-25T18:41:59.121Z" }, + { url = "https://files.pythonhosted.org/packages/20/a7/84c96b61fd13205f2cafbe263cdb2745965974bdf3e0078f121dfeca5f02/schedule-1.2.2-py3-none-any.whl", hash = "sha256:5bef4a2a0183abf44046ae0d164cadcac21b1db011bdd8102e4a0c1e91e06a7d", size = 12220, upload-time = "2024-05-25T18:41:59.121Z" }, ] [[package]] name = "scikit-learn" version = "1.8.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "joblib" }, { name = "numpy" }, { name = "scipy" }, { name = "threadpoolctl" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706, upload-time = "2025-12-10T07:07:48.111Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451, upload-time = "2025-12-10T07:07:49.873Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" }, + { url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" }, + { url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706, upload-time = "2025-12-10T07:07:48.111Z" }, + { url = "https://files.pythonhosted.org/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451, upload-time = "2025-12-10T07:07:49.873Z" }, + { url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" }, + { url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" }, + { url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" }, + { url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" }, + { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, + { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" }, + { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" }, + { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, ] [[package]] name = "scipy" version = "1.17.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, ] [[package]] name = "six" version = "1.17.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] name = "sniffio" version = "1.3.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] [[package]] name = "sse-starlette" version = "3.4.4" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, ] [[package]] name = "stack-data" version = "0.6.3" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asttokens" }, { name = "executing" }, { name = "pure-eval" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, ] [[package]] name = "starlette" version = "1.0.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/08/a3/84e821cc54b4ab50ae6dbc6ac3800a651b65ec35f045cc73785380654057/starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f", size = 2659596, upload-time = "2026-05-21T21:58:58.433Z" } +sdist = { url = "https://files.pythonhosted.org/packages/08/a3/84e821cc54b4ab50ae6dbc6ac3800a651b65ec35f045cc73785380654057/starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f", size = 2659596, upload-time = "2026-05-21T21:58:58.433Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" }, ] [[package]] name = "threadpoolctl" version = "3.6.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, ] [[package]] name = "tickflow" version = "0.1.24" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "typing-extensions" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/56/d911f7d03363a06f69838878df4f92dd01235c899431197f94fb4c0e36ad/tickflow-0.1.24.tar.gz", hash = "sha256:13f6464a9dd1bdf98a312bc8c313fcc24ad0ad83c16d4f83b76bee83df6e11df", size = 36867, upload-time = "2026-06-20T02:39:35.891Z" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/56/d911f7d03363a06f69838878df4f92dd01235c899431197f94fb4c0e36ad/tickflow-0.1.24.tar.gz", hash = "sha256:13f6464a9dd1bdf98a312bc8c313fcc24ad0ad83c16d4f83b76bee83df6e11df", size = 36867, upload-time = "2026-06-20T02:39:35.891Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4c/9d/6c03706054f3bcca8a7113a60258e1a52a762077a555d52a5a84e4f6895f/tickflow-0.1.24-py3-none-any.whl", hash = "sha256:e898867b0e3e668618135c78e3a367542f81b7a289567335d298c707452e5f42", size = 43031, upload-time = "2026-06-20T02:39:34.307Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9d/6c03706054f3bcca8a7113a60258e1a52a762077a555d52a5a84e4f6895f/tickflow-0.1.24-py3-none-any.whl", hash = "sha256:e898867b0e3e668618135c78e3a367542f81b7a289567335d298c707452e5f42", size = 43031, upload-time = "2026-06-20T02:39:34.307Z" }, ] [package.optional-dependencies] @@ -2504,7 +2513,7 @@ all = [ [[package]] name = "tickflow-stock-panel-backend" -version = "0.1.83" +version = "0.2.1" source = { editable = "." } dependencies = [ { name = "apscheduler" }, @@ -2523,6 +2532,7 @@ dependencies = [ { name = "pyarrow" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pypinyin" }, { name = "pytesseract" }, { name = "python-dotenv" }, { name = "python-multipart" }, @@ -2570,6 +2580,7 @@ requires-dist = [ { name = "pyarrow", specifier = ">=16.0" }, { name = "pydantic", specifier = ">=2.7" }, { name = "pydantic-settings", specifier = ">=2.4" }, + { name = "pypinyin", specifier = ">=0.50" }, { name = "pytesseract", specifier = ">=0.3.10" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, @@ -2589,86 +2600,86 @@ provides-extras = ["legacy-cpu", "backtest", "desktop", "dev"] [[package]] name = "tqdm" version = "4.67.3" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] [[package]] name = "traitlets" version = "5.15.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1b/22/40f55b26baeab80c2d7b3f1db0682f8954e4617fee7d90ce634022ef05c6/traitlets-5.15.0.tar.gz", hash = "sha256:4fead733f81cf1c4c938e06f8ca4633896833c9d89eff878159457f4d4392971", size = 163197, upload-time = "2026-05-06T08:05:58.016Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/22/40f55b26baeab80c2d7b3f1db0682f8954e4617fee7d90ce634022ef05c6/traitlets-5.15.0.tar.gz", hash = "sha256:4fead733f81cf1c4c938e06f8ca4633896833c9d89eff878159457f4d4392971", size = 163197, upload-time = "2026-05-06T08:05:58.016Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/98/a9937a969d018a23badfea0b381f66783649d48e0ea6c41923265c3cbeb3/traitlets-5.15.0-py3-none-any.whl", hash = "sha256:fb36a18867a6803deab09f3c5e0fa81bb7b26a5c9e82501c9933f759166eff40", size = 85877, upload-time = "2026-05-06T08:05:55.853Z" }, + { url = "https://files.pythonhosted.org/packages/da/98/a9937a969d018a23badfea0b381f66783649d48e0ea6c41923265c3cbeb3/traitlets-5.15.0-py3-none-any.whl", hash = "sha256:fb36a18867a6803deab09f3c5e0fa81bb7b26a5c9e82501c9933f759166eff40", size = 85877, upload-time = "2026-05-06T08:05:55.853Z" }, ] [[package]] name = "typing-extensions" version = "4.15.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] name = "typing-inspection" version = "0.4.2" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] name = "tzdata" version = "2026.2" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] [[package]] name = "tzlocal" version = "5.3.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, ] [[package]] name = "urllib3" version = "2.7.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] name = "uvicorn" version = "0.47.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/b1/8e7077a8641086aea449e1b5752a570f1b5906c64e0a33cd6d93b63a066b/uvicorn-0.47.0.tar.gz", hash = "sha256:7c9a0ea1a9414106bbab7324609c162d8fa0cdcdcb703060987269d77c7bb533", size = 90582, upload-time = "2026-05-14T18:16:54.455Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b1/8e7077a8641086aea449e1b5752a570f1b5906c64e0a33cd6d93b63a066b/uvicorn-0.47.0.tar.gz", hash = "sha256:7c9a0ea1a9414106bbab7324609c162d8fa0cdcdcb703060987269d77c7bb533", size = 90582, upload-time = "2026-05-14T18:16:54.455Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" }, + { url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" }, ] [package.optional-dependencies] @@ -2685,45 +2696,45 @@ standard = [ [[package]] name = "uvloop" version = "0.22.1" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, ] [[package]] name = "vectorbt" version = "0.28.2" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dateparser" }, { name = "dill" }, @@ -2742,197 +2753,197 @@ dependencies = [ { name = "scipy" }, { name = "tqdm" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/a7/15e6af76aa8dafd18c7fae2068de889a385fd13eb804aacb8e0310765f1b/vectorbt-0.28.2.tar.gz", hash = "sha256:e1a5b7a11c0e2b5b271f18093cb7d1ea075d94d711388c0f423355e83c63c104", size = 487377, upload-time = "2025-12-12T16:18:12.818Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/a7/15e6af76aa8dafd18c7fae2068de889a385fd13eb804aacb8e0310765f1b/vectorbt-0.28.2.tar.gz", hash = "sha256:e1a5b7a11c0e2b5b271f18093cb7d1ea075d94d711388c0f423355e83c63c104", size = 487377, upload-time = "2025-12-12T16:18:12.818Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/91/b9/250f7a1d033618bd0e43ae40bc180aa88895c907876ca39e219a45caecca/vectorbt-0.28.2-py3-none-any.whl", hash = "sha256:93e5fb20d2ff072b7fed78603b516eb64f967c9bf9420ce8ba28329af0410e7d", size = 527808, upload-time = "2025-12-12T16:18:10.624Z" }, + { url = "https://files.pythonhosted.org/packages/91/b9/250f7a1d033618bd0e43ae40bc180aa88895c907876ca39e219a45caecca/vectorbt-0.28.2-py3-none-any.whl", hash = "sha256:93e5fb20d2ff072b7fed78603b516eb64f967c9bf9420ce8ba28329af0410e7d", size = 527808, upload-time = "2025-12-12T16:18:10.624Z" }, ] [[package]] name = "watchfiles" version = "1.2.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, ] [[package]] name = "wcwidth" version = "0.7.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, ] [[package]] name = "websockets" version = "16.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] [[package]] name = "widgetsnbextension" version = "4.0.15" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/f4/c67440c7fb409a71b7404b7aefcd7569a9c0d6bd071299bf4198ae7a5d95/widgetsnbextension-4.0.15.tar.gz", hash = "sha256:de8610639996f1567952d763a5a41af8af37f2575a41f9852a38f947eb82a3b9", size = 1097402, upload-time = "2025-11-01T21:15:55.178Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b7404b7aefcd7569a9c0d6bd071299bf4198ae7a5d95/widgetsnbextension-4.0.15.tar.gz", hash = "sha256:de8610639996f1567952d763a5a41af8af37f2575a41f9852a38f947eb82a3b9", size = 1097402, upload-time = "2025-11-01T21:15:55.178Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, ] [[package]] name = "winotify" version = "1.1.0" -source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a1/b0/1b304fdd8fd810f1f8e81a2708f8bf72e0987de1a763f0ee81f6d08bcae7/winotify-1.1.0.tar.gz", hash = "sha256:f8a0d6ff00cb2c1b3dcdfe825431f46f6aa5dc8ce84ffc59e8fda8c7e36687fe", size = 10101, upload-time = "2022-02-07T12:34:46.236Z" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/b0/1b304fdd8fd810f1f8e81a2708f8bf72e0987de1a763f0ee81f6d08bcae7/winotify-1.1.0.tar.gz", hash = "sha256:f8a0d6ff00cb2c1b3dcdfe825431f46f6aa5dc8ce84ffc59e8fda8c7e36687fe", size = 10101, upload-time = "2022-02-07T12:34:46.236Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/85/6cc4c738080d60b62cad59d0f32386b8277d40e2bf8c06fd1e4101a17238/winotify-1.1.0-py3-none-any.whl", hash = "sha256:13aa9b1196b02ab3e699645b4407371ca73348421f8662565100d70c7cf552d9", size = 15034, upload-time = "2022-02-07T12:34:44.341Z" }, + { url = "https://files.pythonhosted.org/packages/72/85/6cc4c738080d60b62cad59d0f32386b8277d40e2bf8c06fd1e4101a17238/winotify-1.1.0-py3-none-any.whl", hash = "sha256:13aa9b1196b02ab3e699645b4407371ca73348421f8662565100d70c7cf552d9", size = 15034, upload-time = "2022-02-07T12:34:44.341Z" }, ] diff --git a/dev.ps1 b/dev.ps1 index a89464f..e32f4ff 100644 --- a/dev.ps1 +++ b/dev.ps1 @@ -18,8 +18,42 @@ param( $ErrorActionPreference = 'Stop' -# Port precedence: CLI arg > env var > default -if ($BackendPort -le 0) { $BackendPort = if ($env:BACKEND_PORT) { [int]$env:BACKEND_PORT } else { 3018 } } +$Root = Split-Path -Parent $MyInvocation.MyCommand.Path +$BackendDir = Join-Path $Root 'backend' +$FrontendDir = Join-Path $Root 'frontend' +$EnvFile = Join-Path $Root '.env' + +# Read only launcher-owned keys. Do not execute .env as PowerShell code. +function Read-DotEnvValue($Path, $Name) { + if (-not (Test-Path $Path)) { return $null } + $escaped = [Regex]::Escape($Name) + foreach ($line in Get-Content $Path) { + if ($line -match "^\s*$escaped\s*=\s*(.*?)\s*$") { + $value = $Matches[1].Trim() + $value = ($value -replace '\s+#.*$', '').Trim() + if ($value.Length -ge 2 -and + (($value.StartsWith('"') -and $value.EndsWith('"')) -or + ($value.StartsWith("'") -and $value.EndsWith("'")))) { + return $value.Substring(1, $value.Length - 2) + } + return $value + } + } + return $null +} + +$DotEnvHost = Read-DotEnvValue $EnvFile 'HOST' +$DotEnvPort = Read-DotEnvValue $EnvFile 'PORT' +$BindAddress = if ($env:HOST) { $env:HOST } elseif ($DotEnvHost) { $DotEnvHost } else { '0.0.0.0' } +$DisplayHost = if ($BindAddress -in @('0.0.0.0', '::')) { 'localhost' } else { $BindAddress } + +# Port precedence: CLI arg > BACKEND_PORT env > PORT env > .env PORT > default +if ($BackendPort -le 0) { + if ($env:BACKEND_PORT) { $BackendPort = [int]$env:BACKEND_PORT } + elseif ($env:PORT) { $BackendPort = [int]$env:PORT } + elseif ($DotEnvPort) { $BackendPort = [int]$DotEnvPort } + else { $BackendPort = 3018 } +} if ($FrontendPort -le 0) { $FrontendPort = if ($env:FRONTEND_PORT) { [int]$env:FRONTEND_PORT } else { 3011 } } # Force UTF-8 console output so child process logs aren't garbled @@ -28,10 +62,6 @@ try { $OutputEncoding = New-Object System.Text.UTF8Encoding $false } catch {} -$Root = Split-Path -Parent $MyInvocation.MyCommand.Path -$BackendDir = Join-Path $Root 'backend' -$FrontendDir = Join-Path $Root 'frontend' - function Log-Info($m) { Write-Host "[dev] $m" -ForegroundColor DarkGray } function Log-Ok ($m) { Write-Host "[dev] $m" -ForegroundColor Green } function Log-Warn($m) { Write-Host "[dev] $m" -ForegroundColor Yellow } @@ -113,15 +143,7 @@ Free-Port 'frontend' $FrontendPort # select Polars' rtcompat runtime before the backend starts. $BackendExtras = $env:BACKEND_EXTRAS if (-not (Test-Path Env:BACKEND_EXTRAS)) { - $envFile = Join-Path $Root '.env' - if (Test-Path $envFile) { - foreach ($line in Get-Content $envFile) { - if ($line -match '^\s*BACKEND_EXTRAS\s*=\s*(.*?)\s*$') { - $BackendExtras = $Matches[1] - break - } - } - } + $BackendExtras = Read-DotEnvValue $EnvFile 'BACKEND_EXTRAS' } $BackendExtraArgs = @() @@ -138,7 +160,7 @@ if (-not (Test-Path (Join-Path $BackendDir '.venv')) -or $BackendExtraArgs.Count Log-Info 'first run - installing Python deps (1-2 min)...' } Push-Location $BackendDir - try { & uv sync @BackendExtraArgs } finally { Pop-Location } + try { & uv sync --frozen @BackendExtraArgs } finally { Pop-Location } if ($LASTEXITCODE -ne 0) { Log-Err 'uv sync failed'; exit 1 } Log-Ok 'backend deps installed' } @@ -156,8 +178,8 @@ Write-Host '' Write-Host '+----------------------------------------------+' -ForegroundColor Blue Write-Host '| tickflow-stock-panel |' -ForegroundColor Blue Write-Host '| |' -ForegroundColor Blue -Write-Host "| backend http://localhost:$BackendPort" -ForegroundColor Blue -Write-Host "| frontend http://localhost:$FrontendPort" -ForegroundColor Blue +Write-Host "| backend http://${DisplayHost}:$BackendPort" -ForegroundColor Blue +Write-Host "| frontend http://${DisplayHost}:$FrontendPort" -ForegroundColor Blue Write-Host '| |' -ForegroundColor Blue Write-Host '| Ctrl-C closes both |' -ForegroundColor Blue Write-Host '+----------------------------------------------+' -ForegroundColor Blue @@ -170,7 +192,7 @@ $backendPidFile = [System.IO.Path]::GetTempFileName() $frontendPidFile = [System.IO.Path]::GetTempFileName() $backendJob = Start-Job -Name 'backend' -ScriptBlock { - param($pidFile, $dir, $port) + param($pidFile, $dir, $envFile, $bindAddress, $port) # Start-Job 开的是全新 powershell.exe 子进程, 不继承主进程的 UTF-8 设置, # 默认用系统 ANSI (中文 Windows = GBK/cp936) 解码后端 UTF-8 输出 → 中文乱码。 # 这里强制子进程用 UTF-8, 与 app/__init__.py 的 stdout/stderr 编码对齐。 @@ -179,18 +201,21 @@ $backendJob = Start-Job -Name 'backend' -ScriptBlock { $PID | Out-File -FilePath $pidFile -Encoding ascii -Force $env:PYTHONUNBUFFERED = '1' Set-Location $dir - & .\.venv\Scripts\python.exe -m uvicorn app.main:app --reload --host 0.0.0.0 --port $port 2>&1 -} -ArgumentList $backendPidFile, $BackendDir, $BackendPort + $envArgs = if (Test-Path $envFile) { @('--env-file', $envFile) } else { @() } + & .\.venv\Scripts\python.exe -m uvicorn app.main:app @envArgs --reload --host $bindAddress --port $port 2>&1 +} -ArgumentList $backendPidFile, $BackendDir, $EnvFile, $BindAddress, $BackendPort $frontendJob = Start-Job -Name 'frontend' -ScriptBlock { - param($pidFile, $dir, $port) + param($pidFile, $dir, $bindAddress, $backendPort, $port) # 同上: job 子进程默认 GBK, pnpm/前端工具链也是 UTF-8 输出, 需对齐。 [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false $OutputEncoding = New-Object System.Text.UTF8Encoding $false $PID | Out-File -FilePath $pidFile -Encoding ascii -Force Set-Location $dir - & pnpm dev --host 0.0.0.0 --port $port 2>&1 -} -ArgumentList $frontendPidFile, $FrontendDir, $FrontendPort + $env:BACKEND_HOST = $bindAddress + $env:BACKEND_PORT = [string]$backendPort + & pnpm dev --host $bindAddress --port $port 2>&1 +} -ArgumentList $frontendPidFile, $FrontendDir, $BindAddress, $BackendPort, $FrontendPort # Wait up to 5 seconds for the PID files to materialise function Read-JobPid($file) { diff --git a/dev.sh b/dev.sh index 36bcc0c..3a08a02 100755 --- a/dev.sh +++ b/dev.sh @@ -13,13 +13,48 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BACKEND_DIR="$ROOT/backend" FRONTEND_DIR="$ROOT/frontend" -BACKEND_PORT="${BACKEND_PORT:-3018}" + +# Read only the launcher-owned keys from .env. Do not source the whole file: +# .env is data, not a shell script, and may contain values that are unsafe or +# invalid as Bash syntax. Exported environment variables keep highest priority. +read_dotenv_value() { + local key="$1" + if [[ ! -f "$ROOT/.env" ]]; then + return 0 + fi + awk -v wanted="$key" ' + $0 ~ "^[[:space:]]*" wanted "[[:space:]]*=" { + sub(/^[^=]*=/, "") + sub(/[[:space:]]+#.*$/, "") + gsub(/^[[:space:]]+|[[:space:]]+$/, "") + if (($0 ~ /^".*"$/) || ($0 ~ /^\047.*\047$/)) { + $0 = substr($0, 2, length($0) - 2) + } + print + exit + } + ' "$ROOT/.env" +} + +ENV_HOST="$(read_dotenv_value HOST)" +ENV_PORT="$(read_dotenv_value PORT)" +BACKEND_HOST="${HOST:-${ENV_HOST:-0.0.0.0}}" +# Keep BACKEND_PORT as a backwards-compatible explicit override. +BACKEND_PORT="${BACKEND_PORT:-${PORT:-${ENV_PORT:-3018}}}" FRONTEND_PORT="${FRONTEND_PORT:-3011}" +UVICORN_ENV_ARGS=() +if [[ -f "$ROOT/.env" ]]; then + UVICORN_ENV_ARGS=(--env-file "$ROOT/.env") +fi +DISPLAY_HOST="$BACKEND_HOST" +if [[ "$DISPLAY_HOST" == "0.0.0.0" || "$DISPLAY_HOST" == "::" ]]; then + DISPLAY_HOST="localhost" +fi # Match Docker's BACKEND_EXTRAS behavior so old CPUs can select Polars' # rtcompat runtime before the backend starts. An exported value wins over .env. if [[ -z "${BACKEND_EXTRAS+x}" && -f "$ROOT/.env" ]]; then - BACKEND_EXTRAS="$(awk '/^[[:space:]]*BACKEND_EXTRAS[[:space:]]*=/ {sub(/^[^=]*=/, ""); gsub(/^[[:space:]]+|[[:space:]]+$/, ""); print; exit}' "$ROOT/.env")" + BACKEND_EXTRAS="$(read_dotenv_value BACKEND_EXTRAS)" fi BACKEND_EXTRAS="${BACKEND_EXTRAS:-}" BACKEND_EXTRA_ARGS=() @@ -92,7 +127,7 @@ if [ ! -d "$BACKEND_DIR/.venv" ] || [ "${#BACKEND_EXTRA_ARGS[@]}" -gt 0 ]; then else info "后端首次启动 — 安装 Python 依赖(约 1-2 分钟)..." fi - ( cd "$BACKEND_DIR" && uv sync "${BACKEND_EXTRA_ARGS[@]}" ) + ( cd "$BACKEND_DIR" && uv sync --frozen "${BACKEND_EXTRA_ARGS[@]}" ) ok "后端依赖装好了" fi @@ -129,8 +164,8 @@ echo echo -e "${BLUE}╭──────────────────────────────────────────────╮${NC}" echo -e "${BLUE}│${NC} ${GREEN}tickflow-stock-panel${NC} ${BLUE}│${NC}" echo -e "${BLUE}│${NC} ${BLUE}│${NC}" -echo -e "${BLUE}│${NC} backend ${YELLOW}http://localhost:$BACKEND_PORT${NC} ${BLUE}│${NC}" -echo -e "${BLUE}│${NC} frontend ${YELLOW}http://localhost:$FRONTEND_PORT${NC} ${BLUE}│${NC}" +echo -e "${BLUE}│${NC} backend ${YELLOW}http://$DISPLAY_HOST:$BACKEND_PORT${NC} ${BLUE}│${NC}" +echo -e "${BLUE}│${NC} frontend ${YELLOW}http://$DISPLAY_HOST:$FRONTEND_PORT${NC} ${BLUE}│${NC}" echo -e "${BLUE}│${NC} ${BLUE}│${NC}" echo -e "${BLUE}│${NC} Ctrl-C 同时关闭两端 ${BLUE}│${NC}" echo -e "${BLUE}╰──────────────────────────────────────────────╯${NC}" @@ -138,16 +173,18 @@ echo ( cd "$BACKEND_DIR" - # --no-sync: 跳过依赖解析, 直接用已安装的 .venv 环境。 - # 避免 uv 每次启动都访问镜像源校验 lockfile (镜像源 403/网络抖动会导致后端起不来)。 - uv run --no-sync uvicorn app.main:app --reload --host 0.0.0.0 --port "$BACKEND_PORT" 2>&1 \ + # --no-sync: 跳过依赖解析, 直接用已安装的 .venv。 + # 比 --frozen 更彻底: 不校验 lockfile, 避免镜像源 403/网络抖动导致后端起不来。 + uv run --no-sync uvicorn app.main:app "${UVICORN_ENV_ARGS[@]}" --reload \ + --host "$BACKEND_HOST" --port "$BACKEND_PORT" 2>&1 \ | prefix_awk "$(printf "${BLUE}[backend ]${NC} ")" ) & PIDS+=("$!") ( cd "$FRONTEND_DIR" - pnpm dev --host 0.0.0.0 --port "$FRONTEND_PORT" 2>&1 \ + BACKEND_HOST="$BACKEND_HOST" BACKEND_PORT="$BACKEND_PORT" \ + pnpm dev --host "$BACKEND_HOST" --port "$FRONTEND_PORT" 2>&1 \ | prefix_awk "$(printf "${GREEN}[frontend]${NC} ")" ) & PIDS+=("$!") diff --git a/docker-compose.yml b/docker-compose.yml index f705549..c4ed028 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,7 @@ services: CODEX_CLI_VERSION: ${CODEX_CLI_VERSION:-0.144.3} container_name: TickFlow_Stock_Panel ports: - - "${PORT:-3018}:3018" + - "${HOST:-0.0.0.0}:${PORT:-3018}:3018" extra_hosts: - "host.docker.internal:host-gateway" env_file: @@ -26,6 +26,8 @@ services: volumes: - ./data:/app/data - ./tiers.yaml:/app/tiers.yaml:ro + # 保留原始 dotenv 值供首次密码初始化读取,避免 Compose 展开密码中的 $VAR。 + - ./.env:/app/.env:ro # 复用主机 Codex 登录态;后端只读后复制到单次请求的临时 CODEX_HOME。 # Windows PowerShell/CMD 下 HOME 常未设置, 可通过 .env 里 CODEX_HOME_HOST 覆盖。 - ${CODEX_HOME_HOST:-${HOME}/.codex}:/root/.codex:ro diff --git a/docs/configuration.md b/docs/configuration.md index ca774cf..15e8ef1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -28,6 +28,8 @@ TICKFLOW_API_KEY= # 留空 = None 模式(历史日K免费);填 Key > 完整能力矩阵见 [tickflow.org/pricing](https://tickflow.org/pricing/),高等档位含较低档全部权益。 > 在面板 **设置 → 凭据与能力** 点「重新检测」可查看当前档位标签。 +> +> **档位仅适用于 TickFlow 数据源**。功能门槛的统一标准是"能力"(`kline.minute.batch`、`depth5.batch`、`financial` 等能力键):其他第三方/自定义数据源以声明的数据集能力为准,系统会按当前数据源配置自动合并判定,UI 提示一律以能力名表达,不再依赖 TickFlow 档位名。 --- @@ -58,13 +60,13 @@ AI_DAILY_TOKEN_BUDGET=500000 # 每日 token 预算上限 ## 服务 ```ini -HOST=0.0.0.0 # 监听地址 -PORT=3018 # 服务端口 +HOST=0.0.0.0 # 开发服务监听地址 / Docker 主机绑定地址 +PORT=3018 # 开发后端端口 / Docker 主机映射端口 LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR ``` - `HOST`:`0.0.0.0` 监听所有网卡(容器/公网部署需要);仅本机用可设 `127.0.0.1` -- `PORT`:默认 `3018`,改端口后 Docker 映射、SSH 转发命令里的端口也要同步改 +- `PORT`:默认 `3018`;开发模式兼容显式的 `BACKEND_PORT` 覆盖,改端口后 SSH 转发命令也要同步改 - `LOG_LEVEL`:排查问题时改 `DEBUG` --- @@ -84,10 +86,11 @@ DATA_DIR=./data # Parquet / DuckDB 数据存储目录 ## 访问密码(公网部署) ```ini -AUTH_PASSWORD=你的密码 # 至少 6 位;仅首次生效,已设过则不覆盖 +AUTH_PASSWORD='你的密码' # 至少 6 位;仅首次生效,已设过则不覆盖 ``` 面板首次设置访问密码时,出于安全考虑**仅允许本机或内网访问**(防公网陌生人抢先设置锁死面板)。公网服务器部署可通过此环境变量预置首个密码。 +密码建议使用单引号包裹,Docker 启动时会把整个原始 `.env` 只读挂载到容器内 `/app/.env`,兼容已有的未加引号配置。容器可以读取其中的密钥但不能修改该文件,请保持主机文件权限为 `600` 并仅运行可信镜像。 详细步骤、SSH 转发方案、重置密码方法见 [deployment.md → 访问密码设置](./deployment.md#访问密码设置公网部署必读)。 diff --git a/docs/deploy-password.md b/docs/deploy-password.md index c828b31..9a1c053 100644 --- a/docs/deploy-password.md +++ b/docs/deploy-password.md @@ -16,7 +16,7 @@ ```bash # 编辑服务器上的 .env (通常在项目根目录或 backend/ 下) -AUTH_PASSWORD=你的密码 +AUTH_PASSWORD='你的密码' ``` 然后重启服务。启动时会自动: @@ -30,6 +30,7 @@ AUTH_PASSWORD=你的密码 - **密码至少 6 位**,否则会被跳过并记一条 warning 日志 - **仅在未设过密码时生效**。已设过密码后,改这里不会覆盖(避免重启时重置你在 UI 改的密码) +- 密码建议使用单引号包裹,避免 Docker Compose 插值 `$VAR`;启动时也会从只读挂载的原始 `.env` 初始化,兼容已有的未加引号配置 - `.env` 文件权限保持 `600`,**不要提交到 Git** - 明文密码只存在于 `.env` / 环境变量中,落盘的是哈希,安全性等同 `auth.json` diff --git a/docs/deployment.md b/docs/deployment.md index 8e006a5..4ca16bf 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -11,8 +11,8 @@ 由于刚开源近期更新频繁,推荐开发模式运行,可随时 `git pull` 同步最新代码。 ```bash -git clone https://github.com/shy3130/tickflow-stock-panel.git -cd tickflow-stock-panel +git clone https://github.com/shy3130/tick-stock-panel.git +cd tick-stock-panel cp .env.example .env # 按需填 TICKFLOW_API_KEY(留空 = None 模式) ./dev.sh # Windows: .\dev.ps1 ``` @@ -123,7 +123,7 @@ git pull 在 `.env` 文件(或 Docker / 系统环境变量)里设置 `AUTH_PASSWORD`: ```bash -AUTH_PASSWORD=你的密码 +AUTH_PASSWORD='你的密码' ``` 然后重启服务。启动时会自动: @@ -138,6 +138,7 @@ AUTH_PASSWORD=你的密码 - **密码至少 6 位**,否则会被跳过并记一条 warning 日志 - **仅在未设过密码时生效**。已设过密码后,改这里不会覆盖(避免重启时重置你在 UI 改的密码) +- 密码建议使用单引号包裹,避免 Docker Compose 插值 `$VAR`;启动时也会从只读挂载的原始 `.env` 初始化,兼容已有的未加引号配置 - `.env` 文件权限保持 `600`,**不要提交到 Git** - 明文密码只存在于 `.env` / 环境变量中,落盘的是哈希,安全性等同 `auth.json` diff --git a/docs/features.md b/docs/features.md index 3fa5cce..8e9c09d 100644 --- a/docs/features.md +++ b/docs/features.md @@ -8,7 +8,7 @@ ## 🔍 选股引擎(Screener) -**20 个内置策略**,每个策略一个独立 Python 文件,基于 Polars 表达式向量化实现(`backend/app/strategy/builtin/`): +**18 个内置策略**,每个策略一个独立 Python 文件,基于 Polars 表达式向量化实现(`backend/app/strategy/builtin/`): | 类型 | 代表策略 | | :---------- | :------------------------------------------------------- | @@ -54,6 +54,10 @@ 输出净值曲线 · 夏普 · 最大回撤 · 胜率 · 交易明细。SSE 流式进度支持切页重连,不会丢失回测任务。 +**因子与策略挖掘**:复用已有日频因子和 matrix-native 策略,通过 T-1 市场环境、相关去重和嵌套样本外验证生成研究候选。任务在 spawn worker 中运行,支持持久 run ID、取消、刷新重连和显式发布;自动周度任务默认关闭且永不自动发布。完整口径见 [因子与策略挖掘](./mining.md)。 + +**市场阶段与主线**:市场环境页在原 5 档状态之外新增情绪周期阶段(冰点/启动/主升/高潮/退潮/修复,由连板梯队的高度、宽度、晋级率、梯队完整度判定,平均段长约 10 天)与主线识别(概念/行业维度的涨停梯队聚合排名,可配置宽基标签过滤)。完整口径见 [市场阶段与主线识别](./market-phase.md)。 + **ETF 支持**:三种模式的后端与 API 均支持 `asset_type=etf`,回测面板改从 `kline_etf_enriched` 读取(单次回测为单一资产类型,不混合股票与 ETF)。策略组合与因子回测页均有 `股票 / ETF` 切换,ETF 模式下策略列表与标的搜索跟随资产。需先开启 ETF 拉取并跑盘后管道。 --- diff --git a/docs/market-phase.md b/docs/market-phase.md new file mode 100644 index 0000000..90ba2af --- /dev/null +++ b/docs/market-phase.md @@ -0,0 +1,95 @@ +# 市场阶段(情绪周期)与主线识别 + +本文说明市场环境页的阶段体系与主线识别的口径、阈值来源、持久化设计、已知偏差与运行方式。它与原有的 5 档环境 state 并存,不替代任何既有消费方。 + +## 功能边界 + +阶段体系回答"市场处于情绪周期什么位置",主线识别回答"当前/某阶段内什么板块概念在领涨"。两者都只用本地已存储数据(`consecutive_limit_ups`、`amount`、概念映射快照),不依赖扩展数据源,2020-08 起全历史可回算。 + +明确不做的: + +- 挖掘、回测环境过滤、因子 regime 统计仍用原 5 档 `state`,本期不切换到阶段体系。 +- 概念成分是当前快照,不改成 timeseries 积累模式(见「快照回填偏差」)。 +- 不生成任何交易信号;阶段与主线仅供研究分析。 + +## 与 5 档 state 的关系 + +| | `state`(原有) | `phase`(本期新增) | +|---|---|---| +| 驱动量 | 赚钱/投机/抗跌/趋势 4 维综合分 | 连板梯队:高度、宽度、晋级率、梯队完整度 | +| 档位 | 强势/偏强/震荡/偏弱/弱势(5 档) | 冰点/启动/主升/高潮/退潮/修复(6 阶段) | +| 消费方 | 回测环境过滤、挖掘、策略页 | 市场环境页分析与主线识别 | +| 持续性 | 日频打分,切换频繁(历史 76.6% 天数发生切换,平均段长 1.1-1.5 天) | EMA 平滑 + 2 日确认,平均段长 9.7 天 | + +`state`/`score` 列原样保留,新列(`phase`、`first_board`、`ge2_count`、`ge3_count`、`ge5_count`、`ladder_completeness`、`promo_rate`、`promo_pool`)对既有消费方透明。 + +## 每日梯队指标 + +全部由已存储的 `consecutive_limit_ups` 列向量化派生,实现在 `backend/app/services/market_phase.py`: + +- `first_board`:首板(1 连板)家数。 +- `ge2_count` / `ge3_count` / `ge5_count`:N 板以上家数(宽度)。 +- `promo_rate` 晋级率:昨日连板池今日继续封板的比例。昨日连板数用 `consec.shift(1).over("symbol")` 按个股前一日连接(跨批次边界也正确,`_compute_batch` 在 warmup 截断前计算);池不足 10 家记 `null`(小样本噪声),不填 0。 +- `ladder_completeness` 梯队完整度:2..height 档位中非空档位占比;height < 3 时为 `null`。 +- 高度(`max_consecutive`)与封板率(`seal_rate`)沿用 regime 已有列。 + +## 阶段规则 + +词汇与优先级:`climax 高潮 > rally 主升 > ebb 退潮 > ignite 启动 > ice 冰点 > repair 修复(兜底)`。冰点排在退潮前判定——长期死市不应被标成"自高位退潮"。 + +阈值标定自 2020-08~2026-08 全市场 1454 个交易日的 p10/p60/p90 分位数,全部集中在模块顶部常量,调整只改那里: + +| 阶段 | 核心条件(EMA 平滑后) | +|---|---| +| 高潮 climax | ge2 ≥ 50(p90 的 2 倍)或首板 ≥ 220(p90 的 2.5 倍),历史占比 <2% | +| 主升 rally | 高度 ≥7 且 ge2 ≥15 且晋级率 ≥0.23(全中位以上);或晋级率 ≥0.30(p85+)配合高度 ≥5、ge2 ≥12 | +| 退潮 ebb | 晋级率 <0.15(p20)且宽度自 5 日前高位(ge2>12 或高度>6)回落;或晋级率 <0.13 且封板率 <0.57 双弱 | +| 启动 ignite | 宽度/高度自低位扩张(ge2 较 5 日前 +3 且 ≥8,或高度抬升且 ≥5)且晋级率恢复到 ~0.19-0.20 | +| 冰点 ice | 高度 ≤4、ge2 ≤6、首板 ≤24 同时贴地(均 ~p10) | +| 修复 repair | 兜底 | + +**持续性设计**:驱动量先做 EMA 平滑(alpha=1/3,约 5 日),原始标签出来后再做 2 日确认(切换需连续 2 日出现新标签才生效)。 + +**弱档否决**:5 档 `state ∈ {weak, lean_weak}` 时,正向阶段(主升/高潮/启动)一律降为修复。这修复了一类错标:连板梯队强但大盘崩的交易日(如 2024-01 微盘流动性危机)会被梯队指标误判为主升/启动——涨停生态与大盘背离时,以大盘弱势为准。 + +**回填验收**(1454 天真实数据):平均段长 9.7 天(对比原 5 档的 1.1-1.5 天)。抽查:2024-09-24→10-08 为启动→主升→高潮(九二四行情);2024-01/02 微盘崩为退潮/冰点;高潮 6 年仅 2 段(2024-10-08、2024-10-31 ST 重组潮 17 板),均为真实极端期。 + +已知特性,如实说明:退潮/冰点天然是短段(平均 2.8/2.5 天)——恐慌释放本身快于趋势形成;修复是占比最高的兜底段(~74% 天数),A股大部分时间没有处于可辨认的周期位置。 + +## 主线识别 + +实现在 `backend/app/services/market_mainline.py`。复用 `rps_rotation` 的概念映射加载,窄扫描 `kline_daily_enriched` 的 symbol/date/consecutive_limit_ups/amount 四列,按 (date, 概念/行业) 聚合: + +- `limit_up_count` 涨停家数、`ge2_count` 二板以上家数、`max_boards` 最高板、`boards_sum`、`rungs_filled` 梯队档位数、龙头股(按连板数、成交额排序取第一)。 +- 主线分 = 当日截面 rank 归一后 `0.35*涨停数 + 0.25*最高板 + 0.25*梯队档位数 + 0.15*二板宽度`。这是"同板块涨停越多、梯队越完整越是主升主线"判据的直接量化。 +- 每概念当日涨停 <3 家不参与排名;每日持久化 top30 到 `data/mainline_history/part.parquet`(upsert 按 date+kind 整日替换)。 +- 行业维度取前两级(如 `计算机-软件开发`),作为概念维度的交叉验证。 + +### 宽基/风格标签过滤 + +融资融券(~7700 家)、沪深股通(~3300 家)这类超大概念会垄断 top1,需要过滤。配置存于用户偏好(`preferences.json`),市场环境页「过滤」面板可改: + +- 成员数上限:默认 600,超过视为宽基/风格标签不参与排名(夹取范围 50-5000)。保留华为概念(2006)、人工智能(2166)等真主题。 +- 成员数下限:默认 4(夹取 1-200)。 +- 名称黑名单:手动屏蔽任意概念,支持逗号/分号/空格分隔。 + +修改保存后自动触发全量主线重算(`POST /api/regime/mainline/recompute`,秒级)。API 层为 `PUT /api/preferences/mainline-filter`。 + +### 快照回填偏差 + +概念成分为**当前快照回看历史**(本地自 2026-07 起留存,无历史版本)。新纳入指数或改名的个股会出现在旧时段、成分调整会错归属,因此早年主线存在归属漂移,越近越准。此口径限制以 `membership_note` 字段随 API 返回并在页面展示。若后续把 ext 概念同步改为按月累积快照,主线历史精度会逐月自然提升。 + +## API 与运行 + +- `GET /api/regime/phases?start&end`:连续阶段段列表(阶段、区间、天数、高度/宽度/晋级率/封板率均值、段内 top 主线)。 +- `GET /api/regime/mainline?start&end&top&kind`:窗口内主线排行(top1 天数、日均分、最高板)与每日明细。 +- `POST /api/regime/mainline/recompute`:过滤配置变更后的全量重算(两类维度)。 +- `POST /api/regime/recompute`:扩展为重算 regime 后自动重标 phase 并回填主线。 +- daily pipeline 在 regime 步骤后追加主线增量(复用 `pipeline_regime_enabled` 开关,软失败不阻塞)。 + +阶段标签由 `regime_builder.refresh_phase_labels` 在每次 regime upsert 后对全量历史重标(1454 行,开销可忽略)——因为 EMA 与 2 日确认依赖完整序列。 + +## 测试 + +- `backend/tests/test_market_phase.py`:梯队聚合、晋级率(池不足记 null)、梯队完整度、阶段序列规则、弱档否决、持续性(噪声下不出现 1 日翻转)、refresh 往返。 +- `backend/tests/test_market_mainline.py`:概念/行业聚合、宽基过滤、黑名单、最少涨停家数、同日 upsert 替换、增量补齐、偏好读写与夹取。 diff --git a/docs/mining.md b/docs/mining.md new file mode 100644 index 0000000..79f666e --- /dev/null +++ b/docs/mining.md @@ -0,0 +1,138 @@ +# 因子与策略挖掘 + +本文说明 V1 因子与策略挖掘的金融口径、执行边界、结果解释和运行要求。 + +## 功能边界 + +V1 仅研究仓库已经提供的日频因子和用户明确选择的 matrix-native 日线策略。它完成以下闭环: + +1. 在训练区间重新计算因子方向与统计。 +2. 按日截面 Rank IC 计算因子相关性并去重。 +3. 搜索最多四个因子的受控排名组合;进入 beam 搜索的因子按训练折综合分排序并截断到 `beam_width` 个,保证单因子打分后组合搜索仍有代理预算可用。 +4. 用嵌套样本外验证比较新组合;用户选择的已有策略作为对照轨在每个 outer 测试窗独立评估,不参与因子竞争,也不会占用候选名额。 +5. 将运行、事件和结果持久化,允许刷新后重连。 +6. 将候选保存到研究候选库;只有用户显式确认且通过晋级门槛后才发布独立策略。 + +V1 不生成任意公式,不接受 AI 自由代码,不使用扩展数据生成新因子,也不使用分钟数据优化入场。分钟级成交和分钟因子需要独立的数据完整性、防未来函数和性能边界,属于后续版本范围。 + +因子数量以运行时 `FACTOR_COLUMNS` 目录为准。当前目录与后续版本可能不同;单次请求硬上限为 48,不应把历史设计稿中的固定数量当作稳定 API 契约。 + +当前目录除价量技术类(动量、均线偏离、趋势、波动率、量价、价格位置、超买超卖)外,还包含三个 A 股实证维度:收益形态(`max_ret_20d` 彩票效应、`ret_skew_20d`、`up_days_20d`)、流动性(`amihud_20d` 非流动性、`turnover_z_60d` 换手异动)、涨停基因(`limit_up_count_20d/60d`,基于存储列 `consecutive_limit_ups` 计数,涨停判定沿用环境信号口径)。这些因子仍只用本地日频存储数据计算,不依赖扩展数据源。 + +财务因子(`pb_latest`、`roe_latest`、`gross_margin_latest`、`net_margin_latest`、`revenue_yoy_latest`、`net_income_yoy_latest`、`debt_ratio_latest`)来自本地财务快照(数据页同步),采用严格点时口径:**因子值只在晚于公告日的交易日生效**(公告多在盘后, 保守取公告次一交易日起),同一报告期以最新公告为准。无财务数据的标的、公告前的日期一律为空值并从当日截面剔除,绝不填 0;本地完全没有财务数据时因子回测直接返回明确错误而不是全空结果。回测区间早于首次公告时同样报告"无有效数据"。挖掘中财务因子在训练折覆盖不足时综合分为 0 并被自然淘汰,不会阻塞其他因子。财务报表同步按 `(symbol, period_end)` 累积历史(同 shares 表模式),每次同步只拉最新期并为新标的补全量历史;随着季度累积,财务因子的可回测区间会逐步变长。 + +## 时间与收益口径 + +### 全局交易日标签 + +1、3、5 日 forward return 都按全局交易日轴精确连接。停牌或缺行不会把“下一条标的数据”误当成下一交易日。 + +周频使用每个 ISO 周的第一个实际交易日,月频使用每月第一个实际交易日。节假日不会导致整周漏掉调仓。 + +### T-1 市场环境 + +交易日 T 只能使用上一交易日已经得到的环境标签 T-1。**统计与挖掘口径**聚合为三档: + +```text +strong = strong + lean_strong +range = range +weak = lean_weak + weak +``` + +**策略回测的环境过滤**按原始五档匹配:勾选“强势”只允许 T-1 为 strong 的交易日入场,需要三档口径时必须同时勾选“强势+偏强”。挖掘内部的 strong/range/weak 环境拆分仍按上方三档聚合实现(显式枚举原始状态),两者互不影响。 + +正式区间缺少前驱交易日或前驱环境时会 fail-closed,不会用当日环境、最近自然日或默认环境补齐。 + +### 成交和成本 + +候选策略沿用现有回测撮合规则,包括 T+1、佣金、卖侧印花税、滑点以及涨跌停不可成交约束。因子统计中的 long-short spread 仅用于衡量因子区分能力,是理论价差,不表示 A 股可执行卖空。 + +## 防止未来数据 + +挖掘使用嵌套 walk-forward,而不是在完整样本上选择后再报告同一段表现: + +- 每个 inner 训练折独立学习方向、计算统计、相关去重和组合搜索。 +- inner test 只用于选择候选,不参与该折的训练计算。 +- 选择完成后在完整 outer train 上重新训练。 +- outer test 只进行一次最终样本外评估。 +- 训练和测试之间保留 purge 与 embargo;默认 purge 为 30 个交易日。 + +任何 fold 缺数据、缺 T-1 环境或不能可靠撮合时都记录为 skipped 并给出原因,缺失指标返回 `null`,不会显示为 0。 + +## 候选证据口径 + +run 级的“有效折”统计只描述因子赛道:它按每折被选中的候选聚合,不是任何单个候选的样本量。单个候选的逐折证据由三类行组成,`folds.parquet` 的 `evaluation_kind` 区分: + +- `selected`:该候选在该 outer 折经 inner 验证胜出后的 outer 测试结果。 +- `cross`:该候选的定义在其他折胜出后,在本折补评的跨折结果。胜出定义会在所有 outer 折上评估,使单个候选的证据不再依赖“它恰好在哪里获胜”。 +- `benchmark`:对照策略在该 outer 测试窗的独立评估,与因子赛道成败无关;即使因子赛道在某折没有任何候选完成内部验证,对照行仍会写入。 + +对照策略是固定定义,不需要逐折重拟合,因此每个 outer 折只评估一次;预算耗尽时写入带原因的 skipped 行,不会静默缺失。探索档(exploratory)结果固定为低置信度,只能保存为 pending 候选。 + +## 晋级与发布门槛 + +保存(promote)始终允许,结果进入研究候选库 pending 状态。发布(publish)在服务端强制校验以下证据门槛,不满足即拒绝并返回原因列表: + +- 非探索档置信度(exploratory 运行产生的候选不能发布); +- 至少 2 个有效 outer 折; +- 正收益折比例不低于 2/3; +- 样本外 Sharpe 不低于 0.5; +- 最大回撤不劣于 -25%; +- 样本外交易数不低于 60。 + +门槛按 artifact 行指标在读取时计算,不改变历史 artifact 的 schema,因此旧运行的候选会得到相同的门槛判断。工作台会在候选上显示“达标/未达标”标记,未达标的候选“显式发布”按钮被禁用并展示原因。 + +## 置信度 + +三个预算档使用不同的训练窗口: + +| 档位 | 外层训练 | 外层测试 | 步长 | 用途 | +| --- | ---: | ---: | ---: | --- | +| exploratory | 126 | 63 | 63 | 数据较短时探索,只能作为低置信度 pending 候选 | +| balanced | 504 | 126 | 63 | 默认自动研究,至少需要 3 个 outer folds | +| strict | 756 | 126 | 126 | 更长训练窗,至少需要 3 个 outer folds | + +探索性结果不是已验证策略。候选晋级仍需同时检查正收益折比例、样本外 Sharpe、最大回撤、交易数和环境样本覆盖;发布动作会按上文“晋级与发布门槛”在服务端强制执行同一组阈值。 + +## 任务与资源隔离 + +挖掘通过 `spawn` 子进程执行,不在 FastAPI 请求线程、实时行情回调线程或浏览器连接生命周期内运行。浏览器断开不会取消任务;刷新后可通过持久 run ID 重连。取消请求会进入 `cancelling`,界面应等待后端进入终态。 + +共享重任务限流容量为 2:普通回测、优化、walk-forward 和矩阵预热占 1,挖掘独占 2。因此一个挖掘任务不会与另一项大矩阵计算并发。 + +当前 run store 和重任务 limiter 使用进程内锁。生产环境必须只启动一个应用进程负责挖掘;多 worker Uvicorn 部署不能保证跨进程单飞、事件追加和容量限制。要支持多应用进程,必须先增加操作系统级文件锁或外部任务协调器。 + +每个运行目录包含 manifest、compact summary、最近 256 条事件和四个 Parquet artifact。大面板、完整矩阵对象和逐折宽结果不会通过 worker IPC 返回。 + +## 自动运行 + +周度自动挖掘默认关闭,只允许 balanced 或 strict。启用后,它会在北京时间配置工作日及其后的同周工作日、日线 enriched 刷新和环境更新成功后尝试入队;如果配置日的数据流水线失败,后续工作日可以补跑。 + +同一 ISO 周使用确定性的 claim,只触发一次。已经生成运行记录的失败或 `skipped_prerequisite` 仍会占用该周 claim,不重复消耗资源。数据或 T-1 环境覆盖不足时会生成可见的 `skipped_prerequisite` 运行,不会静默降级到 exploratory。挖掘失败不改变已经成功的日度数据流水线状态。 + +自动任务只生成 pending 结果,永远不会自动发布策略。 + +## 保存与发布 + +“保存候选”只从服务端已注册的 `candidates.parquet` 读取定义,并重新校验 artifact schema、canonical signature、原始请求中的因子/策略范围和当前策略兼容性,再写入研究候选库。客户端只能提交 run ID 与 candidate signature,不能在保存时重交权重、方向、公式或代码。保存按 `(origin_run_id, candidate_signature)` 幂等;如果候选库已写入但 artifact backlink 回写失败,重试只修复 backlink,不会创建重复候选。 + +“发布”是独立的显式动作: + +- 已有策略候选返回原策略 ID,不复制或覆盖源文件。 +- 因子组合候选由服务端根据 run ID 与 candidate signature 派生独立策略 ID;客户端不能提交策略 ID、权重、方向、公式或代码。 +- 不同 run 即使得到相同组合,也会生成互相独立的策略文件;同一 run 与 signature 重试时只验证已有源码并修复 backlink。 +- 发布过程执行 AST/META 校验、create-only 原子写入、引擎 reload、策略缓存失效和监控状态失效。 +- reload 或运行时失效失败会回滚首次创建的新文件;backlink 回写失败不会删除已成功加载的策略,重试可修复 backlink。 +- 发布不会修改共享 `factor_rank_research` 源码或 override。 + +发布仅表示把固定候选变成可使用策略,不代表样本外表现会在未来持续。 + +## 性能口径 + +相关矩阵按交易日计算 pairwise-finite Spearman,只聚合因子平方级的逐日结果,不物化百万行永久 rank 宽表。缺失集合不同的因子会先取共同 finite 样本再排名;无法估计的 pair 保持空值,不按零处理。 + +生产 runtime 只生成本次搜索 horizon 的一列 forward label,并在阶段间释放中间列。内存表示优化对照 run `46ed81d537e9404baa06324a3ac3a45f` 在 132.0 万行、243 个交易日、44 个因子上峰值 RSS 为 1.291 GiB,总耗时 25.140 秒;相对 2.395 GiB、37.358 秒的原始对照基线,RSS 和耗时分别下降 46.09% 和 32.70%,四个 Parquet artifact 逐项完全一致。 + +最终 `mining-v2` 额外修复了缺失掩码下的 pairwise Spearman 口径,因此 correlation artifact 与 v1 不再要求数值相等。最终 run `10574abb5d8e4b32ade7cbdd23975c45` 峰值 RSS 为 1.350 GiB、总耗时 40.699 秒,相对原始对照基线内存下降 43.63%、耗时增加 8.94%;仍满足 1.5 GiB 内存门槛和统计增强耗时不超过 30% 的目标。v2 的 Float32 聚合与 Float64 慢参考具有相同 pair counts、空值位置和 0.75 剪枝判断,相关系数最大绝对误差为 `1.70e-7`。 + +这些结果只证明上述基准工作负载达到目标,不表示所有数据规模和搜索预算都具有固定内存上界。当前验证结果:后端全量测试 `876 passed, 24 warnings`,挖掘相关回归含基准轨、跨折证据与晋级门槛共 145 项通过,前端 TypeScript 检查和生产构建均通过;另以真实数据完成 exploratory 运行核验 `selected`/`cross`/`benchmark` 三类行与发布禁用。 diff --git a/docs/secondary-development.md b/docs/secondary-development.md new file mode 100644 index 0000000..c7e1081 --- /dev/null +++ b/docs/secondary-development.md @@ -0,0 +1,405 @@ +# 代码二次开发与 AI 扩展指南 + +本文面向需要在当前仓库中二次开发的维护者、团队和 AI 编码代理。目标不是禁止修改源码,而是让新增页面、业务规则和定制逻辑尽量通过稳定边界接入,使后续合并上游版本时冲突更少、风险可验证。 + +修改任何代码前,仍须先完整阅读根目录的 [`CONTRIBUTING.md`](../CONTRIBUTING.md)。金融数据口径、缓存、并发、数据源和测试要求以该文档为准。 + +## 1. 文档状态 + +本文同时描述现有能力和后续按需建设的代码扩展契约。两者不能混用: + +| 状态 | 含义 | +| --- | --- | +| 已可用 | 当前仓库中已经存在,可以在确认调用链后直接复用 | +| 按需扩展 | 尚未实现;出现真实用例后才能增加,不能提前假设 API 存在 | + +当前已可用的主要扩展能力: + +- 自定义、AI 和叠加策略目录:`data/strategies/`。 +- 数据源 Provider 与 `plugin.yaml` 机制:详见 [`plugin-development.md`](plugin-development.md)。 +- 扩展数据与声明式分析页面:适合不需要自定义 React 交互的页面。 +- 前端源码扩展注册:`frontend/src/custom//extension.tsx`,支持静态页面、导航和已开放插槽。 +- 后端源码扩展注册:`backend/app/custom/.py`,支持 FastAPI 路由、启动钩子和通知格式化器。 +- 当前前端插槽:`layout.navigation.extra`、`stock-preview.footer`、`watchlist.toolbar`。 +- 当前后端继承点:`NotificationFormatter`。 + +尚未实现、只能在真实需求出现后增加的能力: + +- 更多页面局部插槽。 +- 候选过滤、评分、仓位、风控和回测成本等后端业务策略接口。 +- 配置 schema 迁移注册表。 + +AI 在开始任务前必须通过代码搜索确认能力是否已经实现。找不到定义和测试时,应把示例视为设计规范,不得虚构导入路径或调用结果。 + +## 2. 二次开发分级 + +按升级风险从低到高选择实现方式: + +| 级别 | 实现方式 | 适用场景 | 升级风险 | +| --- | --- | --- | --- | +| L1 | 配置、策略文件、扩展数据 | 已有契约能够完成需求 | 最低 | +| L2 | 前端插槽、路由注册;后端策略接口、注册替换 | 新页面、局部 UI、可替换业务规则 | 较低 | +| L3 | 直接修改核心源码 | 核心流程本身必须变化,现有扩展点无法表达 | 最高 | + +选择原则: + +1. 先确认现有功能能否复用,禁止平行实现第二套数据、策略、缓存或请求逻辑。 +2. 只在存在真实二开需求的位置增加扩展点,不为未来可能出现的需求预埋通用框架。 +3. 插槽或接口无法表达核心行为变化时,可以修改源码;必须缩小改动范围并补回归测试。 +4. 不为了避开一次冲突复制完整页面、服务或引擎。复制会把一次显式冲突变成长期的隐式分叉。 + +## 3. 前端扩展规范 + +### 3.1 何时使用插槽 + +插槽适合在既有页面中增加局部内容: + +- 操作按钮或工具栏命令。 +- 筛选条件或表单字段。 +- 表格列和详情面板。 +- 个股详情、策略详情中的附加标签页。 +- 设置页面中的独立配置区。 + +新增完整页面时应使用路由和导航注册,不要把整页塞入某个插槽。改变核心页面的数据流、状态模型或主要布局时,应直接修改核心代码并按 L3 管理。 + +### 3.2 当前插槽契约 + +核心页面通过已经实现的 `ExtensionSlot` 提供受控上下文: + +```tsx + +``` + +二开模块通过默认导出的注册清单接入,不修改核心文件: + +```tsx +import type { FrontendExtension } from '@/extensions/types' + +function NavigationExtra({ collapsed }: { collapsed: boolean; pathname: string }) { + return collapsed ? null :
二开内容
+} + +const extension: FrontendExtension = { + id: 'company.navigation', + apiVersion: 1, + slots: [{ + name: 'layout.navigation.extra', + id: 'company-summary', + order: 100, + component: NavigationExtra, + }], +} + +export default extension +``` + +插槽设计必须满足: + +- `name` 和注册项 `id` 全局稳定、唯一。 +- `context` 使用明确的 TypeScript 类型,并包含契约版本。 +- 只暴露完成该插槽职责所需的数据和操作,不传递整个页面状态。 +- 插槽通过公开回调修改状态,不直接访问父组件内部 store 或缓存。 +- 单个扩展渲染失败应由错误边界隔离,并显示可定位的扩展 ID。 +- 注册顺序确定,使用 `order` 后再按 `id` 排序,避免加载顺序导致界面漂移。 +- 插槽内容必须遵守项目现有设计系统、响应式和可访问性要求。 + +当前开放: + +```text +layout.navigation.extra +stock-preview.footer +watchlist.toolbar +``` + +各插槽 context 契约(均要求 `apiVersion: 1`,定义见 `frontend/src/extensions/types.ts` 的 `FrontendSlotContextMap`): + +- `layout.navigation.extra`:`{ collapsed, pathname }`,侧边栏导航底部。 +- `stock-preview.footer`:`{ symbol, name, view }`,个股详情对话框底部(日K/分时图表下方);`view` 为 `'daily' | 'intraday'`。适合个股附加面板:龙虎榜、资金流、外部研究链接等。 +- `watchlist.toolbar`:`{ symbols, viewMode, selectedGroup, refresh }`,自选页工具栏末尾;`symbols` 为当前筛选视图中的标的,`refresh` 在扩展修改数据后调用以刷新自选增强数据。适合批量操作入口:自定义分析、导出、组合计算等。 + +新增插槽前必须有真实用例,并同时定义 context 类型、异常隔离和测试;不能只在类型表中预留名字。 + +### 3.3 当前路由与导航契约 + +完整页面通过注册表接入: + +```tsx +const extension: FrontendExtension = { + id: 'company.risk', + apiVersion: 1, + routes: [ + { id: 'company-risk', path: '/company/risk', component: CompanyRiskPage }, + ], + navigation: [ + { + id: 'company-risk', + routeId: 'company-risk', + label: '风险分析', + icon: ShieldCheck, + order: 500, + }, + ], +} +``` + +路由注册与菜单注册已经解耦:页面可以存在但不显示在菜单中;菜单只能引用同一扩展内的已注册路由。首版只支持静态绝对路径。扩展路由不得覆盖核心或其他扩展路径,冲突时只禁用该扩展并输出明确错误。 + +完整前端模板位于 [`frontend/src/custom/_template/extension.tsx.example`](../frontend/src/custom/_template/extension.tsx.example)。 + +### 3.4 前端禁止事项 + +- 不直接在多个组件中拼接后端 URL,统一使用 `frontend/src/lib/api.ts` 或未来公开客户端。 +- 不自行创建与现有 TanStack Query 重复的缓存;查询键仍由 `queryKeys.ts` 集中管理。 +- 不用插槽绕过权限、数据口径或表单校验。 +- 不通过 DOM 查询、全局事件或 monkey patch 修改核心组件。 +- 不把整个核心页面复制到二开目录后长期独立维护。 + +## 4. 后端扩展规范 + +### 4.1 使用小粒度继承 + +后端允许二开类继承稳定、职责单一的抽象基类,再通过注册表或依赖注入接入。不要继承并覆盖大型编排服务。 + +当前已经实现的继承点: + +- `NotificationFormatter`:在监控规则完成评估后统一调整通知文案,不改变事件结构和触发语义。 + +下列是可能适合的小粒度接口,但目前没有实现,不能直接导入: + +- `CandidateFilter`:候选池过滤。 +- `ScoringPolicy`:评分计算。 +- `PositionSizingPolicy`:仓位计算。 +- `RiskPolicy`:风险约束。 +- `NotificationFormatter`:通知文案。 +- `StrategyProvider`:策略发现。 +- `MonitorConditionEvaluator`:自定义监控条件。 +- `BacktestCostModel`:手续费和滑点模型。 + +不适合作为公共继承点的核心类: + +- `StrategyEngine`。 +- `BacktestEngine`。 +- `ScreenerService`。 +- `StrategyMonitorService`。 +- `DataStore` 和仓库实现。 +- FastAPI 主应用及生命周期函数。 + +这些类管理流程、缓存、并发或生命周期。覆盖其中的内部方法会让上游调整执行顺序后产生难以发现的语义错误。 + +### 4.2 当前基类与注册契约 + +后端模块从 `app.extensions` 导入稳定契约: + +```python +from app.extensions import ( + BACKEND_EXTENSION_API_VERSION, + BackendExtensionRegistrar, + NotificationFormatContext, + NotificationFormatter, +) + +EXTENSION_ID = 'company.notice' +EXTENSION_API_VERSION = BACKEND_EXTENSION_API_VERSION + + +class CompanyNotificationFormatter(NotificationFormatter): + def format_message(self, event: dict, context: NotificationFormatContext) -> str: + return f"[公司规则] {event.get('message', '')}".strip() + + +def setup(registrar: BackendExtensionRegistrar) -> None: + registrar.register_notification_formatter( + 'company.notification', + CompanyNotificationFormatter(), + ) +``` + +同一个 `setup` 可以通过 `registrar.include_router(router)` 注册独立 FastAPI 路由。核心路由冲突、重复 ID 或契约版本不匹配时,该扩展整体不注册,不留下半注册状态。 + +核心数据层初始化完成后,可选的 `startup(context: ExtensionContext)` 会收到数据目录和只读仓库协议。启动钩子失败只记录错误,不阻止主程序启动。 + +完整后端模板位于 [`backend/app/custom/_template.py.example`](../backend/app/custom/_template.py.example)。 + +### 4.3 后端契约要求 + +- 上下文优先使用不可变 `dataclass` 或 `Protocol`,不向扩展暴露整个 `app.state`。 +- 抽象方法参数、返回值、单位、空值和异常行为必须有文档及契约测试。 +- 注册 ID 全局唯一;重复注册默认拒绝,不允许静默覆盖官方实现。 +- 默认实现必须存在。没有启用二开实现时,核心行为与当前版本一致。 +- 单个可选实现加载失败时应禁用自身;金融结果无法可靠计算时必须 fail-closed。 +- 注册表在启动完成后冻结,实时线程中不得动态替换实现。 +- 破坏性契约变化必须提升 `api_version`,旧版本至少保留一个大版本的兼容期。 + +### 4.4 继承与组合的边界 + +继承只用于表达稳定的“是一种策略实现”关系。需要同时组合过滤、评分、通知等能力时,分别注册多个小实现,不创建拥有大量可选方法的万能基类。 + +优先组合的场景: + +- 一个服务需要多个独立规则。 +- 行为需要按资产类型或运行上下文选择。 +- 扩展只需要装饰默认结果,而不是完全替换算法。 +- 依赖缓存、仓库或通知服务,需要通过明确构造参数注入。 + +## 5. 直接修改源码 + +扩展点不是限制。核心流程必须变化时允许直接修改源码,但要把升级成本显式管理。 + +### 5.1 修改要求 + +- 一个提交只包含一个二开目的,不混入格式化、依赖升级和无关重构。 +- 优先新增独立模块,再对核心入口做最小接线。 +- 修改公共契约时同时更新后端模型、前端类型、调用方和测试。 +- 修改数据写入时列出持久化、内存缓存、版本、SSE 和前端查询失效链路。 +- 保留旧配置和旧数据读取能力;必须迁移时提供幂等迁移和回滚说明。 +- 在 PR 描述中标记被修改的核心热点及未来合并上游时的复核点。 + +### 5.2 高冲突热点 + +以下文件集中管理启动、路由或公共契约,直接修改时需要重点复核: + +```text +backend/app/main.py +backend/app/strategy/engine.py +backend/app/backtest/engine.py +frontend/src/router.tsx +frontend/src/components/Layout.tsx +frontend/src/lib/api.ts +frontend/src/lib/queryKeys.ts +``` + +高冲突不代表禁止修改,而是要求改动更小、测试更完整。若多个二开需求反复修改同一热点,应把共同接线能力提升为正式插槽或策略接口。 + +## 6. AI 开发工作流 + +AI 必须按以下顺序工作: + +1. 完整阅读 `AGENTS.md`、`CONTRIBUTING.md` 和本文。 +2. 检查 `git status`,保留工作区已有修改。 +3. 搜索目标调用链、相邻实现、现有扩展点和测试。 +4. 明确需求属于 L1、L2 还是 L3,并说明选择依据。 +5. 判断目标插槽或后端基类是否真实存在,不依据本文示例虚构代码。 +6. 写出最小改动计划和完成标准。 +7. 先补能证明行为的测试,再实施必要改动。 +8. 执行对应验证矩阵,检查最终 diff 和兼容性。 + +### 6.1 可直接使用的任务模板 + +```text +请在 Tick Stock Panel 当前仓库中实现:[具体需求]。 + +开始前完整阅读 AGENTS.md、CONTRIBUTING.md 和 +docs/secondary-development.md,并先检查 git status、真实调用链和现有测试。 + +约束: +1. 先判断现有功能能否复用,并将方案归类为 L1/L2/L3。 +2. 前端优先使用已经存在的受控插槽、路由或导航注册;后端优先使用已经存在的 + 小粒度抽象基类和注册机制。必须用搜索和测试证明接口真实存在,不能根据设计文档 + 虚构 API。 +3. 若扩展点尚未实现,先说明最小可行方案;只有该需求确实需要时才新增扩展点。 +4. 允许直接修改源码,但保持改动最小,不复制完整页面或核心服务。 +5. 复用现有 API、数据仓库、缓存、查询键、组件和领域口径,不创建第二套逻辑。 +6. 保持历史配置和数据兼容,扩展失败不能破坏未启用扩展的主流程。 +7. 不覆盖已有修改,不提交、不推送,除非我单独确认。 + +完成后请列出: +- 方案分级及原因; +- 修改文件和关键契约; +- 对缓存、数据、API 和升级兼容性的影响; +- 实际执行的测试、构建和结果; +- 仍需人工确认的风险。 +``` + +### 6.2 让 AI 设计扩展点的模板 + +```text +请只设计并评审以下二次开发需求的扩展边界,暂不修改代码:[具体需求]。 + +请基于当前仓库真实调用链回答: +1. 现有能力是否已经可以实现; +2. 前端应使用局部插槽、路由注册还是直接改源码; +3. 后端应使用哪个小粒度策略接口,为什么不继承大型核心服务; +4. 最小 context/Protocol 应包含哪些字段; +5. 默认实现、失败隔离、契约版本和测试如何设计; +6. 哪些抽象属于当前不需要的过度设计。 + +不要假设本文中的目标 API 已经实现,请给出代码证据和文件位置。 +``` + +## 7. 验证矩阵 + +除 `CONTRIBUTING.md` 的通用要求外,二开还应按接入方式验证: + +| 改动 | 最低验证 | +| --- | --- | +| 前端插槽 | 无注册、单注册、多注册排序、异常隔离、窄屏、前端构建 | +| 路由/导航注册 | 路径冲突、隐藏页面、无权限、直接刷新、未知路由 | +| 后端策略实现 | 默认实现、二开实现、重复 ID、版本不兼容、加载失败隔离 | +| 配置或契约变化 | 旧字段缺失、未知字段、更高版本拒写、迁移幂等 | +| 直接修改核心 | 受影响模块完整回归、缓存失效、历史配置、前后端联调 | + +常用命令: + +```bash +cd backend +uv run --frozen pytest tests/path/to/test_x.py -q +uv run --frozen ruff check app/path.py tests/path.py + +cd ../frontend +pnpm build + +cd .. +git diff --check +git status --short --branch +``` + +不得把“扩展已加载”当作业务验证。测试必须断言真实过滤结果、评分、路由输出、界面状态或失败隔离行为。 + +## 8. 版本与升级约定 + +- 二开分支应记录开始开发时的上游 Git Tag 或 commit,不能只写“基于 v0.x”。 +- 正式发布使用不可变 Tag;二开升级优先合并 Tag,而不是持续变化的开发分支头。 +- 公共插槽和后端扩展接口使用独立的 `api_version`,不要直接等同应用版本。 +- 同一 `api_version` 内只做向后兼容的字段新增;删除、改名或改变语义必须提升主版本。 +- 废弃字段先标记并保留兼容读取,至少跨一个大版本后再移除。 +- 升级后必须重新运行二开契约测试,不能只依赖 Git 显示“无冲突”。 + +直接修改核心源码的二开分支可在升级前运行只读预检: + +```bash +python3 scripts/upgrade_check.py <目标Tag或分支> +``` + +脚本不会执行 merge、修改索引或工作区。它会报告共同基线、双方修改的同一文件以及 Git 三方预演可识别的文本冲突。未提交内容不会进入预演,因此正式评估前应先提交到临时二开分支。 + +## 9. 完成检查表 + +- [ ] 已确认需求属于 L1、L2 或 L3。 +- [ ] 已证明使用的插槽、基类和注册 API 在当前代码中真实存在。 +- [ ] 没有复制已有数据读取、缓存、API 客户端或完整核心页面。 +- [ ] 前端扩展只获得必要 context,后端没有继承大型编排服务。 +- [ ] 默认实现和未启用二开时的行为保持不变。 +- [ ] 重复注册、加载失败、版本不兼容和空数据均有明确行为。 +- [ ] 历史配置、策略和用户数据仍可读取。 +- [ ] 已执行适用的测试、构建、Ruff 和 `git diff --check`。 +- [ ] 最终说明包含升级风险和未来合并上游时的复核点。 + +## 10. 后续扩展原则 + +统一注册基础设施已经完成。后续只按真实业务需求增加能力: + +1. 页面需要局部定制时,在真实位置增加一个类型化插槽及测试。 +2. 后端业务规则需要替换时,从该调用链提取一个小粒度接口、默认实现和契约测试。 +3. 不继承大型编排服务,不暴露整个 `app.state`,不复制核心流程。 +4. 只有出现需要持久化的二开配置后,再增加 schema 迁移注册表。 +5. 需要升级直接修改源码的分支时,使用 `scripts/upgrade_check.py` 预检,再执行真实合并和回归。 + +这种顺序遵循 KISS 和 YAGNI:先解决已经存在的升级冲突,不提前建设完整插件平台,也不阻止开发者在必要时直接修改源码。 diff --git a/docs/strategy.md b/docs/strategy.md index d2b3c1e..a63e454 100644 --- a/docs/strategy.md +++ b/docs/strategy.md @@ -16,6 +16,8 @@ | 量价 / 涨停 | 量价齐升 · 高换手强势 · 连板股 · 断板反包 · 涨停动量 · 接近涨停 | | 反转 / 波动 | 超跌反弹 · 超卖反转 · 新低反转 · 低波动龙头 · 回踩 MA20 · 回踩支撑 · 强势开盘 | +内置目录 `backend/app/strategy/builtin/` 还包含一个仅供挖掘 worker 使用的受控因子排名研究模板。它不出现在普通选股列表,也不能通过普通策略 API 直接运行或保存 override;挖掘结果发布时会生成独立策略。详见 [因子与策略挖掘](./mining.md)。 + 内置目录 `backend/app/strategy/builtin/` 由项目维护,**AI 生成的策略不会落入此目录**。 --- diff --git a/frontend/index.html b/frontend/index.html index 94fcdb5..2339bdd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,7 +18,7 @@ - TickFlow Stock Panel · Quant Terminal + Tick Stock Panel · Quant Terminal diff --git a/frontend/package.json b/frontend/package.json index 358c1c1..c031875 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "tickflow-stock-panel-frontend", "private": true, - "version": "0.1.88", + "version": "0.2.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/AlertToast.tsx b/frontend/src/components/AlertToast.tsx index 86be136..511bf7c 100644 --- a/frontend/src/components/AlertToast.tsx +++ b/frontend/src/components/AlertToast.tsx @@ -89,12 +89,13 @@ const SOURCE_BADGE: Record = { price: { label: '价格', cls: 'bg-emerald-400/15 text-emerald-400' }, market: { label: '异动', cls: 'bg-purple-500/15 text-purple-400' }, sector: { label: '板块', cls: 'bg-cyan-500/15 text-cyan-700 dark:text-cyan-300' }, - pool_entry: { label: '进入', cls: 'bg-emerald-400/15 text-emerald-400' }, - pool_exit: { label: '移出', cls: 'bg-warning/15 text-warning' }, + abnormal: { label: '异动边缘', cls: 'bg-orange-500/15 text-orange-500 dark:text-orange-400' }, + pool_entry: { label: '进入', cls: 'bg-danger/15 text-danger' }, + pool_exit: { label: '移出', cls: 'bg-bear/15 text-bear' }, buy_signal: { label: '买入', cls: 'bg-danger/15 text-danger' }, sell_signal: { label: '卖出', cls: 'bg-bear/15 text-bear' }, - new_entry: { label: '进入', cls: 'bg-emerald-400/15 text-emerald-400' }, - dropped: { label: '移出', cls: 'bg-warning/15 text-warning' }, + new_entry: { label: '进入', cls: 'bg-danger/15 text-danger' }, + dropped: { label: '移出', cls: 'bg-bear/15 text-bear' }, } // ===== 容器 — 挂在 Layout ===== diff --git a/frontend/src/components/EChartsCandlestick.tsx b/frontend/src/components/EChartsCandlestick.tsx index 269b26e..63035fd 100644 --- a/frontend/src/components/EChartsCandlestick.tsx +++ b/frontend/src/components/EChartsCandlestick.tsx @@ -332,6 +332,7 @@ interface Props { symbol?: string linkedPrice?: number | null onDateClick?: (date: string) => void + onPriceDoubleClick?: (price: number, currentPrice: number) => void /** 默认可见蜡烛根数, 默认 60 */ visibleBars?: number /** 已激活的子图 key 列表 (含 vol, 按点击顺序) */ @@ -551,6 +552,24 @@ function buildOption( const series: any[] = [] const xAxisIndices: number[] = [] + const priceLineValues = (priceLines ?? []) + .map(line => line.value) + .filter(value => Number.isFinite(value) && value > 0) + const axisMin = priceLineValues.length > 0 + ? ({ min, max }: { min: number; max: number }) => { + const nextMin = Math.min(min, ...priceLineValues) + const nextMax = Math.max(max, ...priceLineValues) + return nextMin - Math.max((nextMax - nextMin) * 0.03, nextMax * 0.001) + } + : undefined + const axisMax = priceLineValues.length > 0 + ? ({ min, max }: { min: number; max: number }) => { + const nextMin = Math.min(min, ...priceLineValues) + const nextMax = Math.max(max, ...priceLineValues) + return nextMax + Math.max((nextMax - nextMin) * 0.03, nextMax * 0.001) + } + : undefined + // ===== grid 0: K线主图 ===== grids.push({ left, right, top: topPad, height: candleAvail }) xAxes.push({ @@ -562,6 +581,8 @@ function buildOption( }) yAxes.push({ scale: true, + min: axisMin, + max: axisMax, // 上下各留 3% 边距: 防止最高/最低点的蜡烛贴边, 涨停/炸板标签被遮挡 boundaryGap: [0.03, 0.03], splitArea: { show: false }, @@ -791,6 +812,7 @@ export function EChartsCandlestick({ symbol: _symbol, linkedPrice, onDateClick, + onPriceDoubleClick, visibleBars = 60, activeIndicators = [], volumeCompare = { enabled: true, days: 1 }, @@ -801,6 +823,8 @@ export function EChartsCandlestick({ dataRef.current = data const onDateClickRef = useRef(onDateClick) onDateClickRef.current = onDateClick + const onPriceDoubleClickRef = useRef(onPriceDoubleClick) + onPriceDoubleClickRef.current = onPriceDoubleClick // 主题: buildOption/信息栏内部通过 CT() 动态取调色板, 这里只负责切换时触发重建 const theme = useTheme() @@ -982,6 +1006,18 @@ export function EChartsCandlestick({ } }) + const handlePriceDoubleClick = (event: { offsetX: number; offsetY: number }) => { + const pixel: [number, number] = [event.offsetX, event.offsetY] + if (!chart.containPixel({ gridIndex: 0 }, pixel)) return + const coordinate = chart.convertFromPixel({ xAxisIndex: 0, yAxisIndex: 0 }, pixel) + const price = Array.isArray(coordinate) ? Number(coordinate[1]) : NaN + const currentPrice = dataRef.current[dataRef.current.length - 1]?.close + if (Number.isFinite(price) && price > 0 && Number.isFinite(currentPrice) && currentPrice > 0) { + onPriceDoubleClickRef.current?.(price, currentPrice) + } + } + chart.getZr().on('dblclick', handlePriceDoubleClick) + // dataZoom → 只更新 ref,不触发 React re-render // compact 变化时需要增量更新 markPoint chart.on('dataZoom', () => { @@ -1007,6 +1043,7 @@ export function EChartsCandlestick({ chart.off('updateAxisPointer') chart.off('click') chart.off('dataZoom') + chart.getZr().off('dblclick', handlePriceDoubleClick) ro.disconnect() chart.dispose() chartRef.current = null diff --git a/frontend/src/components/EChartsIntraday.tsx b/frontend/src/components/EChartsIntraday.tsx index b4ba6a8..ad86406 100644 --- a/frontend/src/components/EChartsIntraday.tsx +++ b/frontend/src/components/EChartsIntraday.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import * as echarts from 'echarts' import type { ECharts, EChartsOption } from 'echarts' import type { MinuteKlineRow, PriceLimitInfo } from '@/lib/api' +import { computeIntradayAverage, formatMinuteTime, FULL_DAY_TIMES } from '@/lib/intraday-chart' import { useChartTheme, type ChartTheme } from '@/lib/theme' type YMode = 'adaptive' | 'limit' @@ -22,30 +23,13 @@ interface Props { date?: string priceLimit?: PriceLimitInfo onPriceHover?: (price: number | null) => void + onPriceDoubleClick?: (price: number, currentPrice: number) => void + currentPrice?: number + priceLines?: { value: number; label?: string; color?: string }[] showLimitLines?: boolean showAvgLine?: boolean } -function fmtTime(dt: string): string { - const match = dt.match(/(\d{2}):(\d{2})/) - if (!match) return dt.slice(11, 16) - const h = (parseInt(match[1]) + 8) % 24 - return `${String(h).padStart(2, '0')}:${match[2]}` -} - -function computeAvgPrice(data: MinuteKlineRow[]): number[] { - // 分时均线 = 累计成交额 / 累计成交量(手→股) - const result: number[] = [] - let sumAmt = 0 - let sumVol = 0 - for (const d of data) { - sumAmt += d.amount - sumVol += d.volume * 100 - result.push(sumVol > 0 ? sumAmt / sumVol : d.close) - } - return result -} - function fmtAmt(v: number): string { if (v >= 1_000_000_000) return `${(v / 1_000_000_000).toFixed(2)}亿` if (v >= 10_000) return `${(v / 10_000).toFixed(0)}万` @@ -56,29 +40,6 @@ function isValidPrice(v: number | null | undefined): v is number { return typeof v === 'number' && Number.isFinite(v) && v > 0 } -/** 生成全天分时时间刻度 9:30 ~ 11:30, 13:00 ~ 15:00, 每分钟一个点 (共242个) */ -function generateFullDayTimes(): string[] { - const times: string[] = [] - // 上午 9:30 ~ 11:30 (121 分钟) - for (let h = 9; h <= 11; h++) { - const startM = h === 9 ? 30 : 0 - const endM = h === 11 ? 30 : 59 - for (let m = startM; m <= endM; m++) { - times.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`) - } - } - // 下午 13:00 ~ 15:00 (121 分钟) - for (let h = 13; h <= 15; h++) { - const endM = h === 15 ? 0 : 59 - for (let m = 0; m <= endM; m++) { - times.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`) - } - } - return times -} - -const FULL_DAY_TIMES = generateFullDayTimes() - /** 计算实际涨跌停价 (四舍五入到2位小数) 和实际涨跌停幅度 */ function getLimitPrices(prevClose: number, priceLimit?: PriceLimitInfo): { limitUp: number // 涨停价 (四舍五入) @@ -101,7 +62,7 @@ function getLimitPrices(prevClose: number, priceLimit?: PriceLimitInfo): { return { limitUp, limitDown, upPct, downPct } } -function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgPrices: number[], lineColor: string, areaColor: string, yMode: YMode, ct: ChartTheme, priceLimit?: PriceLimitInfo, showLimitLines = true, showAvgLine = true): EChartsOption { +function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgPrices: number[], lineColor: string, areaColor: string, yMode: YMode, ct: ChartTheme, priceLimit?: PriceLimitInfo, showLimitLines = true, showAvgLine = true, priceLines: Props['priceLines'] = []): EChartsOption { // 将数据映射到全天时间轴上的正确位置 const timeIndexMap = new Map(FULL_DAY_TIMES.map((t, i) => [t, i])) const closes = new Array(FULL_DAY_TIMES.length).fill(null) as (number | null)[] @@ -112,7 +73,7 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP const volNeutral = 'rgba(161,161,170,0.5)' for (let i = 0; i < data.length; i++) { - const timeKey = fmtTime(data[i].datetime) + const timeKey = formatMinuteTime(data[i].datetime) const idx = timeIndexMap.get(timeKey) if (idx !== undefined) { closes[idx] = data[i].close @@ -148,6 +109,25 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP symbol: 'none', }) } + for (const line of priceLines) { + if (!Number.isFinite(line.value) || line.value <= 0) continue + markLineData.push({ + yAxis: line.value, + lineStyle: { color: line.color ?? ct.text, type: 'dashed', width: 1, opacity: 0.92 }, + label: { + show: !!line.label, + formatter: line.label ?? '', + position: 'insideEndTop', + color: line.color ?? ct.text, + backgroundColor: ct.tooltipBg, + borderRadius: 4, + padding: [2, 6], + fontSize: 10, + fontFamily: 'JetBrains Mono, monospace', + }, + symbol: 'none', + }) + } let yMin: number | undefined let yMax: number | undefined @@ -162,13 +142,19 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP } } + const monitoredDiff = priceLines.reduce((largest, line) => ( + Number.isFinite(line.value) && line.value > 0 + ? Math.max(largest, Math.abs(line.value - prevClose)) + : largest + ), 0) * 1.05 + if (showLimitLines && yMode === 'limit') { const { limitUp, limitDown } = getLimitPrices(prevClose, priceLimit) const limitDiffUp = limitUp - prevClose const limitDiffDown = prevClose - limitDown const limitDiff = Math.max(limitDiffUp, limitDiffDown) // 涨跌停模式: Y 轴按实际涨跌停价 - maxDiff = limitDiff + maxDiff = Math.max(limitDiff, monitoredDiff) yMin = prevClose - maxDiff yMax = prevClose + maxDiff // 加 markLine 标注涨停价和跌停价 (仅虚线, 不显示文字) @@ -199,6 +185,7 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP // 至少保证一个可视范围 (防止数据平时 maxDiff=0)。指数不使用涨跌停范围,最小范围要更紧,否则低波动指数会被压成横线。 const minDiff = showLimitLines ? prevClose * 0.01 : prevClose * 0.001 if (maxDiff < minDiff) maxDiff = minDiff + maxDiff = Math.max(maxDiff, monitoredDiff) yMin = prevClose - maxDiff yMax = prevClose + maxDiff } @@ -246,8 +233,8 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP link: [{ xAxisIndex: 'all' }], }, grid: [ - { left: 60, right: 55, top: 24, bottom: '28%' }, - { left: 60, right: 55, top: '74%', bottom: 20 }, + { left: 60, right: 55, top: 24, bottom: '34%' }, + { left: 60, right: 55, top: '69%', bottom: 20 }, ], xAxis: [ { @@ -399,22 +386,39 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP } } -export function EChartsIntraday({ data, height = 320, prevClose, date, priceLimit, onPriceHover, showLimitLines = true, showAvgLine = true }: Props) { +export function EChartsIntraday({ + data, + height = 320, + prevClose, + date, + priceLimit, + onPriceHover, + onPriceDoubleClick, + currentPrice, + priceLines, + showLimitLines = true, + showAvgLine = true, +}: Props) { const containerRef = useRef(null) const chartRef = useRef(null) const roRef = useRef(null) const moRef = useRef(null) + const priceDoubleClickHandlerRef = useRef<((event: { offsetX: number; offsetY: number }) => void) | null>(null) const dataRef = useRef(data) dataRef.current = data + const currentPriceRef = useRef(currentPrice) + currentPriceRef.current = currentPrice const onPriceHoverRef = useRef(onPriceHover) onPriceHoverRef.current = onPriceHover + const onPriceDoubleClickRef = useRef(onPriceDoubleClick) + onPriceDoubleClickRef.current = onPriceDoubleClick // 全日索引 → 数据数组索引 的映射 (ref 避免重建 chart) const fullDayToDataIdx = useRef>(new Map()) const [infoIdx, setInfoIdx] = useState(data.length - 1) const [yMode, setYMode] = useState('adaptive') const ct = useChartTheme() - const avgPrices = useMemo(() => computeAvgPrice(data), [data]) + const avgPrices = useMemo(() => computeIntradayAverage(data), [data]) // 分时线颜色:基于最新价 vs 昨收 const lastClose = data.length > 0 ? data[data.length - 1].close : null @@ -473,6 +477,19 @@ export function EChartsIntraday({ data, height = 320, prevClose, date, priceLimi chart.on('globalout', () => { onPriceHoverRef.current?.(null) }) + + const handlePriceDoubleClick = (event: { offsetX: number; offsetY: number }) => { + const pixel: [number, number] = [event.offsetX, event.offsetY] + if (!chart!.containPixel({ gridIndex: 0 }, pixel)) return + const coordinate = chart!.convertFromPixel({ xAxisIndex: 0, yAxisIndex: 0 }, pixel) + const clickedPrice = Array.isArray(coordinate) ? Number(coordinate[1]) : NaN + const latestPrice = currentPriceRef.current ?? dataRef.current[dataRef.current.length - 1]?.close + if (Number.isFinite(clickedPrice) && clickedPrice > 0 && Number.isFinite(latestPrice) && latestPrice > 0) { + onPriceDoubleClickRef.current?.(clickedPrice, latestPrice) + } + } + priceDoubleClickHandlerRef.current = handlePriceDoubleClick + chart.getZr().on('dblclick', handlePriceDoubleClick) } if (data.length > 0) { @@ -480,7 +497,7 @@ export function EChartsIntraday({ data, height = 320, prevClose, date, priceLimi const timeIndexMap = new Map(FULL_DAY_TIMES.map((t, i) => [t, i])) const mapping = new Map() for (let i = 0; i < data.length; i++) { - const timeKey = fmtTime(data[i].datetime) + const timeKey = formatMinuteTime(data[i].datetime) const fullDayIdx = timeIndexMap.get(timeKey) if (fullDayIdx !== undefined) { mapping.set(fullDayIdx, i) @@ -488,16 +505,19 @@ export function EChartsIntraday({ data, height = 320, prevClose, date, priceLimi } fullDayToDataIdx.current = mapping - chart.setOption(buildOption(data, prevClose, avgPrices, lineColor, areaFill, yMode, ct, priceLimit, showLimitLines, showAvgLine), true) + chart.setOption(buildOption(data, prevClose, avgPrices, lineColor, areaFill, yMode, ct, priceLimit, showLimitLines, showAvgLine, priceLines), true) } else { chart.clear() } - }, [data, prevClose, height, lineColor, areaFill, yMode, ct, priceLimit, showLimitLines, showAvgLine]) + }, [data, prevClose, height, lineColor, areaFill, yMode, ct, priceLimit, showLimitLines, showAvgLine, priceLines]) useEffect(() => { return () => { chartRef.current?.off('updateAxisPointer') chartRef.current?.off('globalout') + if (priceDoubleClickHandlerRef.current) { + chartRef.current?.getZr().off('dblclick', priceDoubleClickHandlerRef.current) + } moRef.current?.disconnect() roRef.current?.disconnect() chartRef.current?.dispose() diff --git a/frontend/src/components/EChartsMultiDayIntraday.tsx b/frontend/src/components/EChartsMultiDayIntraday.tsx new file mode 100644 index 0000000..63e0cf5 --- /dev/null +++ b/frontend/src/components/EChartsMultiDayIntraday.tsx @@ -0,0 +1,429 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import * as echarts from 'echarts' +import type { ECharts, EChartsOption } from 'echarts' +import type { MinuteKlineRow, MinuteKlineSession } from '@/lib/api' +import { computeIntradayAverage, formatMinuteTime, FULL_DAY_TIMES } from '@/lib/intraday-chart' +import { useChartTheme } from '@/lib/theme' + +const COLORS = { + up: '#C74040', + down: '#2D9B65', + flat: '#A1A1AA', + average: '#F59E0B', + volumeUp: 'rgba(240,68,56,0.58)', + volumeDown: 'rgba(18,183,106,0.58)', + volumeFlat: 'rgba(161,161,170,0.45)', +} + +interface Props { + sessions: MinuteKlineSession[] + height?: number + onPriceDoubleClick?: (price: number, currentPrice: number) => void + priceLines?: { value: number; label?: string; color?: string }[] +} + +interface InfoPoint { + date: string + row: MinuteKlineRow + average: number + prevClose: number | null +} + +function formatAmount(value: number): string { + if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(2)}亿` + if (value >= 10_000) return `${(value / 10_000).toFixed(0)}万` + return value.toFixed(0) +} + +function priceColor(close: number, prevClose: number | null): string { + if (prevClose == null || close === prevClose) return COLORS.flat + return close > prevClose ? COLORS.up : COLORS.down +} + +function buildModel(sessions: MinuteKlineSession[]) { + const categories: string[] = [] + const volumeData: ({ value: number; itemStyle: { color: string } } | null)[] = [] + const dayLabelByIndex = new Map() + const dayStartIndexes: number[] = [] + const pointByIndex = new Map() + const dayRanges: { + start: number + session: MinuteKlineSession + values: (number | null)[] + averages: (number | null)[] + }[] = [] + const priceValues: number[] = [] + + const labelStep = Math.max(1, Math.ceil(sessions.length / 10)) + for (let sessionIndex = 0; sessionIndex < sessions.length; sessionIndex++) { + const session = sessions[sessionIndex] + const start = categories.length + dayStartIndexes.push(start) + if (sessionIndex % labelStep === 0 || sessionIndex === sessions.length - 1) { + dayLabelByIndex.set(start + Math.floor(FULL_DAY_TIMES.length / 2), session.date.slice(5)) + } + + const averagePrices = computeIntradayAverage(session.rows) + const rowsByTime = new Map() + session.rows.forEach((row, index) => { + rowsByTime.set(formatMinuteTime(row.datetime), { + row, + average: averagePrices[index], + }) + }) + + const dayValues: (number | null)[] = [] + const dayAverages: (number | null)[] = [] + for (const time of FULL_DAY_TIMES) { + const point = rowsByTime.get(time) + const index = categories.length + categories.push(`${session.date} ${time}`) + if (!point) { + dayValues.push(null) + dayAverages.push(null) + volumeData.push(null) + continue + } + + const { row, average } = point + dayValues.push(row.close) + dayAverages.push(average) + volumeData.push({ + value: row.volume, + itemStyle: { + color: row.close > row.open + ? COLORS.volumeUp + : row.close < row.open + ? COLORS.volumeDown + : COLORS.volumeFlat, + }, + }) + priceValues.push(row.low, row.high, average) + pointByIndex.set(index, { + date: session.date, + row, + average, + prevClose: session.prev_close, + }) + } + + dayRanges.push({ + start, + session, + values: dayValues, + averages: dayAverages, + }) + + if (sessionIndex < sessions.length - 1) { + categories.push(`${session.date} gap`) + volumeData.push(null) + } + } + + return { + categories, + volumeData, + dayLabelByIndex, + dayStartIndexes, + pointByIndex, + dayRanges, + priceValues, + latest: pointByIndex.size > 0 + ? Array.from(pointByIndex.values())[pointByIndex.size - 1] + : null, + } +} + +export function EChartsMultiDayIntraday({ + sessions, + height = 420, + onPriceDoubleClick, + priceLines = [], +}: Props) { + const containerRef = useRef(null) + const chartRef = useRef(null) + const resizeObserverRef = useRef(null) + const priceDoubleClickHandlerRef = useRef<((event: { offsetX: number; offsetY: number }) => void) | null>(null) + const onPriceDoubleClickRef = useRef(onPriceDoubleClick) + onPriceDoubleClickRef.current = onPriceDoubleClick + const model = useMemo(() => buildModel(sessions), [sessions]) + const modelRef = useRef(model) + modelRef.current = model + const [info, setInfo] = useState(model.latest) + const theme = useChartTheme() + + useEffect(() => { + setInfo(model.latest) + }, [model]) + + useEffect(() => { + const container = containerRef.current + if (!container) return + + let chart = chartRef.current + if (!chart) { + chart = echarts.init(container, undefined, { renderer: 'canvas' }) + chartRef.current = chart + resizeObserverRef.current = new ResizeObserver(() => chart?.resize()) + resizeObserverRef.current.observe(container) + + chart.on('updateAxisPointer', (event: any) => { + const axisInfo = event.axesInfo?.find((item: any) => item.axisDim === 'x' && item.axisIndex === 0) + ?? event.axesInfo?.[0] + const rawValue = axisInfo?.value + const current = modelRef.current + const index = typeof rawValue === 'number' + ? rawValue + : current.categories.indexOf(String(rawValue)) + const point = current.pointByIndex.get(index) + if (point) setInfo(point) + }) + chart.on('globalout', () => setInfo(modelRef.current.latest)) + + const handlePriceDoubleClick = (event: { offsetX: number; offsetY: number }) => { + const pixel: [number, number] = [event.offsetX, event.offsetY] + if (!chart!.containPixel({ gridIndex: 0 }, pixel)) return + const coordinate = chart!.convertFromPixel({ xAxisIndex: 0, yAxisIndex: 0 }, pixel) + const price = Array.isArray(coordinate) ? Number(coordinate[1]) : NaN + const currentPrice = modelRef.current.latest?.row.close + if ( + Number.isFinite(price) + && price > 0 + && typeof currentPrice === 'number' + && Number.isFinite(currentPrice) + && currentPrice > 0 + ) { + onPriceDoubleClickRef.current?.(price, currentPrice) + } + } + priceDoubleClickHandlerRef.current = handlePriceDoubleClick + chart.getZr().on('dblclick', handlePriceDoubleClick) + } + + const monitoredPrices = priceLines + .map(line => line.value) + .filter(value => Number.isFinite(value) && value > 0) + const allPriceValues = [...model.priceValues, ...monitoredPrices] + const minPrice = allPriceValues.length > 0 ? Math.min(...allPriceValues) : 0 + const maxPrice = allPriceValues.length > 0 ? Math.max(...allPriceValues) : 1 + const padding = Math.max((maxPrice - minPrice) * 0.08, maxPrice * 0.002) + const totalLength = model.categories.length + const priceSeries: any[] = model.dayRanges.map(({ start, session, values }) => { + const data = new Array(totalLength).fill(null) as (number | null)[] + for (let index = 0; index < values.length; index++) data[start + index] = values[index] + const last = session.rows[session.rows.length - 1] + const color = last ? priceColor(last.close, session.prev_close) : COLORS.flat + return { + name: session.date, + type: 'line', + data, + symbol: 'none', + smooth: false, + connectNulls: true, + lineStyle: { width: 1.2, color }, + areaStyle: { color, opacity: 0.08 }, + emphasis: { disabled: true }, + } + }) + + const boundaryData = model.dayStartIndexes.slice(1).map(index => ({ + xAxis: model.categories[index], + lineStyle: { color: theme.grid, width: 1 }, + label: { show: false }, + })) + const monitorLineData = priceLines.flatMap(line => { + if (!Number.isFinite(line.value) || line.value <= 0) return [] + return [{ + yAxis: line.value, + lineStyle: { color: line.color ?? theme.text, type: 'dashed', width: 1, opacity: 0.92 }, + label: { + show: !!line.label, + formatter: line.label ?? '', + position: 'insideEndTop', + color: line.color ?? theme.text, + backgroundColor: theme.tooltipBg, + borderRadius: 4, + padding: [2, 6], + fontSize: 10, + fontFamily: 'JetBrains Mono, monospace', + }, + }] + }) + const markLineData = [...boundaryData, ...monitorLineData] + if (priceSeries.length > 0 && markLineData.length > 0) { + priceSeries[0].markLine = { + symbol: 'none', + silent: true, + animation: false, + data: markLineData, + } + } + const averageSeries: any[] = model.dayRanges.map(({ start, session, averages }) => { + const data = new Array(totalLength).fill(null) as (number | null)[] + for (let index = 0; index < averages.length; index++) data[start + index] = averages[index] + return { + name: `${session.date} 均价`, + type: 'line', + data, + symbol: 'none', + connectNulls: true, + lineStyle: { width: 1, color: COLORS.average }, + emphasis: { disabled: true }, + } + }) + const option: EChartsOption = { + animation: false, + backgroundColor: 'transparent', + tooltip: { + trigger: 'axis', + backgroundColor: 'transparent', + borderWidth: 0, + formatter: () => '', + axisPointer: { + type: 'cross', + label: { + show: true, + backgroundColor: theme.tooltipBg, + borderColor: theme.tooltipBorder, + borderWidth: 1, + color: theme.tooltipText, + fontFamily: 'JetBrains Mono, monospace', + fontSize: 10, + }, + crossStyle: { color: theme.crosshair, type: 'dashed', width: 1 }, + lineStyle: { color: theme.crosshair, type: 'dashed', width: 1 }, + }, + }, + axisPointer: { link: [{ xAxisIndex: 'all' }] }, + grid: [ + { left: 58, right: 18, top: 16, bottom: '34%' }, + { left: 58, right: 18, top: '69%', bottom: 22 }, + ], + xAxis: [ + { + type: 'category', + data: model.categories, + boundaryGap: false, + axisLine: { lineStyle: { color: theme.grid } }, + axisTick: { show: false }, + splitLine: { show: false }, + axisLabel: { + color: theme.text, + fontFamily: 'JetBrains Mono, monospace', + fontSize: 10, + interval: 0, + hideOverlap: true, + formatter: (_value: string, index: number) => model.dayLabelByIndex.get(index) ?? '', + }, + axisPointer: { + label: { + formatter: (params: any) => { + const value = String(params.value ?? '') + return value.endsWith(' gap') ? '' : value.slice(5) + }, + }, + }, + }, + { + type: 'category', + gridIndex: 1, + data: model.categories, + boundaryGap: false, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { show: false }, + splitLine: { show: false }, + }, + ], + yAxis: [ + { + type: 'value', + min: minPrice - padding, + max: maxPrice + padding, + scale: true, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { lineStyle: { color: theme.grid } }, + axisLabel: { + color: theme.text, + fontFamily: 'JetBrains Mono, monospace', + fontSize: 10, + formatter: (value: number) => value.toFixed(2), + }, + }, + { + type: 'value', + gridIndex: 1, + scale: true, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { show: false }, + axisLabel: { show: false }, + }, + ], + dataZoom: [{ + type: 'inside', + xAxisIndex: [0, 1], + start: 0, + end: 100, + minValueSpan: FULL_DAY_TIMES.length, + filterMode: 'none', + }], + series: [ + ...priceSeries, + ...averageSeries, + { + name: '成交量', + type: 'bar', + data: model.volumeData, + xAxisIndex: 1, + yAxisIndex: 1, + }, + ], + } + chart.setOption(option, true) + }, [height, model, priceLines, theme]) + + useEffect(() => () => { + chartRef.current?.off('updateAxisPointer') + chartRef.current?.off('globalout') + if (priceDoubleClickHandlerRef.current) { + chartRef.current?.getZr().off('dblclick', priceDoubleClickHandlerRef.current) + } + resizeObserverRef.current?.disconnect() + chartRef.current?.dispose() + chartRef.current = null + }, []) + + const changePct = info?.prevClose + ? (info.row.close - info.prevClose) / info.prevClose * 100 + : null + const infoColor = info ? priceColor(info.row.close, info.prevClose) : COLORS.flat + const rowCount = sessions.reduce((total, session) => total + session.rows.length, 0) + + return ( +
+
+
+ {info ? ( + <> + {info.date} {formatMinuteTime(info.row.datetime)} + {info.row.open.toFixed(2)} + {info.row.high.toFixed(2)} + {info.row.low.toFixed(2)} + {info.row.close.toFixed(2)} + {changePct != null && ( + {changePct >= 0 ? '+' : ''}{changePct.toFixed(2)}% + )} + 均价{info.average.toFixed(2)} + {info.row.volume.toFixed(0)} + {formatAmount(info.row.amount)} + + ) : } +
+
{sessions.length} 个交易日 · {rowCount} 分钟
+
+
+
+ ) +} diff --git a/frontend/src/components/GroupStatsSettings.tsx b/frontend/src/components/GroupStatsSettings.tsx new file mode 100644 index 0000000..d024232 --- /dev/null +++ b/frontend/src/components/GroupStatsSettings.tsx @@ -0,0 +1,170 @@ +import { useEffect, useRef, useState } from 'react' +import { Minus, Plus, SlidersHorizontal } from 'lucide-react' +import { + GROUP_METRICS, + GROUP_SORT_OPTIONS, + GROUP_CARD_TOP_N_MAX, + GROUP_CARD_TOP_N_MIN, + type GroupStatsConfig, + type GroupStatsConfigPatch, +} from '@/lib/watchlistGroupStats' + +/** + * 分组「指标 + 排序」设置弹层 — 分组统计条与分组卡片共用。 + * 状态由父级持有并持久化, 这里只负责弹层交互与展示。 + * showCardLimit 为真时额外暴露分组卡片显示项 (条数/头部彩条/序号, 仅卡片视图有意义)。 + */ +export function GroupStatsSettings({ + config, + onChange, + ariaLabel = '分组统计设置', + showCardLimit = false, +}: { + config: GroupStatsConfig + onChange: (patch: GroupStatsConfigPatch) => void + ariaLabel?: string + showCardLimit?: boolean +}) { + const [open, setOpen] = useState(false) + const rootRef = useRef(null) + + // 点击面板外部关闭 (与自选页搜索框同模式) + useEffect(() => { + if (!open) return + const handleClick = (e: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) { + setOpen(false) + } + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, [open]) + + return ( +
+ + {open && ( +
+
指标
+
+ {GROUP_METRICS.map(m => ( + + ))} +
+
排序
+
+ {GROUP_SORT_OPTIONS.map(s => ( + + ))} +
+ {showCardLimit && ( + <> +
卡片显示
+
+ + + 前 {config.cardTopN} 条 + + +
+
+
+ 头部颜色 + +
+
+ 序号 + +
+
+ + )} +
+ {GROUP_METRICS.find(m => m.id === config.metric)?.hint} +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index c10ade0..2d68a6d 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,9 +1,9 @@ -import { useEffect, useRef, useState, Suspense } from 'react' -import { NavLink, Outlet, useNavigate } from 'react-router-dom' +import { useEffect, useMemo, useRef, useState, Suspense } from 'react' +import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom' import { useQuery, useQueryClient } from '@tanstack/react-query' import { motion } from 'framer-motion' import { useQuoteStream, useQuoteStreamStatus } from '@/lib/useQuoteStream' -import { ToastContainer } from '@/components/Toast' +import { ToastContainer, toast } from '@/components/Toast' import { AlertToastContainer } from '@/components/AlertToast' import { AiAnalysisHost } from '@/components/financials/AiAnalysisHost' import { AiReportBubble } from '@/components/financials/AiReportBubble' @@ -20,14 +20,15 @@ import { useToggleRealtimeQuotes, } from '@/lib/useSharedMutations' import { QK } from '@/lib/queryKeys' -import { tierRank } from '@/lib/capability-labels' import { + Siren, Star, ScanSearch, History, + Pickaxe, FileText, Settings, - Key, + DatabaseZap, Database, Loader2, LayoutDashboard, @@ -42,21 +43,28 @@ import { RadioTower, CheckCircle2, BookOpenCheck, - ExternalLink, + ChevronRight, + ChevronDown, Sun, Moon, X, WifiOff, + PanelLeftClose, + PanelLeftOpen, } from 'lucide-react' import { Logo } from './Logo' import { api, type IndexQuote } from '@/lib/api' import { cn } from '@/lib/cn' +import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors' +import { computeGroupPcts, groupPctColor, groupPctTitle } from '@/lib/watchlistGroupStats' +import { fmtPct } from '@/lib/format' import { toggleTheme, useTheme } from '@/lib/theme' import { setCurrentTotal as setAlertTotal, useUnreadAlerts } from '@/lib/monitorBadge' +import { ExtensionSlot } from '@/extensions/ExtensionSlot' +import { getFrontendExtensionNavigation } from '@/extensions/registry' // 品牌色 — 只用于 logo / brand 区域,不影响功能语义色 const BRAND = '#8B5CF6' -const TICKFLOW_REGISTER_URL = 'https://tickflow.org/auth/register?ref=V3KDKGXPEA' const CORE_INDEXES = [ { symbol: '000001.SH', name: '上证指数' }, @@ -71,14 +79,16 @@ const nav = [ { to: '/', label: '看板', icon: LayoutDashboard }, { to: '/watchlist', label: '自选', icon: Star }, { to: '/screener', label: '策略', icon: ScanSearch }, - { to: '/backtest', label: '回测', icon: History }, + { to: '/backtest', label: '回测', icon: History }, + { to: '/mining', label: '挖掘', icon: Pickaxe }, { to: '/stock-analysis', label: '个股分析', icon: TrendingUp }, { to: '/limit-ladder', label: '连板梯队', icon: Flame }, { to: '/concept-analysis', label: '概念分析', icon: Layers3 }, { to: '/industry-analysis', label: '行业分析', icon: Landmark }, { to: '/financials', label: '财务分析', icon: FileText }, { to: '/monitor', label: '监控中心', icon: RadioTower }, - { to: '/regime', label: '市场环境', icon: Gauge, badge: 'beta' }, + { to: '/regime', label: '市场环境', icon: Gauge }, + { to: '/abnormal', label: '异动监控', icon: Siren }, { to: '/review', label: '复盘', icon: BookOpenCheck }, { to: '/indices', label: '指数', icon: BarChart3 }, { to: '/data', label: '数据', icon: Database }, @@ -135,7 +145,7 @@ function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; i if (items.length === 0) return null const quoteBySymbol = new Map((rows ?? []).map(q => [q.symbol, q])) return ( -
+
{items.map(item => { const q = quoteBySymbol.get(item.symbol) const value = q?.last_price ?? q?.close @@ -162,115 +172,103 @@ function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; i } // ===== 档位卡片 ===== -function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) { +function TierBadge({ label, hasKey, providerName, isTickflow }: { label: string; hasKey?: boolean; providerName: string; isTickflow: boolean }) { const base = label.split(' ')[0].split('+')[0].toLowerCase() const isNone = base === 'none' const tierConfig: Record = { none: { desc: '未配置 Key · 仅历史日K', - tagBg: { background: 'rgba(113,113,122,0.15)' }, dotStyle: { background: '#52525b' }, + tagBg: { background: 'rgba(113,113,122,0.15)' }, labelTextStyle: { color: '#71717a' }, }, free: { desc: '基础日K · 自选实时', - tagBg: { background: 'rgba(113,113,122,0.3)' }, dotStyle: { background: '#71717a' }, + tagBg: { background: 'rgba(113,113,122,0.3)' }, labelTextStyle: { color: '#a1a1aa' }, }, starter: { desc: '批量同步 · 行情池', - tagBg: { background: 'rgba(59,130,246,0.2)' }, dotStyle: { background: '#3b82f6' }, + tagBg: { background: 'rgba(59,130,246,0.2)' }, labelTextStyle: { color: '#60a5fa' }, }, pro: { desc: '分钟K · 实时行情 · 盘口', - tagBg: { background: 'linear-gradient(135deg, rgba(168,85,247,0.2), rgba(124,58,237,0.15))' }, dotStyle: { background: 'linear-gradient(135deg, #a855f7, #7c3aed)' }, + tagBg: { background: 'linear-gradient(135deg, rgba(168,85,247,0.2), rgba(124,58,237,0.15))' }, labelTextStyle: { background: 'linear-gradient(135deg, #c084fc, #a855f7)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' }, }, expert: { desc: 'WebSocket · 财务数据', - tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' }, dotStyle: { background: 'linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b)' }, + tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' }, labelTextStyle: { background: 'linear-gradient(135deg, #60a5fa, #c084fc, #fbbf24)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' }, }, } const t = tierConfig[base] || tierConfig.none - // none 档显示英文「None」,无 label 时也显示「None」 const displayLabel = isNone ? 'None' : (label || 'None') + const descText = isNone && !hasKey ? '配置 Key 解锁更多能力' : t.desc return ( -
-
-
-
- -
-
-
- TickFlow - -
-
- {isNone && !hasKey ? '配置 Key 解锁更多能力' : t.desc} -
-
- - {displayLabel} - - -
- -
+ + + + {providerName || '数据源'} + + + {isTickflow && ( + + {displayLabel} + + )} ) } function AIConfigBadge({ configured, model }: { configured?: boolean; model?: string }) { + const descText = configured ? (model || '已接入模型') : '接入策略生成模型' return ( -
-
-
-
- -
-
-
- AI 配置 - -
-
- {configured ? (model || '已接入模型') : '接入策略生成模型'} -
-
- -
-
+ + + {configured ? ( + + {model || '已接入模型'} + + ) : ( + <> + AI 配置 + 未配置 + + )} + ) } @@ -294,6 +292,49 @@ export function Layout() { queryFn: api.analysisMenus, }) + // 自选分组 — 仅当用户开启「显示在侧边栏」时拉取 + const groupsInNav = prefs?.watchlist_groups_in_nav ?? false + const location = useLocation() + const { data: watchlistGroupsData } = useQuery({ + queryKey: QK.watchlistGroups, + queryFn: api.watchlistGroups, + enabled: groupsInNav, + staleTime: 60_000, + }) + const watchlistGroups = watchlistGroupsData?.groups ?? [] + // 自选二级菜单展开状态 — 默认当前在自选页时展开 + const [watchlistNavExpanded, setWatchlistNavExpanded] = useState(location.pathname === '/watchlist') + + // 侧边栏收起状态 — 持久化到 localStorage + const [navCollapsed, setNavCollapsed] = useState(() => { + if (typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches) return true + try { return localStorage.getItem('tf-nav-collapsed') === '1' } catch { return false } + }) + + // 分组等权平均涨跌幅 — 复用 watchlist/enriched 查询缓存(与自选页同 key, + // 盘中随 SSE 刷新)。可见性门控: 子菜单实际可见(侧栏展开 + 二级菜单展开) + // 时才拉取, 收起状态下不为隐藏 UI 发请求。 + const navGroupPctVisible = groupsInNav && !navCollapsed && watchlistNavExpanded + const { data: navWatchlist } = useQuery({ + queryKey: QK.watchlist, + queryFn: api.watchlistList, + enabled: navGroupPctVisible, + staleTime: 60_000, + }) + const { data: navEnriched } = useQuery({ + queryKey: QK.watchlistEnriched(undefined), + queryFn: () => api.watchlistEnriched(), + enabled: navGroupPctVisible, + staleTime: 60_000, + }) + const navGroupPcts = useMemo( + () => computeGroupPcts( + navWatchlist?.symbols ?? [], + new Map((navEnriched?.rows ?? []).map((r: any) => [r.symbol as string, r])), + ), + [navWatchlist, navEnriched], + ) + // 数据同步状态轮询: 有活跃 job 时「数据」菜单项显示转圈 const { data: pipelineJobs } = useQuery({ queryKey: QK.pipelineJobs, @@ -322,8 +363,28 @@ export function Layout() { const navigate = useNavigate() const version = versionData?.version const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false - // Free 档监控限制提示: 可手动关闭, 不持久化 (刷新后恢复显示) + // 自选实时模式限制提示: 可手动关闭, 不持久化 (刷新后恢复显示) const [dismissFreeHint, setDismissFreeHint] = useState(false) + useEffect(() => { + const compact = window.matchMedia('(max-width: 767px)') + const syncSidebarWithViewport = (event: MediaQueryListEvent | MediaQueryList) => { + if (event.matches) { + setNavCollapsed(true) + return + } + try { setNavCollapsed(localStorage.getItem('tf-nav-collapsed') === '1') } catch {} + } + syncSidebarWithViewport(compact) + compact.addEventListener('change', syncSidebarWithViewport) + return () => compact.removeEventListener('change', syncSidebarWithViewport) + }, []) + const toggleNavCollapsed = () => { + setNavCollapsed(prev => { + const next = !prev + try { localStorage.setItem('tf-nav-collapsed', next ? '1' : '0') } catch {} + return next + }) + } const indicesPinned = prefs?.indices_nav_pinned ?? true const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? CORE_INDEXES.map(p => p.symbol) const sidebarIndexes = CORE_INDEXES.filter(item => sidebarIndexSymbols.includes(item.symbol)) @@ -346,32 +407,57 @@ export function Layout() { const isTrading = quoteStatus?.is_trading_hours ?? false // 管道/数据修正运行期间实时行情被临时暂停 — 此时禁止开启 const isPaused = quoteStatus?.paused ?? false - const tier = tierRank(caps?.label ?? '') - const isNoneTier = tier < 0 - const isWatchlistMode = tier === 0 + // 实时模式以 quote_status 为准 (数据源无关): none=不可用 / watchlist=自选实时 / full_market=全市场 + const quoteMode = quoteStatus?.mode ?? 'none' + const realtimeUnavailable = quoteMode === 'none' + const isWatchlistMode = quoteMode === 'watchlist' const realtimeModeLabel = isWatchlistMode ? '自选股' : '全市场' // 当前实时行情数据源名称 (custom 时显示源名, tickflow 时不显示) const realtimeProvider = prefs?.realtime_data_provider const realtimeProviderName = realtimeProvider && realtimeProvider !== 'tickflow' ? (dataSources?.custom?.find(s => s.name === realtimeProvider)?.display_name || realtimeProvider) : null + const realtimeToggleDisabled = toggleQuote.isPending || isPaused + const realtimeActive = realtimeEnabled && isRunning && isTrading + const realtimeStatusLabel = toggleQuote.isPending + ? '正在更新' + : isPaused + ? '同步期间暂停' + : realtimeActive + ? '运行中' + : realtimeEnabled + ? (isTrading ? '正在连接' : '等待交易时段') + : '已关闭' + const realtimeStatusClass = realtimeActive + ? 'text-accent' + : realtimeEnabled || isPaused + ? 'text-warning/80' + : 'text-muted' + const realtimeIndicatorClass = realtimeActive + ? 'bg-accent animate-pulse' + : realtimeEnabled || isPaused + ? 'bg-warning/70' + : 'bg-muted' + const realtimeToggleTitle = isPaused + ? '数据同步运行中,实时行情已临时暂停' + : toggleQuote.isPending + ? '正在更新实时行情设置' + : realtimeEnabled + ? '关闭实时行情' + : '开启实时行情' - // 当前主数据源 (用于菜单底部状态条) + // 当前主数据源 (用于侧边栏数据源状态卡) const activeProvider = prefs?.daily_data_provider || 'tickflow' const activeProviderName = activeProvider === 'tickflow' ? 'TickFlow' : (dataSources?.custom?.find(s => s.name === activeProvider)?.display_name || activeProvider) - const activeProviderDatasets = activeProvider === 'tickflow' - ? ['daily', 'adj_factor', 'realtime', 'minute'] - : (dataSources?.custom?.find(s => s.name === activeProvider)?.datasets || []) const isCustomActive = activeProvider !== 'tickflow' - // 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒) + // 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒; 后台标签页由 SSE 事件驱动, 不轮询) const alertsTotalQuery = useQuery({ queryKey: ['alerts-total'], queryFn: () => api.alertsList({ days: 7, limit: 1 }), refetchInterval: 15000, - refetchIntervalInBackground: true, select: (data) => data.total, }) // 只在拿到真实总数时同步徽标 (避免 data=undefined 时传 0 重置 lastSeen) @@ -385,18 +471,40 @@ export function Layout() { const analysisNav: NavItem[] = (analysisMenus?.items ?? []) .filter(m => m.visible) .map(m => ({ to: `/analysis/${m.id}`, label: m.label, icon: m.icon === 'tags' ? Tags : BarChart3 })) + const extensionNav: NavItem[] = getFrontendExtensionNavigation().map(item => ({ + to: item.route.path, + label: item.label, + icon: item.icon, + badge: item.badge, + })) - const allNav: NavItem[] = [...nav, ...analysisNav] + const allNav: NavItem[] = [...nav, ...analysisNav, ...extensionNav] const savedOrder = prefs?.nav_order ?? [] const navItems = savedOrder.length > 0 ? (() => { const byTo = new Map(allNav.map(n => [n.to, n])) - const ordered = savedOrder + const ordered = (savedOrder .map(id => byTo.get(id) ?? byTo.get(`/analysis/${id}`)) - .filter(Boolean) - const seen = new Set(ordered.map(n => n!.to)) - return [...ordered as typeof allNav, ...allNav.filter(n => !seen.has(n.to))] + .filter(Boolean)) as typeof allNav + const seen = new Set(ordered.map(n => n.to)) + const merged = [...ordered] + for (const item of allNav) { + if (seen.has(item.to)) continue + // 未保存过排序的新条目: 内置页插回默认位置(排在已保存的默认前驱之后), + // 分析/扩展菜单仍追加到末尾 + const defaultIndex = nav.findIndex(n => n.to === item.to) + let anchor = -1 + if (defaultIndex > 0) { + for (let i = defaultIndex - 1; i >= 0 && anchor < 0; i -= 1) { + anchor = merged.findIndex(n => n.to === nav[i].to) + } + } + if (anchor >= 0) merged.splice(anchor + 1, 0, item) + else if (defaultIndex >= 0) merged.unshift(item) + else merged.push(item) + } + return merged })() : allNav @@ -404,15 +512,17 @@ export function Layout() { const visibleNavItems = navItems.filter(n => !hiddenIds.has(n.to) && !hiddenIds.has(n.to.replace(/^\/analysis\//, ''))) const handleToggle = async (enabled: boolean) => { - // 开启时重新校验档位 + // 开启时重新校验实时权限 (以 quote_status 的数据源无关判定为准) if (enabled) { const fresh = await qc.fetchQuery({ - queryKey: QK.capabilities, - queryFn: api.capabilities, + queryKey: QK.quoteStatus, + queryFn: api.quoteStatus, }) - const freshTier = tierRank(fresh.label ?? '') - if (freshTier < 0) return - if (freshTier === 0 && (prefs?.realtime_watchlist_symbols?.length ?? 0) === 0) { + if (!fresh.realtime_allowed) { + toast('当前数据源无实时行情能力, 请先配置数据源', 'error') + return + } + if (fresh.mode === 'watchlist' && (prefs?.realtime_watchlist_symbols?.length ?? 0) === 0) { navigate('/watchlist') return } @@ -425,238 +535,344 @@ export function Layout() { } return ( -
+
diff --git a/frontend/src/components/Logo.tsx b/frontend/src/components/Logo.tsx index cc4baff..6623e5b 100644 --- a/frontend/src/components/Logo.tsx +++ b/frontend/src/components/Logo.tsx @@ -22,7 +22,7 @@ export function Logo({ className, size = 32, style }: LogoProps) { className={className} style={style} role="img" - aria-label="TickFlow Stock Panel" + aria-label="Tick Stock Panel" > {/* 左方括号 */} + directions: Record + onChange: (value: Record, directions: Record) => void + fallbackLabels?: Record +} + +function weightsToPercentages(values: Record) { + const entries = Object.entries(values).map(([name, value]) => [ + name, + Math.max(0, Number(value) || 0), + ] as const) + const total = entries.reduce((sum, [, value]) => sum + value, 0) + if (total <= 0) { + return Object.fromEntries(entries.map(([name]) => [name, 0])) as Record + } + + const shares = entries.map(([name, value], index) => { + const exact = value / total * 100 + return { name, index, value: Math.floor(exact), remainder: exact - Math.floor(exact) } + }) + let remaining = 100 - shares.reduce((sum, item) => sum + item.value, 0) + for (const item of [...shares].sort((a, b) => b.remainder - a.remainder || a.index - b.index)) { + if (remaining <= 0) break + item.value += 1 + remaining -= 1 + } + return Object.fromEntries(shares.map(item => [item.name, item.value])) as Record +} + +function normalizePercentages(values: Record) { + const active = Object.entries(values).filter(([, value]) => Number(value) > 0) + const total = active.reduce((sum, [, value]) => sum + Number(value), 0) + if (total <= 0) return {} + return Object.fromEntries( + active.map(([name, value]) => [name, +(Number(value) / total).toFixed(6)]), + ) as Record +} + +function ScoringRow({ name, label, weight, direction, editing, onWeightChange, onDirectionChange, onRemove }: { + name: string + label: string + weight: number + direction: ScoringDirection + editing: boolean + onWeightChange: (value: number) => void + onDirectionChange: (value: ScoringDirection) => void + onRemove: () => void +}) { + return ( +
+ {label} + {editing ? ( +
+ {([['high', ArrowUp, '偏好高值'], ['low', ArrowDown, '偏好低值']] as const).map(([value, Icon, title]) => ( + + ))} +
+ ) : ( + + {direction === 'low' ? : } + {direction === 'low' ? '低值' : '高值'} + + )} + {editing ? ( + onWeightChange(Number(event.target.value))} + className="h-1 min-w-0 cursor-pointer accent-amber-400" + aria-label={`${label}权重`} + /> + ) : ( +
+
+
+ )} + {weight}% + {editing ? ( + + ) :
+ ) +} + +export function ScoringEditor({ value, directions, onChange, fallbackLabels = {} }: Props) { + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState>(() => weightsToPercentages(value)) + const [directionDraft, setDirectionDraft] = useState>(directions) + const [factorToAdd, setFactorToAdd] = useState('') + const factors = useQuery({ + queryKey: QK.factorColumns, + queryFn: api.factorColumns, + staleTime: 5 * 60_000, + }) + const factorLabels = useMemo(() => Object.fromEntries( + (factors.data?.columns ?? []).map(item => [item.id, item.label]), + ), [factors.data]) + const factorGroups = useMemo(() => { + const groups: Record = {} + for (const item of factors.data?.columns ?? []) { + ;(groups[item.group] ??= []).push(item) + } + return groups + }, [factors.data]) + + useEffect(() => { + if (editing) return + setDraft(weightsToPercentages(value)) + setDirectionDraft(directions) + }, [directions, editing, value]) + + const startEditing = () => { + setDraft(weightsToPercentages(value)) + setDirectionDraft(directions) + setFactorToAdd('') + setEditing(true) + } + const cancelEditing = () => { + setDraft(weightsToPercentages(value)) + setDirectionDraft(directions) + setFactorToAdd('') + setEditing(false) + } + const saveDraft = () => { + const normalized = normalizePercentages(draft) + const nextDirections = Object.fromEntries( + Object.keys(normalized).map(name => [name, directionDraft[name] ?? 'high']), + ) as Record + onChange(normalized, nextDirections) + setFactorToAdd('') + setEditing(false) + } + const addFactor = () => { + if (!factorToAdd || factorToAdd in draft) return + setDraft(current => ({ ...current, [factorToAdd]: Object.keys(current).length > 0 ? 10 : 100 })) + setDirectionDraft(current => ({ ...current, [factorToAdd]: 'high' })) + setFactorToAdd('') + } + const removeFactor = (name: string) => { + setDraft(current => Object.fromEntries( + Object.entries(current).filter(([key]) => key !== name), + )) + setDirectionDraft(current => Object.fromEntries( + Object.entries(current).filter(([key]) => key !== name), + ) as Record) + } + + const visibleWeights = editing ? draft : weightsToPercentages(value) + const visibleDirections = editing ? directionDraft : directions + const visibleKeys = Object.keys(visibleWeights) + const draftTotal = Object.values(visibleWeights).reduce((sum, weight) => sum + weight, 0) + + return ( +
+ {editing && ( +
+ + +
+ )} + + {visibleKeys.length > 0 ? ( +
+ {visibleKeys.map(name => ( + setDraft(current => ({ ...current, [name]: Math.max(0, weight) }))} + onDirectionChange={direction => setDirectionDraft(current => ({ ...current, [name]: direction }))} + onRemove={() => removeFactor(name)} + /> + ))} +
+ ) : ( +
+ {editing ? '请选择评分因子' : '当前策略不使用因子评分'} +
+ )} + +
+
+ 权重 + {editing ? draftTotal : visibleKeys.length > 0 ? 100 : 0}% + +
+
+ {editing && ( + + )} + +
+
+
+ ) +} diff --git a/frontend/src/components/SealedBadge.tsx b/frontend/src/components/SealedBadge.tsx index 1e76a2c..8c1a4b9 100644 --- a/frontend/src/components/SealedBadge.tsx +++ b/frontend/src/components/SealedBadge.tsx @@ -70,7 +70,7 @@ export function SealedBadge({ degraded, hasDepth, isHistorical, sealedReady, sea // 组装原因文案(仅降级时用) const reasons: string[] = [] - if (!hasDepth) reasons.push('当前套餐无五档盘口能力(需 Pro+),涨停判定基于收盘价,可能含假涨停') + if (!hasDepth) reasons.push('五档盘口数据不可用,涨停判定基于收盘价,可能含假涨停') if (isHistorical) reasons.push('历史日期的盘口快照不可获取,无法判定真假板') if (hasDepth && !isHistorical && !sealedReady) reasons.push('盘中 sealed 数据尚未就绪,收盘后自动恢复') @@ -126,7 +126,7 @@ export function SealedBadge({ degraded, hasDepth, isHistorical, sealedReady, sea
))}
- 真假板判定依赖五档盘口实时快照(卖一/买一量)。Pro+ 套餐的当天数据在收盘后自动恢复。 + 真假板判定依赖五档盘口实时快照(卖一/买一量)。配置提供五档盘口的数据源后,当天数据在收盘后自动恢复。
) : ( diff --git a/frontend/src/components/StockDailyKChart.tsx b/frontend/src/components/StockDailyKChart.tsx index c58747d..9f87e67 100644 --- a/frontend/src/components/StockDailyKChart.tsx +++ b/frontend/src/components/StockDailyKChart.tsx @@ -50,6 +50,7 @@ interface Props { visibleBars?: number linkedPrice?: number | null onDateClick?: (date: string) => void + onPriceDoubleClick?: (price: number, currentPrice: number) => void onDataChange?: (result: StockDailyKChartResult) => void /** 扩展数据列参数(逗号分隔 config_id.field_name),透传给 klineDaily 接口 */ extColumns?: string @@ -132,6 +133,7 @@ export function StockDailyKChart({ visibleBars = 60, linkedPrice, onDateClick, + onPriceDoubleClick, onDataChange, extColumns, }: Props) { @@ -279,6 +281,7 @@ export function StockDailyKChart({ symbol={symbol} linkedPrice={linkedPrice} onDateClick={onDateClick} + onPriceDoubleClick={onPriceDoubleClick} visibleBars={visibleBars} activeIndicators={activeIndicators} volumeCompare={volumeCompare} diff --git a/frontend/src/components/StockInfoBar.tsx b/frontend/src/components/StockInfoBar.tsx index 1063249..7b5891f 100644 --- a/frontend/src/components/StockInfoBar.tsx +++ b/frontend/src/components/StockInfoBar.tsx @@ -3,6 +3,7 @@ import { Settings2, RadioTower, Star } from 'lucide-react' import type { KlineRow, FinancialMetricRecord } from '@/lib/api' import { fmtPrice, fmtBigNum, fmtVolume } from '@/lib/format' import { ListColumnCustomizer } from '@/components/ListColumnCustomizer' +import { WatchlistAddMenu } from '@/components/WatchlistAddMenu' import { INFO_GROUPS, type ColumnConfig } from '@/lib/stock-info-fields' const BULL = '#C74040' @@ -20,9 +21,11 @@ interface Props { financialMetrics?: FinancialMetricRecord /** 加监控回调 (个股弹窗传入, 有值时渲染 RadioTower 图标) */ onMonitor?: () => void - /** 加自选回调 + 是否已自选 (有 onToggle 时渲染 Star 图标) */ + /** 自选状态与操作(传入对应回调时渲染 Star 图标) */ inWatchlist?: boolean - onToggleWatchlist?: () => void + onAddToWatchlist?: (groupId: string | null) => void + onRemoveFromWatchlist?: () => void + watchlistPending?: boolean } /** @@ -91,7 +94,20 @@ function renderExtInline( ) } -export function StockInfoBar({ symbol, name, stockInfo, rows, fields, onFieldsChange, financialMetrics, onMonitor, inWatchlist, onToggleWatchlist }: Props) { +export function StockInfoBar({ + symbol, + name, + stockInfo, + rows, + fields, + onFieldsChange, + financialMetrics, + onMonitor, + inWatchlist, + onAddToWatchlist, + onRemoveFromWatchlist, + watchlistPending, +}: Props) { // 弹窗开关:纯本地状态,与数据/配置无关,放早期 return 之前 const [customizerOpen, setCustomizerOpen] = useState(false) // ext 标签展开状态:按 symbol::colId,切股/切字段时互不干扰 @@ -216,15 +232,27 @@ export function StockInfoBar({ symbol, name, stockInfo, rows, fields, onFieldsCh {/* 右侧操作按钮:加自选 + 加监控 + 信息条配置 */}
- {onToggleWatchlist && ( + {inWatchlist && onRemoveFromWatchlist ? ( - )} + ) : !inWatchlist && onAddToWatchlist ? ( + + + + ) : null} {onMonitor && (
diff --git a/frontend/src/components/StockMultiDayIntradayChart.tsx b/frontend/src/components/StockMultiDayIntradayChart.tsx new file mode 100644 index 0000000..852fb37 --- /dev/null +++ b/frontend/src/components/StockMultiDayIntradayChart.tsx @@ -0,0 +1,198 @@ +import { useEffect, useMemo, useRef } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Download, Loader2, RefreshCw } from 'lucide-react' +import { api, type MinuteKlineSession } from '@/lib/api' +import { QK } from '@/lib/queryKeys' +import { toast } from '@/components/Toast' +import { EChartsMultiDayIntraday } from '@/components/EChartsMultiDayIntraday' + +interface Props { + symbol: string + days: number + height?: number + refetchIntervalMs?: number + onPriceDoubleClick?: (price: number, currentPrice: number) => void + priceLines?: { value: number; label?: string; color?: string }[] +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : '分钟数据获取失败' +} + +export function StockMultiDayIntradayChart({ + symbol, + days, + height = 420, + refetchIntervalMs, + onPriceDoubleClick, + priceLines, +}: Props) { + const queryClient = useQueryClient() + const history = useQuery({ + queryKey: QK.klineMinuteRange(symbol, days), + queryFn: () => api.klineMinuteRange(symbol, days), + enabled: !!symbol, + placeholderData: (previous, previousQuery) => + previousQuery?.queryKey[1] === symbol ? previous : undefined, + }) + const latest = useQuery({ + queryKey: QK.klineMinute(symbol, ''), + queryFn: () => api.klineMinute(symbol), + enabled: !!symbol, + refetchInterval: refetchIntervalMs, + }) + + const sessions = useMemo(() => { + const byDate = new Map() + for (const session of history.data?.sessions ?? []) byDate.set(session.date, session) + + const latestDate = latest.data?.date + const latestRows = latest.data?.rows ?? [] + if (latestDate && latestRows.length > 0) { + const existing = byDate.get(latestDate) + byDate.set(latestDate, { + date: latestDate, + prev_close: latest.data?.prev_close ?? existing?.prev_close ?? null, + rows: latestRows, + }) + } + + return Array.from(byDate.values()) + .sort((left, right) => left.date.localeCompare(right.date)) + .slice(-days) + }, [days, history.data?.sessions, latest.data]) + + const syncMinute = useMutation({ + mutationFn: () => api.syncMinuteSingle(symbol, days), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['kline-minute-range', symbol] }), + queryClient.invalidateQueries({ queryKey: ['kline-minute', symbol] }), + ]) + }, + onError: (e: Error) => { + const msg = e.message || '' + if (msg.includes('403') || msg.includes('Pro')) { + toast('分钟K(批量)数据不可用', 'error') + } else { + toast(`补齐数据失败: ${msg}`, 'error') + } + }, + }) + + const loading = sessions.length === 0 && (history.isLoading || latest.isLoading) + const queryError = sessions.length === 0 ? history.error ?? latest.error : null + const isIndex = history.data?.asset_type === 'index' || latest.data?.asset_type === 'index' + const missingDays = Math.max(0, days - sessions.length) + const showCoverage = !history.isPlaceholderData && sessions.length > 0 && missingDays > 0 && !isIndex + + // 自动补齐: 数据不足且非指数时, 自动触发同步 + // 用 ref 记录已触发的 symbol:days, 避免重复 + const autoSyncRef = useRef(null) + useEffect(() => { + // 后端没运行时 history 会 error, 此时 missingDays 计算无意义, 跳过 + if (history.error || history.isPlaceholderData || loading || isIndex || sessions.length >= days) return + if (syncMinute.isPending) return + + const key = `${symbol}:${days}` + if (autoSyncRef.current === key) return // 本组合已触发过 + autoSyncRef.current = key + syncMinute.mutate() + }, [symbol, days, sessions.length, loading, isIndex, history.error, history.isPlaceholderData, syncMinute.isPending]) + + const chartHeight = Math.max(260, height - (showCoverage || syncMinute.isPending ? 32 : 0)) + + if (loading) { + return ( +
+ + 正在加载近 {days} 日分时… +
+ ) + } + + if (queryError) { + return ( +
+ {errorMessage(queryError)} + +
+ ) + } + + if (sessions.length === 0) { + return ( +
+ {syncMinute.isPending ? ( + <> + + 正在获取近 {days} 日分钟 K… + + ) : ( + <> + {isIndex ? '指数暂无分钟数据' : '本地暂无可展示的分钟数据'} + {!isIndex && ( + + )} + + )} + {syncMinute.isError && {errorMessage(syncMinute.error)}} +
+ ) + } + + return ( +
+ {(showCoverage || (syncMinute.isPending && !isIndex)) && ( +
+ {syncMinute.isPending ? ( + + + 正在补齐最近 {days} 日分时数据… + + ) : syncMinute.isError ? ( + 当前 {sessions.length} 日,目标 {days} 日 — 补齐失败 + ) : ( + 当前 {sessions.length} 个交易日数据,目标 {days} 日 + )} + {!syncMinute.isPending && ( + + )} +
+ )} + + {syncMinute.isError && ( +
{errorMessage(syncMinute.error)}
+ )} +
+ ) +} diff --git a/frontend/src/components/StockPanel.tsx b/frontend/src/components/StockPanel.tsx index e6e496c..ef5aa90 100644 --- a/frontend/src/components/StockPanel.tsx +++ b/frontend/src/components/StockPanel.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useCallback, useRef, useMemo } from 'react' +import { X } from 'lucide-react' import { type KlineRow, type FinancialMetricRecord } from '@/lib/api' import { StockInfoBar } from '@/components/StockInfoBar' import { StockDailyKChart, getDefaultRange, type StockDailyKChartResult } from '@/components/StockDailyKChart' @@ -29,11 +30,16 @@ interface Props { showMarkerToggle?: boolean /** 加监控回调 (传入后信息条显示 RadioTower 图标) */ onMonitor?: () => void - /** 加自选 (传入后信息条显示 Star 图标) */ + onPriceDoubleClick?: (price: number, currentPrice: number) => void + /** 自选操作(传入后信息条显示 Star 图标) */ inWatchlist?: boolean - onToggleWatchlist?: () => void + onAddToWatchlist?: (groupId: string | null) => void + onRemoveFromWatchlist?: () => void + watchlistPending?: boolean /** 分时图自动刷新间隔(ms)。undefined = 不轮询。个股对话框盘中实时刷新时传入。 */ refetchIntervalMs?: number + /** 只渲染信息条, 隐藏图表 (用于分时 tab 共享信息条) */ + infoBarOnly?: boolean } export { getDefaultRange } @@ -51,12 +57,17 @@ export function StockPanel({ showLimitMarkers = true, showMarkerToggle = true, onMonitor, + onPriceDoubleClick, inWatchlist, - onToggleWatchlist, + onAddToWatchlist, + onRemoveFromWatchlist, + watchlistPending, refetchIntervalMs, + infoBarOnly = false, }: Props) { const [linkedPrice, setLinkedPrice] = useState(null) const [selectedDate, setSelectedDate] = useState(null) + const [intradayDismissed, setIntradayDismissed] = useState(false) const [dailyResult, setDailyResult] = useState(null) // 信息条指标配置提升到此层:同时供 StockInfoBar 渲染与 StockDailyKChart 请求 ext 数据 const [fields, setFields] = useState(loadInfoFields) @@ -67,7 +78,7 @@ export function StockPanel({ saveInfoFields(next) }, []) - // 财务指标:仅当信息条配置含可见的财务字段且用户具备 FINANCIAL 能力 (Expert) 时才请求 + // 财务指标:仅当信息条配置含可见的财务字段且用户具备财务数据能力 (financial) 时才请求 // 无能力时跳过请求, 避免后端抛 CapabilityDenied (403) 导致 free/starter 档弹错误提示 const { data: caps } = useCapabilities() const hasFinancialCap = !!caps?.capabilities?.['financial'] @@ -82,6 +93,7 @@ export function StockPanel({ const handleDateClick = useCallback((date: string) => { setSelectedDate(date) + setIntradayDismissed(false) onSelectDate?.(date) }, [onSelectDate]) @@ -132,9 +144,12 @@ export function StockPanel({ financialMetrics={financialMetrics} onMonitor={onMonitor} inWatchlist={inWatchlist} - onToggleWatchlist={onToggleWatchlist} + onAddToWatchlist={onAddToWatchlist} + onRemoveFromWatchlist={onRemoveFromWatchlist} + watchlistPending={watchlistPending} /> + {infoBarOnly ? null : (
- {showIntraday && selectedDate && ( - + {showIntraday && selectedDate && !intradayDismissed && ( +
+ + +
)}
+ )}
) } diff --git a/frontend/src/components/StockPreviewDialog.tsx b/frontend/src/components/StockPreviewDialog.tsx index f49a3ab..968d915 100644 --- a/frontend/src/components/StockPreviewDialog.tsx +++ b/frontend/src/components/StockPreviewDialog.tsx @@ -1,16 +1,24 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { motion, AnimatePresence } from 'framer-motion' -import { X, RefreshCw, Clock } from 'lucide-react' +import { X, RefreshCw, Clock, LineChart, Star, RadioTower, Maximize2, Minimize2, Activity } from 'lucide-react' import { api } from '@/lib/api' import { QK } from '@/lib/queryKeys' +import { cn } from '@/lib/cn' import { cnSignal } from '@/lib/signals' +import { fmtPct } from '@/lib/format' import { StockPanel, getDefaultRange } from '@/components/StockPanel' +import { WatchlistAddMenu } from '@/components/WatchlistAddMenu' +import { StockMultiDayIntradayChart } from '@/components/StockMultiDayIntradayChart' import { DatePicker } from '@/components/DatePicker' import { RuleEditor } from '@/components/monitor/RuleEditor' +import { PriceAlertDialog } from '@/components/stock-analysis/PriceAlertDialog' +import { buildMonitorPriceLines } from '@/lib/price-alerts' import { usePreferences, useQuoteStatus } from '@/lib/useSharedQueries' import { setFocusSymbol, clearFocusSymbol } from '@/lib/useQuoteStream' import { useDialogBackdrop } from '@/lib/useDialogBackdrop' +import { storage } from '@/lib/storage' +import { ExtensionSlot } from '@/extensions/ExtensionSlot' interface Props { symbol: string | null @@ -34,6 +42,21 @@ const PRESETS: { label: string; months: number }[] = [ { label: '1年', months: 12 }, ] +type PreviewView = 'daily' | 'intraday' +interface PriceAlertDraft { + id: number + targetPrice: number + currentPrice: number +} +const INTRADAY_DAY_OPTIONS = [1, 5, 10, 20] as const + +function loadIntradayDays(): number { + const saved = storage.stockPreviewIntradayDays.get(10) + return INTRADAY_DAY_OPTIONS.includes(saved as typeof INTRADAY_DAY_OPTIONS[number]) + ? saved + : 10 +} + function boardTag(symbol: string): { label: string; color: string } | null { if (/^(300|301)/.test(symbol)) return { label: '创', color: 'text-[#f97316] bg-[#f97316]/12 border-[#f97316]/25' } if (/^688/.test(symbol)) return { label: '科', color: 'text-purple-400 bg-purple-400/12 border-purple-400/25' } @@ -41,10 +64,28 @@ function boardTag(symbol: string): { label: string; color: string } | null { return null } +// ===== 异动边缘 (与异动页同口径) ===== + +const AB_STATUS_META: Record = { + triggered: { label: '已触发', cls: 'bg-danger/20 text-danger font-semibold', bar: 'border-b border-danger/30 bg-danger/[0.08]', icon: 'text-danger' }, + edge: { label: '异动边缘', cls: 'bg-warning/20 text-warning font-semibold', bar: 'border-b border-warning/30 bg-warning/[0.07]', icon: 'text-warning' }, + watch: { label: '观察', cls: 'bg-elevated text-secondary font-semibold', bar: 'border-b border-border bg-surface', icon: 'text-secondary' }, +} + +/** 异动引擎计算时间 (服务端 asof 秒级时间戳 → 月-日 时:分:秒) */ +function fmtAbnormalCalcTime(asofSec: number): string { + const d = new Date(asofSec * 1000) + const pad = (n: number) => String(n).padStart(2, '0') + return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +} + export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props) { - const [showIntraday, setShowIntraday] = useState(false) + const [view, setView] = useState('daily') + const [intradayDays, setIntradayDays] = useState(loadIntradayDays) const [dateRange, setDateRange] = useState(getDefaultRange) const [showMonitorEditor, setShowMonitorEditor] = useState(false) + const [priceAlertDraft, setPriceAlertDraft] = useState(null) + const [maximized, setMaximized] = useState(false) const qc = useQueryClient() const backdrop = useDialogBackdrop(onClose) @@ -53,10 +94,43 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props queryFn: api.watchlistList, enabled: !!symbol, }) + const monitorRules = useQuery({ + queryKey: QK.monitorRules, + queryFn: api.monitorRulesList, + enabled: !!symbol, + }) + // 异动边缘: 与异动页同 queryKey 共享缓存; 该股处于观察/边缘/触发状态时在图表上方显示信息条 + const abnormal = useQuery({ + queryKey: QK.abnormalOverview(0.5, 300), + queryFn: () => api.abnormalOverview(0.5, 300), + enabled: !!symbol, + }) + const abRow = symbol + ? abnormal.data?.rows.find(r => r.symbol === symbol) + : undefined + // 接近度最高的窗口 (信息条中高亮) + const abDominantWindow = abRow + ? Object.entries(abRow.windows).reduce( + (best, [k, w]) => (!best || w.closeness > best[1].closeness ? [k, w] as const : best), + undefined as undefined | readonly [string, { value: number; threshold: number; closeness: number }], + ) + : undefined + const monitorPriceLines = useMemo( + () => symbol ? buildMonitorPriceLines(monitorRules.data?.rules ?? [], symbol) : [], + [monitorRules.data?.rules, symbol], + ) const inWatchlist = (watchlist.data?.symbols ?? []).some((s: any) => s.symbol === symbol) const toggleWatchlist = useMutation({ - mutationFn: () => inWatchlist ? api.watchlistRemove(symbol!) : api.watchlistAdd(symbol!), + mutationFn: ({ + action, + groupId, + }: { + action: 'add' | 'remove' + groupId?: string | null + }) => action === 'remove' + ? api.watchlistRemove(symbol!) + : api.watchlistAdd(symbol!, '', groupId), onSuccess: () => { qc.invalidateQueries({ queryKey: QK.watchlist }) qc.invalidateQueries({ queryKey: ['watchlist-enriched'] }) @@ -67,11 +141,16 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props useEffect(() => { if (!symbol) return const handler = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() + if (e.key === 'Escape' && !priceAlertDraft) onClose() } document.addEventListener('keydown', handler) return () => document.removeEventListener('keydown', handler) - }, [symbol, onClose]) + }, [symbol, onClose, priceAlertDraft]) + + useEffect(() => { + if (symbol) setView('daily') + setPriceAlertDraft(null) + }, [symbol]) // 焦点股票注册: SSE quotes_updated 推送时精准 invalidate 当前股票日K, // 让对话框日K最后一根蜡烛随实时价变化 (后端只读内存, 不调 TickFlow)。 @@ -94,12 +173,23 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props const handleRefresh = () => { if (!symbol) return - qc.invalidateQueries({ queryKey: ['kline', symbol!] }) - if (showIntraday) { + if (view === 'daily') { + qc.invalidateQueries({ queryKey: ['kline', symbol] }) + } else { + qc.invalidateQueries({ queryKey: ['kline-minute-range', symbol] }) qc.invalidateQueries({ queryKey: ['kline-minute', symbol!] }) } } + const selectIntradayDays = (days: number) => { + setIntradayDays(days) + storage.stockPreviewIntradayDays.set(days) + } + + const openPriceAlert = (targetPrice: number, currentPrice: number) => { + setPriceAlertDraft({ id: Date.now(), targetPrice, currentPrice }) + } + return ( {symbol && ( @@ -120,11 +210,14 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.97, y: 8 }} transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }} - className="relative w-[92vw] max-w-[1100px] max-h-[95vh] rounded-card border border-border bg-base shadow-2xl overflow-hidden flex flex-col" + className={cn( + 'relative rounded-card border border-border bg-base shadow-2xl overflow-hidden flex flex-col transition-all duration-200 ease-smooth', + maximized ? 'w-screen h-screen max-w-none max-h-none' : 'w-[92vw] max-w-[1100px] max-h-[95vh]', + )} > {/* 顶栏 */} -
-
+
+
{(() => { const board = symbol ? boardTag(symbol) : null return board ? ( @@ -133,79 +226,159 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props ) : null })()} - {symbol} - {name && {name}} + {symbol} + {name && {name}}
-
- {/* 日期范围快捷 */} - {PRESETS.map(p => { - const now = new Date() - const s = new Date(now) - s.setMonth(s.getMonth() - p.months) - const expected = s.toISOString().slice(0, 10) - const isActive = dateRange.start === expected - return ( - - ) - })} - setDateRange(prev => ({ ...prev, start: v }))} - max={dateRange.end} - /> - ~ - setDateRange(prev => ({ ...prev, end: v }))} - min={dateRange.start} - /> +
+ {/* 区间选择 — 随视图切换 */} + {view === 'daily' ? ( +
+ {PRESETS.map(p => { + const now = new Date() + const s = new Date(now) + s.setMonth(s.getMonth() - p.months) + const expected = s.toISOString().slice(0, 10) + const isActive = dateRange.start === expected + return ( + + ) + })} + setDateRange(prev => ({ ...prev, start: v }))} + max={dateRange.end} + /> + ~ + setDateRange(prev => ({ ...prev, end: v }))} + min={dateRange.start} + /> +
+ ) : ( +
+
+ {INTRADAY_DAY_OPTIONS.map(days => ( + + ))} +
+
+ )} - | + - {/* 分时开关 */} + {/* 日K / 分时 切换 */} +
+ + +
+ + + + {/* 自选 */} + {inWatchlist ? ( + + ) : ( + toggleWatchlist.mutate({ action: 'add', groupId })} + disabled={toggleWatchlist.isPending} + triggerClassName="rounded-btn p-1.5 text-muted transition-colors cursor-pointer hover:bg-elevated hover:text-foreground disabled:opacity-50" + ariaLabel={`将 ${symbol} 加入自选`} + > + + + )} + {/* 加监控 */} - | - {/* 刷新 */} + + {/* 放大 / 缩小 */} + - {/* 关闭 */} @@ -253,18 +426,84 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
)} - {/* K 线内容 */} + {/* 异动边缘信息条 (与异动页同源; 该股无异动数据时不显示)。整条按状态着色提升辨识度 */} + {abRow && (() => { + const meta = AB_STATUS_META[abRow.status] ?? AB_STATUS_META.watch + return ( +
+ + + 异动 + + {meta.label} + + + {Object.entries(abRow.windows) + .sort((a, b) => parseInt(a[0], 10) - parseInt(b[0], 10)) + .map(([w, info]) => { + const dominant = abDominantWindow?.[0] === w + return ( + + {parseInt(w, 10)}日{' '} + = 0 ? 'text-bull' : 'text-bear'}>{fmtPct(info.value, 1)} + / ±{(info.threshold * 100).toFixed(0)}% + · 接近{(info.closeness * 100).toFixed(0)}% + + ) + })} + + 计算于 {fmtAbnormalCalcTime(abnormal.data?.asof ?? 0)} + +
+ ) + })()} + + {/* 图表内容 */}
- { if (!showIntraday) setShowIntraday(true) }} - dateRange={dateRange} - onMonitor={() => setShowMonitorEditor(true)} - inWatchlist={inWatchlist} - onToggleWatchlist={() => toggleWatchlist.mutate()} - refetchIntervalMs={intradayRefetchMs} + {view === 'daily' ? ( + + ) : ( + <> + + + + )} +
+ + {/* 扩展插槽: 对话框底部二开区 (无注册时不渲染) */} +
+
@@ -298,6 +537,16 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
)} + {symbol && priceAlertDraft && ( + setPriceAlertDraft(null)} + /> + )} ) } diff --git a/frontend/src/components/WatchlistAddMenu.tsx b/frontend/src/components/WatchlistAddMenu.tsx new file mode 100644 index 0000000..1728906 --- /dev/null +++ b/frontend/src/components/WatchlistAddMenu.tsx @@ -0,0 +1,272 @@ +import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react' +import { createPortal } from 'react-dom' +import { useQuery } from '@tanstack/react-query' +import { Check, Folder, Inbox, List, LoaderCircle, RefreshCw } from 'lucide-react' +import { api } from '@/lib/api' +import { QK } from '@/lib/queryKeys' +import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors' + +const MENU_WIDTH = 224 +const MENU_MAX_HEIGHT = 320 +const VIEWPORT_GAP = 8 +const TRIGGER_GAP = 6 + +export interface WatchlistGroupMenuProps { + children: ReactNode + onSelect: (groupId: string | null) => void + disabled?: boolean + preferredGroupId?: string | null + includeAll?: boolean + counts?: Record + total?: number + disableEmpty?: boolean + menuLabel?: string + align?: 'left' | 'right' + triggerClassName?: string + title?: string + ariaLabel?: string +} + +/** + * 自选分组选择菜单。 + * 分组仅在菜单打开时读取,React Query 会在多个入口间共享同一份缓存。 + */ +export function WatchlistGroupMenu({ + children, + onSelect, + disabled = false, + preferredGroupId, + includeAll = false, + counts, + total = 0, + disableEmpty = false, + menuLabel = '选择自选分组', + align = 'right', + triggerClassName = '', + title = '加入自选', + ariaLabel = title, +}: WatchlistGroupMenuProps) { + const [open, setOpen] = useState(false) + const [position, setPosition] = useState({ top: 0, left: 0 }) + const triggerRef = useRef(null) + const menuRef = useRef(null) + const menuId = useId() + + const groupsQuery = useQuery({ + queryKey: QK.watchlistGroups, + queryFn: api.watchlistGroups, + enabled: open, + staleTime: 60_000, + }) + const groups = groupsQuery.data?.groups ?? [] + const showPreferred = preferredGroupId !== undefined + + const placeMenu = () => { + const trigger = triggerRef.current + if (!trigger) return + + const rect = trigger.getBoundingClientRect() + const menuHeight = Math.min(menuRef.current?.offsetHeight ?? MENU_MAX_HEIGHT, MENU_MAX_HEIGHT) + const spaceBelow = window.innerHeight - rect.bottom + const spaceAbove = rect.top + const dropUp = spaceBelow < menuHeight + TRIGGER_GAP && spaceAbove > spaceBelow + const top = dropUp + ? Math.max(VIEWPORT_GAP, rect.top - menuHeight - TRIGGER_GAP) + : Math.min(rect.bottom + TRIGGER_GAP, window.innerHeight - menuHeight - VIEWPORT_GAP) + const rawLeft = align === 'left' ? rect.left : rect.right - MENU_WIDTH + const left = Math.max( + VIEWPORT_GAP, + Math.min(rawLeft, window.innerWidth - MENU_WIDTH - VIEWPORT_GAP), + ) + setPosition({ top, left }) + } + + const toggleMenu = () => { + if (disabled) return + if (open) { + setOpen(false) + return + } + placeMenu() + setOpen(true) + } + + useLayoutEffect(() => { + if (!open) return + placeMenu() + }, [open, groups.length, groupsQuery.isPending]) + + useEffect(() => { + if (!open) return + + const closeOnOutsideClick = (event: MouseEvent) => { + const target = event.target as Node + if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return + setOpen(false) + } + // 菜单内部分组列表可滚动 (max-h-60), 其 scroll 事件不应触发关闭; 仅页面/祖先容器滚动时关闭 + const closeOnViewportChange = (event: Event) => { + if (event.target instanceof Node && menuRef.current?.contains(event.target)) return + setOpen(false) + } + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return + event.preventDefault() + event.stopPropagation() + setOpen(false) + triggerRef.current?.focus() + } + + document.addEventListener('mousedown', closeOnOutsideClick) + window.addEventListener('keydown', closeOnEscape, true) + window.addEventListener('scroll', closeOnViewportChange, true) + window.addEventListener('resize', closeOnViewportChange) + return () => { + document.removeEventListener('mousedown', closeOnOutsideClick) + window.removeEventListener('keydown', closeOnEscape, true) + window.removeEventListener('scroll', closeOnViewportChange, true) + window.removeEventListener('resize', closeOnViewportChange) + } + }, [open]) + + useEffect(() => { + if (!open || groupsQuery.isPending) return + menuRef.current?.querySelector('[role="menuitem"]:not(:disabled)')?.focus() + }, [open, groups.length, groupsQuery.isPending]) + + const choose = (groupId: string | null) => { + setOpen(false) + onSelect(groupId) + } + + const handleMenuKeyDown = (event: React.KeyboardEvent) => { + if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return + const items = Array.from(menuRef.current?.querySelectorAll('[role="menuitem"]:not(:disabled)') ?? []) + if (items.length === 0) return + + event.preventDefault() + const current = items.indexOf(document.activeElement as HTMLButtonElement) + if (event.key === 'Home') items[0].focus() + else if (event.key === 'End') items[items.length - 1].focus() + else if (event.key === 'ArrowDown') items[(current + 1 + items.length) % items.length].focus() + else items[(current - 1 + items.length) % items.length].focus() + } + + const menuItemClass = 'flex h-8 w-full items-center gap-2 rounded-btn px-2 text-left text-xs text-secondary outline-none transition-colors hover:bg-elevated hover:text-foreground focus:bg-elevated focus:text-foreground disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-secondary' + const showCounts = counts !== undefined + const ungroupedCount = counts?.ungrouped ?? 0 + + return ( + <> + + + {open && createPortal( + {!hasMinuteCap && ( - 需 Pro+ + )}
diff --git a/frontend/src/components/data/PageSettingsModal.tsx b/frontend/src/components/data/PageSettingsModal.tsx index 4104a48..d326a20 100644 --- a/frontend/src/components/data/PageSettingsModal.tsx +++ b/frontend/src/components/data/PageSettingsModal.tsx @@ -41,8 +41,8 @@ export const DATA_CARD_DEFS: CardDef[] = [ { key: 'enriched', label: 'Enriched', desc: '技术指标计算结果', defaultHiddenIfNoCap: false }, { key: 'index', label: '指数', desc: '主要市场指数日K', defaultHiddenIfNoCap: false }, { key: 'etf', label: 'ETF', desc: '场内交易基金日K', defaultHiddenIfNoCap: false, defaultHidden: true }, - { key: 'minute', label: '分钟 K', desc: '分钟级K线(需 Pro+)', defaultHiddenIfNoCap: true }, - { key: 'financials', label: '财务数据', desc: '财报数据(需 Expert)', defaultHiddenIfNoCap: true }, + { key: 'minute', label: '分钟 K', desc: '分钟级K线(依赖分钟K批量数据)', defaultHiddenIfNoCap: true }, + { key: 'financials', label: '财务数据', desc: '财报数据(依赖财务数据)', defaultHiddenIfNoCap: true }, { key: 'regime', label: '市场环境', desc: '每日环境状态(本地计算)', defaultHiddenIfNoCap: false }, ] diff --git a/frontend/src/components/data/RepairDailyPanel.tsx b/frontend/src/components/data/RepairDailyPanel.tsx index 0e88b01..f3ae7fa 100644 --- a/frontend/src/components/data/RepairDailyPanel.tsx +++ b/frontend/src/components/data/RepairDailyPanel.tsx @@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { Loader2 } from 'lucide-react' import { api } from '@/lib/api' import { QK } from '@/lib/queryKeys' +import { MissingCapChip } from '@/lib/capability-labels' import { DatePicker } from '@/components/DatePicker' function pad(n: number) { return String(n).padStart(2, '0') } @@ -98,8 +99,8 @@ export function RepairDailyPanel({ caps, isRunning, latestDate, onStart }: { {!hasBatchCap && ( - - 需 Pro+ 权限 + + )}
diff --git a/frontend/src/components/data/StatCard.tsx b/frontend/src/components/data/StatCard.tsx index 17e9dd9..00c682b 100644 --- a/frontend/src/components/data/StatCard.tsx +++ b/frontend/src/components/data/StatCard.tsx @@ -2,24 +2,25 @@ import { motion } from 'framer-motion' import { Loader2, CheckCircle2, Settings, Table2 } from 'lucide-react' import { formatNumber } from '@/lib/format' import { fmtDate } from '@/lib/format' +import { MissingCapChip } from '@/lib/capability-labels' import { Skeleton } from './Skeleton' -// 卡片能力定义:capKey → 查 capability limits;tierReq → 无权限时显示的档位要求 -// capKey 为空串表示该数据在 free-api 服务器(None 档/Free 档)即可获取,无需付费能力门控。 +// 卡片能力定义:capKey → 查 capability limits;missingCapName → 无权限时提示的能力名 +// capKey 为空串表示该数据在免费服务器(None/Free)或本地即可获取,无需能力门控。 export const CARD_META: Record = { - // 标的维表走 exchanges 端点,free-api 服务器即可获取,无需付费能力 - instruments: { capKey: '', tierReq: '' }, - daily: { capKey: 'kline.daily.batch', tierReq: 'Starter+' }, - adj_factor: { capKey: 'adj_factor', tierReq: 'Starter+' }, - enriched: { capKey: '', tierReq: '' }, - // ETF 复用日K批量能力(免费档 kline.daily.batch 即可),不显示档位徽章 - etf: { capKey: 'kline.daily.batch', tierReq: '' }, - minute: { capKey: 'kline.minute.batch', tierReq: 'Pro+' }, - financials: { capKey: 'financial', tierReq: 'Expert' }, - regime: { capKey: '', tierReq: '' }, + // 标的维表走 exchanges 端点,免费服务器即可获取,无需付费能力 + instruments: { capKey: '', missingCapName: '' }, + daily: { capKey: 'kline.daily.batch', missingCapName: '日 K(批量)' }, + adj_factor: { capKey: 'adj_factor', missingCapName: '复权因子' }, + enriched: { capKey: '', missingCapName: '' }, + // ETF 复用日K批量能力(免费档即可),缺能力时随日K卡提示,不单独显示徽章 + etf: { capKey: 'kline.daily.batch', missingCapName: '' }, + minute: { capKey: 'kline.minute.batch', missingCapName: '分钟 K(批量)' }, + financials: { capKey: 'financial', missingCapName: '财务数据' }, + regime: { capKey: '', missingCapName: '' }, } export function Pill({ label, value }: { label: string; value: number | string }) { @@ -31,16 +32,15 @@ export function Pill({ label, value }: { label: string; value: number | string } ) } -function CapBadge({ hasCap, isLocal, tierLabel, tierReq, capInfo, localSuffix, customProvider }: { +function CapBadge({ hasCap, isLocal, missingCapName, capInfo, localSuffix, customProvider }: { hasCap: boolean isLocal: boolean - tierLabel?: string - tierReq?: string + missingCapName?: string capInfo?: { rpm: number | null; batch: number | null; subscribe: number | null } | undefined localSuffix?: string customProvider?: string | null }) { - // 走自定义数据源时, 显示数据源名而非 TickFlow 档位 + // 走自定义数据源时, 显示数据源名 (能力来源对所有数据源统一表达) if (customProvider) { return ( @@ -57,8 +57,8 @@ function CapBadge({ hasCap, isLocal, tierLabel, tierReq, capInfo, localSuffix, c ) } - if (hasCap && capInfo && tierLabel) { - const parts = [tierLabel, `${capInfo.rpm}/min`] + if (hasCap && capInfo) { + const parts = ['可用', `${capInfo.rpm}/min`] if (capInfo.batch != null && capInfo.batch > 1) parts.push(`${capInfo.batch}股/批`) return ( @@ -67,20 +67,15 @@ function CapBadge({ hasCap, isLocal, tierLabel, tierReq, capInfo, localSuffix, c ) } - if (!hasCap && tierReq && tierReq !== 'Free') { - // 缺权限且非 Free 档(付费档位才提示升级);Free 档人人可用, - // 若显示"需 Free"会造成 Expert 等用户困惑(通常是探测瞬时失败丢能力) - return ( - - 需 {tierReq} - - ) + if (!hasCap && missingCapName) { + // 能力标准对所有数据源一致: 缺能力提示能力名而非档位, 点击跳数据源设置 + return } if (hasCap) { return ( - {tierLabel ?? '已授权'} + 可用 ) } @@ -93,7 +88,7 @@ export type FieldTab = { label: string; table: string } export function StatCard({ title, hint, stats, isInstrument = false, loading = false, active = false, done = false, skipped = false, stagePct = 0, - tierKey, capLimits, tierLabel, customProvider, + tierKey, capLimits, customProvider, auto, onSettings, onShowFields, settingsOpen, subLabel, localBadgeSuffix, fieldTabs, }: { title: string @@ -107,7 +102,6 @@ export function StatCard({ stagePct?: number tierKey?: string capLimits?: Record - tierLabel?: string customProvider?: string | null onSettings?: () => void onShowFields?: (table?: string) => void @@ -248,8 +242,7 @@ export function StatCard({
+
+
+
拉取起始时间 (留空=不限)
+ setTimeWindowStart(e.target.value)} + className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-[10px] font-mono text-foreground" + /> +
+
+
拉取结束时间 (留空=不限)
+ setTimeWindowEnd(e.target.value)} + className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-[10px] font-mono text-foreground" + /> +
+
+
字段映射 (外部名 → 内部名,JSON,可选)