From 7369358d08d5a212fb46d96e831d93174f551148 Mon Sep 17 00:00:00 2001
From: shy3130 <415333856@qq.com>
Date: Thu, 18 Jun 2026 17:18:23 +0800
Subject: [PATCH] Initial release v0.1.19
---
.env.example | 18 +
.gitignore | 86 +
Dockerfile | 31 +
LICENSE | 21 +
README.md | 304 ++
VERSION | 1 +
backend/app/__init__.py | 3 +
backend/app/api/__init__.py | 1 +
backend/app/api/analysis.py | 204 ++
backend/app/api/backtest.py | 461 +++
backend/app/api/data.py | 710 +++++
backend/app/api/ext_data.py | 825 ++++++
backend/app/api/financials.py | 120 +
backend/app/api/indices.py | 149 +
backend/app/api/intraday.py | 183 ++
backend/app/api/kline.py | 716 +++++
backend/app/api/overview.py | 540 ++++
backend/app/api/pipeline.py | 107 +
backend/app/api/routes.py | 39 +
backend/app/api/screener.py | 575 ++++
backend/app/api/settings.py | 667 +++++
backend/app/api/signals.py | 100 +
backend/app/api/strategy.py | 468 +++
backend/app/api/watchlist.py | 202 ++
backend/app/backtest/__init__.py | 4 +
backend/app/backtest/engine.py | 1485 ++++++++++
backend/app/backtest/factor.py | 480 +++
backend/app/backtest/strategy.py | 701 +++++
backend/app/config.py | 61 +
backend/app/indicators/__init__.py | 1 +
backend/app/indicators/pipeline.py | 1494 ++++++++++
backend/app/jobs/__init__.py | 1 +
backend/app/jobs/daily_pipeline.py | 499 ++++
backend/app/main.py | 170 ++
backend/app/secrets_store.py | 96 +
backend/app/services/__init__.py | 1 +
backend/app/services/backtest.py | 392 +++
backend/app/services/ext_data.py | 483 +++
backend/app/services/ext_pull.py | 216 ++
backend/app/services/extend_history.py | 224 ++
backend/app/services/financial_sync.py | 273 ++
backend/app/services/index_sync.py | 146 +
backend/app/services/instrument_sync.py | 122 +
backend/app/services/kline_sync.py | 617 ++++
backend/app/services/pipeline_jobs.py | 250 ++
backend/app/services/preferences.py | 250 ++
backend/app/services/quote_service.py | 673 +++++
backend/app/services/screener.py | 585 ++++
backend/app/services/strategy_cache.py | 150 +
backend/app/services/watchlist.py | 126 +
backend/app/strategy/__init__.py | 0
backend/app/strategy/ai_generator.py | 149 +
backend/app/strategy/builtin/__init__.py | 0
backend/app/strategy/builtin/boll_breakout.py | 31 +
.../strategy/builtin/broken_board_recovery.py | 35 +
.../app/strategy/builtin/bullish_alignment.py | 29 +
.../strategy/builtin/consecutive_limit_ups.py | 31 +
.../strategy/builtin/high_turnover_surge.py | 34 +
.../app/strategy/builtin/limit_up_momentum.py | 34 +
.../strategy/builtin/low_volatility_leader.py | 32 +
.../app/strategy/builtin/ma_golden_cross.py | 32 +
backend/app/strategy/builtin/macd_golden.py | 31 +
.../strategy/builtin/n_day_low_reversal.py | 32 +
backend/app/strategy/builtin/near_limit_up.py | 55 +
.../app/strategy/builtin/oversold_bounce.py | 37 +
.../app/strategy/builtin/oversold_reversal.py | 37 +
.../strategy/builtin/pullback_ma20_bounce.py | 34 +
.../strategy/builtin/pullback_to_support.py | 37 +
backend/app/strategy/builtin/strong_open.py | 35 +
.../app/strategy/builtin/trend_breakout.py | 42 +
.../strategy/builtin/volume_price_surge.py | 32 +
backend/app/strategy/config.py | 71 +
backend/app/strategy/custom_signals.py | 207 ++
backend/app/strategy/engine.py | 453 +++
backend/app/strategy/monitor.py | 204 ++
backend/app/strategy/prompt_builder.py | 65 +
backend/app/tickflow/__init__.py | 1 +
backend/app/tickflow/capabilities.py | 75 +
backend/app/tickflow/client.py | 72 +
backend/app/tickflow/policy.py | 390 +++
backend/app/tickflow/pools.py | 158 +
backend/app/tickflow/repository.py | 965 ++++++
backend/app/tickflow/scheduler.py | 66 +
backend/pyproject.toml | 65 +
backend/scripts/__init__.py | 0
backend/scripts/cleanup_halt_days.py | 96 +
.../tests/backtest/test_engine_portfolio.py | 312 ++
.../backtest/test_full_simulation_tail.py | 59 +
.../test_strategy_backtest_correctness.py | 163 +
backend/uv.lock | 2638 +++++++++++++++++
data/.gitkeep | 0
dev.ps1 | 242 ++
dev.sh | 142 +
docker-compose.yml | 16 +
docs/screenshots/backtest.png | Bin 0 -> 668676 bytes
docs/screenshots/dashboard.png | Bin 0 -> 641420 bytes
docs/screenshots/screener.png | Bin 0 -> 528414 bytes
docs/strategy-builder-step1.md | 175 ++
docs/strategy-builder-step2.md | 34 +
docs/strategy-example.md | 153 +
docs/strategy-guide.md | 332 +++
frontend/.gitignore | 5 +
frontend/index.html | 19 +
frontend/package.json | 42 +
frontend/pnpm-lock.yaml | 1917 ++++++++++++
frontend/postcss.config.js | 6 +
frontend/public/favicon.svg | 12 +
frontend/src/components/CandlestickChart.tsx | 154 +
frontend/src/components/ColumnCustomizer.tsx | 24 +
frontend/src/components/DatePicker.tsx | 235 ++
.../src/components/EChartsCandlestick.tsx | 1085 +++++++
frontend/src/components/EChartsIntraday.tsx | 592 ++++
frontend/src/components/EmptyState.tsx | 20 +
.../src/components/EndpointTestDialog.tsx | 259 ++
.../src/components/ExtDimensionAnalysis.tsx | 436 +++
frontend/src/components/Layout.tsx | 449 +++
.../src/components/ListColumnCustomizer.tsx | 714 +++++
frontend/src/components/Logo.tsx | 59 +
frontend/src/components/PageHeader.tsx | 25 +
frontend/src/components/StockDailyKChart.tsx | 232 ++
frontend/src/components/StockInfoBar.tsx | 66 +
.../src/components/StockIntradayChart.tsx | 120 +
frontend/src/components/StockPanel.tsx | 115 +
.../src/components/StockPreviewDialog.tsx | 212 ++
frontend/src/components/Toast.tsx | 49 +
frontend/src/components/analysis-shared.tsx | 451 +++
.../src/components/data/ActiveJobCard.tsx | 135 +
.../components/data/EnrichedRebuildPanel.tsx | 111 +
.../components/data/ExtendHistoryPanel.tsx | 98 +
.../src/components/data/MinuteSyncConfig.tsx | 189 ++
.../src/components/data/QuoteConfigCard.tsx | 165 ++
.../src/components/data/ScheduleEditor.tsx | 43 +
frontend/src/components/data/SchemaModal.tsx | 102 +
frontend/src/components/data/SectionTitle.tsx | 61 +
.../src/components/data/SettingsModal.tsx | 27 +
frontend/src/components/data/Skeleton.tsx | 7 +
frontend/src/components/data/StatCard.tsx | 312 ++
.../components/ext-data/CreateExtDialog.tsx | 383 +++
.../src/components/ext-data/EditExtDialog.tsx | 252 ++
.../components/ext-data/ExtDataApiPanel.tsx | 79 +
.../components/ext-data/ExtDataPullPanel.tsx | 212 ++
.../components/ext-data/ExtDataStatCard.tsx | 315 ++
.../components/screener/ScreenerFilter.tsx | 162 +
.../src/components/screener/ScreenerTable.tsx | 320 ++
.../screener/StrategyBuilderDialog.tsx | 402 +++
.../src/components/screener/StrategyCard.tsx | 198 ++
.../screener/StrategyPoolDialog.tsx | 240 ++
.../screener/StrategySettingsDialog.tsx | 597 ++++
.../screener/StrategyStoreDialog.tsx | 87 +
.../stock-table/MiniCandlestick.tsx | 81 +
.../components/stock-table/StockDataTable.tsx | 120 +
.../src/components/stock-table/primitives.tsx | 163 +
.../components/stock-table/useTableSort.ts | 50 +
frontend/src/index.css | 79 +
frontend/src/lib/analysis-adapter.ts | 341 +++
frontend/src/lib/api.ts | 1273 ++++++++
frontend/src/lib/backtestTask.ts | 223 ++
frontend/src/lib/board.ts | 25 +
frontend/src/lib/capability-labels.ts | 15 +
frontend/src/lib/cn.ts | 6 +
frontend/src/lib/colors.ts | 31 +
frontend/src/lib/format.ts | 81 +
frontend/src/lib/list-columns.ts | 196 ++
frontend/src/lib/queryKeys.ts | 79 +
frontend/src/lib/screener-columns.ts | 128 +
frontend/src/lib/stock-table.ts | 108 +
frontend/src/lib/storage.ts | 99 +
frontend/src/lib/useFinancials.ts | 38 +
frontend/src/lib/useQueryConfig.ts | 40 +
frontend/src/lib/useQuoteStream.ts | 114 +
frontend/src/lib/useSharedMutations.ts | 42 +
frontend/src/lib/useSharedQueries.ts | 71 +
frontend/src/lib/useStrategyPool.ts | 25 +
frontend/src/lib/watchlist-columns.ts | 166 ++
frontend/src/main.tsx | 23 +
frontend/src/pages/Analysis.tsx | 291 ++
frontend/src/pages/AnalysisDetail.tsx | 7 +
frontend/src/pages/Backtest.tsx | 63 +
frontend/src/pages/Branding.tsx | 203 ++
frontend/src/pages/ConceptAnalysis.tsx | 750 +++++
frontend/src/pages/Dashboard.tsx | 517 ++++
frontend/src/pages/Data.tsx | 946 ++++++
frontend/src/pages/Financials.tsx | 143 +
frontend/src/pages/Indices.tsx | 335 +++
frontend/src/pages/IndustryAnalysis.tsx | 822 +++++
frontend/src/pages/LimitUpLadder.tsx | 1188 ++++++++
frontend/src/pages/MinuteDataProbe.tsx | 189 ++
frontend/src/pages/Monitor.tsx | 77 +
frontend/src/pages/Onboarding.tsx | 14 +
frontend/src/pages/Screener.tsx | 819 +++++
frontend/src/pages/Settings.tsx | 82 +
frontend/src/pages/StockAnalysis.tsx | 10 +
frontend/src/pages/Trading.tsx | 82 +
frontend/src/pages/Watchlist.tsx | 1100 +++++++
.../src/pages/backtest/FactorBacktest.tsx | 447 +++
.../src/pages/backtest/StrategyBacktest.tsx | 2213 ++++++++++++++
.../backtest/charts/FactorGroupNavChart.tsx | 124 +
.../pages/backtest/charts/FactorICChart.tsx | 88 +
.../charts/ReturnDistributionChart.tsx | 63 +
.../backtest/charts/StrategyNavChart.tsx | 209 ++
.../src/pages/backtest/charts/useECharts.ts | 37 +
.../backtest/components/TradeKlineModal.tsx | 170 ++
frontend/src/pages/settings/AI.tsx | 200 ++
frontend/src/pages/settings/CustomSignals.tsx | 259 ++
frontend/src/pages/settings/ExtPages.tsx | 291 ++
frontend/src/pages/settings/Keys.tsx | 373 +++
frontend/src/pages/settings/MenuSettings.tsx | 278 ++
frontend/src/pages/settings/Monitoring.tsx | 398 +++
frontend/src/pages/settings/System.tsx | 135 +
frontend/src/router.tsx | 54 +
frontend/src/vite-env.d.ts | 6 +
frontend/tailwind.config.ts | 44 +
frontend/tsconfig.json | 30 +
frontend/tsconfig.node.json | 14 +
frontend/vite.config.d.ts | 2 +
frontend/vite.config.js | 35 +
frontend/vite.config.ts | 36 +
tiers.yaml | 46 +
218 files changed, 52566 insertions(+)
create mode 100644 .env.example
create mode 100644 .gitignore
create mode 100644 Dockerfile
create mode 100644 LICENSE
create mode 100644 README.md
create mode 100644 VERSION
create mode 100644 backend/app/__init__.py
create mode 100644 backend/app/api/__init__.py
create mode 100644 backend/app/api/analysis.py
create mode 100644 backend/app/api/backtest.py
create mode 100644 backend/app/api/data.py
create mode 100644 backend/app/api/ext_data.py
create mode 100644 backend/app/api/financials.py
create mode 100644 backend/app/api/indices.py
create mode 100644 backend/app/api/intraday.py
create mode 100644 backend/app/api/kline.py
create mode 100644 backend/app/api/overview.py
create mode 100644 backend/app/api/pipeline.py
create mode 100644 backend/app/api/routes.py
create mode 100644 backend/app/api/screener.py
create mode 100644 backend/app/api/settings.py
create mode 100644 backend/app/api/signals.py
create mode 100644 backend/app/api/strategy.py
create mode 100644 backend/app/api/watchlist.py
create mode 100644 backend/app/backtest/__init__.py
create mode 100644 backend/app/backtest/engine.py
create mode 100644 backend/app/backtest/factor.py
create mode 100644 backend/app/backtest/strategy.py
create mode 100644 backend/app/config.py
create mode 100644 backend/app/indicators/__init__.py
create mode 100644 backend/app/indicators/pipeline.py
create mode 100644 backend/app/jobs/__init__.py
create mode 100644 backend/app/jobs/daily_pipeline.py
create mode 100644 backend/app/main.py
create mode 100644 backend/app/secrets_store.py
create mode 100644 backend/app/services/__init__.py
create mode 100644 backend/app/services/backtest.py
create mode 100644 backend/app/services/ext_data.py
create mode 100644 backend/app/services/ext_pull.py
create mode 100644 backend/app/services/extend_history.py
create mode 100644 backend/app/services/financial_sync.py
create mode 100644 backend/app/services/index_sync.py
create mode 100644 backend/app/services/instrument_sync.py
create mode 100644 backend/app/services/kline_sync.py
create mode 100644 backend/app/services/pipeline_jobs.py
create mode 100644 backend/app/services/preferences.py
create mode 100644 backend/app/services/quote_service.py
create mode 100644 backend/app/services/screener.py
create mode 100644 backend/app/services/strategy_cache.py
create mode 100644 backend/app/services/watchlist.py
create mode 100644 backend/app/strategy/__init__.py
create mode 100644 backend/app/strategy/ai_generator.py
create mode 100644 backend/app/strategy/builtin/__init__.py
create mode 100644 backend/app/strategy/builtin/boll_breakout.py
create mode 100644 backend/app/strategy/builtin/broken_board_recovery.py
create mode 100644 backend/app/strategy/builtin/bullish_alignment.py
create mode 100644 backend/app/strategy/builtin/consecutive_limit_ups.py
create mode 100644 backend/app/strategy/builtin/high_turnover_surge.py
create mode 100644 backend/app/strategy/builtin/limit_up_momentum.py
create mode 100644 backend/app/strategy/builtin/low_volatility_leader.py
create mode 100644 backend/app/strategy/builtin/ma_golden_cross.py
create mode 100644 backend/app/strategy/builtin/macd_golden.py
create mode 100644 backend/app/strategy/builtin/n_day_low_reversal.py
create mode 100644 backend/app/strategy/builtin/near_limit_up.py
create mode 100644 backend/app/strategy/builtin/oversold_bounce.py
create mode 100644 backend/app/strategy/builtin/oversold_reversal.py
create mode 100644 backend/app/strategy/builtin/pullback_ma20_bounce.py
create mode 100644 backend/app/strategy/builtin/pullback_to_support.py
create mode 100644 backend/app/strategy/builtin/strong_open.py
create mode 100644 backend/app/strategy/builtin/trend_breakout.py
create mode 100644 backend/app/strategy/builtin/volume_price_surge.py
create mode 100644 backend/app/strategy/config.py
create mode 100644 backend/app/strategy/custom_signals.py
create mode 100644 backend/app/strategy/engine.py
create mode 100644 backend/app/strategy/monitor.py
create mode 100644 backend/app/strategy/prompt_builder.py
create mode 100644 backend/app/tickflow/__init__.py
create mode 100644 backend/app/tickflow/capabilities.py
create mode 100644 backend/app/tickflow/client.py
create mode 100644 backend/app/tickflow/policy.py
create mode 100644 backend/app/tickflow/pools.py
create mode 100644 backend/app/tickflow/repository.py
create mode 100644 backend/app/tickflow/scheduler.py
create mode 100644 backend/pyproject.toml
create mode 100644 backend/scripts/__init__.py
create mode 100644 backend/scripts/cleanup_halt_days.py
create mode 100644 backend/tests/backtest/test_engine_portfolio.py
create mode 100644 backend/tests/backtest/test_full_simulation_tail.py
create mode 100644 backend/tests/backtest/test_strategy_backtest_correctness.py
create mode 100644 backend/uv.lock
create mode 100644 data/.gitkeep
create mode 100644 dev.ps1
create mode 100755 dev.sh
create mode 100644 docker-compose.yml
create mode 100644 docs/screenshots/backtest.png
create mode 100644 docs/screenshots/dashboard.png
create mode 100644 docs/screenshots/screener.png
create mode 100644 docs/strategy-builder-step1.md
create mode 100644 docs/strategy-builder-step2.md
create mode 100644 docs/strategy-example.md
create mode 100644 docs/strategy-guide.md
create mode 100644 frontend/.gitignore
create mode 100644 frontend/index.html
create mode 100644 frontend/package.json
create mode 100644 frontend/pnpm-lock.yaml
create mode 100644 frontend/postcss.config.js
create mode 100644 frontend/public/favicon.svg
create mode 100644 frontend/src/components/CandlestickChart.tsx
create mode 100644 frontend/src/components/ColumnCustomizer.tsx
create mode 100644 frontend/src/components/DatePicker.tsx
create mode 100644 frontend/src/components/EChartsCandlestick.tsx
create mode 100644 frontend/src/components/EChartsIntraday.tsx
create mode 100644 frontend/src/components/EmptyState.tsx
create mode 100644 frontend/src/components/EndpointTestDialog.tsx
create mode 100644 frontend/src/components/ExtDimensionAnalysis.tsx
create mode 100644 frontend/src/components/Layout.tsx
create mode 100644 frontend/src/components/ListColumnCustomizer.tsx
create mode 100644 frontend/src/components/Logo.tsx
create mode 100644 frontend/src/components/PageHeader.tsx
create mode 100644 frontend/src/components/StockDailyKChart.tsx
create mode 100644 frontend/src/components/StockInfoBar.tsx
create mode 100644 frontend/src/components/StockIntradayChart.tsx
create mode 100644 frontend/src/components/StockPanel.tsx
create mode 100644 frontend/src/components/StockPreviewDialog.tsx
create mode 100644 frontend/src/components/Toast.tsx
create mode 100644 frontend/src/components/analysis-shared.tsx
create mode 100644 frontend/src/components/data/ActiveJobCard.tsx
create mode 100644 frontend/src/components/data/EnrichedRebuildPanel.tsx
create mode 100644 frontend/src/components/data/ExtendHistoryPanel.tsx
create mode 100644 frontend/src/components/data/MinuteSyncConfig.tsx
create mode 100644 frontend/src/components/data/QuoteConfigCard.tsx
create mode 100644 frontend/src/components/data/ScheduleEditor.tsx
create mode 100644 frontend/src/components/data/SchemaModal.tsx
create mode 100644 frontend/src/components/data/SectionTitle.tsx
create mode 100644 frontend/src/components/data/SettingsModal.tsx
create mode 100644 frontend/src/components/data/Skeleton.tsx
create mode 100644 frontend/src/components/data/StatCard.tsx
create mode 100644 frontend/src/components/ext-data/CreateExtDialog.tsx
create mode 100644 frontend/src/components/ext-data/EditExtDialog.tsx
create mode 100644 frontend/src/components/ext-data/ExtDataApiPanel.tsx
create mode 100644 frontend/src/components/ext-data/ExtDataPullPanel.tsx
create mode 100644 frontend/src/components/ext-data/ExtDataStatCard.tsx
create mode 100644 frontend/src/components/screener/ScreenerFilter.tsx
create mode 100644 frontend/src/components/screener/ScreenerTable.tsx
create mode 100644 frontend/src/components/screener/StrategyBuilderDialog.tsx
create mode 100644 frontend/src/components/screener/StrategyCard.tsx
create mode 100644 frontend/src/components/screener/StrategyPoolDialog.tsx
create mode 100644 frontend/src/components/screener/StrategySettingsDialog.tsx
create mode 100644 frontend/src/components/screener/StrategyStoreDialog.tsx
create mode 100644 frontend/src/components/stock-table/MiniCandlestick.tsx
create mode 100644 frontend/src/components/stock-table/StockDataTable.tsx
create mode 100644 frontend/src/components/stock-table/primitives.tsx
create mode 100644 frontend/src/components/stock-table/useTableSort.ts
create mode 100644 frontend/src/index.css
create mode 100644 frontend/src/lib/analysis-adapter.ts
create mode 100644 frontend/src/lib/api.ts
create mode 100644 frontend/src/lib/backtestTask.ts
create mode 100644 frontend/src/lib/board.ts
create mode 100644 frontend/src/lib/capability-labels.ts
create mode 100644 frontend/src/lib/cn.ts
create mode 100644 frontend/src/lib/colors.ts
create mode 100644 frontend/src/lib/format.ts
create mode 100644 frontend/src/lib/list-columns.ts
create mode 100644 frontend/src/lib/queryKeys.ts
create mode 100644 frontend/src/lib/screener-columns.ts
create mode 100644 frontend/src/lib/stock-table.ts
create mode 100644 frontend/src/lib/storage.ts
create mode 100644 frontend/src/lib/useFinancials.ts
create mode 100644 frontend/src/lib/useQueryConfig.ts
create mode 100644 frontend/src/lib/useQuoteStream.ts
create mode 100644 frontend/src/lib/useSharedMutations.ts
create mode 100644 frontend/src/lib/useSharedQueries.ts
create mode 100644 frontend/src/lib/useStrategyPool.ts
create mode 100644 frontend/src/lib/watchlist-columns.ts
create mode 100644 frontend/src/main.tsx
create mode 100644 frontend/src/pages/Analysis.tsx
create mode 100644 frontend/src/pages/AnalysisDetail.tsx
create mode 100644 frontend/src/pages/Backtest.tsx
create mode 100644 frontend/src/pages/Branding.tsx
create mode 100644 frontend/src/pages/ConceptAnalysis.tsx
create mode 100644 frontend/src/pages/Dashboard.tsx
create mode 100644 frontend/src/pages/Data.tsx
create mode 100644 frontend/src/pages/Financials.tsx
create mode 100644 frontend/src/pages/Indices.tsx
create mode 100644 frontend/src/pages/IndustryAnalysis.tsx
create mode 100644 frontend/src/pages/LimitUpLadder.tsx
create mode 100644 frontend/src/pages/MinuteDataProbe.tsx
create mode 100644 frontend/src/pages/Monitor.tsx
create mode 100644 frontend/src/pages/Onboarding.tsx
create mode 100644 frontend/src/pages/Screener.tsx
create mode 100644 frontend/src/pages/Settings.tsx
create mode 100644 frontend/src/pages/StockAnalysis.tsx
create mode 100644 frontend/src/pages/Trading.tsx
create mode 100644 frontend/src/pages/Watchlist.tsx
create mode 100644 frontend/src/pages/backtest/FactorBacktest.tsx
create mode 100644 frontend/src/pages/backtest/StrategyBacktest.tsx
create mode 100644 frontend/src/pages/backtest/charts/FactorGroupNavChart.tsx
create mode 100644 frontend/src/pages/backtest/charts/FactorICChart.tsx
create mode 100644 frontend/src/pages/backtest/charts/ReturnDistributionChart.tsx
create mode 100644 frontend/src/pages/backtest/charts/StrategyNavChart.tsx
create mode 100644 frontend/src/pages/backtest/charts/useECharts.ts
create mode 100644 frontend/src/pages/backtest/components/TradeKlineModal.tsx
create mode 100644 frontend/src/pages/settings/AI.tsx
create mode 100644 frontend/src/pages/settings/CustomSignals.tsx
create mode 100644 frontend/src/pages/settings/ExtPages.tsx
create mode 100644 frontend/src/pages/settings/Keys.tsx
create mode 100644 frontend/src/pages/settings/MenuSettings.tsx
create mode 100644 frontend/src/pages/settings/Monitoring.tsx
create mode 100644 frontend/src/pages/settings/System.tsx
create mode 100644 frontend/src/router.tsx
create mode 100644 frontend/src/vite-env.d.ts
create mode 100644 frontend/tailwind.config.ts
create mode 100644 frontend/tsconfig.json
create mode 100644 frontend/tsconfig.node.json
create mode 100644 frontend/vite.config.d.ts
create mode 100644 frontend/vite.config.js
create mode 100644 frontend/vite.config.ts
create mode 100644 tiers.yaml
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..5ed6b31
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,18 @@
+# ===== TickFlow =====
+# 留空或不填则启用 Free 试用模式(TickFlow.free()),仅能拿历史日 K 单股数据
+TICKFLOW_API_KEY=
+
+# ===== AI(可选,留空跳过 AI 功能) =====
+AI_PROVIDER=openai_compat # openai_compat | ollama
+AI_BASE_URL=https://api.deepseek.com/v1
+AI_API_KEY=
+AI_MODEL=deepseek-chat
+AI_DAILY_TOKEN_BUDGET=500000
+
+# ===== Server =====
+HOST=0.0.0.0
+PORT=3018
+LOG_LEVEL=INFO
+
+# ===== Data =====
+DATA_DIR=./data
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..32d0b57
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,86 @@
+# ===== Python =====
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+.venv/
+venv/
+env/
+.python-version
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+.coverage
+htmlcov/
+dist/
+build/
+*.egg-info/
+
+# ===== uv =====
+.uv/
+
+# ===== Node / pnpm =====
+node_modules/
+.pnpm-store/
+.pnpm-debug.log*
+.npm/
+*.log
+
+# ===== Vite =====
+frontend/dist/
+frontend/.vite/
+backend/static/
+backend/app/static/
+
+# ===== IDE =====
+.idea/
+.vscode/
+*.swp
+*.swo
+.DS_Store
+
+# ===== Project data (Parquet/DuckDB) =====
+backend/data/**
+!backend/data/.gitkeep
+data/**
+!data/.gitkeep
+
+# ===== Secrets =====
+.env
+.env.local
+.env.*.local
+backend/.env
+
+# ===== User-derived prompts =====
+backend/user_prompts/
+
+# ===== Browser automation =====
+.playwright-mcp/
+
+# ===== AI IDE =====
+.trae/
+
+# ===== TypeScript build cache =====
+frontend/tsconfig*.tsbuildinfo
+
+# ===== Data files =====
+*.xlsx
+
+# ===== Private deploy / 服务器私有信息(不开源) =====
+DEPLOY.md
+deploy.sh
+
+# ===== 内部设计文档(不开源) =====
+DESIGN.md
+DESIGN-proposal.md
+CLAUDE.md
+
+# ===== 私人脚本(不开源) =====
+docs/zhihu/
+
+# ===== 用户自定义策略(私有,不开源) =====
+data/strategies/custom/
+
+# ===== 临时 / 调试产物 =====
+backend._recheck*.py
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..e37e509
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,31 @@
+# 两阶段构建:前端 dist 拷进后端镜像,单容器运行
+# === Stage 1: 前端构建 ===
+FROM node:20-alpine AS frontend-builder
+WORKDIR /build
+RUN corepack enable && corepack prepare pnpm@9 --activate
+COPY frontend/package.json frontend/pnpm-lock.yaml* ./
+RUN pnpm install --frozen-lockfile || pnpm install
+COPY frontend/ ./
+RUN pnpm build
+
+# === Stage 2: Python 运行时 ===
+FROM python:3.11-slim AS runtime
+WORKDIR /app
+
+# 安装 uv(快)
+RUN pip install --no-cache-dir uv
+
+# Backend deps
+COPY backend/pyproject.toml backend/uv.lock* ./
+RUN uv sync --frozen --no-dev || uv sync --no-dev
+
+# Backend code
+COPY backend/app ./app
+COPY tiers.yaml /app/tiers.yaml
+
+# Frontend 静态产物
+COPY --from=frontend-builder /build/dist ./static
+
+ENV PYTHONPATH=/app
+EXPOSE 3018
+CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3018"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f7888c9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 tf-stocks-panel contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..93c2819
--- /dev/null
+++ b/README.md
@@ -0,0 +1,304 @@
+
+
+# 📈 tf-stocks-panel
+
+**自托管、零运维的 A 股「选股 + 监控 + 回测」量化工作台**
+
+[](./LICENSE)
+[](https://www.python.org/)
+[](https://react.dev/)
+[](https://tickflow.org/auth/register?ref=V3KDKGXPEA)
+[](./Dockerfile)
+
+基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 数据 · 🚀 **开箱即用**(单容器 / Free 模式无需 Key)
+能力驱动,适配 Free → Expert 全档位订阅 · 🔌 **自由接入第三方扩展数据**(例如 Tushare、自有量化项目数据)
+
+**[核心功能](#-核心功能)** · **[快速开始](#-快速开始)** · **[配置](#️-配置)** · **[路线图](#-路线图)**
+
+
+
+---
+
+## 🎯 项目定位
+
+让任何**个人散户 / 量化爱好者**,**零运维**地拥有一套**与自己订阅档位严格匹配**的 A 股分析、选股、监控工作台。
+基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) Key **低成本**获取数据。**填写邀请码 `V3KDKGXPEA` 免费领取概念行业等扩展数据**。
+**任意接入第三方数据**(Tushare 等),页面可视化自定义配置扩展数据表。
+
+**项目所需配置**:
+
+| 配置项 | 说明 | 是否必填 |
+| :--- | :--- | :--- |
+| **TickFlow API Key** | 数据源凭证,留空启用 Free 模式(无需注册即可体验) | 可选 |
+| **AI 大模型 API Key** | 用于 AI 生成策略、个股分析(开发中)、行情分析(开发中),任意 OpenAI 兼容接口,留空关闭 | 可选 |
+
+
+
+ | 看板 Dashboard |
+ 选股 Screener |
+ 回测 Backtest |
+
+
+  |
+  |
+  |
+
+
+
+> ### ⚠️ 🚧 项目持续优化,功能陆续开放,敬请期待。
+
+> **明确不做**:不对标同花顺/通达信的全功能股票软件;不内置任何「AI 荐股 / 涨停预测」。
+
+---
+
+## ✨ 核心功能
+
+### 🔍 选股引擎(Screener)
+
+**20 个内置策略** —— 每个策略是一个独立 Python 文件(`backend/app/strategy/builtin/`),基于 Polars 表达式实现:
+
+| 类型 | 代表策略 |
+| :--- | :--- |
+| 趋势 | 趋势突破 · 均线多头 · 缩量回踩 |
+| 形态 | MA 金叉 · MACD 金叉放量 · 布林突破 |
+| 量价 | 量价齐升 · 高换手强势 · 强势高开 |
+| 涨停 | 连板股 · 断板反包 · 逼近涨停 · 涨停动量 |
+| 反转 | 超跌反弹 · 超卖反转 · 新低反转 |
+| 波动 | 低波动龙头 · 回踩 MA20 反弹 |
+
+- **自定义信号系统** —— 在 UI 上用 `字段 + 操作符 + 阈值` 组合(entry / exit / both),编译成 Polars 表达式热加载,**无需写代码**即可定义自己的买卖信号。
+- **策略商店** —— 内置策略 + 用户自定义策略统一管理,支持参数覆盖(`params` 暴露阈值)。
+
+#### ➕ 添加自己的策略
+
+除 20 个内置策略外,你可以用两种方式扩展:
+
+| 方式 | 说明 | 前提 |
+| :--- | :--- | :--- |
+| **🤖 AI 生成** | 用自然语言描述策略思路,LLM 读取 [strategy-guide.md](./docs/strategy-guide.md) 自动生成完整 Polars 策略文件(经 `ast` 安全校验,限定 `import polars as pl`)。生成后落入 `data/strategies/ai/`,即刻可用 | 需先在 [配置](#️-配置) 中填入 AI Key |
+| **📝 代码自定义 / 策略迁移** | 参照 [策略开发指南](./docs/strategy-guide.md) 的文件结构模板,把你**已有的自有策略**改写为 Polars 文件放入 `data/strategies/custom/`(文件名/ID 建议 `custom_时间戳`),引擎自动发现加载——**轻松迁移你现成的量化项目策略**,无需从头重写 | 无 |
+| **🎛️ 自定义信号配置** | 不写代码,在 UI 上用 `字段 + 操作符 + 阈值` 组合(entry / exit / both),编译成 Polars 表达式热加载,即可定义自己的买卖信号 | 无 |
+
+> 引擎按 `source` 标记来源:`builtin`(内置)/ `custom`(手写或迁移)/ `ai`(生成),三者统一进入策略商店管理。
+
+### 📊 指标流水线(Indicators)
+
+原生 Polars 向量化计算,全 A 股一次扫表落盘为 enriched Parquet:
+
+| 分类 | 指标 |
+| :--- | :--- |
+| 均线系 | MA(5/10/20/30/60)· EMA(5/10/12/20/26/30/60) |
+| 趋势系 | MACD(DIF/DEA/HIST)· 动量(5/10/20/30/60d)· 布林带(上/下轨) |
+| 震荡系 | RSI(可配周期)· KDJ(K/D/J) |
+| 波动系 | ATR(14)· 年化波动率(20d)· 振幅 |
+| 量能系 | 量比(5d/10d)· 量均线 |
+| 涨跌停 | 涨停信号 · 连板数 · 涨跌幅 · 涨跌额 |
+| 原子信号 | MA 金叉/死叉 · MA20 突破/跌破 · MACD 金叉/死叉 · N 日新高/新低 · 布林突破 |
+| 复权 | 基于除权因子自动计算前复权(`ex_factor` / `cum_factor`),回测与指标一致 |
+
+### 🧪 回测引擎(Backtest)
+
+基于 vectorbt(全项目**唯一**一处 pandas 出现地):
+
+- **三种回测模式**:个股 · 策略组合 · 自由信号组合
+- **真实约束**:T+1 · 手续费 · 滑点(基点) · 止损 · 最大持仓天数
+- **组合管理**:最大持仓数 · 最大敞口 · 等权 / 自定义仓位
+- **SSE 流式进度**:长任务实时推送进度,支持刷新 / 切页后**重连恢复**(相同参数任务只启动一次)
+- **统计输出**:净值曲线 · 夏普 · 最大回撤 · 胜率 · 每笔交易明细
+
+### 📡 实时监控(Strategy Monitor)
+
+- **盘中 SSE 推送**:行情刷新(`quotes_updated`)+ 策略告警(`strategy_alert`)双事件流,前端实时弹通知
+- **策略监控**:订阅策略的 entry / exit 信号 + 自定义提醒条件(如 `rsi_14 > 80`),命中即推送
+- **Webhook 告警**:命中规则后可选推送外部 webhook
+
+### 🤖 AI 策略生成(可选)
+
+- **自然语言 → 策略代码**:用一句话描述策略思路,LLM 读取 `docs/strategy-guide.md` 生成完整 Polars 策略文件
+- **沙箱约束**:生成代码经 `ast` 校验、限定 `import polars as pl`,避免逐行循环,优先向量化表达
+- **可插拔**:留空 AI 配置即跳过整个模块,不影响核心功能
+
+### 🧰 数据与扩展
+
+- **多源数据**:TickFlow 日 K / 分钟 K / 指数 / 财务(利润 / 资产负债 / 现金流)/ 自选行情
+- **🔌 第三方数据接入(重点)** —— TickFlow 之外的数据也能用:
+ - 支持 **Tushare** 等第三方数据源,通过 **HTTP 定时拉取**自动入库
+ - 支持 **CSV / Excel 上传** · **JSON 写入**,自动 schema 发现与符号归一
+ - **页面可视化配置**扩展数据表,无需改代码
+ - 可接入**你自己的量化项目数据**,统一并入 DuckDB 查询面,与内置数据同台分析
+- **盘后定时管道**:APScheduler 15:30 CST 自动拉日 K + 重算 enriched 表 + 跑监控
+- **令牌桶限流**:适配各档位 rpm / batch 上限,批量合并 + 增量拉取,同一份数据多面板复用
+
+---
+
+## 🚀 快速开始
+
+### 前置依赖
+
+| 工具 | 版本 | 安装 |
+| :--- | :--- | :--- |
+| Python | ≥ 3.11 | [python.org](https://www.python.org/) |
+| Node | ≥ 20 | [nodejs.org](https://nodejs.org/) |
+| [`uv`](https://docs.astral.sh/uv/) | latest | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
+| `pnpm` | 9 | `npm i -g pnpm` 或 `corepack enable && corepack prepare pnpm@9 --activate` |
+
+### 方式 A:Docker(最省心,生产推荐)
+
+```bash
+cp .env.example .env # 按需填写 Key(留空即 Free 模式,可直接体验)
+docker compose up --build
+# 打开 http://localhost:3018
+```
+
+### 方式 B:Dev 模式(二次开发)
+
+```bash
+cp .env.example .env # 填 TICKFLOW_API_KEY,留空则启用 Free 试用
+```
+
+**一键启动**(推荐,自动检查依赖 / 释放端口 / 同时起前后端,Ctrl-C 一并关闭):
+
+| 平台 | 命令 |
+| :--- | :--- |
+| **macOS / Linux** | `./dev.sh` |
+| **Windows (PowerShell)** | `.\dev.ps1` |
+
+首次运行会自动安装前后端依赖(约 1-2 分钟),之后直接启动:
+
+- 后端 →
+- 前端 →
+
+自定义端口:`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh`(Windows:`.\dev.ps1 -BackendPort 8000 -FrontendPort 5173`)
+
+
+手动分别启动(备选)
+
+```bash
+# 终端 1:后端
+cd backend
+uv sync
+uv run uvicorn app.main:app --reload --port 3018
+
+# 终端 2:前端
+cd frontend
+pnpm install
+pnpm dev # http://localhost:3011
+```
+
+
+
+> **启用回测**:`cd backend && uv sync --extra backtest`
+> vectorbt → numba 体积较大,故作为可选 extras。macOS / Intel 无预构建 wheel 时需 `brew install cmake` 现场编译。
+
+---
+
+## 🧭 第一次使用
+
+1. 打开面板 → **设置 → 凭据与能力** → 点 **重新检测**,确认 Tier Label
+2. 点 **立即跑盘后管道** —— 拉日 K + 计算 enriched 表
+ - **Free 用户**:只同步内置 DEMO_SYMBOLS(浦发 / 招商 / 茅台等 10 只)
+ - **Starter+**:同步全 A 或可获取的 instruments 列表
+3. **自选**页:添加跟踪标的;点代码进 **K 线**页看蜡烛图 + 买卖点
+4. **选股**页:点任一内置策略卡片即时扫描;或用自定义信号组合条件
+5. **回测**页:选策略 / 信号 + 时间区间 → 跑回测 → 看净值 / 夏普 / 交易明细(SSE 实时进度)
+6. **监控**页:配置告警规则,盘中 SSE 推送行情与策略信号;命中后写入告警日志(可选 webhook)
+
+---
+
+## ⚙️ 配置
+
+所有配置通过项目根目录的 `.env` 文件读取(复制 `.env.example` 开始)。配置也可在面板 **设置** 页面内修改。
+
+### 数据源:TickFlow
+
+TickFlow 提供订阅制 A 股数据。**留空 `TICKFLOW_API_KEY` 即启用 Free 模式,无需注册即可体验**。
+
+```ini
+TICKFLOW_API_KEY= # 留空 = Free 模式;填入 Key = 按订阅档位解锁
+```
+
+> 完整能力矩阵见 [tickflow.org/pricing](https://tickflow.org/pricing/)。系统启动时会自动探测你的真实能力集,UI 显示「≈ Pro」等友好标签。
+
+### AI(可选):策略生成
+
+AI 模块用于「自然语言生成策略代码」。**所有配置留空即跳过 AI 功能,不影响核心使用**。支持任何 **OpenAI 兼容接口**:
+
+```ini
+AI_PROVIDER=openai_compat # openai_compat | ollama
+AI_BASE_URL=https://api.deepseek.com/v1
+AI_API_KEY= # 留空 = 关闭 AI
+AI_MODEL=deepseek-chat
+AI_DAILY_TOKEN_BUDGET=500000 # 每日 token 预算上限
+```
+
+> 切换 `AI_PROVIDER=ollama` 时无需 `AI_API_KEY`,适合本地部署大模型。
+
+### 服务与数据
+
+```ini
+HOST=0.0.0.0 # 监听地址
+PORT=3018 # 服务端口
+LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR
+DATA_DIR=./data # Parquet / DuckDB 数据存储目录
+```
+
+---
+
+## 🏗️ 技术栈
+
+| 层 | 选型 |
+| :--- | :--- |
+| **后端** | FastAPI · Pydantic v2 · APScheduler · sse-starlette |
+| **数据** | Polars(计算)· DuckDB(查询)· Parquet(存储)· PyArrow |
+| **回测** | vectorbt(全项目唯一 pandas 边界) |
+| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK(`tickflow[all]`) |
+| **AI**(可选) | OpenAI 兼容接口(DeepSeek / 通义 / Ollama 等) |
+| **前端** | React 18 · Vite · TypeScript · Tailwind CSS · Framer Motion · Tanstack Query · Lightweight Charts · ECharts · dnd-kit |
+| **部署** | Docker 两阶段构建,前端 dist 拷进后端镜像,**单容器** |
+
+---
+
+## 🗺️ 路线图
+
+| Phase | 内容 | 状态 |
+| :--- | :--- | :--- |
+| **0** | 仓库骨架 / FastAPI 壳 / Vite + React SPA / Docker 一键起 | ✅ |
+| **1** | 能力探测 + Kline 同步 + K 线分析页 | ✅ |
+| **2** | Polars enriched 流水线 + Screener + 信号扫描 | ✅ |
+| **3** | vectorbt 回测 + T+1 + 手续费 + 止损 + max-hold | ✅ |
+| **4** | 监控引擎 + 告警规则 + Webhook + APScheduler 盘后定时 | ✅ |
+| **v2** | 自定义信号 / 策略商店 / AI 策略生成 / 外部数据源插件 / 早晚报 / Onboarding | 🚧 |
+
+---
+
+## 📚 文档
+
+- [docs/strategy-guide.md](./docs/strategy-guide.md) —— 策略开发指南(AI 生成器与手写策略的规范)
+- [docs/](./docs) —— 策略构建步骤、示例
+
+---
+
+## 🤝 贡献
+
+欢迎 Issue 和 PR。本地开发:
+
+```bash
+cd backend && uv sync --extra backtest # 含回测依赖
+cd ../frontend && pnpm install && pnpm dev
+```
+
+新增内置策略:在 `backend/app/strategy/builtin/` 参照现有策略文件,实现 `StrategyDef` 即可被引擎自动发现。
+
+---
+
+## ⚠️ 免责声明
+
+本项目仅供**学习与量化研究**,**不构成任何投资建议**。回测结果不代表未来收益。A 股有风险,入市需谨慎。数据准确性以数据源 TickFlow 官方为准。
+
+---
+
+## 📄 License
+
+[MIT](./LICENSE) © tf-stocks-panel contributors
+
+本项目依赖 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 提供数据服务,使用前请遵守其服务条款。
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..02dc6f6
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+v0.1.19
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
new file mode 100644
index 0000000..b5b011b
--- /dev/null
+++ b/backend/app/__init__.py
@@ -0,0 +1,3 @@
+"""TF-Stocks-Panel backend."""
+
+__version__ = "0.1.0"
diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py
new file mode 100644
index 0000000..8a5a42a
--- /dev/null
+++ b/backend/app/api/__init__.py
@@ -0,0 +1 @@
+"""FastAPI 路由汇总。"""
diff --git a/backend/app/api/analysis.py b/backend/app/api/analysis.py
new file mode 100644
index 0000000..18b572e
--- /dev/null
+++ b/backend/app/api/analysis.py
@@ -0,0 +1,204 @@
+"""自定义分析菜单 API。"""
+from __future__ import annotations
+
+import json
+from datetime import datetime
+from pathlib import Path
+from typing import Literal
+
+from fastapi import APIRouter, HTTPException, Request
+from pydantic import BaseModel, Field
+
+from app.services.ext_data import ExtConfigStore
+
+router = APIRouter(prefix="/api/analysis-menus", tags=["analysis-menus"])
+
+
+class AnalysisColumn(BaseModel):
+ field: str
+ label: str = ""
+ type: Literal["string", "number", "percent", "amount", "date"] = "string"
+ width: int | None = None
+ sortable: bool = False
+ precision: int | None = None
+ format: str | None = None
+ aggregate: Literal["count", "avg", "sum", "min", "max"] | None = None
+ visible: bool = True
+
+
+class DefaultSort(BaseModel):
+ field: str
+ order: Literal["asc", "desc"] = "desc"
+
+
+class AnalysisMenu(BaseModel):
+ id: str = Field(..., min_length=1, max_length=64, pattern=r"^[a-zA-Z0-9_]+$")
+ label: str = Field(..., min_length=1, max_length=64)
+ icon: str = "chart"
+ data_source: str = Field(..., min_length=1)
+ template: Literal["dimension_rank", "ranking", "table"] = "dimension_rank"
+ dimension_field: str | None = None
+ rank_field: str | None = None
+ group_columns: list[AnalysisColumn] = Field(default_factory=list)
+ detail_columns: list[AnalysisColumn] = Field(default_factory=list)
+ default_sort: DefaultSort | None = None
+ visible: bool = True
+ order: int = 0
+ created_at: str | None = None
+ updated_at: str | None = None
+ builtin: bool = False
+
+
+class UpsertAnalysisMenu(BaseModel):
+ label: str = Field(..., min_length=1, max_length=64)
+ icon: str = "chart"
+ data_source: str = Field(..., min_length=1)
+ template: Literal["dimension_rank", "ranking", "table"] = "dimension_rank"
+ dimension_field: str | None = None
+ rank_field: str | None = None
+ group_columns: list[AnalysisColumn] = Field(default_factory=list)
+ detail_columns: list[AnalysisColumn] = Field(default_factory=list)
+ default_sort: DefaultSort | None = None
+ visible: bool = True
+ order: int = 0
+
+
+class ReorderMenusReq(BaseModel):
+ ids: list[str] = Field(..., min_length=1)
+
+
+def _data_dir(request: Request) -> Path:
+ return request.app.state.repo.store.data_dir
+
+
+def _base_dir(request: Request) -> Path:
+ return _data_dir(request) / "analysis_menus"
+
+
+def _path(request: Request, menu_id: str) -> Path:
+ return _base_dir(request) / f"{menu_id}.json"
+
+
+def _load_saved(request: Request) -> list[AnalysisMenu]:
+ base = _base_dir(request)
+ if not base.exists():
+ return []
+ items: list[AnalysisMenu] = []
+ for p in sorted(base.glob("*.json")):
+ try:
+ raw = json.loads(p.read_text(encoding="utf-8"))
+ items.append(AnalysisMenu(**raw))
+ except Exception:
+ continue
+ return items
+
+
+def _ordered(items: list[AnalysisMenu]) -> list[AnalysisMenu]:
+ return sorted(items, key=lambda m: (m.order, m.label, m.id))
+
+
+def _save(request: Request, menu: AnalysisMenu) -> AnalysisMenu:
+ now = datetime.now().isoformat()
+ if not menu.created_at:
+ menu.created_at = now
+ menu.updated_at = now
+ menu.builtin = False
+ base = _base_dir(request)
+ base.mkdir(parents=True, exist_ok=True)
+ _path(request, menu.id).write_text(
+ json.dumps(menu.model_dump(), ensure_ascii=False, indent=2),
+ encoding="utf-8",
+ )
+ return menu
+
+
+def _default_menus(request: Request) -> list[AnalysisMenu]:
+ ext_store = ExtConfigStore(_data_dir(request))
+ menus: list[AnalysisMenu] = []
+ for cfg in ext_store.load_all():
+ fields = cfg.fields
+ concept = next((f for f in fields if "概念" in f.name or "概念" in f.label or "concept" in f.name.lower()), None)
+ if concept:
+ detail_names = ["股票简称", "股票代码", concept.name, "人气排名", "资金流向", "PE", "PB"]
+ detail_columns = []
+ for name in detail_names:
+ f = next((x for x in fields if x.name == name), None)
+ if not f:
+ continue
+ is_num = f.dtype in ("int", "float")
+ detail_columns.append(AnalysisColumn(
+ field=f.name,
+ label=f.label or f.name,
+ type="number" if is_num else "string",
+ sortable=is_num,
+ precision=2 if f.dtype == "float" else None,
+ ))
+ menus.append(AnalysisMenu(
+ id="concept_analysis",
+ label="概念分析",
+ icon="tags",
+ data_source=cfg.id,
+ template="dimension_rank",
+ dimension_field=concept.name,
+ group_columns=[
+ AnalysisColumn(field="__dimension", label="概念"),
+ AnalysisColumn(field="__count", label="股票数", type="number", sortable=True),
+ ],
+ detail_columns=detail_columns,
+ default_sort=DefaultSort(field="人气排名", order="asc") if any(c.field == "人气排名" for c in detail_columns) else None,
+ order=100,
+ builtin=True,
+ ))
+ break
+ return menus
+
+
+@router.get("")
+def list_menus(request: Request):
+ saved = _load_saved(request)
+ saved_ids = {m.id for m in saved}
+ defaults = [m for m in _default_menus(request) if m.id not in saved_ids]
+ return {"items": _ordered(saved + defaults)}
+
+
+@router.get("/{menu_id}")
+def get_menu(request: Request, menu_id: str):
+ for menu in _ordered(_load_saved(request) + _default_menus(request)):
+ if menu.id == menu_id:
+ return menu
+ raise HTTPException(404, f"分析菜单 '{menu_id}' 不存在")
+
+
+@router.post("/reorder")
+def reorder_menus(request: Request, body: ReorderMenusReq):
+ saved = {m.id: m for m in _load_saved(request)}
+ defaults = {m.id: m for m in _default_menus(request)}
+ for idx, menu_id in enumerate(body.ids):
+ menu = saved.get(menu_id) or defaults.get(menu_id)
+ if not menu:
+ continue
+ menu.order = idx
+ _save(request, menu)
+ return {"items": _ordered(_load_saved(request))}
+
+
+@router.post("/{menu_id}")
+def upsert_menu(request: Request, menu_id: str, body: UpsertAnalysisMenu):
+ if not menu_id.replace("_", "").isalnum():
+ raise HTTPException(400, "菜单标识只能包含字母、数字和下划线")
+ existing = next((m for m in _load_saved(request) if m.id == menu_id), None)
+ menu = AnalysisMenu(
+ id=menu_id,
+ created_at=existing.created_at if existing else None,
+ **body.model_dump(),
+ )
+ return _save(request, menu)
+
+
+@router.delete("/{menu_id}")
+def delete_menu(request: Request, menu_id: str):
+ p = _path(request, menu_id)
+ if not p.exists():
+ raise HTTPException(404, f"分析菜单 '{menu_id}' 不存在或为默认菜单")
+ p.unlink()
+ return {"status": "deleted"}
diff --git a/backend/app/api/backtest.py b/backend/app/api/backtest.py
new file mode 100644
index 0000000..216a360
--- /dev/null
+++ b/backend/app/api/backtest.py
@@ -0,0 +1,461 @@
+"""回测 API — 信号回测 + 因子回测 + 策略回测。"""
+from __future__ import annotations
+
+import asyncio
+import json
+import queue
+import threading
+from dataclasses import asdict
+from datetime import date, timedelta
+from typing import Literal
+
+from fastapi import APIRouter, HTTPException, Request
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel, Field
+
+from app.config import settings
+from app.services.backtest import (
+ BacktestConfig,
+ BacktestService,
+ VectorbtUnavailable,
+ is_available,
+)
+
+router = APIRouter(prefix="/api/backtest", tags=["backtest"])
+
+FACTOR_DEFAULT_DAYS = 180
+STRATEGY_DEFAULT_DAYS = 365 * 3
+BACKTEST_MAX_SERVER_DAYS = 186
+FACTOR_MAX_SYMBOLS = 1000
+BACKTEST_SERVER_GUARD_MESSAGE = (
+ "当前服务器内存约 1.8GB,回测区间最多支持 6 个月;"
+ "更长周期容易触发 OOM,建议在 8GB 以上内存环境或本机运行。"
+)
+
+
+def _get_engine(request: Request):
+ """获取或创建 BacktestEngine (单例,PanelCache 跨请求生效)。"""
+ from app.backtest.engine import BacktestEngine
+ engine = getattr(request.app.state, "backtest_engine", None)
+ if engine is None:
+ engine = BacktestEngine(request.app.state.repo)
+ request.app.state.backtest_engine = engine
+ return engine
+
+
+def _resolve_start(req: BaseModel, end: date, default_days: int) -> date:
+ """未传 start 使用默认区间;显式传 null/空值表示全部历史。"""
+ start = getattr(req, "start")
+ if start is not None:
+ return start
+ if "start" in req.model_fields_set:
+ return date(1900, 1, 1)
+ return end - timedelta(days=default_days)
+
+
+def _guard_server_backtest_range(start: date, end: date):
+ if not settings.backtest_range_guard:
+ return
+ days = (end - start).days + 1
+ if days > BACKTEST_MAX_SERVER_DAYS:
+ raise HTTPException(status_code=400, detail=BACKTEST_SERVER_GUARD_MESSAGE)
+
+
+# ================================================================
+# 状态
+# ================================================================
+
+@router.get("/status")
+def status():
+ """前端可用此接口判断回测页是否要灰显。"""
+ return {"available": True}
+
+
+# ================================================================
+# 信号回测 (现有接口,保持不变)
+# ================================================================
+
+class BacktestRequest(BaseModel):
+ symbols: list[str] = Field(..., min_length=1)
+ start: date | None = None
+ end: date | None = None
+ entries: list[str] = []
+ exits: list[str] = []
+ stop_loss_pct: float | None = None
+ max_hold_days: int | None = None
+ fees_pct: float = 0.0002
+ slippage_bps: float = 5
+ matching: Literal["close_t", "open_t+1"] = "close_t"
+
+
+@router.post("/run")
+def run(req: BacktestRequest, request: Request):
+ """信号回测 — 现有接口,向后兼容。"""
+ repo = request.app.state.repo
+ svc = BacktestService(repo)
+ end = req.end or date.today()
+ start = req.start or (end - timedelta(days=365 * 3))
+
+ cfg = BacktestConfig(
+ symbols=req.symbols,
+ start=start,
+ end=end,
+ entries=req.entries,
+ exits=req.exits,
+ stop_loss_pct=req.stop_loss_pct,
+ max_hold_days=req.max_hold_days,
+ fees_pct=req.fees_pct,
+ slippage_bps=req.slippage_bps,
+ matching=req.matching,
+ )
+ try:
+ result = svc.run(cfg)
+ except VectorbtUnavailable as e:
+ raise HTTPException(status_code=503, detail=str(e)) from e
+ return asdict(result)
+
+
+# ================================================================
+# 因子回测
+# ================================================================
+
+class FactorColumnsResponse(BaseModel):
+ columns: list[dict]
+
+
+@router.get("/factor/columns")
+def factor_columns():
+ """返回可用的因子列列表。"""
+ from app.backtest.factor import FACTOR_COLUMNS
+ return {"columns": FACTOR_COLUMNS}
+
+
+class FactorBacktestRequest(BaseModel):
+ factor_name: str
+ symbols: list[str] | None = None
+ start: date | None = None
+ end: date | None = None
+ 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
+
+
+@router.post("/factor/run")
+def factor_run(req: FactorBacktestRequest, request: Request):
+ """因子回测 — IC/IR 分析 + 分层回测。"""
+ from app.backtest.factor import FactorBacktestService, FactorConfig
+
+ engine = _get_engine(request)
+ svc = FactorBacktestService(engine)
+
+ 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} 只,请缩小标的范围。",
+ )
+
+ cfg = FactorConfig(
+ factor_name=req.factor_name,
+ 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,
+ )
+ result = svc.run(cfg)
+ return asdict(result)
+
+
+# ================================================================
+# 策略回测
+# ================================================================
+
+class StrategyBacktestRequest(BaseModel):
+ strategy_id: str
+ symbols: list[str] | None = None
+ start: date | None = None
+ end: date | None = None
+ params: dict | None = None
+ overrides: dict | None = None
+ matching: Literal["close_t", "open_t+1"] = "open_t+1"
+ fees_pct: float = 0.0002
+ slippage_bps: float = 5.0
+ max_positions: int = 10
+ max_exposure_pct: float = 1.0
+ initial_capital: float = 1_000_000.0
+ position_sizing: Literal["equal", "score_weight"] = "equal"
+ mode: Literal["position", "full"] = "position"
+ holding_days: int = 5
+
+
+@router.post("/strategy/run")
+def strategy_run(req: StrategyBacktestRequest, request: Request):
+ """策略回测 — 复用 StrategyDef 体系做全周期回测。"""
+ from app.backtest.strategy import StrategyBacktestService, StrategyBacktestConfig
+
+ engine = _get_engine(request)
+ strategy_engine = request.app.state.strategy_engine
+ svc = StrategyBacktestService(engine, strategy_engine)
+
+ end = req.end or date.today()
+ start = _resolve_start(req, end, FACTOR_DEFAULT_DAYS)
+ _guard_server_backtest_range(start, end)
+
+ cfg = StrategyBacktestConfig(
+ strategy_id=req.strategy_id,
+ symbols=req.symbols if req.symbols else None,
+ start=start,
+ end=end,
+ params=req.params,
+ overrides=req.overrides,
+ matching=req.matching,
+ fees_pct=req.fees_pct,
+ slippage_bps=req.slippage_bps,
+ max_positions=req.max_positions,
+ max_exposure_pct=req.max_exposure_pct,
+ initial_capital=req.initial_capital,
+ position_sizing=req.position_sizing,
+ mode=req.mode,
+ holding_days=req.holding_days,
+ )
+ result = svc.run(cfg)
+ return asdict(result)
+
+
+# ── SSE 流式回测 (实时进度 + 可取消 + 支持重连) ───────────────────
+
+import time
+import hashlib
+
+
+class _BacktestJob:
+ """单个回测任务的状态, 存模块级供重连使用。"""
+ __slots__ = ("key", "cancel_event", "progress", "result", "error", "done", "finish_ts")
+
+ def __init__(self, key: str):
+ self.key = key
+ self.cancel_event = threading.Event()
+ self.progress: list[dict] = [] # 进度历史 (新连接可回放)
+ self.result = None # 完成后的结果
+ self.error: str | None = None
+ self.done = False
+ self.finish_ts: float = 0.0
+
+
+# 模块级任务表: key -> _BacktestJob
+_running_jobs: dict[str, _BacktestJob] = {}
+_jobs_lock = threading.Lock()
+_JOB_TTL = 300 # 完成后保留 5 分钟
+
+
+def _cleanup_stale_jobs():
+ """清理过期任务 (完成超过 TTL 的)。"""
+ now = time.time()
+ stale = [k for k, j in _running_jobs.items() if j.done and now - j.finish_ts > _JOB_TTL]
+ for k in stale:
+ _running_jobs.pop(k, None)
+
+
+def _make_job_key(
+ strategy_id: str, symbols: str | None, start: str | None, end: str | None,
+ matching: str, fees_pct: float, slippage_bps: float,
+ max_positions: int, max_exposure_pct: float, initial_capital: float, position_sizing: str,
+ params: str | None, overrides: str | None,
+ mode: str = "position", holding_days: int = 5,
+) -> str:
+ raw = f"{strategy_id}|{symbols}|{start}|{end}|{matching}|{fees_pct}|{slippage_bps}|{max_positions}|{max_exposure_pct}|{initial_capital}|{position_sizing}|{params}|{overrides}|{mode}|{holding_days}"
+ return hashlib.md5(raw.encode()).hexdigest()[:12]
+
+
+@router.get("/strategy/stream")
+async def strategy_stream(
+ request: Request,
+ strategy_id: str,
+ symbols: str | None = None,
+ start: str | None = None,
+ end: str | None = None,
+ matching: str = "open_t+1",
+ fees_pct: float = 0.0002,
+ slippage_bps: float = 5.0,
+ max_positions: int = 10,
+ max_exposure_pct: float = 1.0,
+ initial_capital: float = 1_000_000.0,
+ position_sizing: str = "equal",
+ params: str | None = None,
+ overrides: str | None = None,
+ mode: str = "position",
+ holding_days: int = 5,
+):
+ """SSE 流式策略回测: 实时推送进度, 完成后推送结果, 支持重连 (刷新/切页后恢复)。
+
+ - 相同参数的任务只启动一次, 多次连接订阅同一个任务
+ - 断开连接不会取消任务 (除非显式调用 cancel)
+ - 结果保留 5 分钟供重连
+
+ 事件类型:
+ - progress: {day, total, date, equity}
+ - done: {result} (完整回测结果)
+ - error: {message}
+ """
+ from app.backtest.strategy import StrategyBacktestService, StrategyBacktestConfig
+
+ engine = _get_engine(request)
+ strategy_engine = request.app.state.strategy_engine
+ svc = StrategyBacktestService(engine, strategy_engine)
+
+ end_date = date.fromisoformat(end) if end else date.today()
+ if start:
+ start_date = date.fromisoformat(start)
+ else:
+ # 空 start = 全部历史: 用本地最早日K日期, 查不到再回退到默认窗口
+ earliest = request.app.state.repo.earliest_daily_date()
+ start_date = earliest or (end_date - timedelta(days=FACTOR_DEFAULT_DAYS))
+
+ # 服务端范围保护
+ guard_violated = False
+ if settings.backtest_range_guard:
+ days = (end_date - start_date).days + 1
+ if days > BACKTEST_MAX_SERVER_DAYS:
+ guard_violated = True
+
+ job_key = _make_job_key(
+ strategy_id, symbols, start, end,
+ matching, fees_pct, slippage_bps, max_positions, max_exposure_pct, initial_capital, position_sizing,
+ params, overrides,
+ mode, holding_days,
+ )
+
+ _cleanup_stale_jobs()
+
+ # 获取或创建任务
+ with _jobs_lock:
+ job = _running_jobs.get(job_key)
+ if job is None:
+ job = _BacktestJob(job_key)
+ _running_jobs[job_key] = job
+ is_new = True
+ else:
+ is_new = False
+
+ async def event_generator():
+ # 范围保护: 直接报错
+ if guard_violated:
+ yield f"event: error\ndata: {json.dumps({'message': BACKTEST_SERVER_GUARD_MESSAGE}, ensure_ascii=False)}\n\n"
+ return
+
+ # 如果是新任务, 启动回测线程
+ if is_new and not job.done:
+ cfg = StrategyBacktestConfig(
+ strategy_id=strategy_id,
+ symbols=[s.strip() for s in symbols.split(",") if s.strip()] if symbols else None,
+ start=start_date,
+ end=end_date,
+ params=json.loads(params) if params else None,
+ overrides=json.loads(overrides) if overrides else None,
+ matching=matching,
+ fees_pct=fees_pct,
+ slippage_bps=slippage_bps,
+ max_positions=int(max_positions),
+ max_exposure_pct=float(max_exposure_pct),
+ initial_capital=float(initial_capital),
+ position_sizing=position_sizing,
+ mode=mode,
+ holding_days=int(holding_days),
+ )
+
+ def _run_backtest():
+ try:
+ result = svc.run(cfg, lambda d: job.progress.append(d), job.cancel_event)
+ job.result = result
+ job.done = True
+ job.finish_ts = time.time()
+ except Exception as e:
+ job.error = str(e)
+ job.done = True
+ job.finish_ts = time.time()
+
+ # 启动后台线程 (不阻塞事件循环)
+ threading.Thread(target=_run_backtest, daemon=True).start()
+
+ # 订阅进度: 用读指针读 job.progress 列表 (多连接互不干扰)
+ cursor = 0
+ tick = 0
+
+ try:
+ while True:
+ # 已完成: 推送最终结果/错误并退出
+ if job.done:
+ if job.error:
+ yield f"event: error\ndata: {json.dumps({'message': job.error}, ensure_ascii=False)}\n\n"
+ elif job.result is not None:
+ r = job.result
+ if hasattr(r, "error") and r.error == "cancelled":
+ yield f"event: error\ndata: {json.dumps({'message': '回测已取消'}, ensure_ascii=False)}\n\n"
+ elif hasattr(r, "error") and r.error:
+ yield f"event: error\ndata: {json.dumps({'message': r.error}, ensure_ascii=False)}\n\n"
+ else:
+ yield f"event: done\ndata: {json.dumps(asdict(r), ensure_ascii=False, default=str)}\n\n"
+ return
+
+ # 断开检测: 每 4 轮检查一次 (降低 GIL 抢占频率)
+ tick += 1
+ if tick % 4 == 0 and await request.is_disconnected():
+ break
+
+ # 推送新进度 (从 cursor 开始读)
+ prog_list = job.progress
+ while cursor < len(prog_list):
+ msg = prog_list[cursor]
+ cursor += 1
+ yield f"event: progress\ndata: {json.dumps(msg, ensure_ascii=False, default=str)}\n\n"
+
+ await asyncio.sleep(0.5)
+
+ except asyncio.CancelledError:
+ raise
+
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
+
+
+@router.post("/strategy/cancel")
+async def strategy_cancel(request: Request):
+ """取消正在运行的回测任务 (前端传 query string, 后端算 job_key)。"""
+ body = await request.json()
+ qs = body.get("qs", "")
+ # 解析 qs 得到参数
+ from urllib.parse import parse_qs
+ p = parse_qs(qs)
+ def _get(key: str, default: str = "") -> str:
+ return p.get(key, [default])[0]
+ job_key = _make_job_key(
+ _get("strategy_id"),
+ _get("symbols") or None,
+ _get("start") or None,
+ _get("end") or None,
+ _get("matching", "open_t+1"),
+ float(_get("fees_pct", "0.0002")),
+ float(_get("slippage_bps", "5")),
+ int(_get("max_positions", "10")),
+ float(_get("max_exposure_pct", "1")),
+ float(_get("initial_capital", "1000000")),
+ _get("position_sizing", "equal"),
+ _get("params") or None,
+ _get("overrides") or None,
+ _get("mode", "position"),
+ int(_get("holding_days", "5")),
+ )
+ job = _running_jobs.get(job_key)
+ if job and not job.done:
+ job.cancel_event.set()
+ return {"ok": True}
+ return {"ok": False, "message": "任务不存在或已完成"}
+
diff --git a/backend/app/api/data.py b/backend/app/api/data.py
new file mode 100644
index 0000000..b1feec7
--- /dev/null
+++ b/backend/app/api/data.py
@@ -0,0 +1,710 @@
+"""数据画像 API —— 让前端知道"我们本地有什么数据"。"""
+from __future__ import annotations
+
+import logging
+import os
+import threading
+import time
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Callable
+
+from fastapi import APIRouter, Request
+
+from app.indicators.pipeline import ENRICHED_COLUMNS
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/data", tags=["data"])
+
+# ===== 缓存:storage(文件扫描) + 每张表 aggregate 各自缓存 =====
+# 同步期间前端 2s 轮一次 status,每张表 aggregate 全表 count + min/max + distinct
+# 太重,加 TTL + 事件失效。stage 写完只清对应那张表的缓存。
+
+_TABLE_TTL = 30.0 # 兜底 TTL,即使没人调 invalidate 也会过期
+_TABLE_TTL_LARGE = 120.0 # 大表(分钟K等)单独 TTL,避免多分区聚合反复重算
+_STORAGE_TTL = 60.0 # storage 文件扫描独立 TTL,stage 写完不触发重算
+
+# 聚合慢的大表(分区数多、行数多),使用更长的 TTL
+_LARGE_TABLES = {"minute"}
+
+_storage_cache: dict[str, Any] | None = None
+_storage_cache_ts: float = 0.0
+_storage_lock = threading.Lock()
+
+_table_cache: dict[str, dict | None] = {
+ "daily": None,
+ "enriched": None,
+ "index_daily": None,
+ "index_enriched": None,
+ "index_instruments": None,
+ "minute": None,
+ "adj_factor": None,
+ "instruments": None,
+ "financials": None,
+}
+_table_cache_ts: dict[str, float] = {k: 0.0 for k in _table_cache}
+_table_cache_lock = threading.Lock()
+
+_last_finished_cache: dict[str, str | None] | None = None
+_last_finished_lock = threading.Lock()
+
+
+def invalidate_data_cache(table: str | None = None) -> None:
+ """数据写入/清除后调用。
+
+ table=None 时清所有表 cache + storage(粗粒度,用于 pipeline 完成/clear);
+ 指定 table 时只清那张表,不影响 storage(细粒度,用于单 stage 写完)。
+ """
+ with _table_cache_lock:
+ if table is None:
+ global _storage_cache, _storage_cache_ts, _last_finished_cache
+ _storage_cache = None
+ _storage_cache_ts = 0.0
+ _last_finished_cache = None
+ for k in _table_cache:
+ _table_cache[k] = None
+ _table_cache_ts[k] = 0.0
+ elif table in _table_cache:
+ _table_cache[table] = None
+ _table_cache_ts[table] = 0.0
+
+
+def invalidate_storage_cache() -> None:
+ """向后兼容入口 — 清全部缓存。新代码请用 invalidate_data_cache(table)。"""
+ invalidate_data_cache(None)
+
+
+def _get_table_stats(name: str, fetch: Callable[[], dict | None]) -> dict | None:
+ """走 TTL+事件 双重缓存。fetch 在锁外执行避免阻塞别的请求。"""
+ ttl = _TABLE_TTL_LARGE if name in _LARGE_TABLES else _TABLE_TTL
+ now = time.time()
+ with _table_cache_lock:
+ cached = _table_cache.get(name)
+ cached_ts = _table_cache_ts.get(name, 0.0)
+ if cached is not None and (now - cached_ts) < ttl:
+ return cached
+
+ fresh = fetch()
+
+ with _table_cache_lock:
+ _table_cache[name] = fresh
+ _table_cache_ts[name] = now
+ return fresh
+
+
+def _safe_aggregate(repo, view: str) -> dict | None:
+ """聚合视图基础统计;视图不存在或为空时返 None。"""
+ try:
+ row = repo.execute_one(
+ f"""SELECT count(*) AS rows,
+ min(date) AS earliest,
+ max(date) AS latest,
+ count(DISTINCT symbol) AS symbols,
+ count(DISTINCT date) AS trading_days
+ FROM {view}"""
+ )
+ except Exception as e: # noqa: BLE001
+ logger.debug("aggregate %s failed: %s", view, e)
+ return None
+ if not row or not row[0]:
+ return None
+ return {
+ "rows": int(row[0]),
+ "earliest_date": str(row[1]) if row[1] else None,
+ "latest_date": str(row[2]) if row[2] else None,
+ "symbols_covered": int(row[3] or 0),
+ "trading_days": int(row[4] or 0),
+ }
+
+
+def _safe_aggregate_daily(repo, view: str = "kline_daily") -> dict | None:
+ """日K轻量统计 — 零数据扫描。
+
+ 从分区目录名获取日期范围和交易日数,不读任何 parquet。
+ 标的数从 instruments 小表获取(~5000行,毫秒级)。
+ """
+ daily_dir = repo.store.data_dir / "kline_daily"
+ if not daily_dir.exists():
+ return None
+ dates: list[str] = []
+ for d in daily_dir.iterdir():
+ if d.is_dir() and d.name.startswith("date="):
+ dates.append(d.name[5:])
+ if not dates:
+ return None
+ dates.sort()
+
+ symbols = _count_instruments_symbols(repo)
+
+ return {
+ "rows": 0,
+ "earliest_date": dates[0],
+ "latest_date": dates[-1],
+ "symbols_covered": symbols,
+ "trading_days": len(dates),
+ }
+
+
+def _safe_aggregate_enriched(repo) -> dict | None:
+ """Enriched 轻量统计 — 零数据扫描。
+
+ 字段数从 DESCRIBE 读 schema(不碰数据),毫秒级。
+ 日期范围从分区目录名获取(同 minute 策略),不读任何 parquet。
+ 标的数从 instruments 小表取。
+ """
+ # 字段数:读 schema,不碰数据
+ fields = 0
+ try:
+ cols = repo.execute_all("DESCRIBE kline_enriched")
+ fields = len(cols)
+ except Exception: # noqa: BLE001
+ pass
+
+ # 日期范围:从分区目录名获取,不扫数据
+ enriched_dir = repo.store.data_dir / "kline_daily_enriched"
+ if not enriched_dir.exists():
+ return None
+ dates: list[str] = []
+ for d in enriched_dir.iterdir():
+ if d.is_dir() and d.name.startswith("date="):
+ dates.append(d.name[5:])
+ if not dates:
+ return None
+ dates.sort()
+
+ symbols = _count_instruments_symbols(repo)
+
+ return {
+ "rows": 0,
+ "fields": fields,
+ "earliest_date": dates[0],
+ "latest_date": dates[-1],
+ "symbols_covered": symbols,
+ "trading_days": len(dates),
+ }
+
+
+def _count_instruments_symbols(repo) -> int:
+ """从 instruments 小表取标的数(~5000行,毫秒级)。"""
+ try:
+ sym_row = repo.execute_one(
+ "SELECT count(DISTINCT symbol) FROM instruments"
+ )
+ if sym_row and sym_row[0]:
+ return int(sym_row[0])
+ except Exception: # noqa: BLE001
+ pass
+ return 0
+
+
+def _safe_aggregate_instruments(repo) -> dict | None:
+ """instruments 视图统计(无 date 列,用 as_of)。"""
+ try:
+ row = repo.execute_one(
+ """SELECT count(*) AS rows,
+ count(DISTINCT symbol) AS symbols,
+ max(as_of) AS latest_as_of,
+ count_if(name IS NOT NULL AND name != '') AS named
+ FROM instruments"""
+ )
+ except Exception as e: # noqa: BLE001
+ logger.debug("aggregate instruments failed: %s", e)
+ return None
+ if not row or not row[0]:
+ return None
+ return {
+ "rows": int(row[0]),
+ "symbols_covered": int(row[1] or 0),
+ "latest_as_of": str(row[2]) if row[2] else None,
+ "named": int(row[3] or 0),
+ }
+
+
+def _safe_aggregate_index_daily(repo) -> dict | None:
+ """指数日K统计。指数数据量较小,直接读取 parquet 元数据统计真实行数。"""
+ return _safe_aggregate(repo, "kline_index_daily")
+
+
+def _safe_aggregate_index_enriched(repo) -> dict | None:
+ """指数 enriched 统计。指数数据量较小,直接读取 parquet 元数据统计真实行数。"""
+ fields = 0
+ try:
+ cols = repo.execute_all("DESCRIBE kline_index_enriched")
+ fields = len(cols)
+ except Exception: # noqa: BLE001
+ pass
+ stats = _safe_aggregate(repo, "kline_index_enriched")
+ if not stats:
+ return None
+ return {**stats, "fields": fields}
+
+
+def _safe_aggregate_index_instruments(repo) -> dict | None:
+ """指数 instruments 视图统计。"""
+ try:
+ row = repo.execute_one(
+ """SELECT count(*) AS rows,
+ count(DISTINCT symbol) AS symbols,
+ count_if(name IS NOT NULL AND name != '') AS named
+ FROM instruments_index"""
+ )
+ except Exception as e: # noqa: BLE001
+ logger.debug("aggregate instruments_index failed: %s", e)
+ return None
+ if not row or not row[0]:
+ return None
+ return {
+ "rows": int(row[0]),
+ "symbols_covered": int(row[1] or 0),
+ "latest_as_of": None,
+ "named": int(row[2] or 0),
+ }
+
+
+def _safe_aggregate_adj_factor(repo) -> dict | None:
+ """adj_factor 视图统计,日期范围对齐日 K 覆盖区间。"""
+ try:
+ # 取日 K 的日期范围作为过滤条件
+ dr = repo.execute_one(
+ "SELECT min(date), max(date) FROM kline_daily"
+ )
+ if not dr or not dr[0]:
+ return None
+ d_min, d_max = dr[0], dr[1]
+ row = repo.execute_one(
+ """SELECT count(*) AS rows,
+ count(DISTINCT symbol) AS symbols,
+ count(DISTINCT trade_date) AS trading_days
+ FROM adj_factor
+ WHERE trade_date BETWEEN ? AND ?""",
+ [str(d_min), str(d_max)],
+ )
+ if not row or not row[0]:
+ return None
+ return {
+ "rows": int(row[0]),
+ "symbols_covered": int(row[1]) if isinstance(row[1], (int, float)) else 0,
+ "earliest_date": str(d_min),
+ "latest_date": str(d_max),
+ "trading_days": int(row[2] or 0),
+ }
+ except Exception as e: # noqa: BLE001
+ logger.debug("aggregate adj_factor failed: %s", e)
+ return None
+
+
+def _safe_aggregate_minute(repo) -> dict | None:
+ """kline_minute 统计 — 从分区目录名获取交易日数,跳过全表扫描。
+
+ 分钟 K 按 date=YYYY-MM-DD 分区存储,直接数目录即可,
+ 无需 count(*) / count(DISTINCT ...) 等昂贵查询。
+ """
+ minute_dir = repo.store.data_dir / "kline_minute"
+ if not minute_dir.exists():
+ return None
+
+ # 从 date=YYYY-MM-DD 目录名提取交易日
+ dates: list[str] = []
+ for d in minute_dir.iterdir():
+ if d.is_dir() and d.name.startswith("date="):
+ dates.append(d.name[5:])
+
+ if not dates:
+ return None
+
+ dates.sort()
+ return {
+ "rows": 0, # 不再查询行数
+ "earliest_date": dates[0],
+ "latest_date": dates[-1],
+ "symbols_covered": 0, # 不再查询标的数
+ "trading_days": len(dates),
+ }
+
+
+def _safe_aggregate_financials(repo) -> dict | None:
+ """财务数据统计 — 检查各表文件是否存在及行数。"""
+ data_dir = repo.store.data_dir
+ tables_info: dict[str, dict] = {}
+ total_rows = 0
+
+ for table in ("metrics", "income", "balance_sheet", "cash_flow"):
+ path = data_dir / "financials" / table / "part.parquet"
+ if path.exists():
+ try:
+ import polars as pl
+ df = pl.read_parquet(path, columns=["symbol"])
+ rows = len(df)
+ symbols = df["symbol"].n_unique() if not df.is_empty() else 0
+ tables_info[table] = {"rows": rows, "symbols": symbols}
+ total_rows += rows
+ except Exception:
+ tables_info[table] = {"rows": 0, "symbols": 0}
+ else:
+ tables_info[table] = {"rows": 0, "symbols": 0}
+
+ if total_rows == 0:
+ return None
+
+ return {
+ "rows": total_rows,
+ "tables": tables_info,
+ }
+
+
+def _scan_dir_stats(dirpath: Path) -> tuple[int, float]:
+ """单次遍历统计目录下文件数和总大小(MB)。比 rglob+stat 快很多。"""
+ if not dirpath.exists():
+ return 0, 0.0
+ count = 0
+ total = 0
+ for entry in os.scandir(dirpath):
+ if entry.is_dir(follow_symlinks=False):
+ c, s = _scan_dir_recursive(entry)
+ count += c
+ total += s
+ elif entry.is_file(follow_symlinks=False):
+ try:
+ total += entry.stat().st_size
+ except OSError:
+ pass
+ count += 1
+ return count, round(total / 1048576, 2)
+
+
+def _scan_dir_recursive(entry: os.DirEntry) -> tuple[int, int]:
+ """递归统计一个 DirEntry 下的文件数和总字节数。"""
+ count = 0
+ total = 0
+ try:
+ for sub in os.scandir(entry.path):
+ if sub.is_dir(follow_symlinks=False):
+ c, s = _scan_dir_recursive(sub)
+ count += c
+ total += s
+ elif sub.is_file(follow_symlinks=False):
+ try:
+ total += sub.stat().st_size
+ except OSError:
+ pass
+ count += 1
+ except PermissionError:
+ pass
+ return count, total
+
+
+def _compute_storage(data_dir: Path) -> dict:
+ """单次遍历计算 storage 统计,避免多次 rglob。"""
+ import os
+
+ # 只统计关心的子目录
+ subdirs = {
+ "daily": data_dir / "kline_daily",
+ "enriched": data_dir / "kline_daily_enriched",
+ "index_daily": data_dir / "kline_index_daily",
+ "index_enriched": data_dir / "kline_index_enriched",
+ "index_instruments": data_dir / "instruments_index",
+ "minute": data_dir / "kline_minute",
+ "adj_factor": data_dir / "adj_factor",
+ "instruments": data_dir / "instruments",
+ "ext_data": data_dir / "ext_data",
+ }
+ stats = {}
+ total_size = 0
+ for key, d in subdirs.items():
+ fc, sz = _scan_dir_stats(d)
+ total_size += sz
+ 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"]
+ for name in other_dirs:
+ d = data_dir / name
+ if d.exists():
+ _, s = _scan_dir_stats(d)
+ total_size += s
+
+ # financials 单独统计
+ 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):
+ try:
+ total_size += entry.stat().st_size / 1048576
+ except OSError:
+ pass
+ stats["total_size_mb"] = round(total_size, 2)
+ return stats
+
+
+def _next_cron_run(scheduler, job_id: str) -> str | None:
+ """读 APScheduler 下次执行时间。"""
+ if not scheduler:
+ return None
+ try:
+ job = scheduler.get_job(job_id)
+ if job and job.next_run_time:
+ return job.next_run_time.isoformat(timespec="seconds")
+ except Exception: # noqa: BLE001
+ pass
+ return None
+
+
+def _get_storage(data_dir: Path) -> dict:
+ """返回缓存的 storage 统计;走独立 TTL,stage 写完不触发重算。"""
+ global _storage_cache, _storage_cache_ts
+ now = time.time()
+ with _storage_lock:
+ if _storage_cache is not None and (now - _storage_cache_ts) < _STORAGE_TTL:
+ return _storage_cache
+ fresh = _compute_storage(data_dir)
+ with _storage_lock:
+ _storage_cache = fresh
+ _storage_cache_ts = now
+ return fresh
+
+
+def _last_finished(job_label: str) -> str | None:
+ """从 JobStore 读最近一次该类型任务的完成时间(缓存到 pipeline 终态失效)。"""
+ global _last_finished_cache
+ with _last_finished_lock:
+ if _last_finished_cache is not None:
+ return _last_finished_cache.get(job_label)
+
+ from app.services.pipeline_jobs import job_store
+ jobs = job_store.list_recent(limit=50)
+ cache: dict[str, str | None] = {}
+ for j in jobs:
+ if j["status"] not in ("succeeded", "failed"):
+ continue
+ if "instruments_rows" in (j.get("result") or {}) and "instruments" not in cache:
+ cache["instruments"] = j["finished_at"]
+ if "daily_days" in (j.get("result") or {}) and "pipeline" not in cache:
+ cache["pipeline"] = j["finished_at"]
+ with _last_finished_lock:
+ _last_finished_cache = cache
+ return cache.get(job_label)
+
+
+@router.get("/status")
+def status(request: Request) -> dict:
+ repo = request.app.state.repo
+ scheduler = getattr(request.app.state, "scheduler", None)
+ data_dir = repo.store.data_dir
+
+ return {
+ "daily": _get_table_stats("daily", lambda: _safe_aggregate_daily(repo)),
+ "enriched": _get_table_stats("enriched", lambda: _safe_aggregate_enriched(repo)),
+ "index_daily": _get_table_stats("index_daily", lambda: _safe_aggregate_index_daily(repo)),
+ "index_enriched": _get_table_stats("index_enriched", lambda: _safe_aggregate_index_enriched(repo)),
+ "index_instruments": _get_table_stats("index_instruments", lambda: _safe_aggregate_index_instruments(repo)),
+ "minute": _get_table_stats("minute", lambda: _safe_aggregate_minute(repo)),
+ "adj_factor": _get_table_stats("adj_factor", lambda: _safe_aggregate_adj_factor(repo)),
+ "instruments": _get_table_stats("instruments", lambda: _safe_aggregate_instruments(repo)),
+ "financials": _get_table_stats("financials", lambda: _safe_aggregate_financials(repo)),
+
+ # 文件层面信息(缓存)
+ "storage": _get_storage(data_dir),
+
+ # 调度
+ "next_instruments_run": _next_cron_run(scheduler, "pre_market_instruments"),
+ "next_pipeline_run": _next_cron_run(scheduler, "daily_pipeline"),
+ "last_instruments_run": _last_finished("instruments"),
+ "last_pipeline_run": _last_finished("pipeline"),
+ "checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
+ }
+
+
+@router.post("/clear")
+def clear_data(request: Request):
+ """清除所有本地 Parquet 数据(保留 capabilities.json 和目录结构)。"""
+ import shutil
+
+ repo = request.app.state.repo
+ data_dir = repo.store.data_dir
+ deleted = 0
+
+ for sub in (
+ "kline_daily", "kline_daily_enriched", "kline_index_daily", "kline_index_enriched", "kline_minute",
+ "adj_factor", "instruments", "instruments_index", "pools", "financials",
+ "backtest_results", "screener_results", "ai_cache",
+ ):
+ d = data_dir / sub
+ if d.exists():
+ # 先删所有 parquet 文件
+ for f in d.rglob("*.parquet"):
+ f.unlink()
+ deleted += 1
+ # 再删除空的日期分区子目录(date=YYYY-MM-DD 等)
+ for child in list(d.iterdir()):
+ if child.is_dir():
+ shutil.rmtree(child, ignore_errors=True)
+
+ # 清除同步历史(内存 + 磁盘 job_store/ 文件夹)
+ from app.services.pipeline_jobs import job_store
+ job_store.clear()
+
+ # 清除财务数据
+ fin_dir = data_dir / "financials"
+ for sub in ("metrics", "income", "balance_sheet", "cash_flow"):
+ fp = fin_dir / sub / "part.parquet"
+ if fp.exists():
+ fp.unlink()
+ deleted += 1
+
+ # 清除 Polars 缓存
+ repo.refresh_cache()
+
+ # 刷新 DuckDB 视图(空 parquet 目录也需要重新挂载)
+ d = data_dir.as_posix()
+ for name, path in {
+ "kline_daily": f"{d}/kline_daily/**/*.parquet",
+ "kline_enriched": f"{d}/kline_daily_enriched/**/*.parquet",
+ "kline_index_daily": f"{d}/kline_index_daily/**/*.parquet",
+ "kline_index_enriched": f"{d}/kline_index_enriched/**/*.parquet",
+ "kline_minute": f"{d}/kline_minute/**/*.parquet",
+ "adj_factor": f"{d}/adj_factor/**/*.parquet",
+ "instruments": f"{d}/instruments/**/*.parquet",
+ "instruments_index": f"{d}/instruments_index/**/*.parquet",
+ }.items():
+ try:
+ repo.db.execute(
+ f"CREATE OR REPLACE VIEW {name} AS "
+ f"SELECT * FROM read_parquet('{path}', union_by_name=true)"
+ )
+ except Exception:
+ pass
+
+ logger.info("数据已清除: 删除 %d 个 parquet 文件", deleted)
+ invalidate_data_cache(None)
+ return {"deleted_files": deleted}
+
+
+# 各表字段说明
+_TABLE_FIELD_DESC: dict[str, dict[str, str]] = {
+ "kline_daily": {
+ "symbol": "股票代码",
+ "date": "交易日期",
+ "open": "开盘价",
+ "high": "最高价",
+ "low": "最低价",
+ "close": "收盘价",
+ "volume": "成交量",
+ "amount": "成交额",
+ },
+ "kline_enriched": ENRICHED_COLUMNS,
+ "kline_index_daily": {
+ "symbol": "指数代码",
+ "date": "交易日期",
+ "open": "开盘点位",
+ "high": "最高点位",
+ "low": "最低点位",
+ "close": "收盘点位",
+ "volume": "成交量",
+ "amount": "成交额",
+ },
+ "kline_index_enriched": ENRICHED_COLUMNS,
+ "kline_minute": {
+ "symbol": "股票代码",
+ "datetime": "分钟时间戳",
+ "open": "开盘价",
+ "high": "最高价",
+ "low": "最低价",
+ "close": "收盘价",
+ "volume": "成交量",
+ "amount": "成交额",
+ },
+ "adj_factor": {
+ "symbol": "股票代码",
+ "timestamp": "除权除息时间戳(ms)",
+ "trade_date": "除权除息日",
+ "ex_factor": "复权因子",
+ },
+ "instruments": {
+ "symbol": "股票代码",
+ "name": "股票名称",
+ "code": "股票编码(纯数字)",
+ "exchange": "交易所(SH/SZ/BJ)",
+ "region": "地区",
+ "type": "证券类型",
+ "listing_date": "上市日期",
+ "total_shares": "总股本",
+ "float_shares": "流通股本",
+ "tick_size": "最小价格变动单位",
+ "limit_up": "涨停限制(%)",
+ "limit_down": "跌停限制(%)",
+ "as_of": "快照日期",
+ },
+ "instruments_index": {
+ "symbol": "指数代码",
+ "name": "指数名称",
+ "code": "指数编码(纯数字)",
+ "asset_type": "资产类型(index)",
+ },
+}
+
+# view 名 → DuckDB 视图名
+_SCHEMA_VIEWS: dict[str, str] = {
+ "daily": "kline_daily",
+ "enriched": "kline_enriched",
+ "index_daily": "kline_index_daily",
+ "index_enriched": "kline_index_enriched",
+ "index_instruments": "instruments_index",
+ "minute": "kline_minute",
+ "adj_factor": "adj_factor",
+ "instruments": "instruments",
+}
+
+
+@router.get("/schema/{table}")
+def table_schema(request: Request, table: str) -> list[dict]:
+ """返回指定表的字段名、类型和中文说明。
+
+ 优先从 DuckDB DESCRIBE 读取(有数据时含精确类型);
+ 视图不存在(无数据)时回退到 _TABLE_FIELD_DESC 静态定义。
+ """
+ view = _SCHEMA_VIEWS.get(table)
+ if not view:
+ return []
+ desc_map = _TABLE_FIELD_DESC.get(view, {})
+ repo = request.app.state.repo
+ fields: list[dict] = []
+ try:
+ cols = repo.execute_all(f"DESCRIBE {view}")
+ for col in cols:
+ name = col[0]
+ dtype = col[1]
+ fields.append({
+ "name": name,
+ "type": dtype,
+ "desc": desc_map.get(name, ""),
+ })
+ except Exception: # noqa: BLE001
+ # 视图不存在(本地无数据),用静态字段定义兜底
+ if desc_map:
+ for name, desc in desc_map.items():
+ fields.append({"name": name, "type": "—", "desc": desc})
+ return fields
+
+
+@router.get("/version")
+def get_version(request: Request) -> dict:
+ """返回当前项目版本号(读取项目根目录 VERSION 文件)。"""
+ from app.config import settings
+ version_file = Path(settings.data_dir).parent / "VERSION"
+ version = "v0.0.0"
+ if version_file.exists():
+ version = version_file.read_text(encoding="utf-8").strip() or version
+ return {"version": version}
diff --git a/backend/app/api/ext_data.py b/backend/app/api/ext_data.py
new file mode 100644
index 0000000..1d4ec84
--- /dev/null
+++ b/backend/app/api/ext_data.py
@@ -0,0 +1,825 @@
+"""扩展数据 API — CRUD + 文件上传 + JSON 写入 + 定时拉取 + schema 发现。"""
+from __future__ import annotations
+
+import json
+import logging
+import math
+import shutil
+import tempfile
+from datetime import date, datetime
+from pathlib import Path
+from typing import Literal
+
+import polars as pl
+from fastapi import APIRouter, File, HTTPException, Query, Request, UploadFile
+from pydantic import BaseModel, Field
+
+from app.services.ext_data import (
+ ExtConfig,
+ ExtConfigStore,
+ ExtField,
+ PullConfig,
+ fix_symbol_format,
+ normalize_symbol,
+ parse_upload_file,
+ write_ext_parquet,
+ rows_to_parquet,
+)
+from app.services.ext_pull import fetch_and_ingest, pull_scheduler
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/ext-data", tags=["ext-data"])
+
+
+# ---------------------------------------------------------------------------
+# Pydantic 模型
+# ---------------------------------------------------------------------------
+
+class FieldDef(BaseModel):
+ name: str
+ dtype: str = "string" # string | int | float | bool
+ label: str = ""
+
+
+class CreateExtReq(BaseModel):
+ id: str = Field(..., min_length=1, max_length=64, pattern=r"^[a-zA-Z0-9_]+$")
+ label: str = Field(..., min_length=1, max_length=64)
+ mode: Literal["snapshot", "timeseries"]
+ fields: list[FieldDef] = Field(..., min_length=1)
+ description: str = ""
+ symbol_map: dict = {} # {"type": "mapped", "col": "..."} 或 {"type": "computed", "from": "code", "method": "append_exchange"}
+ code_map: dict = {} # {"type": "mapped", "col": "..."} 或 {"type": "computed", "from": "symbol", "method": "strip_exchange"}
+
+
+class UpdateExtReq(BaseModel):
+ label: str | None = None
+ fields: list[FieldDef] | None = None
+ description: str | None = None
+ symbol_map: dict | None = None
+ code_map: dict | None = None
+
+
+class IngestReq(BaseModel):
+ """JSON 批量写入请求。"""
+ date: str | None = None # YYYY-MM-DD,不传默认今天
+ rows: list[dict] = Field(..., min_length=1)
+
+
+class PullConfigReq(BaseModel):
+ """定时拉取配置请求。"""
+ url: str = Field(..., min_length=1)
+ method: str = "GET"
+ headers: dict[str, str] | None = None
+ body: str | None = None
+ response_path: str = "" # dot-path to rows array
+ field_map: dict[str, str] | None = None # external → internal field name
+ schedule_minutes: int = Field(1440, ge=1)
+ enabled: bool = False
+
+
+# ---------------------------------------------------------------------------
+# 辅助
+# ---------------------------------------------------------------------------
+
+def _store(request: Request) -> ExtConfigStore:
+ return ExtConfigStore(request.app.state.repo.store.data_dir)
+
+
+def _data_dir(request: Request) -> Path:
+ return request.app.state.repo.store.data_dir
+
+
+# ---------------------------------------------------------------------------
+# CRUD
+# ---------------------------------------------------------------------------
+
+def _apply_mapping(df: pl.DataFrame, config: ExtConfig, data_dir: Path) -> pl.DataFrame:
+ """根据 config 的 symbol_map / code_map 自动生成 symbol 和 code 列。
+
+ 执行顺序:先 mapped(从文件列复制),再 computed(从已生成的列计算)。
+ """
+ sm = config.symbol_map or {}
+ cm = config.code_map or {}
+
+ # --- 第一步:mapped 类型,直接从文件列映射 ---
+ if sm.get("type") == "mapped" and sm["col"] in df.columns:
+ df = df.with_columns(df[sm["col"]].cast(pl.Utf8).alias("symbol"))
+
+ if cm.get("type") == "mapped" and cm["col"] in df.columns:
+ df = df.with_columns(df[cm["col"]].cast(pl.Utf8).alias("code"))
+
+ # --- 第二步:computed 类型,从已生成的列计算 ---
+ if "symbol" not in df.columns and sm.get("type") == "computed":
+ if sm.get("from") == "code" and "code" in df.columns:
+ # code → symbol: 000001 → 000001.SZ
+ from app.services.ext_data import build_code_lookup
+ lookup = build_code_lookup(data_dir)
+ df = df.with_columns(normalize_symbol(df["code"].cast(pl.Utf8), lookup).alias("symbol"))
+
+ if "code" not in df.columns and cm.get("type") == "computed":
+ if cm.get("from") == "symbol" and "symbol" in df.columns:
+ # symbol → code: 000001.SZ → 000001
+ df = df.with_columns(
+ df["symbol"].cast(pl.Utf8).str.split(".").list.first().alias("code")
+ )
+
+ # --- 兜底:如果只有一个,自动生成另一个 ---
+ if "symbol" in df.columns and "code" not in df.columns:
+ df = df.with_columns(
+ df["symbol"].cast(pl.Utf8).str.split(".").list.first().alias("code")
+ )
+ elif "code" in df.columns and "symbol" not in df.columns:
+ from app.services.ext_data import build_code_lookup
+ lookup = build_code_lookup(data_dir)
+ df = df.with_columns(normalize_symbol(df["code"].cast(pl.Utf8), lookup).alias("symbol"))
+
+ # 标准化 symbol 列
+ if "symbol" in df.columns:
+ from app.services.ext_data import build_code_lookup
+ lookup = build_code_lookup(data_dir)
+ df = df.with_columns(normalize_symbol(df["symbol"].cast(pl.Utf8), lookup))
+
+ return df
+
+
+def _clean_col_names(df: pl.DataFrame) -> pl.DataFrame:
+ """清洗列名:去掉所有 (...) 及其内容,避免时间戳导致列名不稳定。"""
+ import re
+ renames = {col: re.sub(r"\([^)]*\)", "", col).strip() for col in df.columns}
+ # 去重:如果清洗后重名,加序号后缀
+ seen: dict[str, int] = {}
+ final = {}
+ for old, new in renames.items():
+ if new in seen:
+ seen[new] += 1
+ final[old] = f"{new}_{seen[new]}"
+ else:
+ seen[new] = 0
+ final[old] = new
+ return df.rename(final)
+
+
+def _ext_data_dir(config: ExtConfig, data_dir: Path) -> Path:
+ """返回扩展数据的数据目录。
+
+ - snapshot: data/ext_data/{id}/(part.parquet 与 config.json 同级)
+ - timeseries: data/ext_data/{id}/timeseries/
+ """
+ cfg_dir = data_dir / "ext_data" / config.id
+ if config.mode == "timeseries":
+ return cfg_dir / "timeseries"
+ return cfg_dir
+
+
+def _parquet_glob(config: ExtConfig, data_dir: Path) -> str:
+ """返回该扩展配置下所有 parquet 文件的 glob 模式。
+
+ snapshot: 'data/ext_data/{id}/*.parquet'(只有 part.parquet)
+ timeseries: 'data/ext_data/{id}/timeseries/**/*.parquet'
+ """
+ cfg_dir = data_dir / "ext_data" / config.id
+ if config.mode == "snapshot":
+ return str(cfg_dir / "*.parquet")
+ return str(cfg_dir / "timeseries" / "**" / "*.parquet")
+
+
+def _safe_json_value(value):
+ if isinstance(value, float) and not math.isfinite(value):
+ return None
+ if isinstance(value, (date, datetime)):
+ return value.isoformat()
+ return value
+
+
+def _read_ext_dataframe(
+ config: ExtConfig,
+ data_dir: Path,
+ snapshot_date: str | None = None,
+) -> tuple[pl.DataFrame, str | None]:
+ cfg_dir = data_dir / "ext_data" / config.id
+
+ if config.mode == "snapshot":
+ path = cfg_dir / "part.parquet"
+ if not path.exists():
+ return pl.DataFrame(), None
+ return pl.read_parquet(path), _latest_sync_date(config, data_dir)
+
+ base = cfg_dir / "timeseries"
+ if not base.exists():
+ return pl.DataFrame(), None
+
+ if snapshot_date:
+ path = base / f"date={snapshot_date}" / "part.parquet"
+ if not path.exists():
+ return pl.DataFrame(), snapshot_date
+ return pl.read_parquet(path), snapshot_date
+
+ partitions = sorted(
+ d for d in base.iterdir()
+ if d.is_dir() and d.name.startswith("date=") and (d / "part.parquet").exists()
+ )
+ if not partitions:
+ return pl.DataFrame(), None
+
+ latest = partitions[-1]
+ latest_date = latest.name[5:]
+ return pl.read_parquet(latest / "part.parquet"), latest_date
+
+
+def _with_instrument_name(df: pl.DataFrame, data_dir: Path) -> pl.DataFrame:
+ if df.is_empty() or "symbol" not in df.columns or "name" in df.columns:
+ return df
+ path = data_dir / "instruments" / "instruments.parquet"
+ if not path.exists():
+ return df
+ try:
+ inst = pl.read_parquet(path)
+ if "symbol" in inst.columns and "name" in inst.columns:
+ inst = inst.select(["symbol", "name"]).unique(subset=["symbol"], keep="last")
+ return df.join(inst, on="symbol", how="left")
+ except Exception:
+ return df
+ return df
+
+
+def _latest_sync_date(config: ExtConfig, data_dir: Path) -> str | None:
+ """扫描数据文件,返回该扩展配置的最新同步时间(含时分秒)。
+
+ - snapshot: 直接取 ext_data/{id}/part.parquet 的 mtime
+ - timeseries: 扫描 ext_data/{id}/timeseries/date=xxx 分区目录
+ """
+ from datetime import datetime
+
+ if config.mode == "snapshot":
+ # 快照: part.parquet 与 config.json 同级
+ p = data_dir / "ext_data" / config.id / "part.parquet"
+ if p.exists():
+ ts = datetime.fromtimestamp(p.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
+ return ts
+ # 兼容旧路径
+ old = data_dir / "instruments_ext"
+ if old.exists():
+ return _latest_sync_from_partitions(old)
+ return None
+
+ # 时序: 扫描 timeseries/date=xxx
+ base = _ext_data_dir(config, data_dir)
+ if not base.exists():
+ # 兼容旧路径
+ base = data_dir / "kline_ext"
+ if not base.exists():
+ return None
+ return _latest_sync_from_partitions(base)
+
+
+def _latest_sync_from_partitions(base: Path) -> str | None:
+ """从 date=xxx 分区目录中找到最新分区的修改时间。"""
+ from datetime import datetime
+ latest_ts: float = 0
+ latest_date: str | None = None
+ for d in base.iterdir():
+ if d.is_dir() and d.name.startswith("date="):
+ for f in d.glob("*.parquet"):
+ mtime = f.stat().st_mtime
+ if mtime > latest_ts:
+ latest_ts = mtime
+ latest_date = d.name[5:]
+ if latest_date and latest_ts > 0:
+ ts = datetime.fromtimestamp(latest_ts).strftime("%H:%M:%S")
+ return f"{latest_date} {ts}"
+ return latest_date
+
+
+def _date_range(config: ExtConfig, data_dir: Path) -> list[str] | None:
+ """返回时序型扩展数据的日期范围 [最早, 最新]。"""
+ if config.mode != "timeseries":
+ return None
+ base = _ext_data_dir(config, data_dir)
+ if not base.exists():
+ # 兼容旧路径
+ base = data_dir / "kline_ext"
+ if not base.exists():
+ return None
+ dates: list[str] = []
+ for d in base.iterdir():
+ if d.is_dir() and d.name.startswith("date="):
+ dates.append(d.name[5:])
+ if len(dates) < 1:
+ return None
+ dates.sort()
+ return [dates[0], dates[-1]]
+
+
+@router.get("")
+def list_configs(request: Request):
+ """列出所有扩展数据配置。"""
+ configs = _store(request).load_all()
+ data_dir = _data_dir(request)
+ items = []
+ for c in configs:
+ d = c.to_dict()
+ d["latest_sync_date"] = _latest_sync_date(c, data_dir)
+ d["date_range"] = _date_range(c, data_dir)
+ items.append(d)
+ return {"items": items}
+
+
+@router.post("")
+def create_config(request: Request, body: CreateExtReq):
+ """创建扩展数据配置。"""
+ store = _store(request)
+ if store.get(body.id):
+ raise HTTPException(400, f"配置 '{body.id}' 已存在")
+ config = ExtConfig(
+ id=body.id,
+ label=body.label,
+ mode=body.mode,
+ fields=[ExtField(f.name, f.dtype, f.label) for f in body.fields],
+ description=body.description,
+ symbol_map=body.symbol_map,
+ code_map=body.code_map,
+ )
+ store.upsert(config)
+ return config.to_dict()
+
+
+@router.put("/{config_id}")
+def update_config(request: Request, config_id: str, body: UpdateExtReq):
+ """更新扩展数据配置。"""
+ store = _store(request)
+ config = store.get(config_id)
+ if not config:
+ raise HTTPException(404, f"配置 '{config_id}' 不存在")
+ if body.label is not None:
+ config.label = body.label
+ if body.fields is not None:
+ config.fields = [ExtField(f.name, f.dtype, f.label) for f in body.fields]
+ if body.description is not None:
+ config.description = body.description
+ if body.symbol_map is not None:
+ config.symbol_map = body.symbol_map
+ if body.code_map is not None:
+ config.code_map = body.code_map
+ store.upsert(config)
+ return config.to_dict()
+
+
+@router.delete("/{config_id}")
+def delete_config(request: Request, config_id: str):
+ """删除扩展数据配置。"""
+ store = _store(request)
+ if not store.delete(config_id):
+ raise HTTPException(404, f"配置 '{config_id}' 不存在")
+ return {"status": "deleted"}
+
+
+@router.get("/{config_id}/rows")
+def list_rows(
+ request: Request,
+ config_id: str,
+ snapshot_date: str | None = Query(None, alias="date"),
+ columns: str | None = Query(None, description="逗号分隔的字段列表"),
+ limit: int = Query(1000, ge=1, le=20000),
+):
+ """读取扩展数据明细。
+
+ - snapshot: 返回当前快照。
+ - timeseries: 默认返回最新日期分区,也可通过 date=YYYY-MM-DD 指定。
+ """
+ config = _store(request).get(config_id)
+ if not config:
+ raise HTTPException(404, f"配置 '{config_id}' 不存在")
+
+ data_dir = _data_dir(request)
+ df, active_date = _read_ext_dataframe(config, data_dir, snapshot_date)
+ df = _with_instrument_name(df, data_dir)
+ requested = [c.strip() for c in (columns or "").split(",") if c.strip()]
+ if requested:
+ keep = [c for c in ["symbol", "code", "name", *requested] if c in df.columns]
+ if keep:
+ df = df.select(list(dict.fromkeys(keep)))
+ total = len(df)
+ if total > limit:
+ df = df.head(limit)
+
+ rows = []
+ for row in df.to_dicts():
+ rows.append({k: _safe_json_value(v) for k, v in row.items()})
+
+ return {
+ "id": config.id,
+ "label": config.label,
+ "mode": config.mode,
+ "date": active_date,
+ "total": total,
+ "limit": limit,
+ "fields": [f.to_dict() for f in config.fields],
+ "rows": rows,
+ }
+
+
+# ---------------------------------------------------------------------------
+# 文件上传
+# ---------------------------------------------------------------------------
+
+@router.post("/{config_id}/upload")
+async def upload_data(
+ request: Request,
+ config_id: str,
+ file: UploadFile = File(...),
+ snapshot_date: str | None = None,
+):
+ """上传 CSV/Excel 文件写入扩展数据。"""
+ store = _store(request)
+ config = store.get(config_id)
+ if not config:
+ raise HTTPException(404, f"配置 '{config_id}' 不存在")
+
+ # 校验文件后缀
+ suffix = Path(file.filename or "").suffix.lower()
+ if suffix not in (".csv", ".xlsx", ".xls"):
+ raise HTTPException(400, "仅支持 CSV / Excel 文件")
+
+ # 写到临时文件再解析
+ tmp_dir = Path(tempfile.mkdtemp())
+ tmp_path = tmp_dir / f"upload{suffix}"
+ try:
+ with tmp_path.open("wb") as f:
+ content = await file.read()
+ f.write(content)
+
+ # 直接读取文件,不做列重命名
+ if suffix == ".csv":
+ df = pl.read_csv(tmp_path, infer_schema_length=10000)
+ elif suffix in (".xlsx", ".xls"):
+ df = pl.read_excel(tmp_path)
+ else:
+ raise HTTPException(400, f"不支持的文件格式: {suffix}")
+
+ # 清洗列名:去掉括号内的时间戳等信息
+ df = _clean_col_names(df)
+
+ # 按映射关系自动生成 symbol 和 code 列
+ df = _apply_mapping(df, config, _data_dir(request))
+ except ValueError as e:
+ raise HTTPException(400, str(e)) from e
+ finally:
+ shutil.rmtree(tmp_dir, ignore_errors=True)
+
+ # 确保配置的字段列存在于上传数据中(symbol/code 由映射自动生成,不校验)
+ auto_fields = {"symbol", "code"}
+ config_cols = {f.name for f in config.fields} - auto_fields
+ missing = config_cols - set(df.columns)
+ if missing:
+ raise HTTPException(400, f"上传数据缺少字段: {', '.join(sorted(missing))}")
+
+ # 只保留配置中定义的列(包括自动生成的 symbol/code),忽略文件中多余的字段
+ all_config_cols = {f.name for f in config.fields}
+ keep = [c for c in df.columns if c in all_config_cols]
+ df = df.select(keep)
+
+ # 解析快照日期
+ snap = date.fromisoformat(snapshot_date) if snapshot_date else date.today()
+
+ rows = write_ext_parquet(df, config, _data_dir(request), snapshot_date=snap)
+
+ # 刷新 DuckDB 视图
+ _refresh_views(request)
+
+ return {"status": "ok", "rows": rows, "date": snap.isoformat()}
+
+
+# ---------------------------------------------------------------------------
+# JSON 接口写入
+# ---------------------------------------------------------------------------
+
+@router.post("/{config_id}/ingest")
+def ingest_data(request: Request, config_id: str, body: IngestReq):
+ """通过 JSON 接口批量写入扩展数据。"""
+ store = _store(request)
+ config = store.get(config_id)
+ if not config:
+ raise HTTPException(404, f"配置 '{config_id}' 不存在")
+
+ # 校验必填字段
+ configured = {f.name for f in config.fields}
+ required = configured - {"symbol"}
+ for i, row in enumerate(body.rows):
+ if "symbol" not in row:
+ raise HTTPException(400, f"第 {i + 1} 行缺少 symbol 字段")
+ missing = required - set(row.keys())
+ if missing:
+ raise HTTPException(400, f"第 {i + 1} 行缺少字段: {', '.join(sorted(missing))}")
+
+ snap = date.fromisoformat(body.date) if body.date else date.today()
+
+ rows_written = rows_to_parquet(body.rows, config, _data_dir(request), snapshot_date=snap)
+
+ _refresh_views(request)
+
+ return {"status": "ok", "rows": rows_written, "date": snap.isoformat()}
+
+
+# ---------------------------------------------------------------------------
+# 定时拉取
+# ---------------------------------------------------------------------------
+
+@router.put("/{config_id}/pull")
+def configure_pull(request: Request, config_id: str, body: PullConfigReq):
+ """配置(或更新)定时拉取。"""
+ store = _store(request)
+ config = store.get(config_id)
+ if not config:
+ raise HTTPException(404, f"配置 '{config_id}' 不存在")
+
+ # 保留历史状态字段
+ old_pull = config.pull
+ config.pull = PullConfig(
+ url=body.url,
+ method=body.method,
+ headers=body.headers,
+ body=body.body,
+ response_path=body.response_path,
+ field_map=body.field_map,
+ schedule_minutes=body.schedule_minutes,
+ enabled=body.enabled,
+ 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,
+ last_rows=old_pull.last_rows if old_pull else None,
+ )
+ store.upsert(config)
+
+ # 刷新调度器
+ pull_scheduler.refresh(_data_dir(request))
+
+ return {"status": "ok", "pull": config.pull.to_dict()}
+
+
+@router.post("/{config_id}/pull/test")
+async def test_pull(request: Request, config_id: str):
+ """测试拉取:请求外部 API 并返回预览数据,不写入。"""
+ store = _store(request)
+ config = store.get(config_id)
+ if not config:
+ raise HTTPException(404, f"配置 '{config_id}' 不存在")
+ if not config.pull or not config.pull.url:
+ raise HTTPException(400, "拉取未配置或 URL 为空")
+
+ # 临时构建一个带新配置的 config 用于测试
+ from app.services.ext_pull import _extract_rows, _apply_field_map
+ import httpx
+
+ pull = config.pull
+ try:
+ async with httpx.AsyncClient(timeout=30) as client:
+ headers = pull.headers or {}
+ kwargs: dict = {"headers": headers}
+ if pull.method.upper() == "POST" and pull.body:
+ kwargs["content"] = pull.body
+ if "content-type" not in {k.lower() for k in headers}:
+ kwargs["headers"]["Content-Type"] = "application/json"
+ resp = await client.request(pull.method.upper(), pull.url, **kwargs)
+ resp.raise_for_status()
+ data = resp.json()
+
+ rows = _extract_rows(data, pull.response_path)
+ preview = _apply_field_map(rows[:5], pull.field_map)
+ return {
+ "status": "ok",
+ "total_rows": len(rows),
+ "preview": preview,
+ "has_symbol": bool(rows and "symbol" in rows[0]),
+ }
+ except Exception as e:
+ raise HTTPException(400, f"测试失败: {e}") from e
+
+
+@router.post("/{config_id}/pull/run")
+async def run_pull(request: Request, config_id: str):
+ """手动触发一次拉取并写入。"""
+ store = _store(request)
+ config = store.get(config_id)
+ if not config:
+ raise HTTPException(404, f"配置 '{config_id}' 不存在")
+ if not config.pull or not config.pull.url:
+ raise HTTPException(400, "拉取未配置或 URL 为空")
+
+ try:
+ n, d = await fetch_and_ingest(config, _data_dir(request))
+ _refresh_views(request)
+ return {"status": "ok", "rows": n, "date": d}
+ except Exception as e:
+ raise HTTPException(400, f"拉取失败: {e}") from e
+
+
+# ---------------------------------------------------------------------------
+# ---------------------------------------------------------------------------
+# Symbol 格式修复
+# ---------------------------------------------------------------------------
+
+@router.post("/{config_id}/fix-symbol")
+def fix_symbol(request: Request, config_id: str):
+ """扫描已有 Parquet 数据,将 symbol 列标准化为 代码.交易所 格式。"""
+ store = _store(request)
+ config = store.get(config_id)
+ if not config:
+ raise HTTPException(404, f"配置 '{config_id}' 不存在")
+
+ fixed = fix_symbol_format(config, _data_dir(request))
+ _refresh_views(request)
+ return {"status": "ok", "fixed_files": fixed}
+
+
+# ---------------------------------------------------------------------------
+# Schema 发现
+# ---------------------------------------------------------------------------
+
+_POLARS_TYPE_MAP = {
+ "Int64": "int", "Int32": "int", "Int16": "int", "Int8": "int",
+ "UInt64": "int", "UInt32": "int", "UInt16": "int", "UInt8": "int",
+ "Float64": "float", "Float32": "float",
+ "Boolean": "bool",
+ "Utf8": "string", "String": "string",
+ "Date": "string", "Datetime": "string", "Duration": "string",
+ "Categorical": "string",
+}
+
+
+@router.post("/detect-fields")
+async def detect_fields(
+ request: Request,
+ file: UploadFile = File(...),
+):
+ """上传 CSV/Excel 文件,自动检测列名和类型。
+
+ 返回 symbol_candidates(数据匹配 000001.SZ 格式的列)和
+ code_candidates(数据匹配 6位纯数字的列)。
+ """
+ suffix = Path(file.filename or "").suffix.lower()
+ if suffix not in (".csv", ".xlsx", ".xls"):
+ raise HTTPException(400, "仅支持 CSV / Excel 文件")
+
+ tmp_dir = Path(tempfile.mkdtemp())
+ tmp_path = tmp_dir / f"upload{suffix}"
+ try:
+ with tmp_path.open("wb") as f:
+ content = await file.read()
+ f.write(content)
+
+ # 直接读取,不要求 symbol 列
+ if suffix == ".csv":
+ df = pl.read_csv(tmp_path, infer_schema_length=10000)
+ elif suffix in (".xlsx", ".xls"):
+ df = pl.read_excel(tmp_path)
+ else:
+ raise HTTPException(400, f"不支持的文件格式: {suffix}")
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(400, str(e)) from e
+ finally:
+ shutil.rmtree(tmp_dir, ignore_errors=True)
+
+ # 清洗列名:去掉括号内的时间戳等信息
+ df = _clean_col_names(df)
+
+ # 构建字段列表
+ fields = []
+ for col_name in df.columns:
+ pl_type = df[col_name].dtype
+ dtype = _POLARS_TYPE_MAP.get(str(pl_type.base_type()), "string")
+ fields.append({"name": col_name, "dtype": dtype, "label": col_name})
+
+ # --- 分别检测 symbol 候选 (000001.SZ) 和 code 候选 (6位纯数字) ---
+ import re
+ _CODE_PAT = re.compile(r"^\d{6}$")
+ _SYMBOL_PAT = re.compile(r"^\d{6}\.[A-Z]{2}$")
+
+ symbol_candidates: list[str] = [] # 数据匹配 000001.SZ 格式
+ code_candidates: list[str] = [] # 数据匹配 000001 格式
+
+ for col in df.columns:
+ col_data = df[col].cast(pl.Utf8).drop_nulls()
+ if len(col_data) == 0:
+ continue
+ sample = col_data.head(200).to_list()
+ sym_hits = sum(1 for v in sample if _SYMBOL_PAT.match(str(v).strip()))
+ code_hits = sum(1 for v in sample if _CODE_PAT.match(str(v).strip()))
+ total = len(sample)
+ if total > 0:
+ if sym_hits / total > 0.5:
+ symbol_candidates.append(col)
+ elif code_hits / total > 0.5:
+ code_candidates.append(col)
+
+ return {
+ "fields": fields,
+ "rows": len(df),
+ "symbol_candidates": symbol_candidates,
+ "code_candidates": code_candidates,
+ }
+
+
+@router.get("/schema/{config_id}")
+def discover_schema(request: Request, config_id: str):
+ """发现扩展数据的实际 Parquet schema(基于已有数据)。"""
+ config = _store(request).get(config_id)
+ if not config:
+ raise HTTPException(404, f"配置 '{config_id}' 不存在")
+
+ data_dir = _data_dir(request)
+ glob = _parquet_glob(config, data_dir)
+
+ try:
+ import duckdb
+ rows = duckdb.query(
+ f"SELECT column_name, data_type FROM (DESCRIBE SELECT * FROM read_parquet('{glob}', union_by_name=true))"
+ ).fetchall()
+ return {"columns": [{"name": r[0], "type": r[1]} for r in rows]}
+ except Exception:
+ # 无数据时返回配置中定义的字段
+ return {"columns": [f.to_dict() for f in config.fields]}
+
+
+@router.get("/schema-all")
+def discover_all_schemas(request: Request):
+ """发现所有扩展表的 schema(用于前端动态列选择)。"""
+ configs = _store(request).load_all()
+ result = []
+ for config in configs:
+ data_dir = _data_dir(request)
+ glob = _parquet_glob(config, data_dir)
+
+ try:
+ import duckdb
+ cols = duckdb.query(
+ f"SELECT column_name, data_type FROM (DESCRIBE SELECT * FROM read_parquet('{glob}', union_by_name=true))"
+ ).fetchall()
+ field_labels = {f.name: f.label for f in config.fields}
+ columns = [{"name": r[0], "type": r[1], "label": field_labels.get(r[0], r[0])} for r in cols]
+ except Exception:
+ columns = [f.to_dict() for f in config.fields]
+
+ result.append({
+ "id": config.id,
+ "label": config.label,
+ "mode": config.mode,
+ "columns": columns,
+ })
+ return {"items": result}
+
+
+# ---------------------------------------------------------------------------
+# 视图刷新
+# ---------------------------------------------------------------------------
+
+def _refresh_views(request: Request) -> None:
+ """重新注册 DuckDB 视图以包含新的扩展数据。"""
+ repo = request.app.state.repo
+ db = repo.store.db
+ d = repo.store.data_dir.as_posix()
+
+ # 注册旧路径视图(兼容)
+ for name, subdir in [("instruments_ext", "instruments_ext"), ("kline_ext", "kline_ext")]:
+ old_glob = f"{d}/{subdir}/**/*.parquet"
+ old_dir = Path(d) / subdir
+ if old_dir.exists():
+ sql = (
+ f"CREATE OR REPLACE VIEW {name} AS "
+ f"SELECT * FROM read_parquet('{old_glob}', union_by_name=true)"
+ )
+ try:
+ db.execute(sql)
+ except Exception:
+ pass
+
+ # 注册新路径视图:每个扩展表一个视图 ext_{config_id}
+ ext_base = Path(d) / "ext_data"
+ if ext_base.exists():
+ for cfg_dir in ext_base.iterdir():
+ if not cfg_dir.is_dir():
+ continue
+ cp = cfg_dir / "config.json"
+ if not cp.exists():
+ continue
+ try:
+ raw = json.loads(cp.read_text(encoding="utf-8"))
+ cfg_id = raw["id"]
+ # 检查是否有数据文件(snapshot: part.parquet, timeseries: timeseries/ 目录)
+ has_data = (cfg_dir / "part.parquet").exists() or (cfg_dir / "timeseries").exists()
+ if has_data:
+ view_name = f"ext_{cfg_id}"
+ # snapshot: part.parquet 在 cfg_dir/ 根下; timeseries: 在 timeseries/ 子目录
+ mode = raw.get("mode", "snapshot")
+ if mode == "snapshot":
+ glob_pattern = f"{cfg_dir.as_posix()}/*.parquet"
+ else:
+ glob_pattern = f"{cfg_dir.as_posix()}/timeseries/**/*.parquet"
+ sql = (
+ f"CREATE OR REPLACE VIEW {view_name} AS "
+ f"SELECT * FROM read_parquet('{glob_pattern}', union_by_name=true)"
+ )
+ db.execute(sql)
+ except Exception:
+ pass
diff --git a/backend/app/api/financials.py b/backend/app/api/financials.py
new file mode 100644
index 0000000..f642b6c
--- /dev/null
+++ b/backend/app/api/financials.py
@@ -0,0 +1,120 @@
+"""财务数据 API — 独立路由, Cap.FINANCIAL 门控。"""
+from __future__ import annotations
+
+import logging
+
+import polars as pl
+from fastapi import APIRouter, HTTPException, Request
+
+from app.services.financial_sync import get_financial_df
+from app.tickflow.capabilities import Cap
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/financials", tags=["financials"])
+
+
+@router.get("/status")
+def financial_status(request: Request):
+ """返回各财务表的同步状态。无需 FINANCIAL 权限(前端根据 available 决定是否展示)。"""
+ capset = request.app.state.capabilities
+ if not capset.has(Cap.FINANCIAL):
+ return {"available": False, "tables": {}}
+
+ data_dir = request.app.state.repo.store.data_dir
+ tables = {}
+
+ for table in ("metrics", "income", "balance_sheet", "cash_flow"):
+ path = data_dir / "financials" / table / "part.parquet"
+ if path.exists():
+ try:
+ df = pl.read_parquet(path, columns=["symbol"])
+ tables[table] = {
+ "rows": len(df),
+ "symbols": df["symbol"].n_unique() if not df.is_empty() else 0,
+ }
+ except Exception:
+ tables[table] = {"rows": 0, "symbols": 0}
+ else:
+ tables[table] = {"rows": 0, "symbols": 0}
+
+ fs = getattr(request.app.state, "financial_scheduler", None)
+ last_sync = fs.last_sync if fs else {}
+
+ return {"available": True, "tables": tables, "last_sync": last_sync}
+
+
+@router.get("/metrics")
+def get_metrics(request: Request, symbol: str | None = None):
+ """查询核心财务指标。"""
+ capset = request.app.state.capabilities
+ capset.require(Cap.FINANCIAL)
+
+ df = get_financial_df(request.app.state.repo.store.data_dir, "metrics")
+ if df.is_empty():
+ return {"data": []}
+ if symbol:
+ df = df.filter(pl.col("symbol") == symbol)
+ return {"data": df.to_dicts()}
+
+
+@router.get("/income")
+def get_income(request: Request, symbol: str | None = None):
+ """查询利润表。"""
+ capset = request.app.state.capabilities
+ capset.require(Cap.FINANCIAL)
+
+ df = get_financial_df(request.app.state.repo.store.data_dir, "income")
+ if df.is_empty():
+ return {"data": []}
+ if symbol:
+ df = df.filter(pl.col("symbol") == symbol)
+ return {"data": df.to_dicts()}
+
+
+@router.get("/balance-sheet")
+def get_balance_sheet(request: Request, symbol: str | None = None):
+ """查询资产负债表。"""
+ capset = request.app.state.capabilities
+ capset.require(Cap.FINANCIAL)
+
+ df = get_financial_df(request.app.state.repo.store.data_dir, "balance_sheet")
+ if df.is_empty():
+ return {"data": []}
+ if symbol:
+ df = df.filter(pl.col("symbol") == symbol)
+ return {"data": df.to_dicts()}
+
+
+@router.get("/cash-flow")
+def get_cash_flow(request: Request, symbol: str | None = None):
+ """查询现金流量表。"""
+ capset = request.app.state.capabilities
+ capset.require(Cap.FINANCIAL)
+
+ df = get_financial_df(request.app.state.repo.store.data_dir, "cash_flow")
+ if df.is_empty():
+ return {"data": []}
+ if symbol:
+ df = df.filter(pl.col("symbol") == symbol)
+ return {"data": df.to_dicts()}
+
+
+@router.post("/sync/{table}")
+def sync_table(request: Request, table: str):
+ """手动触发同步。table: metrics / income / balance_sheet / cash_flow / all"""
+ capset = request.app.state.capabilities
+ capset.require(Cap.FINANCIAL)
+
+ valid_tables = {"metrics", "income", "balance_sheet", "cash_flow", "all"}
+ if table not in valid_tables:
+ raise HTTPException(400, f"invalid table: {table}, expected one of {valid_tables}")
+
+ fs = getattr(request.app.state, "financial_scheduler", None)
+ if not fs:
+ return {"status": "error", "message": "FinancialScheduler not available"}
+
+ target = None if table == "all" else table
+ result = fs.run_now(target)
+
+ return {"status": "ok", "synced": result}
diff --git a/backend/app/api/indices.py b/backend/app/api/indices.py
new file mode 100644
index 0000000..e78c2a2
--- /dev/null
+++ b/backend/app/api/indices.py
@@ -0,0 +1,149 @@
+"""指数 API。"""
+from __future__ import annotations
+
+import logging
+from datetime import date, datetime, timedelta
+from typing import Optional
+
+import polars as pl
+from fastapi import APIRouter, HTTPException, Query, Request
+
+from app.indicators.pipeline import compute_enriched
+from app.services import index_sync, kline_sync
+from app.tickflow.capabilities import Cap
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/index", tags=["index"])
+
+
+def _index_info(repo, symbol: str) -> dict:
+ df = repo.get_index_instruments()
+ if df.is_empty() or "symbol" not in df.columns:
+ return {}
+ hit = df.filter(pl.col("symbol") == symbol).head(1)
+ if hit.is_empty():
+ return {}
+ return hit.to_dicts()[0]
+
+
+@router.get("/list")
+def list_indices(request: Request):
+ """返回已缓存的 CN_Index 指数列表。"""
+ repo = request.app.state.repo
+ df = repo.get_index_instruments()
+ if df.is_empty():
+ return {"results": [], "count": 0}
+ cols = [c for c in ["symbol", "name", "code", "asset_type"] if c in df.columns]
+ rows = df.select(cols).sort("symbol").to_dicts()
+ return {"results": rows, "count": len(rows)}
+
+
+@router.get("/search")
+def search_indices(
+ request: Request,
+ q: str = Query("", min_length=0, max_length=50, description="搜索关键词"),
+ limit: int = Query(20, ge=1, le=100),
+):
+ """模糊搜索指数。"""
+ repo = request.app.state.repo
+ df = repo.get_index_instruments()
+ if df.is_empty():
+ return {"results": []}
+ if not q.strip():
+ rows = df.head(limit).to_dicts()
+ return {"results": rows}
+
+ keyword = q.strip().upper()
+ masks = []
+ if "code" in df.columns:
+ masks.append(pl.col("code").cast(pl.Utf8).str.contains(keyword, literal=True))
+ masks.append(pl.col("symbol").cast(pl.Utf8).str.to_uppercase().str.contains(keyword, literal=True))
+ if "name" in df.columns:
+ masks.append(pl.col("name").cast(pl.Utf8).str.contains(q.strip(), literal=True))
+
+ mask = masks[0]
+ for m in masks[1:]:
+ mask = mask | m
+ rows = df.filter(mask).head(limit).to_dicts()
+ return {"results": rows}
+
+
+@router.get("/daily")
+def get_index_daily(
+ request: Request,
+ symbol: str = Query(..., description="指数代码, 如 000001.SH"),
+ days: int = Query(120, ge=10, le=2000),
+ start_date: Optional[str] = Query(None, description="起始日期 YYYY-MM-DD, 优先于 days"),
+ end_date: Optional[str] = Query(None, description="截止日期 YYYY-MM-DD, 默认今天"),
+):
+ """读取指数日 K。指数数据使用独立 kline_index_* parquet。"""
+ repo = request.app.state.repo
+ end = date.fromisoformat(end_date) if end_date else date.today()
+ start = date.fromisoformat(start_date) if start_date else end - timedelta(days=days)
+ info = _index_info(repo, symbol)
+
+ df = repo.get_index_daily(symbol, start, end)
+ if not df.is_empty():
+ return {"symbol": symbol, "name": info.get("name"), "index_info": info, "rows": df.to_dicts(), "source": "index_enriched"}
+
+ capset = request.app.state.capabilities
+ if not capset.has(Cap.KLINE_DAILY_BATCH):
+ return {"symbol": symbol, "name": info.get("name"), "index_info": info, "rows": [], "source": "none"}
+
+ try:
+ raw = kline_sync.sync_daily_batch([symbol], count=days + 150)
+ except Exception as e: # noqa: BLE001
+ raise HTTPException(status_code=502, detail=f"TickFlow fetch failed: {e}") from e
+ if raw.is_empty():
+ return {"symbol": symbol, "name": info.get("name"), "index_info": info, "rows": [], "source": "none"}
+
+ enriched = compute_enriched(raw, factors=None, instruments=None)
+ rows = enriched.filter((pl.col("date") >= start) & (pl.col("date") <= end)).to_dicts()
+ return {"symbol": symbol, "name": info.get("name"), "index_info": info, "rows": rows, "source": "live"}
+
+
+@router.get("/minute")
+def get_index_minute(
+ request: Request,
+ symbol: str = Query(..., description="指数代码, 如 000001.SH"),
+ trade_date: date | None = Query(None, alias="date", description="交易日期, 默认今天"),
+):
+ """实时读取指数分钟 K。不写入股票分钟 parquet。"""
+ repo = request.app.state.repo
+ info = _index_info(repo, symbol)
+ day = trade_date or date.today()
+ df = kline_sync.fetch_minute_single(symbol, day)
+ return {
+ "symbol": symbol,
+ "name": info.get("name"),
+ "index_info": info,
+ "date": str(day),
+ "rows": df.to_dicts(),
+ "source": "live" if not df.is_empty() else "none",
+ }
+
+
+@router.post("/sync_instruments")
+def sync_index_instruments(request: Request):
+ """同步 CN_Index 指数标的列表。"""
+ repo = request.app.state.repo
+ count = index_sync.sync_index_instruments(repo)
+ return {"status": "ok", "count": count}
+
+
+@router.post("/sync_daily")
+def sync_index_daily(
+ request: Request,
+ days: int = Query(365, ge=30, le=5000),
+):
+ """同步指数日K到独立 parquet。"""
+ repo = request.app.state.repo
+ capset = request.app.state.capabilities
+ if not capset.has(Cap.KLINE_DAILY_BATCH):
+ raise HTTPException(status_code=403, detail="需要 Pro+ 权限 (batch K-line)")
+ end = datetime.now()
+ start = end - timedelta(days=days)
+ count = index_sync.sync_index_instruments(repo)
+ rows = index_sync.sync_and_persist_index_daily(repo, capset, start_date=start, end_date=end)
+ return {"status": "ok", "index_count": count, "rows_written": rows}
diff --git a/backend/app/api/intraday.py b/backend/app/api/intraday.py
new file mode 100644
index 0000000..988d476
--- /dev/null
+++ b/backend/app/api/intraday.py
@@ -0,0 +1,183 @@
+"""行情状态 / SSE 推送 API。
+
+盘中选股相关端点已迁移至策略页面,此处仅保留全局行情基础设施。
+SSE 推送两种事件 (使用标准 SSE event 字段):
+ - quotes_updated: 行情数据刷新,前端 invalidate 对应 query
+ - strategy_alert: 策略监控/告警触发,前端弹通知
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+import time
+
+from fastapi import APIRouter, Query, Request
+from sse_starlette.sse import EventSourceResponse
+
+router = APIRouter(prefix="/api/intraday", tags=["quotes"])
+
+
+def _get_quote_service(request: Request):
+ """获取全局 QuoteService。"""
+ return getattr(request.app.state, "quote_service", None)
+
+
+def _fallback_index_quotes_from_daily(request: Request, symbols: list[str] | None = None) -> list[dict]:
+ """实时指数缓存为空时,从本地指数日 K 取最近收盘价作为兜底。"""
+ repo = getattr(request.app.state, "repo", None)
+ if not repo:
+ return []
+
+ params: list[str] = []
+ symbol_filter = ""
+ if symbols:
+ placeholders = ", ".join("?" for _ in symbols)
+ symbol_filter = f"WHERE symbol IN ({placeholders})"
+ params.extend(symbols)
+
+ try:
+ rows = repo.execute_all(
+ f"""
+ WITH ranked AS (
+ SELECT symbol, date, close,
+ row_number() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn
+ FROM kline_index_daily
+ {symbol_filter}
+ ), latest AS (
+ SELECT symbol,
+ max(CASE WHEN rn = 1 THEN date END) AS date,
+ max(CASE WHEN rn = 1 THEN close END) AS last_price,
+ max(CASE WHEN rn = 2 THEN close END) AS prev_close
+ FROM ranked
+ WHERE rn <= 2
+ GROUP BY symbol
+ )
+ SELECT latest.symbol, latest.date, latest.last_price, latest.prev_close
+ FROM latest
+ ORDER BY latest.symbol
+ """,
+ params,
+ )
+ except Exception: # noqa: BLE001
+ return []
+
+ out: list[dict] = []
+ for symbol, dt, last_price, prev_close in rows:
+ change_amount = None
+ change_pct = None
+ if last_price is not None and prev_close not in (None, 0):
+ change_amount = float(last_price) - float(prev_close)
+ change_pct = change_amount / float(prev_close) * 100
+ out.append({
+ "symbol": symbol,
+ "name": None,
+ "date": str(dt) if dt else None,
+ "last_price": float(last_price) if last_price is not None else None,
+ "close": float(last_price) if last_price is not None else None,
+ "prev_close": float(prev_close) if prev_close is not None else None,
+ "change_amount": change_amount,
+ "change_pct": change_pct,
+ "source": "index_daily",
+ })
+ return out
+
+
+@router.get("/status")
+def status(request: Request):
+ """行情状态 (来自全局 QuoteService)。"""
+ qs = _get_quote_service(request)
+ if qs:
+ return qs.status()
+ return {"enabled": False, "running": False, "symbol_count": 0, "index_symbol_count": 0,
+ "quote_age_ms": None, "is_trading_hours": False, "last_fetch_ms": None}
+
+
+@router.get("/indices")
+def index_quotes(
+ request: Request,
+ symbols: str | None = Query(None, description="逗号分隔的指数 symbol 列表"),
+):
+ """返回实时指数行情缓存,不触发 TickFlow 请求。"""
+ symbol_list = [s.strip() for s in symbols.split(",") if s.strip()] if symbols else None
+ qs = _get_quote_service(request)
+ if not qs:
+ rows = _fallback_index_quotes_from_daily(request, symbol_list)
+ return {"rows": rows, "count": len(rows), "source": "index_daily"}
+ df = qs.get_index_quotes(symbol_list)
+ rows = df.to_dicts() if not df.is_empty() else []
+ if not rows:
+ rows = _fallback_index_quotes_from_daily(request, symbol_list)
+ return {"rows": rows, "count": len(rows), "source": "index_daily"}
+ return {"rows": rows, "count": len(rows), "source": "realtime"}
+
+
+@router.get("/stream")
+async def quote_stream(request: Request):
+ """SSE 端点: 行情更新 + 告警推送。
+
+ 使用 sse-starlette EventSourceResponse:
+ - 标准 SSE event 字段,前端按 event name 监听
+ - 内置断线检测,客户端断开立即终止 generator
+ - 内置 ping 心跳,保持连接活跃
+ """
+ qs = _get_quote_service(request)
+
+ async def event_generator():
+ while True:
+ # 同时等待行情更新和告警
+ update_task = asyncio.ensure_future(
+ asyncio.to_thread(qs.wait_for_update, timeout=5.0) if qs else asyncio.sleep(5)
+ )
+ alert_task = asyncio.ensure_future(
+ asyncio.to_thread(qs.wait_for_alert, timeout=5.0) if qs else asyncio.sleep(5)
+ )
+
+ done, pending = await asyncio.wait(
+ [update_task, alert_task],
+ timeout=30.0,
+ return_when=asyncio.FIRST_COMPLETED,
+ )
+ for t in pending:
+ t.cancel()
+
+ # 先推送告警 (如果有)
+ if qs:
+ alerts = qs.pop_alerts()
+ if alerts:
+ for chunk_start in range(0, len(alerts), 20):
+ chunk = alerts[chunk_start:chunk_start + 20]
+ yield {
+ "event": "strategy_alert",
+ "data": json.dumps({
+ "ts": int(time.time() * 1000),
+ "alerts": chunk,
+ }, ensure_ascii=False),
+ }
+
+ # 推送行情更新
+ update_result = None
+ for t in done:
+ try:
+ update_result = t.result()
+ except Exception: # noqa: BLE001
+ pass
+
+ if update_result:
+ yield {
+ "event": "quotes_updated",
+ "data": json.dumps({
+ "ts": int(time.time() * 1000),
+ "symbol_count": qs._symbol_count if qs else 0,
+ }),
+ }
+
+ return EventSourceResponse(event_generator())
+
+
+@router.post("/refresh")
+def refresh_quotes(request: Request):
+ """手动刷新一次行情数据。"""
+ qs = _get_quote_service(request)
+ if qs:
+ return qs.refresh()
+ return {"error": "QuoteService not available"}
diff --git a/backend/app/api/kline.py b/backend/app/api/kline.py
new file mode 100644
index 0000000..14d9d36
--- /dev/null
+++ b/backend/app/api/kline.py
@@ -0,0 +1,716 @@
+"""K 线 / 同步 API。"""
+from __future__ import annotations
+
+import logging
+from datetime import date, timedelta
+from typing import Optional
+
+from fastapi import APIRouter, HTTPException, Query, Request
+
+from app.indicators.pipeline import compute_enriched_single
+from app.services import kline_sync
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/kline", tags=["kline"])
+
+
+@router.get("/instruments/search")
+def search_instruments(
+ request: Request,
+ q: str = Query("", min_length=0, max_length=50, description="搜索关键词"),
+ limit: int = Query(20, ge=1, le=50),
+):
+ """模糊搜索标的 (代码 / 名称)。从内存 instruments 缓存中查。"""
+ repo = request.app.state.repo
+ df = repo.get_instruments()
+ if df.is_empty() or not q.strip():
+ return {"results": []}
+
+ keyword = q.strip().upper()
+ import polars as pl
+
+ # code/symbol 前缀优先,再 name 包含匹配
+ prefix_mask = (
+ pl.col("code").str.starts_with(keyword)
+ | pl.col("symbol").str.to_uppercase().str.starts_with(keyword)
+ )
+ contains_mask = (
+ pl.col("code").str.contains(keyword, literal=True)
+ | pl.col("symbol").str.to_uppercase().str.contains(keyword, literal=True)
+ | pl.col("name").str.contains(keyword, literal=True)
+ )
+
+ # 前缀匹配优先,剩余名额用包含匹配补充
+ prefix_hits = df.filter(prefix_mask).head(limit)
+ if prefix_hits.height >= limit:
+ matched = prefix_hits
+ else:
+ remaining = limit - prefix_hits.height
+ # 排除已匹配的 symbol
+ prefix_symbols = set(prefix_hits["symbol"].to_list()) if not prefix_hits.is_empty() else set()
+ contain_hits = df.filter(contains_mask & ~pl.col("symbol").is_in(prefix_symbols)).head(remaining)
+ matched = pl.concat([prefix_hits, contain_hits]) if not prefix_hits.is_empty() else contain_hits
+ rows = matched.select(["symbol", "name", "code"]).to_dicts()
+ return {"results": rows}
+
+
+def _get_stock_info(repo, symbol: str) -> dict:
+ """从 instruments 视图查标的名称 + 股本。"""
+ try:
+ row = repo.execute_one(
+ "SELECT name, total_shares, float_shares FROM instruments WHERE symbol = ? LIMIT 1",
+ [symbol],
+ )
+ except Exception: # noqa: BLE001
+ return {}
+ if not row:
+ return {}
+ return {
+ "name": row[0],
+ "total_shares": row[1],
+ "float_shares": row[2],
+ }
+
+
+@router.get("/daily")
+def get_daily(
+ request: Request,
+ symbol: str = Query(..., description="标的代码,如 000001.SZ"),
+ days: int = Query(120, ge=10, le=2000),
+ start_date: Optional[str] = Query(None, description="起始日期 YYYY-MM-DD, 优先于 days"),
+ end_date: Optional[str] = Query(None, description="截止日期 YYYY-MM-DD, 默认今天"),
+):
+ """读取本地 enriched 表中某只股票的日 K。
+
+ - 若 QuoteService 有实时行情, 追加/覆盖今日实时蜡烛
+ - Free 用户: 若 enriched 表里没有该股票, 实时拉取 + 本地算 enriched 返回
+ """
+ import polars as pl
+
+ repo = request.app.state.repo
+ end = date.fromisoformat(end_date) if end_date else date.today()
+ if start_date:
+ start = date.fromisoformat(start_date)
+ else:
+ start = end - timedelta(days=days)
+
+ stock_info = _get_stock_info(repo, symbol)
+ stock_name = stock_info.get("name")
+
+ # 从 enriched 表读取 (已含前复权 OHLCV + 技术指标 + 信号)
+ df = repo.get_daily(symbol, start, end)
+
+ if df.is_empty():
+ try:
+ raw = kline_sync.sync_daily_batch([symbol], count=days + 30)
+ except Exception as e:
+ raise HTTPException(status_code=502, detail=f"TickFlow fetch failed: {e}") from e
+ if raw.is_empty():
+ return {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": []}
+ enriched = compute_enriched_single(raw)
+ rows = enriched.tail(days).to_dicts()
+ # 即使 live 模式也尝试追加实时蜡烛
+ rows = _maybe_inject_live_candle(request, symbol, rows)
+ return {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "live"}
+
+ rows = df.to_dicts()
+
+ # 追加/覆盖今日实时蜡烛
+ rows = _maybe_inject_live_candle(request, symbol, rows)
+
+ return {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "enriched"}
+
+
+def _maybe_inject_live_candle(request: Request, symbol: str, rows: list[dict]) -> list[dict]:
+ """如果 QuoteService 有实时 enriched 数据, 用实时数据生成今日蜡烛并追加/覆盖。"""
+ qs = getattr(request.app.state, "quote_service", None)
+ if not qs:
+ return rows
+
+ df_today, enriched_date = qs.get_enriched_today()
+ if df_today.is_empty():
+ return rows
+
+ # 非交易日(周末/假日)缓存的行情日期 != 今天,跳过注入避免产生重复蜡烛
+ if not enriched_date or enriched_date != date.today():
+ return rows
+
+ # 查找该 symbol 的实时 enriched 行
+ import polars as pl
+ try:
+ q = df_today.filter(pl.col("symbol") == symbol).to_dicts()
+ if not q:
+ return rows
+ q = q[0]
+ except Exception: # noqa: BLE001
+ return rows
+
+ close_price = q.get("close")
+ if not close_price or close_price <= 0:
+ return rows
+
+ today_str = str(enriched_date)
+
+ # enriched 行已包含 OHLCV + 全套指标, 直接用它
+ # 修复: API 在非交易时段可能返回 open/high/low=0, 用 close 填充避免异常蜡烛
+ raw_open = q.get("open")
+ raw_high = q.get("high")
+ raw_low = q.get("low")
+ live_row: dict = {
+ "date": today_str,
+ "symbol": symbol,
+ "open": raw_open if raw_open and raw_open > 0 else close_price,
+ "high": raw_high if raw_high and raw_high > 0 else close_price,
+ "low": raw_low if raw_low and raw_low > 0 else close_price,
+ "close": close_price,
+ "volume": q.get("volume"),
+ "amount": q.get("amount"),
+ "change_pct": q.get("change_pct"),
+ "is_live": True,
+ }
+ # 补上 enriched 的技术指标字段
+ for key in ("ma5", "ma10", "ma20", "ma30", "ma60",
+ "macd_dif", "macd_dea", "macd_hist",
+ "kdj_k", "kdj_d", "kdj_j",
+ "boll_upper", "boll_lower",
+ "rsi_6", "rsi_14", "rsi_24",
+ "atr_14", "vol_ratio_5d"):
+ if key in q and q[key] is not None:
+ live_row[key] = q[key]
+
+ # 如果已有今天的 enriched 行, 覆盖; 否则追加
+ found = False
+ for i, r in enumerate(rows):
+ if str(r.get("date")) == today_str:
+ r.update(live_row)
+ found = True
+ break
+
+ if not found:
+ rows.append(live_row)
+
+ return rows
+
+
+class DailyBatchRequest:
+ """批量日K请求。"""
+ symbols: list[str]
+ days: int = 12
+
+
+@router.post("/daily-batch")
+def get_daily_batch(request: Request, body: dict):
+ """批量获取多只股票最近 N 天日K (OHLCV)。
+
+ 用于自选列表迷你蜡烛图等场景,只返回基础列,不返回全部 enriched 指标。
+ """
+ symbols = body.get("symbols", [])
+ days = body.get("days", 12)
+ if not symbols:
+ return {"data": {}}
+ days = max(5, min(60, days))
+
+ repo = request.app.state.repo
+ import polars as pl
+ from datetime import date, timedelta
+
+ end = date.today()
+ start = end - timedelta(days=days * 2) # 多取一些确保交易日够
+
+ cols = ["symbol", "date", "open", "high", "low", "close", "volume"]
+ df = repo.get_daily_batch(symbols, start, end, columns=cols)
+
+ if df.is_empty():
+ return {"data": {}}
+
+ # 按 symbol 分组, 每只取最近 N 条
+ result: dict[str, list[dict]] = {}
+ for sym in symbols:
+ sub = df.filter(pl.col("symbol") == sym).sort("date").tail(days)
+ if not sub.is_empty():
+ result[sym] = sub.to_dicts()
+
+ return {"data": result}
+
+
+@router.get("/minute")
+def get_minute(
+ request: Request,
+ symbol: str = Query(..., description="标的代码"),
+ trade_date: date | None = Query(None, alias="date", description="交易日期, 默认最新"),
+):
+ """读取某只股票某天的分钟 K 线。
+
+ - 本地有完整数据(240条) → 直接返回
+ - 本地无数据或不完整 → 从 TickFlow 实时拉取返回(不写入)
+ """
+ repo = request.app.state.repo
+ stock_info = _get_stock_info(repo, symbol)
+ stock_name = stock_info.get("name")
+
+ if trade_date is None:
+ trade_date = repo.latest_minute_date(symbol)
+ if trade_date is None:
+ # 本地无任何分钟K,尝试从 TickFlow 拉取当天
+ trade_date = date.today()
+ df = kline_sync.fetch_minute_single(symbol, trade_date)
+ return {
+ "symbol": symbol, "name": stock_name, "stock_info": stock_info,
+ "date": str(trade_date), "rows": df.to_dicts(), "source": "live",
+ }
+
+ df = repo.get_minute(symbol, trade_date)
+
+ # 完整交易日应有 240 条分钟K;如果是今天(盘中),期望条数按已交易分钟估算
+ expected = 240
+ today = date.today()
+ if trade_date == today:
+ from datetime import datetime as _dt
+ now = _dt.now()
+ h, m = now.hour, now.minute
+ if h < 9 or (h == 9 and m < 30):
+ expected = 0 # 还没开盘
+ elif h < 12 or (h == 12 and m == 0):
+ expected = (h - 9) * 60 + m - 30 # 9:30 起
+ elif h < 13:
+ expected = 120 # 午休
+ elif h < 15:
+ expected = 120 + (h - 13) * 60 + m
+ else:
+ expected = 240
+
+ is_complete = not df.is_empty() and len(df) >= expected * 0.9 # 允许 10% 容差
+
+ if is_complete:
+ return {
+ "symbol": symbol, "name": stock_name, "stock_info": stock_info,
+ "date": str(trade_date), "rows": df.to_dicts(), "source": "local",
+ }
+
+ # 本地不完整或无数据 → 从 TickFlow 实时拉取
+ live_df = kline_sync.fetch_minute_single(symbol, trade_date)
+ return {
+ "symbol": symbol, "name": stock_name, "stock_info": stock_info,
+ "date": str(trade_date), "rows": live_df.to_dicts(),
+ "source": "live" if not live_df.is_empty() else "none",
+ }
+
+
+@router.post("/sync")
+def sync_symbol(
+ request: Request,
+ symbol: str = Query(...),
+ days: int = Query(250, ge=10, le=2000),
+):
+ """手动触发单股同步(Free 用户在 K 线页用)。"""
+ repo = request.app.state.repo
+ capset = request.app.state.capabilities
+ n = kline_sync.sync_and_persist_daily_batch([symbol], repo, capset, count=days)
+ return {"symbol": symbol, "rows_written": n}
+
+
+@router.post("/sync_batch")
+def sync_batch(
+ request: Request,
+ symbols: list[str],
+ days: int = Query(250, ge=10, le=2000),
+):
+ repo = request.app.state.repo
+ capset = request.app.state.capabilities
+ n = kline_sync.sync_and_persist_daily_batch(symbols, repo, capset, count=days)
+ return {"symbols": symbols, "rows_written": n}
+
+
+@router.post("/refresh_views")
+def refresh_views(request: Request):
+ """刷新所有 DuckDB 视图(解决视图状态不一致问题)。"""
+ from app.jobs.daily_pipeline import _refresh_views
+ repo = request.app.state.repo
+ _refresh_views(repo)
+ return {"status": "ok"}
+
+
+@router.post("/sync_minute")
+async def sync_minute(request: Request):
+ """手动触发分钟 K 同步(全市场)。返回 pipeline job_id 可轮询进度。"""
+ import asyncio
+
+ from app.services.pipeline_jobs import job_store
+ from app.api.data import invalidate_storage_cache
+ from app.services.preferences import get_minute_sync_days
+ from app.tickflow.capabilities import Cap
+ from app.tickflow.pools import get_pool
+
+ repo = request.app.state.repo
+ capset = request.app.state.capabilities
+
+ if not capset.has(Cap.KLINE_MINUTE_BATCH):
+ raise HTTPException(status_code=403, detail="需要 Pro+ 权限")
+
+ job_id = job_store.create()
+ existing = job_store.get(job_id)
+ if existing and existing["status"] == "running":
+ return {"status": "reused", "job_id": job_id}
+
+ async def task() -> None:
+ job_store.start(job_id)
+ loop = asyncio.get_event_loop()
+
+ def progress(stage: str, pct: int, msg: str) -> None:
+ job_store.progress(job_id, stage, pct, msg)
+
+ try:
+ progress("sync_minute", 5, "解析标的池…")
+ universe = sorted(set(get_pool("watchlist")) | set(get_pool("CN_Equity_A")))
+ # 补充 instruments 全量标的,覆盖北交所、新股等
+ inst_path = repo.store.data_dir / "instruments" / "instruments.parquet"
+ if inst_path.exists():
+ try:
+ import polars as pl
+ inst = pl.read_parquet(inst_path, columns=["symbol"])
+ universe = sorted(set(universe) | set(inst["symbol"].to_list()))
+ except Exception: # noqa: BLE001
+ pass
+ progress("sync_minute", 10, f"标的池 {len(universe)} 只")
+
+ days = get_minute_sync_days()
+
+ def _run():
+ return kline_sync.sync_and_persist_minute(universe, repo, capset, days=days)
+
+ written = await loop.run_in_executor(_long_task_executor, _run)
+
+ # 刷新视图
+ from app.jobs.daily_pipeline import _refresh_single_view
+ _refresh_single_view(repo, "kline_minute")
+
+ progress("done", 100, f"分钟 K 同步完成,{written} 行")
+ job_store.succeed(job_id, {"minute_rows": written, "universe_size": len(universe)})
+ invalidate_storage_cache()
+ except Exception as e: # noqa: BLE001
+ job_store.fail(job_id, str(e))
+ invalidate_storage_cache()
+
+ asyncio.create_task(task())
+ return {"status": "started", "job_id": job_id}
+
+
+@router.post("/extend_history")
+async def extend_history(request: Request):
+ """向前扩展历史日K数据 — 独立于盘后管道。
+
+ body: { "value": int, "unit": "day"|"month"|"year" }
+ 返回 job_id,可轮询 /api/pipeline/jobs 查看进度。
+ """
+ import asyncio
+ import traceback as _tb
+ try:
+ body = await request.json()
+ value = body.get("value")
+ unit = body.get("unit", "month")
+ if not value or value <= 0:
+ raise HTTPException(status_code=400, detail="value 必须为正整数")
+ if unit not in ("day", "month", "year"):
+ raise HTTPException(status_code=400, detail="unit 只支持 day/month/year")
+
+ repo = request.app.state.repo
+ capset = request.app.state.capabilities
+
+ from app.tickflow.capabilities import Cap
+ if not capset.has(Cap.KLINE_DAILY_BATCH):
+ raise HTTPException(status_code=403, detail="需要 Pro+ 权限 (batch K-line)")
+
+ from app.services.extend_history import run_extend_history
+ from app.services.pipeline_jobs import job_store
+ from app.api.data import invalidate_storage_cache
+
+ job_id = job_store.create()
+ existing = job_store.get(job_id)
+ if existing and existing["status"] == "running":
+ return {"status": "reused", "job_id": job_id}
+
+ async def task() -> None:
+ job_store.start(job_id)
+ loop = asyncio.get_event_loop()
+
+ 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)
+
+ try:
+ result = await loop.run_in_executor(
+ _long_task_executor,
+ lambda: run_extend_history(repo, capset, value, unit, on_progress=progress),
+ )
+ if "error" in result:
+ job_store.fail(job_id, result["error"])
+ else:
+ job_store.succeed(job_id, result)
+ invalidate_storage_cache()
+ except Exception as e:
+ logger.exception("extend_history failed: job_id=%s", job_id)
+ job_store.fail(job_id, str(e))
+ invalidate_storage_cache()
+
+ asyncio.create_task(task())
+ return {"status": "started", "job_id": job_id}
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("extend_history error: %s\n%s", e, _tb.format_exc())
+ raise HTTPException(status_code=500, detail=str(e)) from e
+
+
+@router.post("/rebuild_enriched")
+async def rebuild_enriched(request: Request):
+ """全量重算 enriched 表 — 不获取任何数据,仅基于已有 kline_daily + adj_factor 重算复权+指标。
+
+ 返回 job_id,可轮询 /api/pipeline/jobs 查看进度。
+ """
+ import asyncio
+ try:
+ repo = request.app.state.repo
+
+ from app.services.pipeline_jobs import job_store
+ from app.api.data import invalidate_storage_cache
+
+ job_id = job_store.create()
+ existing = job_store.get(job_id)
+ if existing and existing["status"] == "running":
+ return {"status": "reused", "job_id": job_id}
+
+ async def task() -> None:
+ job_store.start(job_id)
+ loop = asyncio.get_event_loop()
+
+ 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)
+
+ try:
+ progress("rebuild_enriched", 10, "全量计算 enriched…")
+ from app.indicators.pipeline import run_pipeline
+
+ def _batch_progress(cur: int, tot: int) -> None:
+ pct = 10 + int(85 * cur / tot)
+ progress("rebuild_enriched", pct,
+ f"计算指标 批次 {cur}/{tot}",
+ stage_pct=int(100 * cur / tot), skip_log=True)
+
+ written = await loop.run_in_executor(
+ _long_task_executor,
+ lambda: run_pipeline(on_batch_done=_batch_progress),
+ )
+
+ enriched_dir = repo.store.data_dir / "kline_daily_enriched"
+ enriched_days = len(list(enriched_dir.glob("date=*"))) if enriched_dir.exists() else 0
+
+ # 刷新视图
+ d = repo.store.data_dir.as_posix()
+ for view_name, glob in [
+ ("kline_enriched", f"{d}/kline_daily_enriched/**/*.parquet"),
+ ]:
+ try:
+ repo.db.execute(
+ f"CREATE OR REPLACE VIEW {view_name} AS "
+ f"SELECT * FROM read_parquet('{glob}', union_by_name=true)"
+ )
+ except Exception:
+ pass
+
+ progress("rebuild_enriched", 100, f"完成,覆盖 {enriched_days} 天")
+ job_store.succeed(job_id, {
+ "enriched_days": enriched_days,
+ "enriched_rows": written,
+ })
+ invalidate_storage_cache()
+ except Exception as e:
+ logger.exception("rebuild_enriched failed: job_id=%s", job_id)
+ job_store.fail(job_id, str(e))
+ invalidate_storage_cache()
+
+ asyncio.create_task(task())
+ return {"status": "started", "job_id": job_id}
+ except Exception as e:
+ import traceback as _tb
+ logger.error("rebuild_enriched error: %s\n%s", e, _tb.format_exc())
+ raise HTTPException(status_code=500, detail=str(e)) from e
+
+
+# 长时间任务专用线程池(隔离于 FastAPI 默认线程池,防止阻塞请求处理)
+import concurrent.futures as _cf
+_long_task_executor = _cf.ThreadPoolExecutor(max_workers=2, thread_name_prefix="long-task")
+
+
+@router.post("/extend_minute_history")
+async def extend_minute_history(request: Request):
+ """向前扩展分钟K历史数据 — 仅拉数据,不做任何后续处理。
+
+ body: { "value": int, "unit": "day"|"month" }
+ 最大 15 天。返回 job_id,可轮询 /api/pipeline/jobs 查看进度。
+ """
+ import asyncio
+ import traceback as _tb
+ try:
+ body = await request.json()
+ value = body.get("value")
+ unit = body.get("unit", "day")
+ if not value or value <= 0:
+ raise HTTPException(status_code=400, detail="value 必须为正整数")
+ if unit not in ("day", "month"):
+ raise HTTPException(status_code=400, detail="unit 只支持 day/month")
+
+ repo = request.app.state.repo
+ capset = request.app.state.capabilities
+
+ from app.tickflow.capabilities import Cap
+ if not capset.has(Cap.KLINE_MINUTE_BATCH):
+ raise HTTPException(status_code=403, detail="需要 Pro+ 权限 (batch minute K-line)")
+
+ # 计算天数,上限 15
+ from datetime import timedelta
+ if unit == "month":
+ total_days = min(value * 30, 15)
+ else:
+ total_days = min(value, 15)
+
+ if total_days <= 0:
+ raise HTTPException(status_code=400, detail="扩展范围无效")
+
+ from app.services.pipeline_jobs import job_store
+ from app.api.data import invalidate_storage_cache
+
+ job_id = job_store.create()
+ existing = job_store.get(job_id)
+ if existing and existing["status"] == "running":
+ return {"status": "reused", "job_id": job_id}
+
+ async def task() -> None:
+ job_store.start(job_id)
+ loop = asyncio.get_event_loop()
+
+ 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)
+
+ try:
+ # 获取当前最早日期
+ earliest = repo.earliest_minute_date()
+ if not earliest:
+ # 本地无分钟K数据 → 以今天为基准往前获取
+ from datetime import date as _date
+ latest = _date.today()
+ else:
+ latest = earliest
+
+ new_start = latest - timedelta(days=total_days)
+ if new_start >= latest:
+ job_store.fail(job_id, "扩展范围无效")
+ invalidate_storage_cache()
+ return
+
+ start_str = new_start.strftime("%Y-%m-%d")
+ end_str = latest.strftime("%Y-%m-%d")
+
+ progress("extend_minute", 5, "解析标的池…")
+ universe = _resolve_minute_universe(capset, repo)
+ progress("extend_minute", 8, f"标的池: {len(universe)} 只")
+
+ from app.tickflow.capabilities import Cap
+
+ lim = capset.limits(Cap.KLINE_MINUTE_BATCH)
+ batch_size = lim.batch if lim and lim.batch else 100
+ rpm = lim.rpm if lim else 30
+
+ def _run():
+ """全部在 executor 线程里完成,避免阻塞事件循环。"""
+ from app.services.kline_sync import sync_minute_batch
+ from datetime import datetime as _dt
+
+ def _chunk(cur: int, tot: int) -> None:
+ progress("extend_minute", 8 + int(85 * cur / tot),
+ f"分钟K 批次 {cur}/{tot}", stage_pct=int(100 * cur / tot), skip_log=True)
+
+ df = sync_minute_batch(
+ universe,
+ start_time=_dt.combine(new_start, _dt.min.time()),
+ end_time=_dt.combine(latest, _dt.min.time()),
+ batch_size=batch_size, rpm=rpm,
+ on_chunk_done=_chunk,
+ )
+
+ written = 0
+ day_count = 0
+ if not df.is_empty():
+ import polars as pl
+ df = df.with_columns(pl.col("datetime").dt.date().alias("_trade_date"))
+ for day_df in df.partition_by("_trade_date"):
+ trade_date = day_df["_trade_date"][0]
+ out = repo.store.data_dir / "kline_minute" / f"date={trade_date}" / "part.parquet"
+ out.parent.mkdir(parents=True, exist_ok=True)
+ if out.exists():
+ existing_df = pl.read_parquet(out)
+ if "datetime" in existing_df.columns:
+ existing_df = existing_df.filter(pl.col("datetime").is_not_null())
+ day_df = pl.concat([existing_df, day_df.drop("_trade_date")]).unique(
+ subset=["symbol", "datetime"], keep="last",
+ )
+ else:
+ day_df = day_df.drop("_trade_date")
+ day_df = day_df.sort("symbol", "datetime")
+ day_df.write_parquet(out)
+ written += day_df.height
+ day_count += 1
+
+ # 刷新视图
+ d = repo.store.data_dir.as_posix()
+ try:
+ repo.db.execute(
+ f"CREATE OR REPLACE VIEW kline_minute AS "
+ f"SELECT * FROM read_parquet('{d}/kline_minute/**/*.parquet', union_by_name=true)"
+ )
+ except Exception:
+ pass
+ return written, day_count
+
+ progress("extend_minute", 10, f"获取分钟K [{start_str} ~ {end_str}]…")
+ written, day_count = await loop.run_in_executor(_long_task_executor, _run)
+
+ progress("extend_minute", 95, f"分钟K 完成,{day_count} 天")
+ job_store.succeed(job_id, {
+ "minute_days": day_count,
+ "universe_size": len(universe),
+ "earliest_before": (earliest or latest).isoformat(),
+ "earliest_after": new_start.isoformat(),
+ })
+ invalidate_storage_cache()
+ except Exception as e:
+ logger.exception("extend_minute_history failed: job_id=%s", job_id)
+ job_store.fail(job_id, str(e))
+ invalidate_storage_cache()
+
+ asyncio.create_task(task())
+ return {"status": "started", "job_id": job_id}
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("extend_minute_history error: %s\n%s", e, _tb.format_exc())
+ raise HTTPException(status_code=500, detail=str(e)) from e
+
+
+def _resolve_minute_universe(capset, repo) -> list[str]:
+ """分钟K标的池解析。"""
+ from app.tickflow.capabilities import Cap
+ if capset.has(Cap.KLINE_MINUTE_BATCH):
+ try:
+ from app.tickflow.pools import get_pool
+ all_a = get_pool("CN_Equity_A", refresh=True)
+ if all_a:
+ return sorted(all_a)
+ except Exception:
+ pass
+ return []
diff --git a/backend/app/api/overview.py b/backend/app/api/overview.py
new file mode 100644
index 0000000..abb47b4
--- /dev/null
+++ b/backend/app/api/overview.py
@@ -0,0 +1,540 @@
+"""市场总览聚合 API。"""
+from __future__ import annotations
+
+import math
+import re
+import time
+from datetime import date
+from typing import Any
+
+import polars as pl
+from fastapi import APIRouter, Request
+
+from app.services.ext_data import ExtConfig, ExtConfigStore
+from app.services.screener import ScreenerService
+
+router = APIRouter(prefix="/api/overview", tags=["overview"])
+
+_CACHE_TTL = 5.0
+_cache: dict[str, Any] | None = None
+_cache_key: str | None = None
+_cache_ts: float = 0.0
+
+CORE_INDEX_NAMES = {
+ "000001.SH": "上证指数",
+ "399001.SZ": "深证成指",
+ "399006.SZ": "创业板指",
+ "000680.SH": "科创综指",
+}
+CORE_INDEX_SYMBOLS = tuple(CORE_INDEX_NAMES.keys())
+
+_DIMENSION_SEP = re.compile(r"[、,,;;|/\s]+")
+
+
+def _dimension_field(config: ExtConfig, kind: str) -> str | None:
+ candidates = ["概念", "concept", "theme"] if kind == "concept" else ["行业", "industry", "sector"]
+ for candidate in candidates:
+ needle = candidate.lower()
+ for field in config.fields:
+ haystack = f"{field.name} {field.label}".lower()
+ if needle in haystack:
+ return field.name
+ return None
+
+
+def _ext_files(data_dir, config: ExtConfig) -> list[str]:
+ base = data_dir / "ext_data" / config.id
+ if config.mode == "timeseries":
+ root = base / "timeseries"
+ return [str(p) for p in sorted(root.rglob("*.parquet")) if p.is_file()]
+ return [str(p) for p in sorted(base.glob("*.parquet")) if p.is_file()]
+
+
+def _read_ext_rows(data_dir, config: ExtConfig, dimension_field: str) -> list[dict]:
+ files = _ext_files(data_dir, config)
+ if not files:
+ return []
+ try:
+ df = pl.read_parquet(files, hive_partitioning=True)
+ except TypeError:
+ try:
+ df = pl.read_parquet(files)
+ except Exception: # noqa: BLE001
+ return []
+ except Exception: # noqa: BLE001
+ return []
+ if df.is_empty() or dimension_field not in df.columns:
+ return []
+
+ if config.mode == "timeseries" and "date" in df.columns:
+ latest = df.get_column("date").max()
+ if latest is not None:
+ df = df.filter(pl.col("date") == latest)
+
+ symbol_cols = ["symbol", "code", "股票代码", "代码"]
+ for mapping in (config.symbol_map, config.code_map):
+ if isinstance(mapping, dict) and mapping.get("type") == "mapped" and mapping.get("col"):
+ symbol_cols.append(str(mapping["col"]))
+ cols = []
+ for col in [dimension_field, *symbol_cols]:
+ if col in df.columns and col not in cols:
+ cols.append(col)
+ return df.select(cols).to_dicts()
+
+
+def _dimension_values(raw: Any) -> list[str]:
+ if raw is None:
+ return []
+ values = [v.strip() for v in _DIMENSION_SEP.split(str(raw).strip()) if v.strip()]
+ return values
+
+
+def _symbol_keys(row: dict, config: ExtConfig) -> list[str]:
+ fields = ["symbol", "code", "股票代码", "代码"]
+ for mapping in (config.symbol_map, config.code_map):
+ if isinstance(mapping, dict) and mapping.get("type") == "mapped" and mapping.get("col"):
+ fields.append(str(mapping["col"]))
+
+ keys: list[str] = []
+ for field in fields:
+ raw = row.get(field)
+ if raw is None:
+ continue
+ text = str(raw).strip().upper()
+ if not text:
+ continue
+ keys.append(text)
+ if "." in text:
+ keys.append(text.split(".", 1)[0])
+ return keys
+
+
+def _dimension_rank(rows: list[dict], request: Request, kind: str, limit: int = 5, level: int | None = None) -> dict:
+ if not rows:
+ return {"leading": [], "lagging": []}
+
+ quote_map: dict[str, dict] = {}
+ for row in rows:
+ symbol = str(row.get("symbol") or "").strip().upper()
+ if not symbol:
+ continue
+ quote_map[symbol] = row
+ quote_map[symbol.split(".", 1)[0]] = row
+
+ store = ExtConfigStore(request.app.state.repo.store.data_dir)
+ groups: dict[str, dict[str, dict]] = {}
+ for config in store.load_all():
+ field = _dimension_field(config, kind)
+ if not field:
+ continue
+ for ext_row in _read_ext_rows(request.app.state.repo.store.data_dir, config, field):
+ quote = None
+ for key in _symbol_keys(ext_row, config):
+ quote = quote_map.get(key)
+ if quote:
+ break
+ if not quote:
+ continue
+ symbol = str(quote.get("symbol") or "")
+ for value in _dimension_values(ext_row.get(field)):
+ # 行业按 "-" 拆分级: "银行-银行-股份制银行" → level=2 取"银行"(二级)
+ if level is not None and "-" in value:
+ parts = value.split("-")
+ value = parts[level - 1] if level <= len(parts) else parts[-1]
+ groups.setdefault(value, {})[symbol] = quote
+
+ items = []
+ for name, by_symbol in groups.items():
+ stocks = list(by_symbol.values())
+ changes = [_finite(s.get("change_pct")) for s in stocks]
+ changes = [v for v in changes if v is not None]
+ if not changes:
+ continue
+ leader = max(stocks, key=lambda s: _finite(s.get("change_pct")) or -999)
+ items.append({
+ "name": name,
+ "count": len(stocks),
+ "avg_pct": sum(changes) / len(changes),
+ "up_count": sum(1 for v in changes if v > 0),
+ "down_count": sum(1 for v in changes if v < 0),
+ "amount": sum(_finite(s.get("amount")) or 0 for s in stocks),
+ "leader": {
+ "symbol": leader.get("symbol"),
+ "name": leader.get("name"),
+ "change_pct": _finite(leader.get("change_pct")),
+ },
+ })
+
+ leading = sorted(items, key=lambda x: x["avg_pct"], reverse=True)[:limit]
+ lagging = sorted(items, key=lambda x: x["avg_pct"])[:limit]
+ return {"leading": leading, "lagging": lagging}
+
+
+def _finite(v: Any) -> float | None:
+ if v is None:
+ return None
+ try:
+ f = float(v)
+ except (TypeError, ValueError):
+ return None
+ return f if math.isfinite(f) else None
+
+
+def _json_safe(value: Any) -> Any:
+ if isinstance(value, dict):
+ return {k: _json_safe(v) for k, v in value.items()}
+ if isinstance(value, list):
+ return [_json_safe(v) for v in value]
+ if isinstance(value, float) and not math.isfinite(value):
+ return None
+ return value
+
+
+def _board(symbol: str) -> str:
+ if symbol.endswith(".BJ"):
+ return "北交所"
+ if symbol.startswith(("300", "301")):
+ return "创业板"
+ if symbol.startswith(("688", "689")):
+ return "科创板"
+ if symbol.endswith(".SH"):
+ return "沪主板"
+ if symbol.endswith(".SZ"):
+ return "深主板"
+ return "其他"
+
+
+def _score(value: float, low: float, high: float) -> int:
+ if high <= low:
+ return 50
+ return max(0, min(100, round((value - low) / (high - low) * 100)))
+
+
+def _quote_status(request: Request) -> dict:
+ qs = getattr(request.app.state, "quote_service", None)
+ if not qs:
+ return {"enabled": False, "running": False, "quote_age_ms": None, "is_trading_hours": False}
+ return qs.status()
+
+
+def _index_quotes(request: Request, as_of: date | None = None) -> list[dict]:
+ qs = getattr(request.app.state, "quote_service", None)
+ rows: list[dict] = []
+ if qs and as_of is None:
+ df = qs.get_index_quotes(list(CORE_INDEX_SYMBOLS))
+ if not df.is_empty():
+ rows = df.to_dicts()
+
+ if not rows:
+ repo = getattr(request.app.state, "repo", None)
+ if repo:
+ placeholders = ", ".join("?" for _ in CORE_INDEX_SYMBOLS)
+ try:
+ db_rows = repo.execute_all(
+ f"""
+ WITH ranked AS (
+ SELECT symbol, date, close,
+ row_number() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn
+ FROM kline_index_daily
+ WHERE symbol IN ({placeholders})
+ AND (? IS NULL OR date <= ?)
+ ), latest AS (
+ SELECT symbol,
+ max(CASE WHEN rn = 1 THEN date END) AS date,
+ max(CASE WHEN rn = 1 THEN close END) AS last_price,
+ max(CASE WHEN rn = 2 THEN close END) AS prev_close
+ FROM ranked
+ WHERE rn <= 2
+ GROUP BY symbol
+ )
+ SELECT symbol, date, last_price, prev_close
+ FROM latest
+ """,
+ [*CORE_INDEX_SYMBOLS, as_of, as_of],
+ )
+ except Exception: # noqa: BLE001
+ db_rows = []
+ for symbol, dt, last_price, prev_close in db_rows:
+ change_amount = None
+ change_pct = None
+ lp = _finite(last_price)
+ pc = _finite(prev_close)
+ if lp is not None and pc not in (None, 0):
+ change_amount = lp - pc
+ change_pct = change_amount / pc * 100
+ rows.append({
+ "symbol": symbol,
+ "name": CORE_INDEX_NAMES.get(symbol),
+ "date": str(dt) if dt else None,
+ "last_price": lp,
+ "close": lp,
+ "prev_close": pc,
+ "change_amount": change_amount,
+ "change_pct": change_pct,
+ })
+
+ by_symbol = {r.get("symbol"): r for r in rows}
+ out = []
+ for symbol in CORE_INDEX_SYMBOLS:
+ r = by_symbol.get(symbol, {"symbol": symbol})
+ out.append({
+ "symbol": symbol,
+ "name": r.get("name") or CORE_INDEX_NAMES[symbol],
+ "last_price": _finite(r.get("last_price") if r.get("last_price") is not None else r.get("close")),
+ "change_pct": _finite(r.get("change_pct")),
+ "change_amount": _finite(r.get("change_amount")),
+ })
+ return out
+
+
+def _top_rows(rows: list[dict], key: str, descending: bool, limit: int = 8) -> list[dict]:
+ filtered = [r for r in rows if _finite(r.get(key)) is not None]
+ filtered.sort(key=lambda r: _finite(r.get(key)) or 0, reverse=descending)
+ return [
+ {
+ "symbol": r.get("symbol"),
+ "name": r.get("name"),
+ "close": _finite(r.get("close")),
+ "change_pct": _finite(r.get("change_pct")),
+ "amount": _finite(r.get("amount")),
+ "turnover_rate": _finite(r.get("turnover_rate")),
+ "board": _board(str(r.get("symbol") or "")),
+ }
+ for r in filtered[:limit]
+ ]
+
+
+def _pct_band_rows(values: list[float]) -> list[dict]:
+ bands = [
+ ("<-5%", None, -0.05),
+ ("-5~-3%", -0.05, -0.03),
+ ("-3~-1%", -0.03, -0.01),
+ ("-1~0%", -0.01, 0),
+ ("0~1%", 0, 0.01),
+ ("1~3%", 0.01, 0.03),
+ ("3~5%", 0.03, 0.05),
+ (">5%", 0.05, None),
+ ]
+ total = len(values) or 1
+ out = []
+ for label, low, high in bands:
+ count = 0
+ for v in values:
+ if low is None and v < high:
+ count += 1
+ elif high is None and v >= low:
+ count += 1
+ elif low is not None and high is not None and low <= v < high:
+ count += 1
+ out.append({"label": label, "count": count, "pct": count / total * 100})
+ return out
+
+
+def _build_overview(request: Request, as_of: date | None = None) -> dict:
+ repo = request.app.state.repo
+ svc = ScreenerService(repo)
+ as_of = as_of or svc.latest_date()
+ status = _quote_status(request)
+ indices = _index_quotes(request, as_of)
+
+ if not as_of:
+ return {
+ "as_of": None,
+ "quote_status": status,
+ "indices": indices,
+ "breadth": {"total": 0, "up": 0, "down": 0, "flat": 0, "up_pct": 0, "down_pct": 0},
+ "amount": {"total": 0, "avg": 0},
+ "boards": [],
+ "limit": {"limit_up": 0, "broken": 0, "failed": 0, "limit_down": 0, "max_boards": 0, "tiers": []},
+ "distribution": [],
+ "trend": {"above_ma5": 0, "above_ma20": 0, "above_ma60": 0, "above_ma5_pct": 0, "above_ma20_pct": 0, "above_ma60_pct": 0, "new_high": 0, "new_low": 0},
+ "activity": {"avg_turnover": 0, "high_turnover": 0, "high_vol_ratio": 0, "vol_ratio": 1},
+ "radar": [],
+ "emotion": {"score": 50, "label": "暂无"},
+ "top_gainers": [],
+ "top_losers": [],
+ "turnover_leaders": [],
+ "active_leaders": [],
+ "concept_rank": {"leading": [], "lagging": []},
+ "industry_rank": {"leading": [], "lagging": []},
+ }
+
+ df = svc._load_enriched_for_date(as_of)
+ if df.is_empty():
+ rows: list[dict] = []
+ else:
+ cols = [
+ "symbol", "name", "close", "change_pct", "amount", "turnover_rate", "volume",
+ "vol_ratio_5d", "consecutive_limit_ups", "signal_limit_up", "signal_broken_limit_up", "signal_limit_down",
+ "ma5", "ma20", "ma60", "high_60d", "low_60d", "signal_n_day_high", "signal_n_day_low",
+ ]
+ df = df.select([c for c in cols if c in df.columns])
+ rows = df.to_dicts()
+
+ # 过滤真停牌(volume=0 且 change_pct=0),保留有涨跌幅的浮点误差股以对齐同花顺口径
+ if rows and "volume" in rows[0]:
+ rows = [r for r in rows
+ if (_finite(r.get("volume")) or 0) > 0
+ or (_finite(r.get("change_pct")) or 0) != 0]
+
+ total = len(rows)
+ up = sum(1 for r in rows if (_finite(r.get("change_pct")) or 0) > 0)
+ down = sum(1 for r in rows if (_finite(r.get("change_pct")) or 0) < 0)
+ flat = max(0, total - up - down)
+ up_pct = up / total * 100 if total else 0
+ down_pct = down / total * 100 if total else 0
+
+ amounts = [_finite(r.get("amount")) or 0 for r in rows]
+ total_amount = sum(amounts)
+ avg_amount = total_amount / total if total else 0
+
+ pct_values = [_finite(r.get("change_pct")) for r in rows]
+ pct_values = [v for v in pct_values if v is not None]
+ avg_pct = sum(pct_values) / len(pct_values) if pct_values else 0
+ median_pct = sorted(pct_values)[len(pct_values) // 2] if pct_values else 0
+ strong_up = sum(1 for v in pct_values if v >= 0.03)
+ strong_down = sum(1 for v in pct_values if v <= -0.03)
+
+ limit_up = sum(1 for r in rows if bool(r.get("signal_limit_up")) or (_finite(r.get("consecutive_limit_ups")) or 0) > 0)
+ broken = sum(1 for r in rows if bool(r.get("signal_broken_limit_up")))
+ limit_down = sum(1 for r in rows if bool(r.get("signal_limit_down")))
+ max_boards = max([int(_finite(r.get("consecutive_limit_ups")) or 0) for r in rows], default=0)
+ seal_rate = limit_up / (limit_up + broken) * 100 if (limit_up + broken) > 0 else 0
+
+ def above_ma_count(ma_key: str) -> int:
+ return sum(1 for r in rows if (_finite(r.get("close")) is not None and _finite(r.get(ma_key)) is not None and (_finite(r.get("close")) or 0) >= (_finite(r.get(ma_key)) or 0)))
+
+ above_ma5 = above_ma_count("ma5")
+ above_ma20 = above_ma_count("ma20")
+ above_ma60 = above_ma_count("ma60")
+ new_high = sum(1 for r in rows if bool(r.get("signal_n_day_high")) or (_finite(r.get("close")) is not None and _finite(r.get("high_60d")) is not None and (_finite(r.get("close")) or 0) >= (_finite(r.get("high_60d")) or 0)))
+ new_low = sum(1 for r in rows if bool(r.get("signal_n_day_low")) or (_finite(r.get("close")) is not None and _finite(r.get("low_60d")) is not None and (_finite(r.get("close")) or 0) <= (_finite(r.get("low_60d")) or 0)))
+
+ turnovers = [_finite(r.get("turnover_rate")) for r in rows]
+ turnovers = [v for v in turnovers if v is not None]
+ avg_turnover = sum(turnovers) / len(turnovers) if turnovers else 0
+ high_turnover = sum(1 for v in turnovers if v >= 5)
+
+ boards_map: dict[str, dict] = {}
+ for r in rows:
+ b = _board(str(r.get("symbol") or ""))
+ item = boards_map.setdefault(b, {"board": b, "count": 0, "up": 0, "down": 0, "amount": 0.0})
+ item["count"] += 1
+ change = _finite(r.get("change_pct")) or 0
+ if change > 0:
+ item["up"] += 1
+ elif change < 0:
+ item["down"] += 1
+ item["amount"] += _finite(r.get("amount")) or 0
+ boards = sorted(boards_map.values(), key=lambda x: x["amount"], reverse=True)
+ for b in boards:
+ count = b["count"] or 1
+ b["up_pct"] = b["up"] / count * 100
+
+ tiers_map: dict[int, int] = {}
+ for r in rows:
+ n = int(_finite(r.get("consecutive_limit_ups")) or 0)
+ if n > 0:
+ tiers_map[n] = tiers_map.get(n, 0) + 1
+ tiers = [{"boards": k, "count": v} for k, v in sorted(tiers_map.items(), key=lambda item: -item[0])]
+
+ index_changes = [_finite(r.get("change_pct")) for r in indices]
+ index_changes = [v for v in index_changes if v is not None]
+ avg_index_pct = sum(index_changes) / len(index_changes) if index_changes else 0
+ vol_ratios = [_finite(r.get("vol_ratio_5d")) for r in rows]
+ vol_ratios = [v for v in vol_ratios if v is not None]
+ avg_vol_ratio = sum(vol_ratios) / len(vol_ratios) if vol_ratios else 1
+ high_vol_ratio = sum(1 for v in vol_ratios if v >= 1.5)
+
+ concept_rank = _dimension_rank(rows, request, "concept")
+ industry_rank = _dimension_rank(rows, request, "industry", level=2)
+
+ strong_diff_pct = (strong_up - strong_down) / total * 100 if total else 0
+ high_vol_pct = high_vol_ratio / total * 100 if total else 0
+ strong_down_pct = strong_down / total * 100 if total else 0
+ tier2_count = sum(t["count"] for t in tiers if t["boards"] >= 2)
+ mainline_items = [*concept_rank["leading"][:3], *industry_rank["leading"][:3]]
+ mainline_avg = max([_finite(item.get("avg_pct")) or 0 for item in mainline_items], default=0)
+ mainline_cover_pct = max([(_finite(item.get("count")) or 0) / total * 100 for item in mainline_items], default=0) if total else 0
+ mainline_score = round(_score(mainline_avg, -0.005, 0.03) * 0.65 + _score(mainline_cover_pct, 1, 12) * 0.35) if mainline_items else 50
+
+ radar = [
+ {"key": "index", "label": "指数", "value": _score(avg_index_pct, -2.5, 2.5)},
+ {"key": "profit", "label": "赚钱", "value": round(_score(up_pct, 20, 80) * 0.45 + _score(avg_pct, -0.02, 0.02) * 0.25 + _score(median_pct, -0.02, 0.02) * 0.20 + _score(strong_diff_pct, -8, 8) * 0.10)},
+ {"key": "money", "label": "量能", "value": round(_score(avg_vol_ratio, 0.6, 1.8) * 0.70 + _score(high_vol_pct, 2, 12) * 0.30)},
+ {"key": "speculation", "label": "投机", "value": round(_score(limit_up, 5, 90) * 0.25 + _score(seal_rate, 30, 85) * 0.35 + _score(max_boards, 1, 8) * 0.25 + _score(tier2_count, 0, 30) * 0.15)},
+ {"key": "resilience", "label": "抗跌", "value": 100 - round(_score(down_pct, 20, 80) * 0.55 + _score(strong_down_pct, 1, 12) * 0.45)},
+ {"key": "mainline", "label": "主线", "value": mainline_score},
+ ]
+ emotion_score = round(sum(r["value"] for r in radar) / len(radar)) if radar else 50
+ if emotion_score >= 70:
+ emotion_label = "强势"
+ elif emotion_score >= 55:
+ emotion_label = "偏暖"
+ elif emotion_score >= 45:
+ emotion_label = "震荡"
+ elif emotion_score >= 30:
+ emotion_label = "偏冷"
+ else:
+ emotion_label = "冰点"
+
+ return _json_safe({
+ "as_of": str(as_of),
+ "quote_status": status,
+ "indices": indices,
+ "breadth": {
+ "total": total,
+ "up": up,
+ "down": down,
+ "flat": flat,
+ "up_pct": up_pct,
+ "down_pct": down_pct,
+ "avg_pct": avg_pct,
+ "median_pct": median_pct,
+ "strong_up": strong_up,
+ "strong_down": strong_down,
+ },
+ "amount": {"total": total_amount, "avg": avg_amount},
+ "boards": boards,
+ "limit": {"limit_up": limit_up, "broken": broken, "failed": 0, "limit_down": limit_down, "max_boards": max_boards, "seal_rate": seal_rate, "tiers": tiers},
+ "distribution": _pct_band_rows(pct_values),
+ "trend": {
+ "above_ma5": above_ma5,
+ "above_ma20": above_ma20,
+ "above_ma60": above_ma60,
+ "above_ma5_pct": above_ma5 / total * 100 if total else 0,
+ "above_ma20_pct": above_ma20 / total * 100 if total else 0,
+ "above_ma60_pct": above_ma60 / total * 100 if total else 0,
+ "new_high": new_high,
+ "new_low": new_low,
+ },
+ "activity": {
+ "avg_turnover": avg_turnover,
+ "high_turnover": high_turnover,
+ "high_vol_ratio": high_vol_ratio,
+ "vol_ratio": avg_vol_ratio,
+ },
+ "radar": radar,
+ "emotion": {"score": emotion_score, "label": emotion_label},
+ "top_gainers": _top_rows(rows, "change_pct", True),
+ "top_losers": _top_rows(rows, "change_pct", False),
+ "turnover_leaders": _top_rows(rows, "amount", True),
+ "active_leaders": _top_rows(rows, "turnover_rate", True),
+ "concept_rank": concept_rank,
+ "industry_rank": industry_rank,
+ })
+
+
+@router.get("/market")
+def market_overview(request: Request, as_of: date | None = None):
+ """总览页单次请求聚合数据,避免前端拉全市场明细后再计算。"""
+ global _cache, _cache_key, _cache_ts
+ now = time.time()
+ cache_key = as_of.isoformat() if as_of else "latest"
+ if _cache is not None and _cache_key == cache_key and (now - _cache_ts) < _CACHE_TTL:
+ return _cache
+ data = _build_overview(request, as_of)
+ _cache = data
+ _cache_key = cache_key
+ _cache_ts = now
+ return data
diff --git a/backend/app/api/pipeline.py b/backend/app/api/pipeline.py
new file mode 100644
index 0000000..11f522b
--- /dev/null
+++ b/backend/app/api/pipeline.py
@@ -0,0 +1,107 @@
+"""盘后管道 API — 异步触发 + 进度跟踪。"""
+from __future__ import annotations
+
+import asyncio
+import concurrent.futures as _cf
+import logging
+
+from fastapi import APIRouter, HTTPException, Request
+
+from app.jobs import daily_pipeline
+from app.services.pipeline_jobs import job_store
+from app.api.data import invalidate_storage_cache
+
+# 长时间任务专用线程池(隔离于 FastAPI 默认线程池,防止阻塞请求处理)
+_long_task_executor = _cf.ThreadPoolExecutor(max_workers=2, thread_name_prefix="long-task")
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/pipeline", tags=["pipeline"])
+
+
+@router.post("/run")
+async def run_now(request: Request) -> dict:
+ """异步触发盘后管道,立即返回 job_id。客户端轮询 /jobs/{id} 拿进度。
+
+ 若已有任务在跑,**返回该任务 id 而不是开新任务**(防止并发拉数据撞限流)。
+ 但如果该任务已运行超过 10 分钟 (可能因 reload 卡死), 强制标记为失败后重新创建。
+ """
+ repo = request.app.state.repo
+ capset = request.app.state.capabilities
+
+ # 检测卡死的 running job (如 reload 后孤儿 task)
+ existing_id = job_store.active_id()
+ if existing_id:
+ existing = job_store.get(existing_id)
+ if existing and existing["status"] == "running":
+ from datetime import datetime, timezone
+ started = existing.get("started_at")
+ if started:
+ try:
+ start_dt = datetime.fromisoformat(started.replace("Z", "+00:00"))
+ elapsed = (datetime.now(timezone.utc) - start_dt).total_seconds()
+ if elapsed > 600: # 超过 10 分钟视为卡死
+ logger.warning("强制取消卡死 job %s (已运行 %.0fs)", existing_id, elapsed)
+ job_store.fail(existing_id, "超时自动取消 (疑似 reload 后孤儿 task)")
+ except Exception:
+ pass
+
+ job_id = job_store.create()
+
+ # 如果是复用的 active job,直接返回(不重启)
+ existing = job_store.get(job_id)
+ if existing and existing["status"] == "running":
+ return {"job_id": job_id, "reused": True}
+
+ # 在 executor 里跑同步任务(pipeline 内部都是阻塞 IO + CPU)
+ async def task() -> None:
+ job_store.start(job_id)
+ loop = asyncio.get_event_loop()
+
+ 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)
+
+ try:
+ result = await loop.run_in_executor(
+ _long_task_executor,
+ lambda: daily_pipeline.run_now(repo, capset, on_progress=progress),
+ )
+ job_store.succeed(job_id, result)
+ invalidate_storage_cache()
+ repo.refresh_cache() # 刷新 Polars 缓存
+ except Exception as e: # noqa: BLE001
+ logger.exception("pipeline failed")
+ job_store.fail(job_id, str(e))
+ invalidate_storage_cache()
+
+ asyncio.create_task(task())
+ return {"job_id": job_id, "reused": False}
+
+
+@router.get("/jobs/{job_id}")
+def get_job(job_id: str) -> dict:
+ j = job_store.get(job_id)
+ if not j:
+ raise HTTPException(status_code=404, detail="job not found")
+ return j
+
+
+@router.post("/jobs/{job_id}/cancel")
+def cancel_job(job_id: str) -> dict:
+ """手动取消一个 running 的 job。"""
+ j = job_store.get(job_id)
+ if not j:
+ raise HTTPException(status_code=404, detail="job not found")
+ if j["status"] not in ("running", "pending"):
+ raise HTTPException(status_code=400, detail=f"job status is {j['status']}, cannot cancel")
+ job_store.fail(job_id, "用户手动取消")
+ return {"cancelled": job_id}
+
+
+@router.get("/jobs")
+def list_jobs(limit: int = 20) -> dict:
+ return {
+ "active_id": job_store.active_id(),
+ "jobs": job_store.list_recent(limit=limit),
+ }
diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py
new file mode 100644
index 0000000..34d708e
--- /dev/null
+++ b/backend/app/api/routes.py
@@ -0,0 +1,39 @@
+"""API 路由 — Phase 0 仅 /health 与 /api/capabilities。"""
+from __future__ import annotations
+
+from fastapi import APIRouter
+
+from app import __version__
+from app.config import settings
+from app.tickflow.policy import detect_capabilities, tier_label
+
+router = APIRouter()
+
+
+@router.get("/health")
+def health() -> dict:
+ return {
+ "status": "ok",
+ "version": __version__,
+ "mode": "free" if settings.use_free_mode else "api_key",
+ }
+
+
+@router.get("/api/capabilities")
+def capabilities() -> dict:
+ """前端用来决定哪些功能可用、哪些灰显。"""
+ capset = detect_capabilities()
+ return {
+ "label": tier_label(),
+ "capabilities": capset.to_dict(),
+ }
+
+
+@router.post("/api/capabilities/redetect")
+def redetect() -> dict:
+ """用户在设置页"重新检测"按钮。"""
+ capset = detect_capabilities(force=True)
+ return {
+ "label": tier_label(),
+ "capabilities": capset.to_dict(),
+ }
diff --git a/backend/app/api/screener.py b/backend/app/api/screener.py
new file mode 100644
index 0000000..9383f5a
--- /dev/null
+++ b/backend/app/api/screener.py
@@ -0,0 +1,575 @@
+"""Screener API。"""
+from __future__ import annotations
+
+import logging
+import math
+import re
+import time
+from dataclasses import asdict
+from datetime import date, datetime
+from typing import Any, Optional
+
+from fastapi import APIRouter, HTTPException, Query, Request
+from pydantic import BaseModel
+
+from app.services.screener import PRESET_STRATEGIES, ScreenerService
+from app.services import strategy_cache
+from app.strategy import config as strategy_config
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/screener", tags=["screener"])
+
+
+class CustomRequest(BaseModel):
+ conditions: list[str]
+ order_by: Optional[str] = None
+ limit: int = 30
+ pool: Optional[list[str]] = None
+ as_of: Optional[date] = None
+ ext_columns: Optional[str] = None
+
+
+class PresetRequest(BaseModel):
+ strategy_id: str
+ pool: Optional[list[str]] = None
+ as_of: Optional[date] = None
+ ext_columns: Optional[str] = None
+
+
+def _safe(result_dict: dict) -> dict:
+ """sanitize for JSON(NaN / Inf → None)."""
+ rows = result_dict.get("rows", [])
+ for r in rows:
+ for k, v in list(r.items()):
+ if isinstance(v, float) and not math.isfinite(v):
+ r[k] = None
+ return result_dict
+
+
+_EXT_IDENT_RE = re.compile(r"^[A-Za-z0-9_]+$")
+
+
+def _safe_ext_value(value: Any) -> Any:
+ if isinstance(value, float) and not math.isfinite(value):
+ return None
+ if isinstance(value, (date, datetime)):
+ return value.isoformat()
+ return value
+
+
+def _quote_ident(name: str) -> str:
+ return '"' + name.replace('"', '""') + '"'
+
+
+def _load_ext_value_maps(repo, ext_columns: Optional[str]) -> dict[str, dict[str, Any]]:
+ """按请求加载扩展列,返回 {输出列名: {symbol: value}}。
+
+ 策略结果缓存是共享文件,不能被不同 ext_columns 组合污染;因此扩展列只在
+ 返回前通过该投影映射追加到结果副本中。
+ """
+ ext_specs = _parse_ext_columns(ext_columns) if ext_columns else []
+ if not ext_specs:
+ return {}
+
+ import polars as pl
+ from app.api.ext_data import _read_ext_dataframe
+ from app.services.ext_data import ExtConfigStore
+
+ db = repo.store.db
+ data_dir = repo.store.data_dir
+ ext_store = ExtConfigStore(data_dir)
+ configs = {c.id: c for c in ext_store.load_all()}
+ value_maps: dict[str, dict[str, Any]] = {}
+
+ for config_id, field_name in ext_specs:
+ out_col = f"{config_id}__{field_name}"
+ cfg = configs.get(config_id)
+ try:
+ if cfg:
+ # 时序扩展表只取最新分区,避免历史分区把同一 symbol JOIN 放大。
+ ext_df, _ = _read_ext_dataframe(cfg, data_dir)
+ else:
+ view_name = f"ext_{config_id}"
+ ext_df = pl.from_arrow(db.query(
+ f"SELECT symbol, {_quote_ident(field_name)} FROM {view_name}"
+ ).arrow())
+
+ if ext_df.is_empty() or "symbol" not in ext_df.columns or field_name not in ext_df.columns:
+ continue
+
+ ext_df = ext_df.select(["symbol", field_name]).unique(subset=["symbol"], keep="last")
+ value_maps[out_col] = {
+ str(row["symbol"]): _safe_ext_value(row.get(field_name))
+ for row in ext_df.to_dicts()
+ if row.get("symbol")
+ }
+ except Exception as e: # noqa: BLE001
+ logger.debug("screener ext column join skipped for %s.%s: %s", config_id, field_name, e)
+
+ return value_maps
+
+
+def _row_with_ext(row: dict, ext_values: dict[str, dict[str, Any]], symbol: Optional[str] = None) -> dict:
+ next_row = dict(row)
+ sym = symbol or next_row.get("symbol")
+ for out_col, value_map in ext_values.items():
+ next_row[out_col] = value_map.get(str(sym)) if sym else None
+ return next_row
+
+
+def _rows_with_ext(rows: list[dict], ext_values: dict[str, dict[str, Any]]) -> list[dict]:
+ if not ext_values:
+ return rows
+ return [_row_with_ext(r, ext_values) for r in rows]
+
+
+def _result_with_ext(result_dict: dict, ext_values: dict[str, dict[str, Any]]) -> dict:
+ if not ext_values:
+ return result_dict
+ return {**result_dict, "rows": _rows_with_ext(result_dict.get("rows", []), ext_values)}
+
+
+def _results_with_ext(results: dict[str, dict], ext_values: dict[str, dict[str, Any]]) -> dict[str, dict]:
+ if not ext_values:
+ return results
+ return {sid: _result_with_ext(r, ext_values) for sid, r in results.items()}
+
+
+def _cache_payload_with_ext(cached: dict, ext_values: dict[str, dict[str, Any]]) -> dict:
+ if not ext_values:
+ return cached
+
+ payload = dict(cached)
+ payload["results"] = _results_with_ext(cached.get("results", {}), ext_values)
+
+ ever_rows = cached.get("today_ever_rows")
+ if isinstance(ever_rows, dict):
+ enriched_ever: dict[str, dict[str, dict]] = {}
+ for sid, sym_map in ever_rows.items():
+ if not isinstance(sym_map, dict):
+ continue
+ enriched_ever[sid] = {
+ sym: _row_with_ext(row, ext_values, symbol=sym)
+ for sym, row in sym_map.items()
+ if isinstance(row, dict)
+ }
+ payload["today_ever_rows"] = enriched_ever
+
+ return payload
+
+
+def _update_cache_strategy(data_dir, as_of: str, strategy_id: str, safe_data: dict) -> None:
+ """单跑后更新缓存中该策略的结果,保持缓存与最新计算一致。"""
+ from app.services import strategy_cache
+ cached = strategy_cache.read_cache(data_dir)
+ if cached and cached.get("as_of") == as_of:
+ results = cached.get("results", {})
+ results[strategy_id] = {
+ "total": safe_data.get("total", 0),
+ "as_of": as_of,
+ "rows": safe_data.get("rows", []),
+ }
+ strategy_cache.write_cache(data_dir, as_of, results)
+
+
+@router.get("/strategies")
+def strategies(request: Request):
+ """策略清单(内置 + 自定义 + AI)。"""
+ data_dir = request.app.state.repo.store.data_dir
+ presets = []
+ seen_ids: set[str] = set()
+
+ # 内置策略
+ for k, v in PRESET_STRATEGIES.items():
+ overrides = strategy_config.load_override(data_dir, k)
+ name = (overrides.get("name") or v["name"]) if overrides else v["name"]
+ desc = (overrides.get("description") or v["description"]) if overrides else v["description"]
+ presets.append({"id": k, "name": name, "description": desc, "source": "builtin"})
+ seen_ids.add(k)
+
+ # 自定义/AI 策略(不在 PRESET_STRATEGIES 中的)
+ engine = getattr(request.app.state, "strategy_engine", None)
+ if engine:
+ for meta in engine.list_strategies():
+ sid = meta["id"]
+ if sid not in seen_ids:
+ overrides = strategy_config.load_override(data_dir, sid)
+ name = (overrides.get("name") or meta["name"]) if overrides else meta["name"]
+ desc = (overrides.get("description") or meta.get("description", "")) if overrides else meta.get("description", "")
+ presets.append({"id": sid, "name": name, "description": desc, "source": meta.get("source", "custom")})
+ seen_ids.add(sid)
+
+ return {"presets": presets}
+
+
+@router.post("/run")
+def run_custom(req: CustomRequest, request: Request):
+ repo = request.app.state.repo
+ svc = ScreenerService(repo)
+ as_of = req.as_of or svc.latest_date()
+ if not as_of:
+ raise HTTPException(status_code=400,
+ detail="无可用数据日期 — enriched 表为空,请先运行盘后管道")
+ result = svc.run(
+ as_of=as_of,
+ conditions=req.conditions,
+ order_by=req.order_by,
+ limit=req.limit,
+ pool=req.pool,
+ )
+ safe_data = _safe(asdict(result))
+ ext_values = _load_ext_value_maps(repo, req.ext_columns)
+ return _result_with_ext(safe_data, ext_values)
+
+
+@router.post("/run_preset")
+def run_preset(req: PresetRequest, request: Request):
+ repo = request.app.state.repo
+ svc = ScreenerService(repo)
+ as_of = req.as_of or svc.latest_date()
+ if not as_of:
+ raise HTTPException(status_code=400, detail="无可用数据日期")
+
+ # 加载用户保存的策略配置
+ data_dir = request.app.state.repo.store.data_dir
+ ext_values = _load_ext_value_maps(repo, req.ext_columns)
+ overrides = strategy_config.load_override(data_dir, req.strategy_id)
+ bf = overrides.get("basic_filter") if overrides else None
+ dl = overrides.get("display_limit") if overrides else None
+ if dl is None and overrides and "display_limit" in overrides:
+ dl = 0
+
+ # 内置策略
+ if req.strategy_id in PRESET_STRATEGIES:
+ try:
+ result = svc.run_preset(req.strategy_id, as_of=as_of, pool=req.pool, basic_filter=bf, display_limit=dl)
+ except ValueError as e:
+ raise HTTPException(status_code=404, detail=str(e)) from e
+ safe_data = _safe(asdict(result))
+ _update_cache_strategy(data_dir, str(as_of), req.strategy_id, safe_data)
+ return _result_with_ext(safe_data, ext_values)
+
+ # 自定义/AI 策略 — 通过 StrategyEngine 执行
+ engine = getattr(request.app.state, "strategy_engine", None)
+ if not engine:
+ raise HTTPException(status_code=404, detail=f"策略引擎未初始化或策略 {req.strategy_id} 不存在")
+
+ try:
+ result = engine.run(req.strategy_id, as_of, pool=req.pool, overrides=overrides or None)
+ except ValueError as e:
+ raise HTTPException(status_code=404, detail=str(e)) from e
+
+ data = asdict(result)
+
+ if dl is not None and dl > 0:
+ data["rows"] = data["rows"][:dl]
+ data["total"] = min(data["total"], dl)
+
+ # 单跑后更新缓存中该策略的结果(保持缓存最新)
+ safe_data = _safe(data)
+ _update_cache_strategy(data_dir, str(as_of), req.strategy_id, safe_data)
+
+ return _result_with_ext(safe_data, ext_values)
+
+
+@router.get("/cached")
+def get_cached(
+ request: Request,
+ ext_columns: Optional[str] = Query(None, description="逗号分隔: config_id.field_name"),
+):
+ """读取策略结果缓存。返回 None 表示无缓存。"""
+ data_dir = request.app.state.repo.store.data_dir
+ cached = strategy_cache.read_cache(data_dir)
+ if cached is None:
+ return {"as_of": None, "results": {}, "updated_at": None}
+ ext_values = _load_ext_value_maps(request.app.state.repo, ext_columns)
+ return _cache_payload_with_ext(cached, ext_values)
+
+
+@router.get("/market-snapshot")
+def market_snapshot(request: Request):
+ """最新全市场轻量行情快照,供板块/概念聚合分析使用。"""
+ import polars as pl
+
+ repo = request.app.state.repo
+ svc = ScreenerService(repo)
+ as_of = svc.latest_date()
+ if not as_of:
+ return {"as_of": None, "rows": []}
+
+ df = svc._load_enriched_for_date(as_of)
+ if df.is_empty():
+ return {"as_of": str(as_of), "rows": []}
+
+ if "close" in df.columns and "total_shares" in df.columns and "market_cap" not in df.columns:
+ df = df.with_columns((pl.col("close") * pl.col("total_shares")).alias("market_cap"))
+ if "close" in df.columns and "float_shares" in df.columns and "float_market_cap" not in df.columns:
+ df = df.with_columns((pl.col("close") * pl.col("float_shares")).alias("float_market_cap"))
+
+ cols = [
+ "symbol", "name", "close", "change_pct", "amount", "volume",
+ "turnover_rate", "vol_ratio_5d", "total_shares", "float_shares",
+ "market_cap", "float_market_cap", "consecutive_limit_ups",
+ ]
+ df = df.select([c for c in cols if c in df.columns])
+ rows = df.to_dicts()
+ for r in rows:
+ for k, v in list(r.items()):
+ if isinstance(v, float) and not math.isfinite(v):
+ r[k] = None
+
+ return {"as_of": str(as_of), "rows": rows}
+
+
+@router.post("/run_all")
+def run_all(request: Request, body: Optional[dict] = None):
+ """批量运行指定策略,只返回每个策略的命中数。
+
+ 优化: 从 enriched 读取一次目标日期数据, 所有策略共享。
+ body.strategy_ids: 只跑指定的策略 ID 列表, 为空则跑全部。
+ """
+ from datetime import date as date_type
+
+ t_total = time.perf_counter()
+
+ body = body or {}
+ repo = request.app.state.repo
+ svc = ScreenerService(repo)
+
+ # 解析日期
+ raw_date = body.get("as_of")
+ if raw_date:
+ as_of = date_type.fromisoformat(str(raw_date)) if isinstance(raw_date, str) else raw_date
+ else:
+ as_of = svc.latest_date()
+ if not as_of:
+ return {"as_of": None, "results": {}}
+
+ # 一次读取目标日期的全部数据
+ t0 = time.perf_counter()
+ precomputed = svc._load_enriched_for_date(as_of)
+ logger.info("run_all: _load_enriched_for_date took %.1fms", (time.perf_counter() - t0) * 1000)
+
+ results: dict[str, dict] = {}
+ data_dir = request.app.state.repo.store.data_dir
+
+ # 收集需要运行的策略 ID (如果指定了 strategy_ids 则只跑这些)
+ requested_ids = body.get("strategy_ids")
+ all_ids = list(PRESET_STRATEGIES.keys())
+ engine = getattr(request.app.state, "strategy_engine", None)
+ if engine:
+ for meta in engine.list_strategies():
+ sid = meta["id"]
+ if sid not in PRESET_STRATEGIES:
+ all_ids.append(sid)
+
+ if requested_ids and isinstance(requested_ids, list):
+ id_set = set(requested_ids)
+ all_ids = [sid for sid in all_ids if sid in id_set]
+
+ if not all_ids:
+ return {"as_of": str(as_of), "results": {}}
+
+ # 批量预加载所有 override 配置
+ t0 = time.perf_counter()
+ all_overrides = strategy_config.list_overrides(data_dir)
+ logger.info("run_all: list_overrides took %.1fms (%d overrides)", (time.perf_counter() - t0) * 1000, len(all_overrides))
+
+ # 历史策略: 只在需要时加载 (只加载 all_ids 中包含的 filter_history 策略)
+ t0 = time.perf_counter()
+ shared_history = None
+ id_set = set(all_ids)
+ if engine:
+ history_strats = [
+ (sid, s) for sid, s in engine._strategies.items()
+ if s.filter_history_fn and sid in id_set
+ ]
+ if history_strats:
+ max_lb = min(max(s.lookback_days for _, s in history_strats), 30)
+ shared_history = svc._load_enriched_history(as_of, max(1, max_lb))
+ else:
+ history_strats = []
+ logger.info("run_all: _load_enriched_history took %.1fms (history_strats=%d)", (time.perf_counter() - t0) * 1000, len(history_strats))
+
+ for sid in all_ids:
+ try:
+ overrides = all_overrides.get(sid, {})
+ bf = overrides.get("basic_filter") if overrides else None
+ dl = overrides.get("display_limit") if overrides else None
+ if dl is None and overrides and "display_limit" in overrides:
+ dl = 0
+
+ if sid in PRESET_STRATEGIES:
+ r = svc.run_preset(sid, as_of=as_of, precomputed=precomputed, basic_filter=bf, display_limit=dl)
+ else:
+ r = engine.run(
+ sid, as_of, overrides=overrides or None,
+ precomputed=precomputed, precomputed_history=shared_history,
+ )
+ if dl is not None and dl > 0:
+ r.rows = r.rows[:dl]
+ r.total = min(r.total, dl)
+
+ safe_rows = _safe(asdict(r)).get("rows", [])
+ results[sid] = {"total": r.total, "as_of": str(as_of), "rows": safe_rows}
+ except (ValueError, Exception):
+ continue
+
+ elapsed = (time.perf_counter() - t_total) * 1000
+ logger.info("run_all: total took %.1fms (%d strategies)", elapsed, len(all_ids))
+
+ # 写入策略缓存 (供页面秒加载)
+ if results:
+ try:
+ strategy_cache.write_cache(data_dir, str(as_of), results)
+ except Exception: # noqa: BLE001
+ pass
+
+ ext_values = _load_ext_value_maps(repo, body.get("ext_columns"))
+ return {"as_of": str(as_of), "results": _results_with_ext(results, ext_values)}
+
+
+@router.get("/limit-ladder")
+def limit_ladder(
+ request: Request,
+ as_of: Optional[date] = None,
+ ext_columns: Optional[str] = Query(None, description="逗号分隔: config_id.field_name"),
+):
+ """连板梯队 — 按连板数分组, 含涨停/炸板/断板三种状态。
+ 返回: tiers = [{ boards, count, stocks: [{symbol,name,change_pct,status,...}] }]
+ status: limit_up=涨停 | broken=炸板(摸板未封) | failed=断板(晋级失败)
+ ext_columns: 动态 JOIN 扩展数据, 如 "concept.concept,industry.industry"
+ """
+ from datetime import timedelta
+
+ import polars as pl
+
+ repo = request.app.state.repo
+ svc = ScreenerService(repo)
+ as_of = as_of or svc.latest_date()
+ if not as_of:
+ raise HTTPException(status_code=400, detail="无可用数据日期")
+
+ df = svc._load_enriched_for_date(as_of)
+ if df.is_empty():
+ return {"as_of": str(as_of), "tiers": []}
+
+ # 加载前一日数据获取 prev consecutive_limit_ups
+ prev_consec: pl.DataFrame = pl.DataFrame()
+ for delta in range(1, 10):
+ candidate = as_of - timedelta(days=delta)
+ df_prev = svc._load_enriched_for_date(candidate)
+ if not df_prev.is_empty() and "consecutive_limit_ups" in df_prev.columns:
+ prev_consec = df_prev.select(
+ "symbol",
+ pl.col("consecutive_limit_ups").alias("prev_consec"),
+ )
+ break
+
+ if not prev_consec.is_empty():
+ df = df.join(prev_consec, on="symbol", how="left")
+ else:
+ df = df.with_columns(pl.lit(0).cast(pl.UInt32).alias("prev_consec"))
+
+ # 表达式
+ is_limit = pl.col("signal_limit_up").fill_null(False) if "signal_limit_up" in df.columns else pl.lit(False)
+ is_broken = pl.col("signal_broken_limit_up").fill_null(False) if "signal_broken_limit_up" in df.columns else pl.lit(False)
+ consec = pl.col("consecutive_limit_ups").fill_null(0) if "consecutive_limit_ups" in df.columns else pl.lit(0)
+ prev_c = pl.col("prev_consec").fill_null(0)
+
+ # 计算 status + boards
+ is_failed = ~is_limit & ~is_broken & (prev_c > 0)
+ df = df.with_columns([
+ pl.when(is_limit).then(pl.lit("limit_up"))
+ .when(is_broken).then(pl.lit("broken"))
+ .when(is_failed).then(pl.lit("failed"))
+ .otherwise(None).alias("status"),
+ pl.when(is_limit).then(consec)
+ .when(is_broken | is_failed).then(prev_c + 1)
+ .otherwise(0).cast(pl.UInt32).alias("boards"),
+ ])
+
+ df = df.filter(pl.col("status").is_not_null() & (pl.col("boards") > 0))
+
+ # 动态 JOIN 扩展数据
+ ext_specs = _parse_ext_columns(ext_columns) if ext_columns else []
+ ext_col_names: list[str] = []
+ if ext_specs:
+ db = repo.store.db
+ data_dir = repo.store.data_dir
+ from app.services.ext_data import ExtConfigStore
+
+ ext_store = ExtConfigStore(data_dir)
+ configs = {c.id: c for c in ext_store.load_all()}
+
+ for config_id, field_name in ext_specs:
+ view_name = f"ext_{config_id}"
+ ext_col_name = f"{config_id}__{field_name}"
+ try:
+ ext_df = pl.from_arrow(db.query(
+ f"SELECT symbol, \"{field_name}\" FROM {view_name}"
+ ).arrow())
+ if not ext_df.is_empty() and "symbol" in ext_df.columns:
+ ext_df = ext_df.rename({field_name: ext_col_name})
+ df = df.join(ext_df.select(["symbol", ext_col_name]), on="symbol", how="left")
+ ext_col_names.append(ext_col_name)
+ except Exception:
+ cfg = configs.get(config_id)
+ if cfg:
+ try:
+ from app.api.ext_data import _parquet_glob
+ glob = _parquet_glob(cfg, data_dir)
+ ext_df = pl.read_parquet(glob)
+ if not ext_df.is_empty() and "symbol" in ext_df.columns and field_name in ext_df.columns:
+ ext_df = ext_df.select(["symbol", field_name]).rename({field_name: ext_col_name})
+ df = df.join(ext_df, on="symbol", how="left")
+ ext_col_names.append(ext_col_name)
+ except Exception:
+ pass
+
+ # 选择输出列
+ cols = ["symbol", "name", "change_pct", "boards", "status"] + ext_col_names
+ df = df.select([c for c in cols if c in df.columns])
+ # 排序: boards 降序, status 按涨停→炸板→断板
+ status_order = pl.when(pl.col("status") == "limit_up").then(0)
+ status_order = status_order.when(pl.col("status") == "broken").then(1)
+ status_order = status_order.otherwise(2).alias("_status_order")
+ df = df.with_columns(status_order).sort(["boards", "_status_order"], descending=[True, False]).drop("_status_order")
+
+ rows = df.to_dicts()
+ for r in rows:
+ for k, v in list(r.items()):
+ if isinstance(v, float) and not math.isfinite(v):
+ r[k] = None
+
+ # 按 boards 分组
+ tiers: dict[int, list] = {}
+ for r in rows:
+ n = int(r.get("boards") or 0)
+ tiers.setdefault(n, []).append(r)
+
+ tier_list = [
+ {"boards": n, "count": len(stocks), "stocks": stocks}
+ for n, stocks in sorted(tiers.items(), key=lambda x: -x[0])
+ ]
+
+ return {"as_of": str(as_of), "tiers": tier_list}
+
+
+def _parse_ext_columns(ext_columns: str) -> list[tuple[str, str]]:
+ """解析 'config_id1.field1,config_id2.field2' 为 [(config_id, field_name), ...]。"""
+ result = []
+ for part in ext_columns.split(","):
+ part = part.strip()
+ if "." not in part:
+ continue
+ config_id, field_name = part.split(".", 1)
+ config_id = config_id.strip()
+ field_name = field_name.strip()
+ if not config_id or not field_name:
+ continue
+ if not _EXT_IDENT_RE.match(config_id) or "\x00" in field_name:
+ continue
+ result.append((config_id, field_name))
+ return result
diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py
new file mode 100644
index 0000000..d3a3386
--- /dev/null
+++ b/backend/app/api/settings.py
@@ -0,0 +1,667 @@
+"""设置 API — Key 配置 / 模式切换。
+
+提供面向非开发者的 UI 配置入口,避免逼用户改 .env。
+"""
+from __future__ import annotations
+
+import logging
+import time
+
+from fastapi import APIRouter, Request
+from pydantic import BaseModel
+
+from app import secrets_store
+from app.tickflow import client as tf_client
+from app.tickflow.policy import (
+ detect_capabilities,
+ extras_caps,
+ missing_caps,
+ probe_log,
+ tier_label,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/settings", tags=["settings"])
+
+# 默认端点 —— endpoints.json 列表第一项,UI"当前使用"始终对齐此项。
+# 注意:Free 模式 SDK 实际走 free-api(免费数据通道),但 UI 显示统一用默认节点。
+DEFAULT_PAID_ENDPOINT = "https://api.tickflow.org"
+
+
+class TickflowKeyIn(BaseModel):
+ api_key: str
+
+
+@router.get("")
+def get_settings() -> dict:
+ """返回当前配置概况(Key 脱敏)。"""
+ from app.config import settings
+
+ key = secrets_store.get_tickflow_key()
+ return {
+ "mode": tf_client.current_mode(),
+ "tickflow_api_key_masked": secrets_store.mask(key),
+ "has_tickflow_key": bool(key),
+ "tier_label": tier_label(),
+ "current_endpoint": tf_client.current_endpoint(),
+ "probe_log": probe_log(),
+ "missing_caps": missing_caps(),
+ "extras_caps": extras_caps(),
+ # AI 配置
+ "ai_provider": secrets_store.get_ai_config("ai_provider", settings.ai_provider),
+ "ai_base_url": secrets_store.get_ai_config("ai_base_url", settings.ai_base_url),
+ "ai_api_key_masked": secrets_store.mask(secrets_store.get_ai_key()),
+ "has_ai_key": bool(secrets_store.get_ai_key()),
+ "ai_model": secrets_store.get_ai_config("ai_model", settings.ai_model),
+ "ai_daily_token_budget": int(secrets_store.get_ai_config("ai_daily_token_budget", str(settings.ai_daily_token_budget)) or settings.ai_daily_token_budget),
+ }
+
+
+class SwitchEndpointIn(BaseModel):
+ url: str
+
+
+@router.post("/switch_endpoint")
+def switch_endpoint(req: SwitchEndpointIn, request: Request) -> dict:
+ """切换 TickFlow 端点并立即生效。
+
+ endpoints.json 里的端点都是 Starter+ 付费端点,Free 模式无 key
+ 无法使用,故 Free 模式下禁止切换。
+ """
+ # Free 模式没有付费端点权限,禁止切换
+ if tf_client.current_mode() == "free":
+ return {"ok": False, "error": "Free 模式无法切换端点,请先配置 API Key"}
+
+ url = req.url.strip().rstrip("/")
+ if not url.startswith("https://"):
+ return {"ok": False, "error": "仅支持 HTTPS 端点"}
+
+ # 持久化到 secrets.json
+ secrets_store.save({"tickflow_base_url": url})
+ # 重置客户端,下次调用自动用新端点
+ tf_client.reset_clients()
+
+ return {
+ "ok": True,
+ "current_endpoint": tf_client.current_endpoint(),
+ }
+
+
+@router.post("/tickflow-key")
+def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict:
+ """保存 TickFlow API Key 并立即重新探测能力。
+
+ 端点联动:Free → Starter+ 时,Free 模式残留的 free-api 端点不可用于
+ 付费 Key,故自动切到默认付费端点(api.tickflow.org)。
+ """
+ key = req.api_key.strip()
+ if not key:
+ return {"ok": False, "error": "key empty"}
+
+ # 判断是否 Free → Starter+ 转换(此前无 key)
+ was_free = tf_client.current_mode() == "free"
+ updates: dict = {"tickflow_api_key": key}
+ if was_free:
+ # 自动切到默认端点;之前的残留自定义 URL 不再适用
+ updates["tickflow_base_url"] = DEFAULT_PAID_ENDPOINT
+
+ secrets_store.save(updates)
+ tf_client.reset_clients()
+
+ # 立即重新探测
+ capset = detect_capabilities(force=True)
+ request.app.state.capabilities = capset
+
+ return {
+ "ok": True,
+ "tickflow_api_key_masked": secrets_store.mask(key),
+ "mode": "api_key",
+ "tier_label": tier_label(),
+ "current_endpoint": tf_client.current_endpoint(),
+ "probe_log": probe_log(),
+ "capabilities_count": len(capset.all()),
+ }
+
+
+@router.delete("/tickflow-key")
+def clear_tickflow_key(request: Request) -> dict:
+ """清除 Key,退回 Free 模式。
+
+ 同时清除 tickflow_base_url(测速切换的自定义端点),使"当前使用"
+ 回到默认节点 api.tickflow.org;SDK 则自动用 free() 取免费数据。
+ """
+ secrets_store.clear("tickflow_api_key", "tickflow_base_url")
+ tf_client.reset_clients()
+
+ capset = detect_capabilities(force=True)
+ request.app.state.capabilities = capset
+
+ return {
+ "ok": True,
+ "mode": "free",
+ "tier_label": tier_label(),
+ "current_endpoint": tf_client.current_endpoint(),
+ "capabilities_count": len(capset.all()),
+ }
+
+
+class AiSettingsIn(BaseModel):
+ provider: str = "openai_compat"
+ base_url: str = ""
+ api_key: str | None = None
+ model: str = ""
+ daily_token_budget: int = 500_000
+
+
+@router.post("/ai")
+def save_ai_settings(req: AiSettingsIn) -> dict:
+ """保存 AI 配置(全部持久化到 secrets.json)"""
+ from app.config import settings
+
+ updates: 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.model:
+ updates["ai_model"] = req.model
+ settings.ai_model = req.model
+ updates["ai_daily_token_budget"] = req.daily_token_budget
+ settings.ai_daily_token_budget = req.daily_token_budget
+
+ if updates:
+ secrets_store.save(updates)
+
+ return {"ok": True}
+
+
+# ===== 偏好设置 =====
+
+class MinuteSyncPrefs(BaseModel):
+ minute_sync_enabled: bool
+ minute_sync_days: int = 5
+
+
+@router.get("/preferences")
+def get_preferences() -> dict:
+ """返回用户偏好设置。"""
+ from app.services import preferences
+ return {
+ "realtime_quotes_enabled": preferences.get_realtime_quotes_enabled(),
+ "indices_nav_pinned": preferences.get_indices_nav_pinned(),
+ "minute_sync_enabled": preferences.get_minute_sync_enabled(),
+ "minute_sync_days": preferences.get_minute_sync_days(),
+ "pipeline_schedule": preferences.get_pipeline_schedule(),
+ "instruments_schedule": preferences.get_instruments_schedule(),
+ "enriched_batch_size": preferences.get_enriched_batch_size(),
+ "index_daily_batch_size": preferences.get_index_daily_batch_size(),
+ "watchlist_columns": preferences.get_watchlist_columns(),
+ "screener_result_columns": preferences.get_screener_result_columns(),
+ "sse_refresh_pages": preferences.get_sse_refresh_pages(),
+ "strategy_monitor_enabled": preferences.get_strategy_monitor_enabled(),
+ "strategy_monitor_ids": preferences.get_strategy_monitor_ids(),
+ "sidebar_index_symbols": preferences.get_sidebar_index_symbols(),
+ "nav_order": preferences.get_nav_order(),
+ "nav_hidden": preferences.get_nav_hidden(),
+ "screener_auto_run": preferences.get_screener_auto_run(),
+ }
+
+
+@router.get("/preferences/watchlist-columns")
+def get_watchlist_columns() -> dict:
+ """返回自选列表列配置。"""
+ from app.services import preferences
+ cols = preferences.get_watchlist_columns()
+ return {"columns": cols}
+
+
+class NavOrderIn(BaseModel):
+ nav_order: list[str]
+
+
+class NavHiddenIn(BaseModel):
+ nav_hidden: list[str]
+
+
+@router.put("/preferences/nav-order")
+def update_nav_order(req: NavOrderIn) -> dict:
+ """保存左侧菜单排序(内置页面 path + 扩展分析菜单 id 的有序列表)。"""
+ from app.services import preferences
+ saved = preferences.set_nav_order(req.nav_order)
+ return {"nav_order": saved}
+
+
+@router.put("/preferences/nav-hidden")
+def update_nav_hidden(req: NavHiddenIn) -> dict:
+ """保存左侧菜单隐藏项。"""
+ from app.services import preferences
+ saved = preferences.set_nav_hidden(req.nav_hidden)
+ return {"nav_hidden": saved}
+
+
+@router.put("/preferences/watchlist-columns")
+def update_watchlist_columns(req: dict) -> dict:
+ """保存自选列表列配置。"""
+ from app.services import preferences
+ columns = req.get("columns", [])
+ saved = preferences.set_watchlist_columns(columns)
+ return {"columns": saved}
+
+
+@router.get("/preferences/screener-result-columns")
+def get_screener_result_columns() -> dict:
+ """返回策略结果列表列配置。"""
+ from app.services import preferences
+ cols = preferences.get_screener_result_columns()
+ return {"columns": cols}
+
+
+@router.put("/preferences/screener-result-columns")
+def update_screener_result_columns(req: dict) -> dict:
+ """保存策略结果列表列配置。"""
+ from app.services import preferences
+ columns = req.get("columns", [])
+ saved = preferences.set_screener_result_columns(columns)
+ return {"columns": saved}
+
+
+@router.put("/preferences/minute-sync")
+def update_minute_sync(req: MinuteSyncPrefs) -> dict:
+ """保存分钟 K 同步偏好。"""
+ from app.services import preferences
+ days = max(1, min(30, req.minute_sync_days))
+ preferences.save({
+ "minute_sync_enabled": req.minute_sync_enabled,
+ "minute_sync_days": days,
+ })
+ return {
+ "minute_sync_enabled": req.minute_sync_enabled,
+ "minute_sync_days": days,
+ }
+
+
+class RealtimeQuotesPrefs(BaseModel):
+ realtime_quotes_enabled: bool
+
+
+@router.put("/preferences/realtime-quotes")
+def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict:
+ """保存全局实时行情开关。"""
+ from app.services import preferences
+ preferences.save({"realtime_quotes_enabled": req.realtime_quotes_enabled})
+
+ # 动态启停行情服务
+ qs = getattr(request.app.state, "quote_service", None)
+ if qs:
+ if req.realtime_quotes_enabled:
+ qs.enable()
+ else:
+ qs.disable()
+
+ return {"realtime_quotes_enabled": req.realtime_quotes_enabled}
+
+
+class IndicesNavPinnedPrefs(BaseModel):
+ indices_nav_pinned: bool
+
+
+@router.put("/preferences/indices-nav-pinned")
+def update_indices_nav_pinned(req: IndicesNavPinnedPrefs) -> dict:
+ """保存侧栏指数报价卡片固定显示开关。
+ ON=常驻显示;OFF=跟随实时行情开关(仅实时开时显示)。"""
+ from app.services import preferences
+ preferences.save({"indices_nav_pinned": req.indices_nav_pinned})
+ return {"indices_nav_pinned": req.indices_nav_pinned}
+
+
+class RealtimeMonitorConfigIn(BaseModel):
+ sse_refresh_pages: dict[str, bool] | None = None
+ strategy_monitor_enabled: bool | None = None
+ strategy_monitor_ids: list[str] | None = None
+ sidebar_index_symbols: list[str] | None = None
+ screener_auto_run: bool | None = None
+
+
+@router.put("/preferences/realtime-monitor")
+def update_realtime_monitor_config(req: RealtimeMonitorConfigIn, request: Request) -> dict:
+ """更新实时监控配置。"""
+ from app.services import preferences
+
+ cfg = req.model_dump(exclude_none=True)
+ result = preferences.set_realtime_monitor_config(cfg)
+
+ # 如果策略监控开关变化,更新 StrategyMonitorService 的监控池
+ if req.strategy_monitor_ids is not None or req.strategy_monitor_enabled is not None:
+ monitor = getattr(request.app.state, "strategy_monitor", None)
+ if monitor:
+ if preferences.get_strategy_monitor_enabled():
+ # 从策略引擎加载监控配置
+ engine = getattr(request.app.state, "strategy_engine", None)
+ ids = preferences.get_strategy_monitor_ids()
+ if engine and ids:
+ monitor.stop_all()
+ for sid in ids:
+ try:
+ s = engine.get(sid)
+ monitor.start(sid, {
+ "entry_signals": s.entry_signals,
+ "exit_signals": s.exit_signals,
+ "alerts": s.alerts,
+ })
+ except ValueError:
+ pass
+ else:
+ monitor.stop_all()
+
+ return result
+
+
+class QuoteIntervalIn(BaseModel):
+ interval: float
+
+
+@router.put("/preferences/quote-interval")
+def update_quote_interval(req: QuoteIntervalIn, request: Request) -> dict:
+ """更新行情轮询间隔。按档位自动 clamp。"""
+ qs = getattr(request.app.state, "quote_service", None)
+ if not qs:
+ return {"interval": req.interval, "min_interval": qs.get_min_interval(), "max_interval": 60.0}
+ clamped = qs.set_interval(req.interval)
+ return {
+ "interval": clamped,
+ "min_interval": qs.get_min_interval(),
+ "max_interval": qs.MAX_INTERVAL,
+ }
+
+
+@router.get("/preferences/quote-interval")
+def get_quote_interval(request: Request) -> dict:
+ """获取当前行情轮询间隔和档位限制。"""
+ qs = getattr(request.app.state, "quote_service", None)
+ if not qs:
+ return {"interval": 10.0, "min_interval": 5.0, "max_interval": 60.0}
+ return {
+ "interval": qs._interval,
+ "min_interval": qs.get_min_interval(),
+ "max_interval": qs.MAX_INTERVAL,
+ }
+
+
+class TestEndpointIn(BaseModel):
+ url: str
+ # 测试轮数;不传时取 endpoints.json 的 testRounds(默认 5)
+ rounds: int | None = None
+
+
+# 官方端点发现清单 —— 前端浏览器无法直接跨域拉取 tickflow.org/endpoints.json
+# (无 CORS 头),因此由后端代理。缓存 5 分钟,失败时回退到内置列表。
+ENDPOINTS_URL = "https://tickflow.org/endpoints.json"
+ENDPOINTS_TTL = 300.0 # 秒
+
+# 回退列表 —— 与官方 endpoints.json 的 endpoints[] 字段对齐。
+# 当远程拉取失败时使用,保证 UI 永远有内容可显示。
+_FALLBACK_ENDPOINTS: list[dict] = [
+ {
+ "id": "default",
+ "url": "https://api.tickflow.org",
+ "label": "默认端点",
+ "region": "auto",
+ "description": "默认端点",
+ "premium": False,
+ },
+ {
+ "id": "hk",
+ "url": "https://hk-api.tickflow.org",
+ "label": "香港端点",
+ "region": "ap-east-1",
+ "description": "备用端点,部分地区访问更稳定",
+ "premium": False,
+ },
+ {
+ "id": "sg",
+ "url": "https://sg-api.tickflow.org",
+ "label": "新加坡端点",
+ "region": "ap-southeast-1",
+ "description": "备用端点,亚太地区访问更稳定",
+ "premium": False,
+ },
+ {
+ "id": "us",
+ "url": "https://us-api.tickflow.org",
+ "label": "美国端点",
+ "region": "us-east-1",
+ "description": "备用端点,欧美地区访问更稳定",
+ "premium": False,
+ },
+ {
+ "id": "cn",
+ "url": "https://139.196.55.234:50443",
+ "label": "中国大陆端点(Beta)",
+ "region": "cn-east-1",
+ "description": "备用端点,中国大陆地区访问更稳定,目前处于测试阶段,谨慎使用",
+ "premium": False,
+ },
+ {
+ "id": "cn-premium",
+ "url": "https://106.15.238.72:50443",
+ "label": "中国大陆专线端点",
+ "region": "cn-east-1",
+ "description": "专线加速端点,需要专线加速权限(该权限包含在 Expert 及以上套餐中,也可通过自定义组合单独开通)",
+ "premium": True,
+ },
+]
+
+# 进程内缓存:{ "ts": float, "data": dict }
+_endpoints_cache: dict = {"ts": 0.0, "data": None}
+
+
+@router.get("/endpoints")
+def list_endpoints() -> dict:
+ """代理拉取 tickflow.org/endpoints.json 并返回规范化端点列表。
+
+ 前端无法跨域直连该 URL(无 CORS 头),故由本接口代理。带 8s 超时、
+ 5 分钟内存缓存,远程失败时回退到内置列表,保证 UI 始终有内容。
+ 返回结构与原始 endpoints.json 一致(透传 schema/version 等元信息)。
+ """
+ import httpx
+
+ now = time.monotonic()
+ cached = _endpoints_cache.get("data")
+ if cached is not None and (now - _endpoints_cache["ts"]) < ENDPOINTS_TTL:
+ return cached
+
+ source = "remote"
+ data: dict | None = None
+ try:
+ resp = httpx.get(ENDPOINTS_URL, timeout=8.0, follow_redirects=True)
+ if resp.status_code == 200:
+ parsed = resp.json()
+ eps = parsed.get("endpoints")
+ # 校验:必须是列表且每项含必要字段,否则视为无效
+ if isinstance(eps, list) and all(
+ isinstance(e, dict) and "url" in e for e in eps
+ ):
+ data = {
+ "version": parsed.get("version", 1),
+ "description": parsed.get(
+ "description", "TickFlow API 端点配置"
+ ),
+ "healthPath": parsed.get("healthPath", "/health"),
+ "testRounds": parsed.get("testRounds", 5),
+ "endpoints": eps,
+ }
+ except (httpx.HTTPError, ValueError):
+ logger.warning("拉取 endpoints.json 失败,使用内置回退列表", exc_info=True)
+
+ if data is None:
+ source = "fallback"
+ data = {
+ "version": 1,
+ "description": "TickFlow API 端点配置",
+ "healthPath": "/health",
+ "testRounds": 5,
+ "endpoints": _FALLBACK_ENDPOINTS,
+ }
+
+ # 标记数据来源,便于前端提示(回退时显示"内置列表")。
+ data["source"] = source
+ _endpoints_cache["ts"] = now
+ _endpoints_cache["data"] = data
+ return data
+
+
+async def _http_ping(url: str, timeout: float = 10.0) -> float | None:
+ """单次异步 GET 请求并返回延迟(ms),失败返回 None。
+
+ 对齐官方 latency_test.py:用 /health 轻量端点测真实网络延迟,
+ 不携带 API Key(/health 公开)。异步实现,保证多端点并行测速不阻塞。
+ """
+ import httpx
+
+ t0 = time.perf_counter()
+ try:
+ async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
+ resp = await client.get(url)
+ dt = (time.perf_counter() - t0) * 1000
+ # 只把 <400 视为成功;4xx/5xx 也算"不可达"
+ if resp.status_code < 400:
+ return round(dt, 2)
+ return None
+ except (httpx.TimeoutException, httpx.ConnectError, httpx.HTTPError, OSError):
+ return None
+
+
+@router.post("/test_endpoint")
+async def test_endpoint(req: TestEndpointIn) -> dict:
+ """测试端点网络延迟:对 /health 多轮探测取中位数。
+
+ 参考 TickFlow 官方 latency_test.py:
+ - 路径用 /health(公开、轻量),反映真实网络延迟而非业务接口耗时
+ - 多轮探测(默认 5 轮,取自 endpoints.json 的 testRounds),间隔 0.3s
+ - 返回 median/min/max/success,前端显示中位数
+ - 异步实现,保证"全部测速"时多端点真正并行
+ """
+ import asyncio
+ import statistics
+
+ base = req.url.rstrip("/")
+ rounds = max(1, min(10, req.rounds or _endpoints_cache.get("data", {}).get("testRounds", 5)))
+ health_url = base + "/health"
+
+ latencies: list[float] = []
+ for _ in range(rounds):
+ ms = await _http_ping(health_url)
+ if ms is not None:
+ latencies.append(ms)
+ # 官方脚本间隔 0.3s;末轮无需等待
+ await asyncio.sleep(0.3)
+
+ success = len(latencies)
+ if success == 0:
+ return {
+ "ok": False,
+ "error": "不可达",
+ "url": req.url,
+ "rounds": rounds,
+ "success": 0,
+ "median_ms": None,
+ "min_ms": None,
+ "max_ms": None,
+ }
+
+ median = round(statistics.median(latencies), 2)
+ return {
+ "ok": True,
+ "url": req.url,
+ "rounds": rounds,
+ "success": success,
+ "median_ms": median,
+ "min_ms": round(min(latencies), 2),
+ "max_ms": round(max(latencies), 2),
+ # 兼容旧字段:取中位数作为代表延迟
+ "latency_ms": median,
+ }
+
+
+class PipelineScheduleIn(BaseModel):
+ hour: int
+ minute: int
+
+
+@router.put("/preferences/pipeline-schedule")
+def update_pipeline_schedule(req: PipelineScheduleIn, request: Request) -> dict:
+ """保存盘后管道调度时间并立即 reschedule。"""
+ from app.services import preferences
+ sched = preferences.set_pipeline_schedule(req.hour, req.minute)
+
+ # 动态 reschedule
+ from apscheduler.triggers.cron import CronTrigger
+ scheduler = getattr(request.app.state, "scheduler", None)
+ if scheduler:
+ scheduler.reschedule_job(
+ "daily_pipeline",
+ trigger=CronTrigger(
+ day_of_week="mon-fri",
+ hour=sched["hour"],
+ minute=sched["minute"],
+ timezone="Asia/Shanghai",
+ ),
+ )
+ logger.info("pipeline rescheduled to %02d:%02d mon-fri", sched["hour"], sched["minute"])
+
+ return sched
+
+
+@router.put("/preferences/instruments-schedule")
+def update_instruments_schedule(req: PipelineScheduleIn, request: Request) -> dict:
+ """保存盘前标的维表调度时间并立即 reschedule。"""
+ from app.services import preferences
+ sched = preferences.set_instruments_schedule(req.hour, req.minute)
+
+ from apscheduler.triggers.cron import CronTrigger
+ scheduler = getattr(request.app.state, "scheduler", None)
+ if scheduler:
+ scheduler.reschedule_job(
+ "pre_market_instruments",
+ trigger=CronTrigger(
+ day_of_week="mon-fri",
+ hour=sched["hour"],
+ minute=sched["minute"],
+ timezone="Asia/Shanghai",
+ ),
+ )
+ return sched
+
+
+class EnrichedBatchSizeIn(BaseModel):
+ size: int
+
+
+@router.put("/preferences/enriched-batch-size")
+def update_enriched_batch_size(req: EnrichedBatchSizeIn) -> dict:
+ """保存 enriched 全量计算批次大小。"""
+ from app.services import preferences
+ size = preferences.set_enriched_batch_size(req.size)
+ return {"enriched_batch_size": size}
+
+
+class IndexDailyBatchSizeIn(BaseModel):
+ size: int
+
+
+@router.put("/preferences/index-daily-batch-size")
+def update_index_daily_batch_size(req: IndexDailyBatchSizeIn) -> dict:
+ """保存指数日 K 同步批次大小。"""
+ from app.services import preferences
+ size = preferences.set_index_daily_batch_size(req.size)
+ return {"index_daily_batch_size": size}
diff --git a/backend/app/api/signals.py b/backend/app/api/signals.py
new file mode 100644
index 0000000..66e1772
--- /dev/null
+++ b/backend/app/api/signals.py
@@ -0,0 +1,100 @@
+"""自定义信号 API 路由 — HTTP 请求 → 调用 custom_signals 模块 → 返回响应。
+
+只做胶水:校验 → 持久化 → 失效缓存。不含表达式编译逻辑。
+"""
+from __future__ import annotations
+
+from pathlib import Path
+
+from fastapi import APIRouter, HTTPException, Request
+from pydantic import BaseModel
+
+from app.strategy import custom_signals
+
+router = APIRouter(prefix="/api/custom-signals", tags=["custom-signals"])
+
+
+def _data_dir(request: Request) -> Path:
+ return request.app.state.repo.store.data_dir
+
+
+def _invalidate() -> None:
+ """失效 pipeline 的自定义信号缓存,下次计算重新加载。"""
+ from app.indicators.pipeline import invalidate_custom_signals
+ invalidate_custom_signals()
+
+
+class ConditionModel(BaseModel):
+ left: str # 字段名(须在白名单)
+ op: str # > >= < <= == !=
+ right: str # "field:xxx" 或数字字符串
+
+
+class SignalModel(BaseModel):
+ id: str
+ name: str
+ kind: str # entry | exit | both
+ conditions: list[ConditionModel]
+ enabled: bool = True
+
+
+# ── 字段选项 / 运算符 ───────────────────────────────────
+
+
+@router.get("/options")
+def get_options():
+ """返回可选字段与运算符,供前端下拉框使用。"""
+ # 字段带中文标签(取自 ENRICHED_COLUMNS,回退为字段名本身)
+ from app.indicators.pipeline import ENRICHED_COLUMNS
+
+ fields = [
+ {"key": f, "label": ENRICHED_COLUMNS.get(f, f)}
+ for f in sorted(custom_signals.ALLOWED_FIELDS)
+ ]
+ return {
+ "fields": fields,
+ "operators": [">", ">=", "<", "<=", "==", "!="],
+ "kinds": [
+ {"key": "entry", "label": "买入"},
+ {"key": "exit", "label": "卖出"},
+ {"key": "both", "label": "买卖通用"},
+ ],
+ }
+
+
+# ── 列表 ───────────────────────────────────────────────
+
+
+@router.get("")
+def list_signals(request: Request):
+ sigs = custom_signals.load_all(_data_dir(request))
+ return {"signals": sigs}
+
+
+# ── 新建 / 更新 ────────────────────────────────────────
+
+
+@router.post("")
+def save_signal(req: SignalModel, request: Request):
+ sig = req.model_dump()
+ try:
+ custom_signals.validate(sig)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ custom_signals.save_one(_data_dir(request), sig)
+ _invalidate()
+ return {"ok": True, "signal": sig}
+
+
+# ── 删除 ───────────────────────────────────────────────
+
+
+@router.delete("/{signal_id}")
+def delete_signal(signal_id: str, request: Request):
+ if not custom_signals.ID_RE.match(signal_id):
+ raise HTTPException(status_code=400, detail="信号 id 非法")
+ deleted = custom_signals.delete_one(_data_dir(request), signal_id)
+ if not deleted:
+ raise HTTPException(status_code=404, detail="信号不存在")
+ _invalidate()
+ return {"ok": True}
diff --git a/backend/app/api/strategy.py b/backend/app/api/strategy.py
new file mode 100644
index 0000000..a462e5e
--- /dev/null
+++ b/backend/app/api/strategy.py
@@ -0,0 +1,468 @@
+"""策略 API 路由 — HTTP 请求 → 调用策略模块 → 返回响应。
+
+只做胶水,不含业务逻辑。
+"""
+from __future__ import annotations
+
+import math
+from dataclasses import asdict
+from datetime import date
+from pathlib import Path
+from typing import Any
+
+from fastapi import APIRouter, HTTPException, Request
+from pydantic import BaseModel
+
+from app.strategy import config as strategy_config
+from app.strategy.engine import StrategyEngine, StrategyDef
+from app.strategy.ai_generator import AIStrategyGenerator
+from app.strategy.prompt_builder import build_step1, build_step2
+from app.strategy.monitor import StrategyMonitorService, StrategyAlert
+
+router = APIRouter(prefix="/api/strategies", tags=["strategies"])
+
+# ── Helpers ──────────────────────────────────────────────────────────
+
+
+def _get_engine(request: Request) -> StrategyEngine:
+ engine = getattr(request.app.state, "strategy_engine", None)
+ if not engine:
+ raise HTTPException(status_code=503, detail="策略引擎未初始化")
+ return engine
+
+
+def _get_monitor(request: Request) -> StrategyMonitorService:
+ mon = getattr(request.app.state, "strategy_monitor", None)
+ if not mon:
+ raise HTTPException(status_code=503, detail="策略监控未初始化")
+ return mon
+
+
+def _data_dir(request: Request) -> Path:
+ return request.app.state.repo.store.data_dir
+
+
+def _safe(result_dict: dict) -> dict:
+ rows = result_dict.get("rows", [])
+ for r in rows:
+ for k, v in list(r.items()):
+ if isinstance(v, float) and not math.isfinite(v):
+ r[k] = None
+ return result_dict
+
+
+def _strategy_detail(s: StrategyDef, overrides: dict | None = None) -> dict:
+ """策略详情(含用户覆盖)"""
+ bf = {**s.basic_filter}
+ scoring = dict(s.meta.get("scoring", {}))
+ 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"])
+
+ # 名称/描述可被用户覆盖
+ name = overrides.get("name", s.meta.get("name", "")) if overrides else s.meta.get("name", "")
+ description = overrides.get("description", s.meta.get("description", "")) if overrides else s.meta.get("description", "")
+
+ return {
+ "id": s.meta["id"],
+ "name": name or s.meta.get("name", ""),
+ "description": description or s.meta.get("description", ""),
+ "tags": s.meta.get("tags", []),
+ "source": s.source,
+ "version": s.meta.get("version", "1.0.0"),
+ "basic_filter": bf,
+ "params": s.meta.get("params", []),
+ "params_defaults": params_defaults,
+ "scoring": scoring,
+ "entry_signals": s.entry_signals,
+ "exit_signals": s.exit_signals,
+ "stop_loss": overrides.get("stop_loss", s.stop_loss) if overrides else s.stop_loss,
+ "trailing_stop": getattr(s, "trailing_stop", None),
+ "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),
+ "display_limit": overrides.get("display_limit") if overrides and "display_limit" in overrides else None,
+ }
+
+
+# ── Request Models ───────────────────────────────────────────────────
+
+
+class RunRequest(BaseModel):
+ strategy_id: str
+ as_of: date | None = None
+ pool: list[str] | None = None
+ params: dict | None = None
+
+
+class RunAllRequest(BaseModel):
+ as_of: date | None = None
+
+
+class SaveConfigRequest(BaseModel):
+ strategy_id: str
+ overrides: dict
+
+
+class AIGenerateRequest(BaseModel):
+ prompt: str
+
+
+class AISaveRequest(BaseModel):
+ code: str
+ strategy_id: str
+
+
+class MonitorStartRequest(BaseModel):
+ strategy_id: str
+
+
+# ── 列表 / 详情 ─────────────────────────────────────────────────────
+
+
+@router.get("")
+def list_strategies(request: Request):
+ engine = _get_engine(request)
+ data_dir = _data_dir(request)
+ all_overrides = strategy_config.list_overrides(data_dir)
+
+ result = []
+ for meta in engine.list_strategies():
+ sid = meta["id"]
+ s = engine.get(sid)
+ overrides = all_overrides.get(sid)
+ result.append(_strategy_detail(s, overrides))
+ return {"strategies": result}
+
+
+@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
+ overrides = strategy_config.load_override(_data_dir(request), strategy_id)
+ return _strategy_detail(s, overrides or None)
+
+
+# ── 执行选股 ─────────────────────────────────────────────────────────
+
+
+@router.post("/run")
+def run_strategy(req: RunRequest, request: Request):
+ engine = _get_engine(request)
+ data_dir = _data_dir(request)
+
+ # 读取用户覆盖配置
+ overrides = strategy_config.load_override(data_dir, req.strategy_id)
+ params = req.params or {}
+ # 合并用户保存的策略参数
+ if overrides.get("params"):
+ merged = dict(overrides["params"])
+ merged.update(params) # 请求里的优先
+ params = merged
+
+ # 确定日期
+ as_of = req.as_of
+ if not as_of:
+ from app.services.screener import ScreenerService
+ svc = ScreenerService(request.app.state.repo)
+ as_of = svc.latest_date()
+ if not as_of:
+ raise HTTPException(status_code=400, detail="无可用数据日期")
+
+ try:
+ result = engine.run(
+ req.strategy_id, as_of,
+ pool=req.pool,
+ params=params,
+ overrides=overrides or None,
+ )
+ except ValueError as e:
+ raise HTTPException(status_code=404, detail=str(e)) from e
+
+ return _safe(asdict(result))
+
+
+@router.post("/run-all")
+def run_all(req: RunAllRequest, request: Request):
+ engine = _get_engine(request)
+ data_dir = _data_dir(request)
+
+ as_of = req.as_of
+ if not as_of:
+ from app.services.screener import ScreenerService
+ svc = ScreenerService(request.app.state.repo)
+ as_of = svc.latest_date()
+ if not as_of:
+ return {"as_of": None, "results": {}}
+
+ all_overrides = strategy_config.list_overrides(data_dir)
+ results: dict[str, dict] = {}
+ for sid, result in engine.run_all(as_of, overrides_map=all_overrides).items():
+ results[sid] = {"total": result.total, "as_of": str(as_of)}
+
+ return {"as_of": str(as_of), "results": results}
+
+
+# ── 配置持久化 ───────────────────────────────────────────────────────
+
+
+@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} 不存在")
+
+ # 剥离与策略默认值相同的字段,只保存用户真正修改过的值
+ overrides = _strip_defaults(req.strategy_id, req.overrides, engine)
+
+ strategy_config.save_override(_data_dir(request), req.strategy_id, overrides)
+ return {"ok": True}
+
+
+def _strip_defaults(strategy_id: str, overrides: dict, engine) -> dict:
+ """剥离与策略默认值相同的字段,避免默认值被固化到 override 中。
+
+ 核心问题: 前端把策略的默认 basic_filter 全量发回后端保存,
+ 导致隐含的默认过滤条件 (如 market_cap_min, amount_min) 被写入 override 文件。
+ 即使前端 UI 不展示这些字段,它们仍会在策略运行时生效。
+ """
+ s = engine.get(strategy_id)
+ result = dict(overrides)
+
+ # 处理 basic_filter: 只保留与策略默认值不同的键
+ bf = result.get("basic_filter")
+ if bf and isinstance(bf, dict):
+ default_bf = s.basic_filter if s else {}
+ stripped_bf = {}
+ for k, v in bf.items():
+ default_val = default_bf.get(k)
+ # 保留与默认值不同的键,以及没有默认值的键
+ if k not in default_bf or v != default_val:
+ stripped_bf[k] = v
+ if stripped_bf:
+ result["basic_filter"] = stripped_bf
+ else:
+ del result["basic_filter"]
+
+ return result
+
+
+@router.delete("/config/{strategy_id}")
+def reset_config(strategy_id: str, request: Request):
+ strategy_config.delete_override(_data_dir(request), strategy_id)
+ return {"ok": True}
+
+
+# ── AI 生成 ───────────────────────────────────────────────────────────
+
+class BuildRequest(BaseModel):
+ """两步策略构建请求"""
+ step: int # 1 / 2
+ # step1 字段
+ name: str = ""
+ description: str = ""
+ direction: str = "long"
+ rules: str = ""
+ strategy_id: str = ""
+ # step2 字段
+ current_code: str = ""
+ instruction: str = ""
+
+
+@router.get("/ai/status")
+def ai_status(request: Request):
+ """检查 AI 配置状态"""
+ from app.config import settings
+ from app import secrets_store
+ has_key = bool(secrets_store.get_ai_key())
+ has_model = bool(settings.ai_model)
+ return {"configured": has_key and has_model, "has_key": has_key, "has_model": has_model}
+
+
+@router.get("/{strategy_id}/source")
+def get_strategy_source(strategy_id: str, request: Request):
+ """获取策略源文件内容(用于 AI 修改)"""
+ from pathlib import Path
+
+ # 先查 StrategyEngine 获取文件路径
+ engine = _get_engine(request)
+ try:
+ s = engine.get(strategy_id)
+ except ValueError:
+ raise HTTPException(status_code=404, detail=f"策略 {strategy_id} 不存在")
+
+ path = s.file_path
+ if not path or not path.exists():
+ raise HTTPException(status_code=404, detail="策略源文件不存在")
+
+ return {"code": path.read_text(encoding="utf-8"), "source": s.source}
+
+
+@router.post("/ai/test")
+async def ai_test(request: Request):
+ """测试 AI 连通性 — 发送简单请求验证 Key 和模型"""
+ from app.config import settings
+ from app import secrets_store
+ from openai import AsyncOpenAI
+
+ ai_key = secrets_store.get_ai_key()
+ if not ai_key:
+ return {"ok": False, "error": "未配置 API Key"}
+
+ try:
+ client = AsyncOpenAI(api_key=ai_key, base_url=settings.ai_base_url)
+ resp = await client.chat.completions.create(
+ model=settings.ai_model,
+ messages=[{"role": "user", "content": "回复 OK"}],
+ max_tokens=5,
+ timeout=15,
+ )
+ return {"ok": True, "model": resp.model, "usage": {"prompt": resp.usage.prompt_tokens, "completion": resp.usage.completion_tokens} if resp.usage else None}
+ except Exception as e:
+ return {"ok": False, "error": str(e)}
+
+
+@router.post("/build")
+async def build_strategy(req: BuildRequest, request: Request):
+ """两步策略构建。
+ step1: name + description + direction + rules → 完整策略
+ step2: current_code + instruction → 修改任意部分
+ """
+ gen = AIStrategyGenerator()
+
+ if req.step == 1:
+ prompt = build_step1(req.name, req.description, req.direction, req.rules, req.strategy_id)
+ elif req.step == 2:
+ prompt = build_step2(req.current_code, req.instruction)
+ else:
+ raise HTTPException(status_code=400, detail=f"无效步骤: {req.step}")
+
+ try:
+ result = await gen.generate(prompt)
+ except RuntimeError as e:
+ raise HTTPException(status_code=400, detail=str(e)) from e
+ return result
+
+
+
+@router.post("/ai/generate")
+async def ai_generate(req: AIGenerateRequest, request: Request):
+ try:
+ gen = AIStrategyGenerator()
+ result = await gen.generate(req.prompt)
+ except RuntimeError as e:
+ raise HTTPException(status_code=400, detail=str(e)) from e
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"AI生成失败: {e}") from e
+ return result
+
+
+@router.post("/ai/save")
+async def ai_save(req: AISaveRequest, request: Request):
+ data_dir = _data_dir(request)
+ out_dir = data_dir / "strategies" / "ai"
+ out_dir.mkdir(parents=True, exist_ok=True)
+ path = out_dir / f"{req.strategy_id}.py"
+ previous_code = path.read_text(encoding="utf-8") if path.exists() else None
+ path.write_text(req.code, encoding="utf-8")
+
+ # 热重载,并确认保存的策略真的被引擎加载。
+ engine = _get_engine(request)
+ engine.reload()
+ if not engine.has(req.strategy_id):
+ if previous_code is None:
+ path.unlink(missing_ok=True)
+ else:
+ path.write_text(previous_code, encoding="utf-8")
+ engine.reload()
+ raise HTTPException(
+ status_code=400,
+ detail=f"策略保存成功但加载失败: {req.strategy_id},请检查代码语法和 META.id 是否一致",
+ )
+ return {"ok": True, "path": str(path)}
+
+
+@router.delete("/{strategy_id}")
+def delete_strategy(strategy_id: str, request: Request):
+ """删除自定义策略 — 清除 .py 文件 + overrides + 热重载。内置策略不可删除。"""
+ from pathlib import Path
+
+ engine = _get_engine(request)
+ try:
+ s = engine.get(strategy_id)
+ except ValueError:
+ raise HTTPException(status_code=404, detail=f"策略 {strategy_id} 不存在")
+
+ if s.source == "builtin":
+ raise HTTPException(status_code=403, detail="内置策略不可删除")
+
+ # 删除策略文件
+ if s.file_path and s.file_path.exists():
+ s.file_path.unlink()
+
+ # 删除 overrides
+ data_dir = _data_dir(request)
+ override_path = data_dir / "user_data" / "strategy_overrides" / f"{strategy_id}.json"
+ if override_path.exists():
+ override_path.unlink()
+
+ # 热重载
+ engine.reload()
+ return {"ok": True}
+
+
+# ── 监控 ─────────────────────────────────────────────────────────────
+
+
+@router.post("/monitor/start")
+def monitor_start(req: MonitorStartRequest, request: Request):
+ engine = _get_engine(request)
+ monitor = _get_monitor(request)
+ try:
+ s = engine.get(req.strategy_id)
+ except ValueError as e:
+ raise HTTPException(status_code=404, detail=str(e)) from e
+
+ monitor.start(req.strategy_id, {
+ "entry_signals": s.entry_signals,
+ "exit_signals": s.exit_signals,
+ "alerts": s.alerts,
+ })
+ return {"ok": True, "watching": list(monitor.watching.keys())}
+
+
+@router.post("/monitor/stop/{strategy_id}")
+def monitor_stop(strategy_id: str, request: Request):
+ monitor = _get_monitor(request)
+ monitor.stop(strategy_id)
+ return {"ok": True, "watching": list(monitor.watching.keys())}
+
+
+@router.get("/monitor/status")
+def monitor_status(request: Request):
+ monitor = _get_monitor(request)
+ return {"watching": list(monitor.watching.keys())}
+
+
+# ── 热重载 ───────────────────────────────────────────────────────────
+
+
+@router.post("/reload")
+def reload_strategies(request: Request):
+ engine = _get_engine(request)
+ engine.reload()
+ return {"ok": True, "count": len(engine.list_strategies())}
diff --git a/backend/app/api/watchlist.py b/backend/app/api/watchlist.py
new file mode 100644
index 0000000..183b875
--- /dev/null
+++ b/backend/app/api/watchlist.py
@@ -0,0 +1,202 @@
+"""自选股 API。"""
+from __future__ import annotations
+
+import logging
+import math
+import time
+from datetime import date
+
+import polars as pl
+from fastapi import APIRouter, Query, Request
+from pydantic import BaseModel
+
+from app.services import watchlist
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/watchlist", tags=["watchlist"])
+
+
+class AddRequest(BaseModel):
+ symbol: str
+ note: str = ""
+
+
+class BatchAddRequest(BaseModel):
+ symbols: list[str]
+ note: str = ""
+
+
+@router.get("")
+def list_all():
+ return {"symbols": watchlist.list_symbols()}
+
+
+@router.post("")
+def add_one(req: AddRequest):
+ rows = watchlist.add(req.symbol, req.note)
+ return {"symbols": rows}
+
+
+@router.post("/batch")
+def add_batch(req: BatchAddRequest):
+ for sym in req.symbols:
+ watchlist.add(sym, req.note)
+ return {"symbols": watchlist.list_symbols(), "added": len(req.symbols)}
+
+
+@router.delete("/{symbol}")
+def remove_one(symbol: str):
+ rows = watchlist.remove(symbol)
+ return {"symbols": rows}
+
+
+@router.delete("")
+def clear_all():
+ """清空自选列表。"""
+ count = watchlist.clear()
+ return {"removed": count}
+
+
+# 自选页需要的列
+_WATCHLIST_COLS = [
+ "symbol", "close", "change_pct", "change_amount", "amount",
+ "turnover_rate",
+ "amplitude", "annual_vol_20d",
+ "vol_ratio_5d",
+ "ma5", "ma10", "ma20", "ma60",
+ "vol_ma5", "vol_ma10",
+ "high_60d", "low_60d",
+ "rsi_6", "rsi_14", "rsi_24",
+ "macd_dif", "macd_dea", "macd_hist",
+ "kdj_k", "kdj_d", "kdj_j",
+ "boll_upper", "boll_lower",
+ "atr_14",
+ "momentum_5d", "momentum_10d", "momentum_20d", "momentum_30d", "momentum_60d",
+ "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",
+ "signal_boll_breakout_upper", "signal_ma20_breakout",
+ "signal_ma_dead_5_20", "signal_macd_dead", "signal_n_day_low",
+ "signal_boll_breakdown_lower", "signal_ma20_breakdown",
+]
+
+
+@router.get("/enriched")
+def watchlist_enriched(
+ request: Request,
+ ext_columns: str | None = Query(None, description="逗号分隔的 ext 列: config_id.field_name"),
+):
+ """自选股 enriched 数据 — 直接从 enriched 最新日读取, 无即时计算。
+
+ ext_columns 参数示例: "industry_rating.score,fund_flow.net_inflow"
+ 会动态 LEFT JOIN 对应的 ext_{config_id} DuckDB view。
+ """
+ t0 = time.perf_counter()
+
+ repo = request.app.state.repo
+ symbols = [r["symbol"] for r in watchlist.list_symbols()]
+ if not symbols:
+ return {"rows": [], "as_of": None, "elapsed_ms": 0}
+
+ df_e, cache_date = repo.get_enriched_latest()
+ if df_e.is_empty():
+ return {"rows": [], "as_of": None, "elapsed_ms": 0}
+
+ # 按 symbol 过滤
+ df = df_e.filter(pl.col("symbol").is_in(symbols))
+ if df.is_empty():
+ return {"rows": [], "as_of": str(cache_date) if cache_date else None, "elapsed_ms": 0}
+
+ # JOIN instruments 取 name + float_shares
+ df_i = repo.get_instruments()
+ if not df_i.is_empty() and "name" in df_i.columns:
+ inst_cols = [c for c in ["symbol", "name", "float_shares"] if c in df_i.columns]
+ df = df.join(df_i.select(inst_cols), on="symbol", how="left")
+
+ # 选择内置需要的列
+ keep = [c for c in _WATCHLIST_COLS + ["name", "float_shares"] if c in df.columns]
+ df = df.select(keep)
+
+ # 动态 JOIN 扩展数据表
+ ext_specs = _parse_ext_columns(ext_columns) if ext_columns else []
+ if ext_specs:
+ db = repo.store.db
+ data_dir = repo.store.data_dir
+ 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()}
+
+ for config_id, field_name in ext_specs:
+ view_name = f"ext_{config_id}"
+ ext_col_name = f"{config_id}__{field_name}"
+ try:
+ # 扩展时序数据必须只取最新分区;否则一个 symbol 会按历史分区数被 JOIN 放大。
+ cfg = configs.get(config_id)
+ if cfg:
+ ext_df, _ = _read_ext_dataframe(cfg, data_dir)
+ else:
+ ext_df = pl.from_arrow(db.query(
+ f"SELECT symbol, \"{field_name}\" FROM {view_name}"
+ ).arrow())
+ if not ext_df.is_empty() and "symbol" in ext_df.columns:
+ ext_df = (
+ ext_df
+ .select(["symbol", field_name])
+ .unique(subset=["symbol"], keep="last")
+ .rename({field_name: ext_col_name})
+ )
+ df = df.join(ext_df.select(["symbol", ext_col_name]), on="symbol", how="left")
+ except Exception:
+ # view 不存在或字段不存在,尝试直接读 parquet
+ cfg = configs.get(config_id)
+ if cfg:
+ try:
+ ext_df, _ = _read_ext_dataframe(cfg, data_dir)
+ if not ext_df.is_empty() and "symbol" in ext_df.columns and field_name in ext_df.columns:
+ ext_df = (
+ ext_df
+ .select(["symbol", field_name])
+ .unique(subset=["symbol"], keep="last")
+ .rename({field_name: ext_col_name})
+ )
+ df = df.join(ext_df, on="symbol", how="left")
+ except Exception as e2:
+ logger.debug("ext join fallback failed for %s.%s: %s", config_id, field_name, e2)
+
+ # sanitize NaN / Inf
+ float_cols = [c for c in df.columns if df[c].dtype.is_float()]
+ if float_cols:
+ df = df.with_columns([
+ pl.when(pl.col(c).is_nan() | pl.col(c).is_infinite())
+ .then(None)
+ .otherwise(pl.col(c))
+ .alias(c)
+ for c in float_cols
+ ])
+
+ # 按自选添加顺序(新加的在前)重排行
+ order_map = {s: i for i, s in enumerate(symbols)}
+ df = df.with_columns(pl.col("symbol").map_elements(lambda s: order_map.get(s, len(symbols)), return_dtype=pl.Int32).alias("_sort_order"))
+ df = df.sort("_sort_order").drop("_sort_order")
+
+ rows = df.to_dicts()
+ elapsed = (time.perf_counter() - t0) * 1000
+ return {"rows": rows, "as_of": str(cache_date) if cache_date else None, "elapsed_ms": elapsed}
+
+
+def _parse_ext_columns(ext_columns: str) -> list[tuple[str, str]]:
+ """解析 'config_id1.field1,config_id2.field2' 为 [(config_id, field_name), ...]"""
+ result = []
+ for part in ext_columns.split(","):
+ part = part.strip()
+ if "." not in part:
+ continue
+ config_id, field_name = part.split(".", 1)
+ config_id = config_id.strip()
+ field_name = field_name.strip()
+ if config_id and field_name:
+ result.append((config_id, field_name))
+ return result
diff --git a/backend/app/backtest/__init__.py b/backend/app/backtest/__init__.py
new file mode 100644
index 0000000..adfd49b
--- /dev/null
+++ b/backend/app/backtest/__init__.py
@@ -0,0 +1,4 @@
+"""回测模块 — 因子回测 + 策略回测 + 信号回测。
+
+架构: BacktestEngine (共享) → FactorBacktestService / StrategyBacktestService
+"""
diff --git a/backend/app/backtest/engine.py b/backend/app/backtest/engine.py
new file mode 100644
index 0000000..ad11712
--- /dev/null
+++ b/backend/app/backtest/engine.py
@@ -0,0 +1,1485 @@
+"""回测引擎 — 共享数据加载 + 撮合 + 统计计算。
+
+纯 Polars/NumPy 实现,不依赖 pandas/vectorbt。
+"""
+from __future__ import annotations
+
+import hashlib
+import logging
+import time
+from collections import OrderedDict
+from dataclasses import dataclass
+from datetime import date
+from typing import Callable
+
+logger = logging.getLogger(__name__)
+from typing import Literal
+
+import numpy as np
+import polars as pl
+
+from app.tickflow.repository import KlineRepository
+
+logger = logging.getLogger(__name__)
+
+
+# ================================================================
+# 数据结构
+# ================================================================
+
+@dataclass
+class MatcherConfig:
+ matching: Literal["close_t", "open_t+1"] = "close_t"
+ fees_pct: float = 0.0002
+ slippage_bps: float = 5.0
+ stop_loss_pct: float | None = None
+ trailing_stop_pct: float | None = None
+ trailing_take_profit_activate_pct: float | None = None
+ trailing_take_profit_drawdown_pct: float | None = None
+ max_hold_days: int | None = None
+ max_positions: int = 10
+ max_exposure_pct: float = 1.0
+ score_min: float | None = None
+ score_max: float | None = None
+ initial_capital: float = 1_000_000.0
+ position_sizing: Literal["equal", "score_weight"] = "equal"
+
+
+@dataclass
+class TradeRecord:
+ symbol: str
+ entry_date: date
+ exit_date: date
+ entry_price: float
+ exit_price: float
+ pnl_pct: float
+ duration: int
+ exit_reason: str # "signal" | "stop_loss" | "trailing_stop" | "trailing_take_profit" | "max_hold" | "end"
+ name: str = ""
+ shares: float = 0.0
+ lots: float = 0.0
+ position_pct: float = 0.0
+ entry_value: float = 0.0
+ exit_value: float = 0.0
+ pnl_amount: float = 0.0
+ entry_score: float | None = None
+ entry_signal_date: date | str | None = None
+ exit_signal_date: date | str | None = None
+ blocked_exit_days: int = 0
+
+
+@dataclass
+class SimResult:
+ equity_curve: list[dict] # [{date, value}]
+ drawdown_curve: list[dict] # [{date, value}]
+ trades: list[TradeRecord]
+ per_symbol_stats: list[dict]
+ stats: dict
+
+
+# ================================================================
+# PanelCache — 避免重复 scan_parquet + compute_all
+# ================================================================
+
+class _CacheEntry:
+ __slots__ = ("df", "ts")
+
+ def __init__(self, df: pl.DataFrame, ts: float):
+ self.df = df
+ self.ts = ts
+
+
+class PanelCache:
+ """LRU + TTL 数据面板缓存。"""
+
+ def __init__(self, max_size: int = 2, ttl_seconds: int = 180):
+ self._cache: OrderedDict[str, _CacheEntry] = OrderedDict()
+ self._max_size = max_size
+ self._ttl = ttl_seconds
+
+ def get_or_compute(
+ self,
+ symbols: list[str] | None,
+ start: date,
+ end: date,
+ columns: list[str] | None,
+ compute_fn,
+ ) -> pl.DataFrame:
+ key = self._make_key(symbols, start, end, columns)
+ now = time.monotonic()
+
+ if key in self._cache:
+ entry = self._cache[key]
+ if now - entry.ts < self._ttl:
+ self._cache.move_to_end(key)
+ return entry.df
+ del self._cache[key]
+
+ df = compute_fn(symbols, start, end, columns)
+ self._cache[key] = _CacheEntry(df=df, ts=now)
+ if len(self._cache) > self._max_size:
+ self._cache.popitem(last=False)
+ return df
+
+ def invalidate(self) -> None:
+ self._cache.clear()
+
+ @staticmethod
+ def _make_key(symbols: list[str] | None, start: date, end: date, columns: list[str] | 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"{h}:{start}:{end}:{cols}"
+
+
+# ================================================================
+# BacktestEngine
+# ================================================================
+
+class BacktestEngine:
+ """回测引擎 — 数据加载 + 撮合模拟 + 统计计算。"""
+
+ def __init__(self, repo: KlineRepository) -> None:
+ self.repo = repo
+ self._cache = PanelCache()
+
+ # ── 数据加载 ──────────────────────────────────────
+
+ def load_panel(
+ self,
+ symbols: list[str] | None,
+ start: date,
+ end: date,
+ columns: list[str] | None = None,
+ ) -> pl.DataFrame:
+ """加载 enriched 数据面板,带缓存。"""
+ return self._cache.get_or_compute(symbols, start, end, columns, self._load_panel_inner)
+
+ def _load_panel_inner(
+ self,
+ symbols: list[str] | None,
+ start: date,
+ end: date,
+ columns: list[str] | None = None,
+ ) -> pl.DataFrame:
+ t0 = time.perf_counter()
+
+ # 近期区间优先复用 repository 的预计算 enriched 历史缓存,避免重复 scan_parquet + compute_all。
+ try:
+ if self.repo is not None and hasattr(self.repo, "get_enriched_range"):
+ cached = self.repo.get_enriched_range(start, end, symbols=symbols, columns=columns)
+ if cached is not None and not cached.is_empty():
+ elapsed = (time.perf_counter() - t0) * 1000
+ logger.info("load_panel(cache): %.0fms, %d rows, %d columns", elapsed, len(cached), len(cached.columns))
+ return cached
+ except Exception as e: # noqa: BLE001
+ logger.debug("backtest load panel cache miss: %s", e)
+
+ enriched_glob = str(self.repo.store.data_dir / "kline_daily_enriched" / "**" / "*.parquet")
+
+ try:
+ lf = pl.scan_parquet(enriched_glob)
+ if symbols is not None:
+ lf = lf.filter(pl.col("symbol").is_in(symbols))
+ if columns is not None:
+ available = set(lf.collect_schema().names())
+ selected = [c for c in columns if c in available]
+ if "symbol" not in selected and "symbol" in available:
+ selected.insert(0, "symbol")
+ if "date" not in selected and "date" in available:
+ selected.insert(1, "date")
+ lf = lf.select(selected)
+ df = (
+ lf.filter(
+ (pl.col("date") >= start)
+ & (pl.col("date") <= end)
+ )
+ .sort(["symbol", "date"])
+ .collect(streaming=True)
+ )
+ except Exception as e:
+ logger.warning("backtest load panel failed: %s", e)
+ return pl.DataFrame()
+
+ if df.is_empty():
+ return df
+
+ if columns is not None:
+ elapsed = (time.perf_counter() - t0) * 1000
+ logger.info("load_panel: %.0fms, %d rows, %d columns", elapsed, len(df), len(df.columns))
+ return df
+
+ from app.indicators.pipeline import compute_all
+ instruments = self.repo.get_instruments()
+ df = compute_all(df, instruments=instruments)
+ if not instruments.is_empty() and "name" not in df.columns:
+ inst_cols = [c for c in ["symbol", "name"] if c in instruments.columns]
+ if len(inst_cols) == 2:
+ df = df.join(
+ instruments.select(inst_cols).unique(subset=["symbol"]),
+ on="symbol",
+ how="left",
+ )
+
+ elapsed = (time.perf_counter() - t0) * 1000
+ logger.info("load_panel: %.0fms, %d rows", elapsed, len(df))
+ return df
+
+ # ── 撮合模拟 ──────────────────────────────────────
+
+ def simulate(
+ self,
+ panel: pl.DataFrame,
+ entries: pl.Series | None,
+ exits: pl.Series | None,
+ config: MatcherConfig,
+ ) -> SimResult:
+ """纯 NumPy 撮合模拟 — 逐 symbol 状态机。"""
+ if panel.is_empty():
+ return self._empty_result()
+
+ n = len(panel)
+ panel_dates = panel["date"].to_numpy()
+ panel_symbols = panel["symbol"].to_numpy()
+
+ # 构建信号数组
+ ent = np.zeros(n, dtype=bool)
+ ext = np.zeros(n, dtype=bool)
+ if entries is not None and len(entries) == n:
+ ent = entries.to_numpy().astype(bool)
+ if exits is not None and len(exits) == n:
+ ext = exits.to_numpy().astype(bool)
+
+ if not ent.any():
+ return self._empty_result()
+
+ # T+1: 信号右移 1 天 + 使用开盘价撮合
+ if config.matching == "open_t+1":
+ price_col = "open"
+ ent_s = np.zeros(n, dtype=bool)
+ ext_s = np.zeros(n, dtype=bool)
+ ent_s[1:] = ent[:-1]
+ ext_s[1:] = ext[:-1]
+ ent = ent_s
+ ext = ext_s
+ else:
+ price_col = "close"
+
+ prices = panel[price_col].to_numpy()
+ close_prices = panel["close"].to_numpy()
+
+ # 逐 symbol 撮合
+ trades: list[TradeRecord] = []
+ unique_symbols = np.unique(panel_symbols)
+
+ for sym in unique_symbols:
+ mask = panel_symbols == sym
+ sym_ent = ent[mask]
+ sym_ext = ext[mask]
+ sym_prices = prices[mask]
+ sym_close = close_prices[mask]
+ sym_dates = panel_dates[mask]
+
+ holding = False
+ entry_idx = -1
+ entry_price = 0.0
+ hold_days = 0
+
+ for i in range(len(sym_ent)):
+ if not holding:
+ if sym_ent[i]:
+ holding = True
+ entry_idx = i
+ entry_price = float(sym_prices[i])
+ hold_days = 0
+ else:
+ hold_days += 1
+ exit_triggered = False
+ exit_reason = ""
+
+ # 止损 — 用当日 close 检测
+ if config.stop_loss_pct is not None:
+ pnl = (float(sym_close[i]) - entry_price) / entry_price
+ if pnl <= -abs(config.stop_loss_pct):
+ exit_triggered = True
+ exit_reason = "stop_loss"
+
+ # 最大持仓天数
+ if not exit_triggered and config.max_hold_days is not None:
+ if hold_days >= config.max_hold_days:
+ exit_triggered = True
+ exit_reason = "max_hold"
+
+ # 信号退出
+ if not exit_triggered and sym_ext[i]:
+ exit_triggered = True
+ exit_reason = "signal"
+
+ if exit_triggered:
+ exit_price = float(sym_prices[i])
+ pnl_pct = (exit_price - entry_price) / entry_price if entry_price > 0 else 0.0
+ fee_cost = config.fees_pct * 2 + config.slippage_bps / 10000.0 * 2
+ pnl_pct -= fee_cost
+
+ e_date = sym_dates[entry_idx]
+ x_date = sym_dates[i]
+ trades.append(TradeRecord(
+ symbol=str(sym),
+ entry_date=e_date.item() if hasattr(e_date, "item") else e_date,
+ exit_date=x_date.item() if hasattr(x_date, "item") else x_date,
+ entry_price=round(entry_price, 4),
+ exit_price=round(exit_price, 4),
+ pnl_pct=round(pnl_pct, 6),
+ duration=int(hold_days),
+ exit_reason=exit_reason,
+ ))
+ holding = False
+
+ # 净值曲线: 按出场日期归集收益
+ all_dates_sorted = np.sort(np.unique(panel_dates))
+ equity_curve, drawdown_curve = self._build_curves(trades, all_dates_sorted, config.initial_capital)
+
+ # 统计
+ date_min = panel_dates.min()
+ date_max = panel_dates.max()
+ d_min = date_min.item() if hasattr(date_min, "item") else date_min
+ d_max = date_max.item() if hasattr(date_max, "item") else date_max
+ stats = self._calc_stats(trades, config.initial_capital, d_min, d_max)
+ per_symbol = self._calc_per_symbol(trades)
+
+ return SimResult(
+ equity_curve=equity_curve,
+ drawdown_curve=drawdown_curve,
+ trades=trades,
+ per_symbol_stats=per_symbol,
+ stats=stats,
+ )
+
+ def simulate_independent_candidates(
+ self,
+ panel: pl.DataFrame,
+ entries: pl.Series | None,
+ exits: pl.Series | None,
+ config: MatcherConfig,
+ progress_cb: "Callable[[dict], None] | None" = None,
+ cancel_event: "threading.Event | None" = None,
+ ) -> SimResult:
+ """全量候选独立执行:每个买入信号都是独立样本, 不受资金/仓位限制。"""
+ if panel.is_empty():
+ return self._empty_result()
+
+ n = len(panel)
+ panel_dates = panel["date"].to_numpy()
+ panel_symbols = panel["symbol"].to_numpy()
+
+ ent_raw = np.zeros(n, dtype=bool)
+ ext_raw = np.zeros(n, dtype=bool)
+ if entries is not None and len(entries) == n:
+ ent_raw = entries.to_numpy().astype(bool)
+ if exits is not None and len(exits) == n:
+ ext_raw = exits.to_numpy().astype(bool)
+ n_candidates = int(ent_raw.sum())
+ if n_candidates <= 0:
+ return self._empty_result()
+
+ entry_signal_dates = np.array([None] * n, dtype=object)
+ exit_signal_dates = np.array([None] * n, dtype=object)
+ if config.matching == "open_t+1":
+ price_col = "open"
+ ent = np.zeros(n, dtype=bool)
+ ext = np.zeros(n, dtype=bool)
+ same_prev_symbol = panel_symbols[1:] == panel_symbols[:-1]
+ ent[1:] = ent_raw[:-1] & same_prev_symbol
+ ext[1:] = ext_raw[:-1] & same_prev_symbol
+ for idx in np.flatnonzero(ent):
+ entry_signal_dates[idx] = self._date_str(panel_dates[idx - 1])
+ for idx in np.flatnonzero(ext):
+ exit_signal_dates[idx] = self._date_str(panel_dates[idx - 1])
+ else:
+ price_col = "close"
+ ent = ent_raw
+ ext = ext_raw
+ for idx in np.flatnonzero(ent):
+ entry_signal_dates[idx] = self._date_str(panel_dates[idx])
+ for idx in np.flatnonzero(ext):
+ exit_signal_dates[idx] = self._date_str(panel_dates[idx])
+
+ prices = panel[price_col].to_numpy()
+ open_prices = panel["open"].to_numpy()
+ high_prices = panel["high"].to_numpy() if "high" in panel.columns else open_prices
+ low_prices = panel["low"].to_numpy()
+ close_prices = panel["close"].to_numpy()
+ has_volume = "volume" in panel.columns
+ volumes = panel["volume"].fill_null(0).to_numpy() if has_volume else np.ones(n, dtype=float)
+ names = panel["name"].fill_null("").to_numpy() if "name" in panel.columns else np.array([""] * n)
+ scores = panel["score"].fill_null(0).to_numpy() if "score" in panel.columns else np.zeros(n, dtype=float)
+ trade_scores = scores.copy()
+ if config.matching == "open_t+1":
+ trade_scores[1:] = np.where(panel_symbols[1:] == panel_symbols[:-1], scores[:-1], trade_scores[1:])
+ limit_up_flags = (
+ panel["signal_limit_up"].fill_null(False).to_numpy().astype(bool)
+ if "signal_limit_up" in panel.columns else np.zeros(n, dtype=bool)
+ )
+ limit_down_flags = (
+ panel["signal_limit_down"].fill_null(False).to_numpy().astype(bool)
+ if "signal_limit_down" in panel.columns else np.zeros(n, dtype=bool)
+ )
+
+ symbol_rows: dict[str, list[int]] = {}
+ row_pos_in_symbol = np.zeros(n, dtype=int)
+ for i, sym_value in enumerate(panel_symbols):
+ sym = str(sym_value)
+ rows = symbol_rows.setdefault(sym, [])
+ row_pos_in_symbol[i] = len(rows)
+ rows.append(i)
+
+ buy_cost_pct = config.fees_pct + config.slippage_bps / 10000.0
+ sell_cost_pct = config.fees_pct + config.slippage_bps / 10000.0
+ score_min = getattr(config, "score_min", None)
+ score_max = getattr(config, "score_max", None)
+ close_mode = config.matching == "close_t"
+ trades: list[TradeRecord] = []
+ execution_stats: dict[str, int] = {
+ "buy_invalid_price": 0,
+ "buy_suspended": 0,
+ "buy_limit_up": 0,
+ "buy_score_filter": 0,
+ "buy_no_next_bar": max(n_candidates - int(ent.sum()), 0),
+ "sell_invalid_price": 0,
+ "sell_suspended": 0,
+ "sell_limit_down": 0,
+ "sell_no_future": 0,
+ "pending_exit": 0,
+ }
+
+ def _count(key: str) -> None:
+ execution_stats[key] = execution_stats.get(key, 0) + 1
+
+ def _valid_price(value) -> bool:
+ try:
+ v = float(value)
+ except (TypeError, ValueError):
+ return False
+ return v > 0 and np.isfinite(v)
+
+ def _is_suspended(idx: int) -> bool:
+ o = float(open_prices[idx])
+ h = float(high_prices[idx])
+ l = float(low_prices[idx])
+ c = float(close_prices[idx])
+ valid_bar = any(_valid_price(x) for x in (o, h, l, c))
+ if not valid_bar:
+ return True
+ if has_volume and float(volumes[idx] or 0) <= 0:
+ same_price = max(o, h, l, c) - min(o, h, l, c) <= max(abs(c) * 1e-4, 0.01)
+ if same_price:
+ return True
+ return False
+
+ def _is_one_price_limit(idx: int, direction: str) -> bool:
+ if _is_suspended(idx):
+ return False
+ o = float(open_prices[idx])
+ h = float(high_prices[idx])
+ l = float(low_prices[idx])
+ c = float(close_prices[idx])
+ if not all(_valid_price(x) for x in (o, h, l, c)):
+ return False
+ same_price = max(o, h, l, c) - min(o, h, l, c) <= max(abs(c) * 1e-4, 0.01)
+ if direction == "up":
+ return bool(limit_up_flags[idx]) and same_price
+ return bool(limit_down_flags[idx]) and same_price
+
+ def _can_buy(idx: int) -> tuple[bool, str]:
+ if _is_suspended(idx):
+ return False, "buy_suspended"
+ if not _valid_price(prices[idx]):
+ return False, "buy_invalid_price"
+ if _is_one_price_limit(idx, "up"):
+ return False, "buy_limit_up"
+ return True, ""
+
+ def _can_sell(idx: int, exit_price_override: float | None = None) -> tuple[bool, str]:
+ if _is_suspended(idx):
+ return False, "sell_suspended"
+ exit_price = exit_price_override if exit_price_override is not None else prices[idx]
+ if not _valid_price(exit_price):
+ return False, "sell_invalid_price"
+ if _is_one_price_limit(idx, "down"):
+ return False, "sell_limit_down"
+ return True, ""
+
+ def _risk_exit(pos: dict, idx: int) -> tuple[str | None, float | None]:
+ if pos.get("pending_exit_reason") or pos.get("entry_idx") == idx:
+ return None, None
+ entry_price = float(pos["entry_price"])
+ if entry_price <= 0:
+ return None, None
+ open_price = float(open_prices[idx])
+ low_price = float(low_prices[idx])
+ peak_price = float(pos.get("max_high", entry_price))
+ risk_lines: list[tuple[float, str]] = []
+
+ if config.stop_loss_pct is not None:
+ risk_lines.append((entry_price * (1 - abs(config.stop_loss_pct)), "stop_loss"))
+ if config.trailing_stop_pct is not None and peak_price > 0:
+ risk_lines.append((peak_price * (1 - abs(config.trailing_stop_pct)), "trailing_stop"))
+
+ activate_pct = getattr(config, "trailing_take_profit_activate_pct", None)
+ drawdown_pct = getattr(config, "trailing_take_profit_drawdown_pct", None)
+ if activate_pct is not None and drawdown_pct is not None and peak_price > entry_price:
+ peak_profit = peak_price / entry_price - 1
+ if peak_profit >= abs(float(activate_pct)):
+ risk_lines.append((entry_price * (1 + peak_profit - abs(float(drawdown_pct))), "trailing_take_profit"))
+
+ risk_lines = [(line, reason) for line, reason in risk_lines if _valid_price(line)]
+ if not risk_lines:
+ return None, None
+ stop_price, reason = max(risk_lines, key=lambda item: item[0])
+ if _valid_price(open_price) and open_price <= stop_price:
+ return reason, open_price
+ if _valid_price(low_price) and low_price <= stop_price:
+ return reason, stop_price
+ return None, None
+
+ def _try_close(pos: dict, idx: int, reason: str, signal_date: str, exit_price_override: float | None = None) -> bool:
+ ok, block_reason = _can_sell(idx, exit_price_override)
+ if not ok:
+ if not pos.get("pending_exit_reason"):
+ pos["pending_exit_reason"] = reason
+ pos["pending_exit_signal_date"] = signal_date
+ _count("pending_exit")
+ pos["blocked_exit_days"] = int(pos.get("blocked_exit_days", 0)) + 1
+ _count(block_reason)
+ return False
+
+ exit_price = float(exit_price_override) if exit_price_override is not None else float(prices[idx])
+ shares = 100.0
+ entry_value = shares * float(pos["entry_price"]) * (1 + buy_cost_pct)
+ exit_value = shares * exit_price * (1 - sell_cost_pct)
+ pnl_amount = exit_value - entry_value
+ pnl_pct = pnl_amount / entry_value if entry_value > 0 else 0.0
+ trades.append(TradeRecord(
+ symbol=str(pos["symbol"]),
+ name=str(pos.get("name", "")),
+ entry_date=pos["entry_date"],
+ exit_date=self._date_str(panel_dates[idx]),
+ entry_price=round(float(pos["entry_price"]), 4),
+ exit_price=round(exit_price, 4),
+ pnl_pct=round(float(pnl_pct), 6),
+ duration=int(pos["hold_days"]),
+ exit_reason=reason,
+ shares=shares,
+ lots=1.0,
+ position_pct=0.0,
+ entry_value=round(float(entry_value), 2),
+ exit_value=round(float(exit_value), 2),
+ pnl_amount=round(float(pnl_amount), 2),
+ entry_score=round(float(pos["entry_score"]), 2) if pos.get("entry_score") is not None else None,
+ entry_signal_date=pos.get("entry_signal_date"),
+ exit_signal_date=signal_date,
+ blocked_exit_days=int(pos.get("blocked_exit_days", 0)),
+ ))
+ return True
+
+ candidate_indices = np.flatnonzero(ent)
+ for seq, entry_idx in enumerate(candidate_indices, start=1):
+ if cancel_event is not None and cancel_event.is_set():
+ logger.info("全量模拟被用户取消 (第 %d/%d 个候选)", seq, len(candidate_indices))
+ break
+ if progress_cb is not None and (seq == 1 or seq % 500 == 0):
+ try:
+ progress_cb({
+ "day": seq,
+ "total": len(candidate_indices),
+ "date": self._date_str(panel_dates[entry_idx]),
+ "equity": 0,
+ })
+ except Exception:
+ pass
+
+ ok, block_reason = _can_buy(entry_idx)
+ if not ok:
+ _count(block_reason)
+ continue
+ score = float(trade_scores[entry_idx] or 0.0)
+ if score_min is not None and score < score_min:
+ _count("buy_score_filter")
+ continue
+ if score_max is not None and score > score_max:
+ _count("buy_score_filter")
+ continue
+
+ sym = str(panel_symbols[entry_idx])
+ rows = symbol_rows.get(sym, [])
+ start_pos = int(row_pos_in_symbol[entry_idx])
+ if start_pos >= len(rows):
+ _count("sell_no_future")
+ continue
+
+ entry_price = float(prices[entry_idx])
+ pos = {
+ "symbol": sym,
+ "name": str(names[entry_idx] or ""),
+ "entry_idx": entry_idx,
+ "entry_date": self._date_str(panel_dates[entry_idx]),
+ "entry_signal_date": entry_signal_dates[entry_idx] or self._date_str(panel_dates[entry_idx]),
+ "entry_price": entry_price,
+ "entry_score": score,
+ "hold_days": 0,
+ "max_high": entry_price,
+ "pending_exit_reason": None,
+ "pending_exit_signal_date": None,
+ "blocked_exit_days": 0,
+ }
+ hi = float(high_prices[entry_idx])
+ if _valid_price(hi):
+ pos["max_high"] = max(float(pos["max_high"]), hi)
+
+ closed = False
+ last_idx = entry_idx
+ for idx in rows[start_pos + 1:]:
+ last_idx = idx
+ pos["hold_days"] = int(pos["hold_days"]) + 1
+ d_str = self._date_str(panel_dates[idx])
+
+ def _scheduled_reason() -> tuple[str | None, str]:
+ if pos.get("pending_exit_reason"):
+ return str(pos["pending_exit_reason"]), str(pos.get("pending_exit_signal_date") or d_str)
+ if config.max_hold_days is not None and pos["hold_days"] >= config.max_hold_days:
+ return "max_hold", d_str
+ if ext[idx]:
+ return "signal", str(exit_signal_dates[idx] or d_str)
+ if idx == rows[-1]:
+ return "end", d_str
+ return None, d_str
+
+ if close_mode:
+ reason, override_price = _risk_exit(pos, idx)
+ if reason and _try_close(pos, idx, reason, d_str, override_price):
+ closed = True
+ break
+ reason, signal_date = _scheduled_reason()
+ if reason and _try_close(pos, idx, reason, signal_date):
+ closed = True
+ break
+ else:
+ reason, signal_date = _scheduled_reason()
+ if reason and _try_close(pos, idx, reason, signal_date):
+ closed = True
+ break
+ reason, override_price = _risk_exit(pos, idx)
+ if reason and _try_close(pos, idx, reason, d_str, override_price):
+ closed = True
+ break
+
+ hi = float(high_prices[idx])
+ if _valid_price(hi):
+ pos["max_high"] = max(float(pos.get("max_high", entry_price)), hi)
+
+ if not closed:
+ if last_idx == entry_idx:
+ _count("sell_no_future")
+ elif not pos.get("pending_exit_reason"):
+ _try_close(pos, last_idx, "end", self._date_str(panel_dates[last_idx]))
+
+ return self._calc_independent_candidate_result(trades, n_candidates, execution_stats)
+
+ def simulate_portfolio(
+ self,
+ panel: pl.DataFrame,
+ entries: pl.Series | None,
+ exits: pl.Series | None,
+ config: MatcherConfig,
+ progress_cb: "Callable[[dict], None] | None" = None,
+ cancel_event: "threading.Event | None" = None,
+ ) -> SimResult:
+ """账户级组合回测:日线信号 → 成交约束 → 仓位/现金撮合。"""
+ if panel.is_empty():
+ return self._empty_result()
+
+ n = len(panel)
+ panel_dates = panel["date"].to_numpy()
+ panel_symbols = panel["symbol"].to_numpy()
+
+ ent_raw = np.zeros(n, dtype=bool)
+ ext_raw = np.zeros(n, dtype=bool)
+ if entries is not None and len(entries) == n:
+ ent_raw = entries.to_numpy().astype(bool)
+ if exits is not None and len(exits) == n:
+ ext_raw = exits.to_numpy().astype(bool)
+ if not ent_raw.any():
+ return self._empty_result()
+
+ entry_signal_dates = np.array([None] * n, dtype=object)
+ exit_signal_dates = np.array([None] * n, dtype=object)
+ if config.matching == "open_t+1":
+ price_col = "open"
+ ent = np.zeros(n, dtype=bool)
+ ext = np.zeros(n, dtype=bool)
+ same_prev_symbol = panel_symbols[1:] == panel_symbols[:-1]
+ ent[1:] = ent_raw[:-1] & same_prev_symbol
+ ext[1:] = ext_raw[:-1] & same_prev_symbol
+ for idx in np.flatnonzero(ent):
+ entry_signal_dates[idx] = self._date_str(panel_dates[idx - 1])
+ for idx in np.flatnonzero(ext):
+ exit_signal_dates[idx] = self._date_str(panel_dates[idx - 1])
+ else:
+ price_col = "close"
+ ent = ent_raw
+ ext = ext_raw
+ for idx in np.flatnonzero(ent):
+ entry_signal_dates[idx] = self._date_str(panel_dates[idx])
+ for idx in np.flatnonzero(ext):
+ exit_signal_dates[idx] = self._date_str(panel_dates[idx])
+
+ prices = panel[price_col].to_numpy()
+ open_prices = panel["open"].to_numpy()
+ high_prices = panel["high"].to_numpy() if "high" in panel.columns else open_prices
+ low_prices = panel["low"].to_numpy()
+ close_prices = panel["close"].to_numpy()
+ has_volume = "volume" in panel.columns
+ volumes = panel["volume"].fill_null(0).to_numpy() if has_volume else np.ones(n, dtype=float)
+ names = (
+ panel["name"].fill_null("").to_numpy()
+ if "name" in panel.columns else np.array([""] * n)
+ )
+ scores = (
+ panel["score"].fill_null(0).to_numpy()
+ if "score" in panel.columns else np.zeros(n, dtype=float)
+ )
+ trade_scores = scores.copy()
+ if config.matching == "open_t+1":
+ trade_scores[1:] = np.where(panel_symbols[1:] == panel_symbols[:-1], scores[:-1], trade_scores[1:])
+ limit_up_flags = (
+ panel["signal_limit_up"].fill_null(False).to_numpy().astype(bool)
+ if "signal_limit_up" in panel.columns else np.zeros(n, dtype=bool)
+ )
+ limit_down_flags = (
+ panel["signal_limit_down"].fill_null(False).to_numpy().astype(bool)
+ if "signal_limit_down" in panel.columns else np.zeros(n, dtype=bool)
+ )
+
+ date_to_indices: dict[str, list[int]] = {}
+ for i, d in enumerate(panel_dates):
+ d_str = self._date_str(d)
+ date_to_indices.setdefault(d_str, []).append(i)
+ all_dates = sorted(date_to_indices.keys())
+ if not all_dates:
+ return self._empty_result()
+
+ buy_cost_pct = config.fees_pct + config.slippage_bps / 10000.0
+ sell_cost_pct = config.fees_pct + config.slippage_bps / 10000.0
+ cash = float(config.initial_capital)
+ peak = cash
+ max_positions = max(int(config.max_positions), 0)
+ max_exposure_pct = min(max(float(getattr(config, "max_exposure_pct", 1.0)), 0.0), 1.0)
+ score_min = getattr(config, "score_min", None)
+ score_max = getattr(config, "score_max", None)
+ positions: dict[str, dict] = {}
+ last_close: dict[str, float] = {}
+ trades: list[TradeRecord] = []
+ equity_curve: list[dict] = []
+ drawdown_curve: list[dict] = []
+ execution_stats: dict[str, int] = {
+ "buy_invalid_price": 0,
+ "buy_suspended": 0,
+ "buy_limit_up": 0,
+ "buy_no_slot": 0,
+ "buy_cash": 0,
+ "buy_lot_size": 0,
+ "buy_same_day_reentry": 0,
+ "buy_exposure": 0,
+ "buy_score_filter": 0,
+ "sell_invalid_price": 0,
+ "sell_suspended": 0,
+ "sell_limit_down": 0,
+ "pending_exit": 0,
+ }
+
+ def _count(key: str) -> None:
+ execution_stats[key] = execution_stats.get(key, 0) + 1
+
+ def _valid_price(value) -> bool:
+ try:
+ v = float(value)
+ except (TypeError, ValueError):
+ return False
+ return v > 0 and np.isfinite(v)
+
+ def _market_value() -> float:
+ value = 0.0
+ for pos in positions.values():
+ mark = last_close.get(pos["symbol"], pos["entry_price"])
+ value += pos["shares"] * mark
+ return value
+
+ def _is_suspended(idx: int) -> bool:
+ o = float(open_prices[idx])
+ h = float(high_prices[idx])
+ l = float(low_prices[idx])
+ c = float(close_prices[idx])
+ valid_bar = any(_valid_price(x) for x in (o, h, l, c))
+ if not valid_bar:
+ return True
+ if has_volume and float(volumes[idx] or 0) <= 0:
+ same_price = max(o, h, l, c) - min(o, h, l, c) <= max(abs(c) * 1e-4, 0.01)
+ if same_price:
+ return True
+ return False
+
+ def _is_one_price_limit(idx: int, direction: str) -> bool:
+ if _is_suspended(idx):
+ return False
+ o = float(open_prices[idx])
+ h = float(high_prices[idx])
+ l = float(low_prices[idx])
+ c = float(close_prices[idx])
+ if not all(_valid_price(x) for x in (o, h, l, c)):
+ return False
+ same_price = max(o, h, l, c) - min(o, h, l, c) <= max(abs(c) * 1e-4, 0.01)
+ if direction == "up":
+ return bool(limit_up_flags[idx]) and same_price
+ return bool(limit_down_flags[idx]) and same_price
+
+ def _can_buy(idx: int) -> tuple[bool, str]:
+ if _is_suspended(idx):
+ return False, "buy_suspended"
+ if not _valid_price(prices[idx]):
+ return False, "buy_invalid_price"
+ if _is_one_price_limit(idx, "up"):
+ return False, "buy_limit_up"
+ return True, ""
+
+ def _can_sell(idx: int, exit_price_override: float | None = None) -> tuple[bool, str]:
+ if _is_suspended(idx):
+ return False, "sell_suspended"
+ exit_price = exit_price_override if exit_price_override is not None else prices[idx]
+ if not _valid_price(exit_price):
+ return False, "sell_invalid_price"
+ if _is_one_price_limit(idx, "down"):
+ return False, "sell_limit_down"
+ return True, ""
+
+ def _mark_pending(sym: str, reason: str, signal_date: str) -> None:
+ pos = positions[sym]
+ if not pos.get("pending_exit_reason"):
+ pos["pending_exit_reason"] = reason
+ pos["pending_exit_signal_date"] = signal_date
+ _count("pending_exit")
+ pos["blocked_exit_days"] = int(pos.get("blocked_exit_days", 0)) + 1
+
+ def _sell(
+ sym: str,
+ idx: int,
+ reason: str,
+ signal_date: str,
+ sold_today: set[str],
+ exit_price_override: float | None = None,
+ ) -> None:
+ nonlocal cash
+ pos = positions.pop(sym)
+ exit_price = float(exit_price_override) if exit_price_override is not None else float(prices[idx])
+ exit_value = pos["shares"] * exit_price * (1 - sell_cost_pct)
+ cash += exit_value
+ pnl_amount = exit_value - pos["entry_value"]
+ pnl_pct = (exit_value - pos["entry_value"]) / pos["entry_value"] if pos["entry_value"] > 0 else 0.0
+ sold_today.add(sym)
+ trades.append(TradeRecord(
+ symbol=sym,
+ name=pos.get("name", ""),
+ entry_date=pos["entry_date"],
+ exit_date=self._date_str(panel_dates[idx]),
+ entry_price=round(float(pos["entry_price"]), 4),
+ exit_price=round(exit_price, 4),
+ pnl_pct=round(float(pnl_pct), 6),
+ duration=int(pos["hold_days"]),
+ exit_reason=reason,
+ shares=round(float(pos["shares"]), 4),
+ lots=round(float(pos["lots"]), 2),
+ position_pct=round(float(pos.get("position_pct", 0.0)), 6),
+ entry_value=round(float(pos["entry_value"]), 2),
+ exit_value=round(float(exit_value), 2),
+ pnl_amount=round(float(pnl_amount), 2),
+ entry_score=round(float(pos["entry_score"]), 2) if pos.get("entry_score") is not None else None,
+ entry_signal_date=pos.get("entry_signal_date"),
+ exit_signal_date=signal_date,
+ blocked_exit_days=int(pos.get("blocked_exit_days", 0)),
+ ))
+
+ def _try_sell(
+ sym: str,
+ idx: int | None,
+ reason: str,
+ signal_date: str,
+ sold_today: set[str],
+ exit_price_override: float | None = None,
+ ) -> bool:
+ if idx is None:
+ _mark_pending(sym, reason, signal_date)
+ _count("sell_suspended")
+ return False
+ ok, block_reason = _can_sell(idx, exit_price_override)
+ if not ok:
+ _mark_pending(sym, reason, signal_date)
+ _count(block_reason)
+ return False
+ _sell(sym, idx, reason, signal_date, sold_today, exit_price_override)
+ return True
+
+ def _process_scheduled_exits(
+ d_idx: int,
+ d_str: str,
+ row_by_symbol: dict[str, int],
+ sold_today: set[str],
+ ) -> None:
+ for sym in list(positions.keys()):
+ pos = positions.get(sym)
+ if pos is None:
+ continue
+ idx = row_by_symbol.get(sym)
+ reason = ""
+ signal_date = d_str
+ if pos.get("pending_exit_reason"):
+ reason = str(pos["pending_exit_reason"])
+ signal_date = str(pos.get("pending_exit_signal_date") or d_str)
+ elif config.max_hold_days is not None and pos["hold_days"] >= config.max_hold_days:
+ reason = "max_hold"
+ elif idx is not None and ext[idx]:
+ reason = "signal"
+ signal_date = str(exit_signal_dates[idx] or d_str)
+ elif d_idx == len(all_dates) - 1:
+ reason = "end"
+ if reason:
+ _try_sell(sym, idx, reason, signal_date, sold_today)
+
+ def _process_risk_exits(d_str: str, row_by_symbol: dict[str, int], sold_today: set[str]) -> None:
+ for sym in list(positions.keys()):
+ pos = positions.get(sym)
+ if pos is None or pos.get("pending_exit_reason"):
+ continue
+ if pos.get("entry_date") == d_str:
+ continue
+ idx = row_by_symbol.get(sym)
+ if idx is None or pos["entry_price"] <= 0:
+ continue
+ open_price = float(open_prices[idx])
+ low_price = float(low_prices[idx])
+ entry_price = float(pos["entry_price"])
+ peak_price = float(pos.get("max_high", entry_price))
+ risk_lines: list[tuple[float, str]] = []
+
+ if config.stop_loss_pct is not None:
+ risk_lines.append((entry_price * (1 - abs(config.stop_loss_pct)), "stop_loss"))
+
+ if config.trailing_stop_pct is not None and peak_price > 0:
+ risk_lines.append((peak_price * (1 - abs(config.trailing_stop_pct)), "trailing_stop"))
+
+ activate_pct = getattr(config, "trailing_take_profit_activate_pct", None)
+ drawdown_pct = getattr(config, "trailing_take_profit_drawdown_pct", None)
+ if activate_pct is not None and drawdown_pct is not None and peak_price > entry_price:
+ peak_profit = peak_price / entry_price - 1
+ if peak_profit >= abs(float(activate_pct)):
+ take_profit_line = entry_price * (1 + peak_profit - abs(float(drawdown_pct)))
+ risk_lines.append((take_profit_line, "trailing_take_profit"))
+
+ risk_lines = [(line, reason) for line, reason in risk_lines if _valid_price(line)]
+ if not risk_lines:
+ continue
+ stop_price, reason = max(risk_lines, key=lambda item: item[0])
+ exit_price_override = None
+ if _valid_price(open_price) and open_price <= stop_price:
+ exit_price_override = open_price
+ elif _valid_price(low_price) and low_price <= stop_price:
+ exit_price_override = stop_price
+ if exit_price_override is not None:
+ _try_sell(sym, idx, reason, d_str, sold_today, exit_price_override)
+
+ def _process_entries(
+ d_str: str,
+ idxs: list[int],
+ sold_today: set[str],
+ ) -> None:
+ nonlocal cash
+ if max_positions <= 0:
+ return
+ candidates: list[tuple[int, str, float]] = []
+ for idx in idxs:
+ if not ent[idx]:
+ continue
+ sym = str(panel_symbols[idx])
+ if sym in positions:
+ continue
+ if sym in sold_today:
+ _count("buy_same_day_reentry")
+ continue
+ ok, block_reason = _can_buy(idx)
+ if not ok:
+ _count(block_reason)
+ continue
+ score = float(trade_scores[idx] or 0.0)
+ if score_min is not None and score < score_min:
+ _count("buy_score_filter")
+ continue
+ if score_max is not None and score > score_max:
+ _count("buy_score_filter")
+ continue
+ candidates.append((idx, sym, score))
+ if not candidates:
+ return
+ candidates.sort(key=lambda x: x[2], reverse=True)
+
+ slots = max_positions - len(positions)
+ if slots <= 0:
+ execution_stats["buy_no_slot"] += len(candidates)
+ return
+
+ selected = candidates[:slots]
+ market_value_before = _market_value()
+ account_equity_before_buy = cash + market_value_before
+ if account_equity_before_buy <= 0 or max_exposure_pct <= 0:
+ execution_stats["buy_exposure"] += len(selected)
+ return
+ target_position_value = account_equity_before_buy * max_exposure_pct / max_positions
+ max_exposure_value = account_equity_before_buy * max_exposure_pct
+ exposure_capacity = max_exposure_value - market_value_before
+ if exposure_capacity <= 0:
+ execution_stats["buy_exposure"] += len(selected)
+ return
+
+ weights = np.repeat(1 / len(selected), len(selected))
+ if config.position_sizing == "score_weight":
+ raw = np.array([max(x[2], 0.0) for x in selected], dtype=float)
+ if raw.sum() > 0:
+ weights = raw / raw.sum()
+ total_budget = min(cash, exposure_capacity, target_position_value * len(selected))
+
+ for (idx, sym, _score), weight in zip(selected, weights):
+ if len(positions) >= max_positions:
+ _count("buy_no_slot")
+ break
+ current_market_value = _market_value()
+ current_equity = cash + current_market_value
+ current_exposure_capacity = current_equity * max_exposure_pct - current_market_value
+ allocation = min(total_budget * float(weight), target_position_value, cash, current_exposure_capacity)
+ if allocation <= 0:
+ _count("buy_exposure")
+ continue
+ entry_price = float(prices[idx])
+ shares = np.floor(allocation / (entry_price * (1 + buy_cost_pct)) / 100) * 100
+ entry_value = shares * entry_price * (1 + buy_cost_pct)
+ if shares <= 0:
+ _count("buy_lot_size")
+ continue
+ if entry_value > cash + 1e-6:
+ _count("buy_cash")
+ continue
+ if entry_value > current_exposure_capacity + 1e-6:
+ _count("buy_exposure")
+ continue
+ cash -= entry_value
+ positions[sym] = {
+ "symbol": sym,
+ "name": str(names[idx] or ""),
+ "entry_date": self._date_str(panel_dates[idx]),
+ "entry_signal_date": entry_signal_dates[idx] or self._date_str(panel_dates[idx]),
+ "entry_price": entry_price,
+ "entry_value": entry_value,
+ "shares": shares,
+ "lots": shares / 100,
+ "position_pct": entry_value / account_equity_before_buy if account_equity_before_buy > 0 else 0.0,
+ "entry_score": _score,
+ "max_high": entry_price,
+ "hold_days": 0,
+ "pending_exit_reason": None,
+ "pending_exit_signal_date": None,
+ "blocked_exit_days": 0,
+ }
+
+ close_mode = config.matching == "close_t"
+ for d_idx, d_str in enumerate(all_dates):
+ if d_idx % 20 == 0:
+ if cancel_event is not None and cancel_event.is_set():
+ logger.info("回测被用户取消 (第 %d/%d 天)", d_idx, len(all_dates))
+ break
+ if progress_cb is not None:
+ try:
+ progress_cb({
+ "day": d_idx + 1,
+ "total": len(all_dates),
+ "date": str(d_str)[:10],
+ "equity": round(cash + _market_value(), 2),
+ })
+ except Exception:
+ pass
+
+ idxs = date_to_indices[d_str]
+ row_by_symbol = {str(panel_symbols[i]): i for i in idxs}
+ sold_today: set[str] = set()
+
+ for pos in positions.values():
+ pos["hold_days"] += 1
+
+ if close_mode:
+ _process_risk_exits(d_str, row_by_symbol, sold_today)
+ _process_scheduled_exits(d_idx, d_str, row_by_symbol, sold_today)
+ if d_idx < len(all_dates) - 1:
+ _process_entries(d_str, idxs, sold_today)
+ else:
+ _process_scheduled_exits(d_idx, d_str, row_by_symbol, sold_today)
+ if d_idx < len(all_dates) - 1:
+ _process_entries(d_str, idxs, sold_today)
+ _process_risk_exits(d_str, row_by_symbol, sold_today)
+
+ for sym, pos in positions.items():
+ idx = row_by_symbol.get(sym)
+ if idx is not None:
+ hi = float(high_prices[idx])
+ if _valid_price(hi):
+ pos["max_high"] = max(float(pos.get("max_high", pos["entry_price"])), hi)
+
+ for i in idxs:
+ c = float(close_prices[i])
+ if c > 0 and np.isfinite(c):
+ last_close[str(panel_symbols[i])] = c
+
+ market_value = _market_value()
+ equity = cash + market_value
+ peak = max(peak, equity)
+ dd = (equity - peak) / peak if peak > 0 else 0.0
+ exposure = market_value / equity if equity > 0 else 0.0
+ equity_curve.append({
+ "date": d_str[:10],
+ "value": round(float(equity), 2),
+ "cash": round(float(cash), 2),
+ "positions": len(positions),
+ "exposure": round(float(exposure), 4),
+ })
+ drawdown_curve.append({"date": d_str[:10], "value": round(float(dd), 4)})
+
+ stats = self._calc_portfolio_stats(equity_curve, trades, config.initial_capital)
+ stats["execution"] = execution_stats
+ stats["pending_exit_positions"] = sum(1 for p in positions.values() if p.get("pending_exit_reason"))
+ per_symbol = self._calc_per_symbol(trades)
+ return SimResult(
+ equity_curve=equity_curve,
+ drawdown_curve=drawdown_curve,
+ trades=trades,
+ per_symbol_stats=per_symbol,
+ stats=stats,
+ )
+
+ # ── 净值曲线 ──────────────────────────────────────
+
+ @staticmethod
+ def _build_curves(
+ trades: list[TradeRecord],
+ all_dates: np.ndarray,
+ initial_capital: float,
+ ) -> tuple[list[dict], list[dict]]:
+ """从交易记录构建日频净值曲线和回撤曲线。
+
+ 资金模型: 每笔交易等权分配 (1/N_capital),N_capital = 同时持仓数上限。
+ 简化版: 按出场日归集所有已平仓交易的平均收益作为当日组合收益。
+ """
+ if not trades or len(all_dates) == 0:
+ return [], []
+
+ # 按出场日归集 pnl
+ exit_pnl: dict[str, list[float]] = {}
+ for t in trades:
+ d_str = str(t.exit_date)
+ exit_pnl.setdefault(d_str, []).append(t.pnl_pct)
+
+ equity = initial_capital
+ peak = initial_capital
+ curve: list[dict] = []
+ dd_curve: list[dict] = []
+
+ for d in all_dates:
+ d_str = str(d.item() if hasattr(d, "item") else d)
+ pnls = exit_pnl.get(d_str, [])
+ # 当日组合收益 = 该日所有出场交易的平均收益
+ daily_ret = float(np.mean(pnls)) if pnls else 0.0
+ equity *= (1 + daily_ret)
+ peak = max(peak, equity)
+ dd = (equity - peak) / peak if peak > 0 else 0.0
+ curve.append({"date": d_str[:10], "value": round(equity, 2)})
+ dd_curve.append({"date": d_str[:10], "value": round(dd, 4)})
+
+ return curve, dd_curve
+
+ # ── 统计计算 ──────────────────────────────────────
+
+ @staticmethod
+ def _calc_stats(
+ trades: list[TradeRecord],
+ initial_capital: float,
+ start: date,
+ end: date,
+ ) -> dict:
+ if not trades:
+ return {"total_return": 0, "n_trades": 0}
+
+ pnls = np.array([t.pnl_pct for t in trades])
+ n_trades = len(trades)
+
+ # 从净值曲线推算总收益 (等权组合)
+ cumulative = 1.0
+ for p in pnls:
+ cumulative *= (1 + p)
+ # 修正: 等权组合的总收益不等于各笔复乘,用曲线终点更准
+ # 但这里作为简化,用各笔复乘作为近似
+ total_return = cumulative - 1.0
+
+ # 年化
+ n_days = max((end - start).days, 1)
+ years = n_days / 365.25
+ if total_return > -1.0 and years > 0:
+ annual_return = (1 + total_return) ** (1 / years) - 1
+ else:
+ annual_return = total_return
+
+ # 胜率
+ wins = pnls[pnls > 0]
+ losses = pnls[pnls <= 0]
+ win_rate = len(wins) / n_trades
+
+ # 盈亏比
+ avg_win = float(np.mean(wins)) if len(wins) > 0 else 0.0
+ avg_loss = abs(float(np.mean(losses))) if len(losses) > 0 else 0.0
+ profit_factor = avg_win / avg_loss if avg_loss > 0 else (float("inf") if avg_win > 0 else 0.0)
+
+ # 最大回撤 — 用交易序列近似
+ equity = initial_capital
+ peak = initial_capital
+ max_dd = 0.0
+ for p in pnls:
+ equity *= (1 + p)
+ peak = max(peak, equity)
+ dd = (equity - peak) / peak
+ max_dd = min(max_dd, dd)
+
+ # 夏普 — 用交易收益标准差近似
+ sharpe = float(np.mean(pnls) / np.std(pnls)) * np.sqrt(252) if np.std(pnls) > 0 else 0.0
+
+ # Calmar
+ calmar = annual_return / abs(max_dd) if abs(max_dd) > 0.001 else 0.0
+
+ return {
+ "total_return": round(float(total_return), 4),
+ "annual_return": round(float(annual_return), 4),
+ "max_drawdown": round(float(max_dd), 4),
+ "sharpe": round(float(sharpe), 2),
+ "calmar": round(float(calmar), 2),
+ "win_rate": round(float(win_rate), 4),
+ "profit_factor": round(float(profit_factor), 2) if np.isfinite(profit_factor) else None,
+ "n_trades": n_trades,
+ "avg_pnl": round(float(np.mean(pnls)), 4),
+ "avg_win": round(avg_win, 4),
+ "avg_loss": round(avg_loss, 4),
+ }
+
+ @staticmethod
+ def _calc_per_symbol(trades: list[TradeRecord]) -> list[dict]:
+ if not trades:
+ return []
+ by_sym: dict[str, dict] = {}
+ for t in trades:
+ s = t.symbol
+ d = by_sym.setdefault(s, {
+ "symbol": s, "n_trades": 0, "total_return": 1.0,
+ "best": -999.0, "worst": 999.0, "wins": 0, "pnls": [],
+ })
+ d["n_trades"] += 1
+ d["pnls"].append(t.pnl_pct)
+ d["total_return"] *= (1 + t.pnl_pct)
+ d["best"] = max(d["best"], t.pnl_pct)
+ d["worst"] = min(d["worst"], t.pnl_pct)
+ if t.pnl_pct > 0:
+ d["wins"] += 1
+
+ result = []
+ for d in by_sym.values():
+ result.append({
+ "symbol": d["symbol"],
+ "n_trades": d["n_trades"],
+ "total_return": round(d["total_return"] - 1.0, 4),
+ "win_rate": round(d["wins"] / d["n_trades"], 4) if d["n_trades"] > 0 else 0.0,
+ "best": round(d["best"], 4),
+ "worst": round(d["worst"], 4),
+ })
+ return sorted(result, key=lambda x: x["total_return"], reverse=True)
+
+ @staticmethod
+ def _calc_independent_candidate_result(
+ trades: list[TradeRecord],
+ n_candidates: int,
+ execution_stats: dict[str, int],
+ ) -> SimResult:
+ """全量独立候选统计:按每个候选样本的实际执行收益聚合。"""
+ if not trades:
+ return SimResult(
+ equity_curve=[],
+ drawdown_curve=[],
+ trades=[],
+ per_symbol_stats=[],
+ stats={
+ "mode": "full",
+ "full_kind": "candidate_execution",
+ "error": "no executable trades",
+ "n_candidates": int(n_candidates),
+ "n_trades": 0,
+ "execution": execution_stats,
+ },
+ )
+
+ pnls = np.array([t.pnl_pct for t in trades], dtype=float)
+ durations = np.array([t.duration for t in trades], dtype=float)
+ wins = pnls[pnls > 0]
+ losses = pnls[pnls <= 0]
+ avg_win = float(np.mean(wins)) if len(wins) else 0.0
+ avg_loss = abs(float(np.mean(losses))) if len(losses) else 0.0
+
+ # 按退出日聚合已实现样本收益, 构造“样本收益曲线”。它不是账户净值。
+ daily_returns: dict[str, list[float]] = {}
+ for t in trades:
+ daily_returns.setdefault(str(t.exit_date)[:10], []).append(float(t.pnl_pct))
+
+ equity_curve: list[dict] = []
+ drawdown_curve: list[dict] = []
+ equity = 1.0
+ peak = 1.0
+ daily_avg: list[float] = []
+ for d_str in sorted(daily_returns.keys()):
+ values = daily_returns[d_str]
+ avg_ret = float(np.mean(values)) if values else 0.0
+ daily_avg.append(avg_ret)
+ equity *= (1 + avg_ret)
+ peak = max(peak, equity)
+ dd = (equity - peak) / peak if peak > 0 else 0.0
+ equity_curve.append({
+ "date": d_str,
+ "value": round(float(equity), 4),
+ "positions": len(values),
+ })
+ drawdown_curve.append({"date": d_str, "value": round(float(dd), 4)})
+
+ values = np.array([r["value"] for r in equity_curve], dtype=float)
+ total_return = float(values[-1] - 1.0) if len(values) else 0.0
+ peaks = np.maximum.accumulate(values) if len(values) else np.array([])
+ drawdowns = values / peaks - 1 if len(values) else np.array([])
+ max_drawdown = float(drawdowns.min()) if len(drawdowns) else 0.0
+ daily = np.array(daily_avg, dtype=float)
+ sharpe = float(np.mean(daily) / np.std(daily) * np.sqrt(252)) if len(daily) > 1 and np.std(daily) > 0 else 0.0
+
+ lo, hi, nbins = -0.20, 0.20, 20
+ clipped = np.clip(pnls, lo, hi)
+ counts, edges = np.histogram(clipped, bins=nbins, range=(lo, hi))
+ dist = [
+ {
+ "range": f"{(edges[i]*100):+.0f}~{(edges[i+1]*100):+.0f}%",
+ "count": int(counts[i]),
+ "ratio": round(float(counts[i] / pnls.size), 4) if pnls.size else 0.0,
+ }
+ for i in range(nbins)
+ ]
+
+ stats = {
+ "mode": "full",
+ "full_kind": "candidate_execution",
+ "n_candidates": int(n_candidates),
+ "n_trades": int(len(trades)),
+ "n_days": int(len(daily_returns)),
+ "avg_daily_candidates": round(float(len(trades) / max(len(daily_returns), 1)), 1),
+ "avg_return": round(float(np.mean(pnls)), 4),
+ "median_return": round(float(np.median(pnls)), 4),
+ "win_rate": round(float(len(wins) / len(pnls)), 4) if len(pnls) else 0.0,
+ "profit_factor": round(float(avg_win / avg_loss), 2) if avg_loss > 0 else None,
+ "best": round(float(np.max(pnls)), 4),
+ "worst": round(float(np.min(pnls)), 4),
+ "avg_duration": round(float(np.mean(durations)), 1) if len(durations) else 0.0,
+ "total_return": round(float(total_return), 4),
+ "max_drawdown": round(float(max_drawdown), 4),
+ "sharpe": round(float(sharpe), 2),
+ "return_distribution": dist,
+ "execution": execution_stats,
+ }
+
+ return SimResult(
+ equity_curve=equity_curve,
+ drawdown_curve=drawdown_curve,
+ trades=trades,
+ per_symbol_stats=BacktestEngine._calc_per_symbol(trades),
+ stats=stats,
+ )
+
+ @staticmethod
+ def _calc_portfolio_stats(
+ equity_curve: list[dict],
+ trades: list[TradeRecord],
+ initial_capital: float,
+ ) -> dict:
+ if not equity_curve:
+ return {"total_return": 0, "n_trades": 0}
+ final_equity = float(equity_curve[-1]["value"])
+ total_return = final_equity / initial_capital - 1 if initial_capital > 0 else 0.0
+ values = np.array([float(r["value"]) for r in equity_curve], dtype=float)
+ daily = values[1:] / values[:-1] - 1 if len(values) > 1 else np.array([])
+ annual_return = (1 + total_return) ** (252 / max(len(equity_curve), 1)) - 1 if total_return > -1 else total_return
+ peaks = np.maximum.accumulate(values)
+ drawdowns = values / peaks - 1
+ max_drawdown = float(drawdowns.min()) if len(drawdowns) else 0.0
+ sharpe = float(np.mean(daily) / np.std(daily) * np.sqrt(252)) if len(daily) and np.std(daily) > 0 else 0.0
+ pnls = np.array([t.pnl_pct for t in trades], dtype=float) if trades else np.array([])
+ exposures = np.array([float(r.get("exposure", 0.0)) for r in equity_curve], dtype=float)
+ wins = pnls[pnls > 0]
+ losses = pnls[pnls <= 0]
+ avg_win = float(np.mean(wins)) if len(wins) else 0.0
+ avg_loss = abs(float(np.mean(losses))) if len(losses) else 0.0
+ return {
+ "total_return": round(float(total_return), 4),
+ "annual_return": round(float(annual_return), 4),
+ "max_drawdown": round(float(max_drawdown), 4),
+ "sharpe": round(float(sharpe), 2),
+ "calmar": round(float(annual_return / abs(max_drawdown)), 2) if abs(max_drawdown) > 0.001 else 0.0,
+ "win_rate": round(float(len(wins) / len(pnls)), 4) if len(pnls) else 0.0,
+ "profit_factor": round(float(avg_win / avg_loss), 2) if avg_loss > 0 else None,
+ "n_trades": len(trades),
+ "avg_pnl": round(float(np.mean(pnls)), 4) if len(pnls) else 0.0,
+ "avg_win": round(avg_win, 4),
+ "avg_loss": round(avg_loss, 4),
+ "final_equity": round(final_equity, 2),
+ "initial_capital": round(float(initial_capital), 2),
+ "avg_exposure": round(float(np.mean(exposures)), 4) if len(exposures) else 0.0,
+ "max_exposure": round(float(np.max(exposures)), 4) if len(exposures) else 0.0,
+ }
+
+ @staticmethod
+ def _date_str(value) -> str:
+ value = value.item() if hasattr(value, "item") else value
+ return str(value)[:10]
+
+ @staticmethod
+ def _empty_result() -> SimResult:
+ return SimResult(
+ equity_curve=[], drawdown_curve=[], trades=[],
+ per_symbol_stats=[], stats={"error": "no data or no signals"},
+ )
+
+ # ── 截面工具 (因子回测用) ─────────────────────────
+
+ @staticmethod
+ def cross_section_rank(panel: pl.DataFrame, col: str) -> pl.DataFrame:
+ return panel.with_columns(
+ pl.col(col).rank(method="random").over("date").alias(f"{col}_rank")
+ )
+
+ @staticmethod
+ def cross_section_qcut(panel: pl.DataFrame, col: str, n_groups: int) -> pl.DataFrame:
+ return panel.with_columns(
+ pl.col(col).qcut(n_groups, labels=[f"Q{i+1}" for i in range(n_groups)])
+ .over("date").alias("_group")
+ )
diff --git a/backend/app/backtest/factor.py b/backend/app/backtest/factor.py
new file mode 100644
index 0000000..740582a
--- /dev/null
+++ b/backend/app/backtest/factor.py
@@ -0,0 +1,480 @@
+"""因子回测服务 — IC/IR 分析 + 分层回测 + 多空组合。
+
+纯 Polars 向量化实现,无 pandas 依赖。
+"""
+from __future__ import annotations
+
+import logging
+import time
+import uuid
+from dataclasses import dataclass, field
+from datetime import date, timedelta
+from typing import Literal
+
+import numpy as np
+import polars as pl
+
+from app.backtest.engine import BacktestEngine
+
+logger = logging.getLogger(__name__)
+
+# 可用因子列 (从 ENRICHED_COLUMNS 过滤出数值型指标)
+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": "当日振幅 (最高-最低)/昨收"},
+]
+
+FACTOR_WARMUP_DAYS = 120
+
+
+@dataclass
+class FactorConfig:
+ factor_name: 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
+
+
+@dataclass
+class GroupStats:
+ group: int
+ label: str
+ total_return: float
+ annual_return: float
+ max_drawdown: float
+ sharpe: float
+ win_rate: float
+
+
+@dataclass
+class FactorResult:
+ run_id: str
+ config: dict
+ # IC 分析
+ ic_mean: float | None = None
+ ic_std: float | None = None
+ ir: float | None = None
+ ic_win_rate: float | None = None
+ ic_series: list[dict] = field(default_factory=list)
+ # 分层
+ group_stats: list[dict] = field(default_factory=list)
+ group_nav: list[dict] = field(default_factory=list)
+ # 多空
+ long_short_stats: dict = field(default_factory=dict)
+ long_short_nav: list[dict] = 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:
+ t0 = time.perf_counter()
+ run_id = uuid.uuid4().hex[:10]
+
+ 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,
+ )
+
+ # 加载基础面板: 当前 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)
+ load_start = config.start
+ if config.factor_name not in {"turnover_rate"}:
+ load_start = config.start - timedelta(days=FACTOR_WARMUP_DAYS)
+
+ panel = self.engine.load_panel(
+ config.symbols,
+ load_start,
+ config.end,
+ columns=panel_columns,
+ )
+ if panel.is_empty():
+ return _err("无数据,请检查日期范围或先运行盘后管道")
+
+ 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:
+ return _err(f"因子列 '{factor_col}' 不存在于 enriched 数据中, 且无法从基础行情计算")
+ if "close" not in 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))
+
+ # 过滤有效行
+ panel = panel.filter(
+ pl.col(factor_col).is_not_null()
+ & pl.col("close").is_not_null()
+ & (pl.col("close") > 0)
+ )
+ if panel.is_empty():
+ return _err("过滤后无有效数据")
+
+ 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)
+ 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"]))
+ ]
+ 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
+ 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
+
+ # ── 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)
+
+ # ── 3. 多空组合 ──
+ long_short_nav, long_short_stats = self._calc_long_short(group_nav, config)
+
+ elapsed = (time.perf_counter() - t0) * 1000
+ return FactorResult(
+ run_id=run_id,
+ config=self._config_to_dict(config),
+ ic_mean=round(ic_mean, 4) if ic_mean is not None else None,
+ ic_std=round(ic_std, 4) if ic_std is not None else None,
+ ir=round(ir, 4) if ir is not None else None,
+ 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,
+ long_short_stats=long_short_stats,
+ long_short_nav=long_short_nav,
+ 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:
+ 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)
+ return panel
+
+ from app.indicators.pipeline import compute_indicators
+
+ computed = compute_indicators(panel)
+ if factor_col not in computed.columns:
+ return panel
+ return computed.select(["symbol", "date", "close", factor_col])
+
+ # ── IC 计算 ──
+
+ @staticmethod
+ def _calc_ic(panel: pl.DataFrame, factor_col: str) -> pl.DataFrame:
+ """计算截面 Rank IC (因子值 rank vs 下期收益 rank 的相关系数)。"""
+ return (
+ panel.filter(pl.col("_next_return").is_not_null())
+ .group_by("date")
+ .agg(
+ pl.corr(
+ pl.col(factor_col).rank(method="random"),
+ pl.col("_next_return").rank(method="random"),
+ ).alias("ic")
+ )
+ .sort("date")
+ )
+
+ # ── 调仓期收益 ──
+
+ @staticmethod
+ def _calc_period_return(panel: pl.DataFrame, rebalance: str) -> pl.DataFrame:
+ """计算到下个调仓日的收益。
+
+ weekly: 下周一的 open / 今日 close - 1
+ monthly: 下月首个交易日的 open / 今日 close - 1
+ 只在调仓日标记行有效,其他行为 null。
+ """
+ import datetime as _dt
+
+ all_dates = sorted(panel["date"].unique().to_list())
+ date_set = set(all_dates)
+
+ 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)
+ else: # monthly
+ # 调仓日 = 每月首个交易日
+ seen_months: set[str] = set()
+ rebalance_dates = set()
+ for d in sorted(all_dates):
+ m = str(d)[:7] # "YYYY-MM"
+ if m not in seen_months:
+ seen_months.add(m)
+ rebalance_dates.add(d)
+
+ if not rebalance_dates:
+ panel = panel.with_columns(pl.lit(None).cast(pl.Float64).alias("_next_return"))
+ return panel
+
+ # 对每个调仓日,找到下一个调仓日
+ sorted_rebalance = sorted(rebalance_dates)
+ next_rebalance_map: dict = {}
+ for i, d in enumerate(sorted_rebalance):
+ if i + 1 < len(sorted_rebalance):
+ next_rebalance_map[d] = sorted_rebalance[i + 1]
+ # 最后一个调仓日没有下一个,不计算收益
+
+ # 构建 (date, symbol) → next_rebalance_date 的 close 价格映射
+ # 简化: 用下个调仓日的 close / 当前 close
+ panel = panel.sort(["symbol", "date"])
+ dates_col = panel["date"].to_list()
+ close_col = panel["close"].to_list()
+ symbol_col = panel["symbol"].to_list()
+
+ # 先找下个调仓日的 close
+ # 建立 (date, symbol) → close 的快速查找
+ price_map: dict[tuple, float] = {}
+ for i in range(len(dates_col)):
+ price_map[(str(dates_col[i]), symbol_col[i])] = close_col[i]
+
+ next_returns = [None] * len(panel)
+ for i in range(len(panel)):
+ d = dates_col[i]
+ d_val = d if isinstance(d, _dt.date) else _dt.date.fromisoformat(str(d))
+ if d not in rebalance_dates:
+ continue
+ next_d = next_rebalance_map.get(d)
+ if next_d is None:
+ continue
+ next_d_str = str(next_d)[:10]
+ d_str = str(d)[:10]
+ sym = symbol_col[i]
+ next_close = price_map.get((next_d_str, sym))
+ cur_close = close_col[i]
+ if next_close is not None and cur_close and cur_close > 0:
+ next_returns[i] = (next_close / cur_close - 1.0)
+
+ panel = panel.with_columns(
+ pl.Series("_next_return", next_returns, dtype=pl.Float64)
+ )
+ return panel
+
+ # ── 分组 ──
+
+ @staticmethod
+ def _add_groups(panel: pl.DataFrame, factor_col: str, n_groups: int) -> pl.DataFrame:
+ """截面分位数分组。"""
+ return panel.with_columns(
+ pl.col(factor_col)
+ .qcut(n_groups, labels=[f"Q{i+1}" for i in range(n_groups)])
+ .over("date")
+ .alias("_group")
+ )
+
+ # ── 分组净值 ──
+
+ @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"))
+ )
+
+ # pivot: date × group
+ pivot = group_ret.pivot(index="date", columns="_group", values="group_return").sort("date")
+
+ if pivot.is_empty():
+ return []
+
+ group_cols = [c for c in pivot.columns if c != "date"]
+
+ # 累乘净值曲线
+ result: list[dict] = []
+ nav_values: dict[str, float] = {c: 1.0 for c in group_cols}
+
+ for row in pivot.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)
+ result.append(entry)
+
+ return result
+
+ # ── 分组统计 ──
+
+ @staticmethod
+ def _calc_group_stats(
+ group_nav: list[dict], start: date, end: date,
+ ) -> list[dict]:
+ if not group_nav:
+ return []
+
+ group_cols = [k for k in group_nav[0] if k != "date"]
+ n_days = max((end - start).days, 1)
+ years = n_days / 365.25
+
+ stats = []
+ for i, c in enumerate(sorted(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
+
+ # 最大回撤
+ 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)
+
+ # 日收益序列
+ daily_rets = []
+ for j in range(1, len(values)):
+ if values[j - 1] > 0:
+ daily_rets.append(values[j] / values[j - 1] - 1)
+
+ # 夏普
+ if daily_rets:
+ arr = np.array(daily_rets)
+ sharpe = float(np.mean(arr) / np.std(arr)) * np.sqrt(252) if np.std(arr) > 0 else 0.0
+ win_rate = float(np.mean(arr > 0))
+ else:
+ sharpe = 0.0
+ win_rate = 0.0
+
+ stats.append({
+ "group": i + 1,
+ "label": c,
+ "total_return": round(total_return, 4),
+ "annual_return": round(annual_return, 4),
+ "max_drawdown": round(max_dd, 4),
+ "sharpe": round(sharpe, 2),
+ "win_rate": round(win_rate, 4),
+ })
+
+ return stats
+
+ # ── 多空组合 ──
+
+ @staticmethod
+ def _calc_long_short(
+ group_nav: list[dict], config: FactorConfig,
+ ) -> tuple[list[dict], dict]:
+ """多空组合: 做多最高组 + 做空最低组。"""
+ if not group_nav:
+ return [], {}
+
+ group_cols = sorted([k for k in group_nav[0] if k != "date"])
+ if len(group_cols) < 2:
+ return [], {}
+
+ 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] = []
+
+ 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
+
+ # 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)
+
+ 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
+ ls_stats = {
+ "total_return": round(total_ret, 4),
+ "max_drawdown": round(max_dd, 4),
+ "top_group": top_col,
+ "bottom_group": bottom_col,
+ }
+
+ return ls_nav, ls_stats
+
+ @staticmethod
+ def _config_to_dict(c: FactorConfig) -> dict:
+ return {
+ "factor_name": c.factor_name,
+ "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,
+ "slippage_bps": c.slippage_bps,
+ }
diff --git a/backend/app/backtest/strategy.py b/backend/app/backtest/strategy.py
new file mode 100644
index 0000000..e6f597f
--- /dev/null
+++ b/backend/app/backtest/strategy.py
@@ -0,0 +1,701 @@
+"""策略回测服务 — 复用 StrategyDef 体系做全周期回测。
+
+核心优化: 向量化 filter_fn,不逐日调用 StrategyEngine.run()。
+"""
+from __future__ import annotations
+
+import logging
+import time
+import uuid
+from dataclasses import dataclass, field
+from datetime import date, timedelta
+from typing import Callable, Literal
+
+import numpy as np
+import polars as pl
+
+from app.backtest.engine import BacktestEngine, MatcherConfig, SimResult
+from app.strategy.engine import StrategyEngine, StrategyDef
+
+logger = logging.getLogger(__name__)
+
+BENCHMARK_SYMBOL = "000001.SH"
+
+
+@dataclass
+class StrategyBacktestConfig:
+ strategy_id: str
+ symbols: list[str] | None
+ start: date
+ end: date
+ params: dict | None = None
+ overrides: dict | None = None
+ matching: Literal["close_t", "open_t+1"] = "open_t+1"
+ fees_pct: float = 0.0002
+ slippage_bps: float = 5.0
+ max_positions: int = 10
+ max_exposure_pct: float = 1.0
+ initial_capital: float = 1_000_000.0
+ position_sizing: Literal["equal", "score_weight"] = "equal"
+ mode: Literal["position", "full"] = "position"
+ holding_days: int = 5
+
+
+@dataclass
+class StrategyBacktestResult:
+ run_id: str
+ config: dict
+ stats: dict = field(default_factory=dict)
+ equity_curve: list[dict] = field(default_factory=list)
+ drawdown_curve: list[dict] = field(default_factory=list)
+ benchmark_curve: list[dict] = field(default_factory=list)
+ trades: list[dict] = field(default_factory=list)
+ per_symbol_stats: list[dict] = field(default_factory=list)
+ strategy_info: dict = field(default_factory=dict)
+ elapsed_ms: float = 0.0
+ error: str | None = None
+
+
+class StrategyBacktestService:
+ def __init__(
+ self,
+ engine: BacktestEngine,
+ strategy_engine: StrategyEngine,
+ ) -> None:
+ self.engine = engine
+ self.strategy_engine = strategy_engine
+
+ def run(
+ self,
+ config: StrategyBacktestConfig,
+ progress_cb: "Callable[[dict], None] | None" = None,
+ cancel_event: "threading.Event | None" = None,
+ ) -> StrategyBacktestResult:
+ t0 = time.perf_counter()
+ run_id = uuid.uuid4().hex[:10]
+
+ def _err(msg: str) -> StrategyBacktestResult:
+ return StrategyBacktestResult(
+ run_id=run_id,
+ config=self._config_to_dict(config),
+ error=msg,
+ elapsed_ms=(time.perf_counter() - t0) * 1000,
+ )
+
+ # 获取策略定义
+ try:
+ s = self.strategy_engine.get(config.strategy_id)
+ except ValueError as e:
+ return _err(str(e))
+
+ params = self._normalize_params(config.params or {}, s)
+ overrides = config.overrides or {}
+ basic_filter = self._effective_basic_filter(s, overrides)
+ entry_signals = self._effective_signals(overrides, "entry_signals", s.entry_signals)
+ exit_signals = self._effective_signals(overrides, "exit_signals", s.exit_signals)
+ stop_loss = self._override_value(overrides, "stop_loss", s.stop_loss)
+ trailing_stop = self._normalize_pct(
+ self._override_value(overrides, "trailing_stop", getattr(s, "trailing_stop", None)),
+ 0.005,
+ 0.5,
+ )
+ trailing_take_profit_activate = self._normalize_pct(
+ self._override_value(overrides, "trailing_take_profit_activate", getattr(s, "trailing_take_profit_activate", None)),
+ 0.01,
+ 2.0,
+ )
+ trailing_take_profit_drawdown = self._normalize_pct(
+ self._override_value(overrides, "trailing_take_profit_drawdown", getattr(s, "trailing_take_profit_drawdown", None)),
+ 0.005,
+ 0.5,
+ )
+ if trailing_take_profit_activate is not None and trailing_take_profit_drawdown is not None:
+ trailing_take_profit_drawdown = min(trailing_take_profit_drawdown, trailing_take_profit_activate)
+ max_hold_days = self._override_value(overrides, "max_hold_days", s.max_hold_days)
+ score_min, score_max = self._normalize_score_range(
+ overrides.get("score_min"),
+ overrides.get("score_max"),
+ )
+
+ timing_ms: dict[str, float] = {}
+
+ # 加载面板 (含 warmup + 全量指标 + 信号)。warmup 只用于指标/形态计算, 不参与正式交易。
+ warmup_days = max(120, int(max(s.lookback_days or 1, 1) * 1.5))
+ load_start = config.start - timedelta(days=warmup_days)
+
+ # 全量模式: entries 只在正式区间触发, exits 需要 end 之后的尾部数据继续执行策略卖点。
+ # 若策略有 max_hold_days, 用它决定尾部窗口;否则 holding_days 只作为兜底观察上限。
+ full_horizon_days = int(max_hold_days or config.holding_days or 5)
+ full_horizon_days = max(full_horizon_days, 1)
+ load_end = config.end
+ if config.mode == "full":
+ fwd_buffer = full_horizon_days + 5 # 多取几天, 容错停牌缺口/open_t+1
+ load_end = config.end + timedelta(days=fwd_buffer * 2) # 日历日放宽, 确保覆盖 N 个交易日
+
+ t_load = time.perf_counter()
+ panel = self.engine.load_panel(config.symbols, load_start, load_end)
+ timing_ms["load_panel"] = round((time.perf_counter() - t_load) * 1000, 1)
+ if panel.is_empty():
+ return _err("无数据,请检查日期范围或先运行盘后管道")
+
+ formal_range = self._date_range_mask(panel, config.start, config.end)
+ if not formal_range.any():
+ return _err("正式回测区间内无数据")
+
+ t_signal = time.perf_counter()
+
+ # basic_filter 只影响买入候选, 不能删除行情 panel, 否则持仓 mark / 卖出 / full forward return 都会失真。
+ basic_mask = pl.Series("_basic", [True] * len(panel), dtype=pl.Boolean)
+ if basic_filter and basic_filter.get("enabled", True):
+ expr = StrategyEngine._basic_filter_expr(panel, basic_filter)
+ if expr is not None:
+ try:
+ basic_mask = panel.select(expr.alias("_basic"))["_basic"].fill_null(False).cast(pl.Boolean)
+ except Exception as e: # noqa: BLE001
+ logger.warning("basic_filter mask failed: %s", e)
+ return _err(f"基础过滤计算失败: {e}")
+
+ # 策略候选层用于评分归一化;entry_signals 只是买点层, 不参与 score universe。
+ candidate_filter_mask = self._build_candidate_filter_mask(panel, s, params)
+ candidate_mask = basic_mask & candidate_filter_mask
+ panel = self._apply_score(panel, s, overrides, universe_mask=candidate_mask)
+
+ entry_mask = self._build_entry_mask_from_candidate(panel, candidate_mask, s, entry_signals)
+ entry_mask = entry_mask & formal_range
+ raw_exit_mask = self._build_signal_mask(panel, exit_signals, "_exit")
+ exit_mask = raw_exit_mask & (self._date_range_mask(panel, config.start, load_end) if config.mode == "full" else formal_range)
+ timing_ms["signals_score"] = round((time.perf_counter() - t_signal) * 1000, 1)
+
+ if not entry_mask.any():
+ return _err("在指定区间内未产生买入信号")
+
+ # warmup 之后才交给撮合;full mode 保留 end 之后前瞻段用于 shift(-N)。
+ sim_end = load_end if config.mode == "full" else config.end
+ sim_range = self._date_range_mask(panel, config.start, sim_end)
+ sim_panel = panel.filter(sim_range)
+ sim_entry_mask = entry_mask.filter(sim_range)
+ sim_exit_mask = exit_mask.filter(sim_range)
+ if sim_panel.is_empty():
+ return _err("正式回测区间内无数据")
+
+ t_sim = time.perf_counter()
+ matcher_config = MatcherConfig(
+ matching=config.matching,
+ fees_pct=config.fees_pct,
+ slippage_bps=config.slippage_bps,
+ stop_loss_pct=stop_loss,
+ trailing_stop_pct=trailing_stop,
+ trailing_take_profit_activate_pct=trailing_take_profit_activate,
+ trailing_take_profit_drawdown_pct=trailing_take_profit_drawdown,
+ max_hold_days=max_hold_days,
+ max_positions=config.max_positions,
+ max_exposure_pct=config.max_exposure_pct,
+ score_min=score_min,
+ score_max=score_max,
+ initial_capital=config.initial_capital,
+ position_sizing=config.position_sizing,
+ )
+ # 撮合 — full 为全候选独立执行;position 为账户级仓位模拟。
+ if config.mode == "full":
+ result = self.engine.simulate_independent_candidates(
+ sim_panel,
+ sim_entry_mask,
+ sim_exit_mask,
+ matcher_config,
+ progress_cb,
+ cancel_event,
+ )
+ else:
+ result = self.engine.simulate_portfolio(sim_panel, sim_entry_mask, sim_exit_mask, matcher_config, progress_cb, cancel_event)
+ timing_ms["simulate"] = round((time.perf_counter() - t_sim) * 1000, 1)
+
+ # 检查是否被取消
+ if cancel_event is not None and cancel_event.is_set():
+ return StrategyBacktestResult(
+ run_id=run_id,
+ config=self._config_to_dict(config),
+ error="cancelled",
+ elapsed_ms=round((time.perf_counter() - t0) * 1000, 1),
+ )
+
+ if result.stats.get("error"):
+ return _err(result.stats["error"])
+
+ timing_ms["total"] = round((time.perf_counter() - t0) * 1000, 1)
+ result.stats["timing_ms"] = timing_ms
+ result.stats["panel_rows"] = int(sim_panel.height)
+
+ benchmark_curve = self._build_benchmark_curve(config.start, config.end)
+
+ # 构建策略信息
+ strategy_info = {
+ "id": s.meta.get("id", config.strategy_id),
+ "name": s.meta.get("name", config.strategy_id),
+ "description": s.meta.get("description", ""),
+ "entry_signals": entry_signals,
+ "exit_signals": exit_signals,
+ "stop_loss": stop_loss,
+ "trailing_stop": trailing_stop,
+ "trailing_take_profit_activate": trailing_take_profit_activate,
+ "trailing_take_profit_drawdown": trailing_take_profit_drawdown,
+ "max_hold_days": max_hold_days,
+ "full_horizon_days": full_horizon_days,
+ "score_min": score_min,
+ "score_max": score_max,
+ "source": s.source,
+ }
+
+ elapsed = (time.perf_counter() - t0) * 1000
+
+ return StrategyBacktestResult(
+ run_id=run_id,
+ config=self._config_to_dict(config),
+ stats=result.stats,
+ equity_curve=result.equity_curve,
+ drawdown_curve=result.drawdown_curve,
+ benchmark_curve=benchmark_curve,
+ trades=[self._trade_to_dict(t) for t in result.trades],
+ per_symbol_stats=result.per_symbol_stats,
+ strategy_info=strategy_info,
+ elapsed_ms=round(elapsed, 1),
+ )
+
+ # ── 全量模拟 (选股能力统计, 不建组合不算净值) ──
+
+ def _run_full_simulation(
+ self,
+ panel: pl.DataFrame,
+ entry_mask: pl.Series,
+ holding_days: int,
+ ) -> SimResult:
+ """对 entry_mask 命中的全部候选, 算持有 N 天后的前瞻收益统计。
+
+ 不受 max_positions/资金约束, 反映策略选股能力本身。
+ equity_curve 复用为"累计日均超额收益曲线"(基准归零)。
+ """
+ n = holding_days if holding_days and holding_days > 0 else 5
+
+ df = panel.with_columns([
+ entry_mask.cast(pl.Boolean).alias("_is_candidate"),
+ (pl.col("close").shift(-n).over("symbol") / pl.col("close") - 1).alias("_fwd_return"),
+ ]).filter(
+ pl.col("_is_candidate")
+ & pl.col("_fwd_return").is_not_null()
+ & pl.col("_fwd_return").is_not_nan()
+ )
+
+ if df.is_empty():
+ return self.engine._empty_result()
+
+ fwd = df["_fwd_return"].to_numpy()
+ wins = fwd[fwd > 0]
+ losses = fwd[fwd <= 0]
+ avg_win = float(wins.mean()) if wins.size else 0.0
+ avg_loss = abs(float(losses.mean())) if losses.size else 0.0
+
+ # 按日聚合: 当日候选的平均前瞻收益
+ daily = (
+ df.group_by("date").agg(
+ pl.col("_fwd_return").mean().alias("avg_ret"),
+ pl.col("_fwd_return").count().alias("n_cand"),
+ ).sort("date")
+ )
+
+ # 累计超额曲线: 每日复利平均收益 (基准归零, 故 equity 即累计策略收益)
+ equity_curve: list[dict] = []
+ equity = 1.0
+ peak = 1.0
+ drawdown_curve: list[dict] = []
+ for row in daily.iter_rows(named=True):
+ ret = float(row["avg_ret"] or 0.0)
+ equity *= (1 + ret)
+ peak = max(peak, equity)
+ dd = (equity - peak) / peak if peak > 0 else 0.0
+ d_str = str(row["date"])[:10]
+ equity_curve.append({
+ "date": d_str,
+ "value": round(equity, 4),
+ "positions": int(row["n_cand"]),
+ })
+ drawdown_curve.append({"date": d_str, "value": round(dd, 4)})
+
+ # 同期上证收益 (用 benchmark close 算)
+ benchmark_curve = self._build_benchmark_curve(
+ daily["date"].min(), daily["date"].max()
+ )
+ benchmark_return = 0.0
+ if benchmark_curve:
+ closes = [b["close"] for b in benchmark_curve if b.get("close")]
+ if len(closes) >= 2 and closes[0] > 0:
+ benchmark_return = closes[-1] / closes[0] - 1
+
+ total_return = equity - 1.0
+ max_dd = min((d["value"] for d in drawdown_curve), default=0.0)
+
+ # 日收益序列算 Sharpe (年化)
+ daily_rets = daily["avg_ret"].to_numpy()
+ sharpe = (
+ float(daily_rets.mean() / daily_rets.std() * np.sqrt(252))
+ if daily_rets.size > 1 and daily_rets.std() > 0 else 0.0
+ )
+
+ # 收益分布直方图: 按 [-20%, +20%] 分 21 档 (每档 2%), 超出归入首尾档
+ lo, hi, nbins = -0.20, 0.20, 20
+ clipped = np.clip(fwd, lo, hi)
+ counts, edges = np.histogram(clipped, bins=nbins, range=(lo, hi))
+ dist = [
+ {
+ "range": f"{(edges[i]*100):+.0f}~{(edges[i+1]*100):+.0f}%",
+ "count": int(counts[i]),
+ "ratio": round(float(counts[i] / fwd.size), 4) if fwd.size else 0.0,
+ }
+ for i in range(nbins)
+ ]
+
+ stats = {
+ "mode": "full",
+ "n_candidates": int(fwd.size),
+ "n_days": int(daily.height),
+ "avg_daily_candidates": round(float(daily["n_cand"].mean()), 1),
+ "avg_return": round(float(fwd.mean()), 4),
+ "median_return": round(float(np.median(fwd)), 4),
+ "win_rate": round(float(wins.size / fwd.size), 4) if fwd.size else 0.0,
+ "profit_factor": round(avg_win / avg_loss, 2) if avg_loss > 0 else None,
+ "best": round(float(fwd.max()), 4),
+ "worst": round(float(fwd.min()), 4),
+ "total_return": round(float(total_return), 4),
+ "max_drawdown": round(float(max_dd), 4),
+ "sharpe": round(sharpe, 2),
+ "benchmark_return": round(float(benchmark_return), 4),
+ "excess": round(float(total_return - benchmark_return), 4),
+ "return_distribution": dist,
+ }
+
+ return SimResult(
+ equity_curve=equity_curve,
+ drawdown_curve=drawdown_curve,
+ trades=[],
+ per_symbol_stats=[],
+ stats=stats,
+ )
+
+ # ── 向量化信号生成 ──
+
+ @staticmethod
+ def _date_range_mask(panel: pl.DataFrame, start: date, end: date) -> pl.Series:
+ return panel.select(
+ ((pl.col("date") >= start) & (pl.col("date") <= end)).alias("_range")
+ )["_range"].fill_null(False).cast(pl.Boolean)
+
+ def _build_candidate_filter_mask(
+ self,
+ panel: pl.DataFrame,
+ s: StrategyDef,
+ params: dict,
+ ) -> pl.Series:
+ """生成策略候选层 mask。filter_history/filter 决定候选池, 不包含 entry_signals。"""
+ false_mask = pl.Series("_candidate_filter", [False] * len(panel), dtype=pl.Boolean)
+ true_mask = pl.Series("_candidate_filter", [True] * len(panel), dtype=pl.Boolean)
+
+ history_failed = False
+ # 优先: filter_history_fn 策略 (涨停/反包等多日形态, 与选股路径共用同一逻辑)
+ if s.filter_history_fn:
+ try:
+ hit_df = s.filter_history_fn(panel, params)
+ if hit_df is None or hit_df.is_empty():
+ return false_mask
+ # 命中行 (symbol,date) → 转 panel 等长布尔 mask
+ hits = hit_df.select(["symbol", "date"]).unique()
+ marked = (
+ panel.select(["symbol", "date"])
+ .join(
+ hits.with_columns(pl.lit(True).alias("_hit")),
+ on=["symbol", "date"],
+ how="left",
+ )
+ )
+ return marked["_hit"].fill_null(False).cast(pl.Boolean)
+ except Exception as e:
+ history_failed = True
+ logger.warning("strategy filter_history_fn failed: %s", e)
+ # 失败则回退到 filter_fn (若存在)
+
+ # 策略 filter_fn: 候选层 (filter_history 不可用或失败时)
+ if s.filter_fn:
+ try:
+ expr = s.filter_fn(panel, params)
+ if expr is not None:
+ result = panel.select(expr.alias("_candidate_filter"))
+ if not result.is_empty():
+ return result["_candidate_filter"].fill_null(False).cast(pl.Boolean)
+ except Exception as e:
+ logger.warning("strategy filter_fn failed: %s", e)
+ return false_mask
+
+ if history_failed:
+ return false_mask
+
+ # 没有策略候选层时, 由 entry_signals 直接决定买点。
+ return true_mask
+
+ def _build_entry_mask_from_candidate(
+ self,
+ panel: pl.DataFrame,
+ candidate_mask: pl.Series,
+ s: StrategyDef,
+ entry_signals: list[str],
+ ) -> pl.Series:
+ """向量化生成买入掩码:候选层 AND 买点层;无买点时只用策略候选层。"""
+ signal_mask = self._build_signal_mask(panel, entry_signals, "_entry_signal")
+ if entry_signals:
+ return candidate_mask & signal_mask
+ if s.filter_history_fn or s.filter_fn:
+ return candidate_mask
+ return pl.Series("_entry", [False] * len(panel), dtype=pl.Boolean)
+
+ def _build_entry_mask(
+ self,
+ panel: pl.DataFrame,
+ s: StrategyDef,
+ params: dict,
+ entry_signals: list[str],
+ ) -> pl.Series:
+ """兼容旧调用: 候选层 AND 买点层。"""
+ candidate_mask = self._build_candidate_filter_mask(panel, s, params)
+ return self._build_entry_mask_from_candidate(panel, candidate_mask, s, entry_signals)
+
+ @staticmethod
+ def _build_signal_mask(panel: pl.DataFrame, signals: list[str], name: str) -> pl.Series:
+ """向量化合并信号列,多个信号 OR。支持内置 signal_ 与自定义 csg_ 前缀。"""
+ masks: list[pl.Series] = []
+ for sig in signals:
+ # csg_ (自定义信号) 直接用;否则按 signal_ 解析
+ col = sig if (sig.startswith("signal_") or sig.startswith("csg_")) else f"signal_{sig}"
+ if col in panel.columns:
+ masks.append(panel[col].fill_null(False).cast(pl.Boolean))
+
+ if not masks:
+ return pl.Series(name, [False] * len(panel), dtype=pl.Boolean)
+
+ combined = masks[0]
+ for m in masks[1:]:
+ combined = combined | m
+ return combined
+
+ def _build_benchmark_curve(self, start: date, end: date) -> list[dict]:
+ try:
+ df = self.engine.repo.get_index_daily(BENCHMARK_SYMBOL, start, end, columns=["date", "close"])
+ except Exception as e:
+ logger.warning("load benchmark %s failed: %s", BENCHMARK_SYMBOL, e)
+ return []
+
+ if df.is_empty() or "close" not in df.columns:
+ return []
+
+ df = df.filter(pl.col("close").is_not_null() & (pl.col("close") > 0)).sort("date")
+ if df.is_empty():
+ return []
+
+ return [
+ {
+ "date": str(row["date"])[:10],
+ "value": round(float(row["close"]), 4),
+ "close": round(float(row["close"]), 4),
+ "name": "上证指数",
+ "symbol": BENCHMARK_SYMBOL,
+ }
+ for row in df.iter_rows(named=True)
+ if row["close"] is not None
+ ]
+
+ # ── 工具 ──
+
+ @staticmethod
+ def _effective_basic_filter(s: StrategyDef, overrides: dict) -> dict:
+ basic_filter = dict(s.basic_filter or {})
+ override_filter = overrides.get("basic_filter")
+ if isinstance(override_filter, dict):
+ basic_filter.update(override_filter)
+ return basic_filter
+
+ @staticmethod
+ def _effective_signals(overrides: dict, key: str, default: list[str]) -> list[str]:
+ value = overrides.get(key)
+ if isinstance(value, list):
+ return [str(v) for v in value if v]
+ return list(default or [])
+
+ @staticmethod
+ def _override_value(overrides: dict, key: str, default):
+ if key in overrides:
+ return overrides.get(key)
+ return default
+
+ @staticmethod
+ def _normalize_pct(value, min_value: float, max_value: float) -> float | None:
+ if value is None or value == "":
+ return None
+ try:
+ pct = abs(float(value))
+ except (TypeError, ValueError):
+ return None
+ return min(max(pct, min_value), max_value)
+
+ @staticmethod
+ def _normalize_score_range(min_value, max_value) -> tuple[float | None, float | None]:
+ def _bound(value) -> float | None:
+ if value is None or value == "":
+ return None
+ try:
+ score = float(value)
+ except (TypeError, ValueError):
+ return None
+ if not np.isfinite(score):
+ return None
+ return min(max(score, 0.0), 100.0)
+
+ score_min = _bound(min_value)
+ score_max = _bound(max_value)
+ if score_min is not None and score_max is not None and score_min > score_max:
+ score_min, score_max = score_max, score_min
+ return score_min, score_max
+
+ @staticmethod
+ def _normalize_params(params: dict, s: StrategyDef) -> dict:
+ normalized = dict(params)
+ for param in s.meta.get("params", []):
+ pid = param.get("id")
+ if not pid:
+ continue
+ value = normalized.get(pid, param.get("default"))
+ p_type = param.get("type")
+ if p_type in {"float", "int"}:
+ try:
+ num = float(value)
+ except (TypeError, ValueError):
+ num = float(param.get("default", 0) or 0)
+ if param.get("min") is not None:
+ num = max(num, float(param["min"]))
+ if param.get("max") is not None:
+ num = min(num, float(param["max"]))
+ normalized[pid] = int(num) if p_type == "int" else num
+ elif p_type == "select" and param.get("options"):
+ normalized[pid] = value if value in param["options"] else param.get("default")
+ elif p_type == "bool":
+ if isinstance(value, bool):
+ normalized[pid] = value
+ elif isinstance(value, str):
+ normalized[pid] = value.lower() == "true"
+ else:
+ normalized[pid] = bool(param.get("default", False))
+ else:
+ normalized[pid] = value
+ return normalized
+
+ @staticmethod
+ def _trade_to_dict(t) -> dict:
+ return {
+ "symbol": t.symbol,
+ "name": t.name,
+ "entry_date": str(t.entry_date) if isinstance(t.entry_date, date) else str(t.entry_date),
+ "exit_date": str(t.exit_date) if isinstance(t.exit_date, date) else str(t.exit_date),
+ "entry_price": t.entry_price,
+ "exit_price": t.exit_price,
+ "pnl_pct": t.pnl_pct,
+ "duration": t.duration,
+ "exit_reason": t.exit_reason,
+ "shares": t.shares,
+ "lots": t.lots,
+ "position_pct": t.position_pct,
+ "entry_value": t.entry_value,
+ "exit_value": t.exit_value,
+ "pnl_amount": t.pnl_amount,
+ "entry_score": getattr(t, "entry_score", None),
+ "entry_signal_date": str(t.entry_signal_date) if getattr(t, "entry_signal_date", None) is not None else None,
+ "exit_signal_date": str(t.exit_signal_date) if getattr(t, "exit_signal_date", None) is not None else None,
+ "blocked_exit_days": getattr(t, "blocked_exit_days", 0),
+ }
+
+ @staticmethod
+ def _config_to_dict(c: StrategyBacktestConfig) -> dict:
+ score_min, score_max = StrategyBacktestService._normalize_score_range(
+ (c.overrides or {}).get("score_min"),
+ (c.overrides or {}).get("score_max"),
+ )
+ return {
+ "strategy_id": c.strategy_id,
+ "symbols": c.symbols,
+ "start": str(c.start),
+ "end": str(c.end),
+ "params": c.params,
+ "overrides": c.overrides,
+ "score_min": score_min,
+ "score_max": score_max,
+ "matching": c.matching,
+ "fees_pct": c.fees_pct,
+ "slippage_bps": c.slippage_bps,
+ "max_positions": c.max_positions,
+ "max_exposure_pct": c.max_exposure_pct,
+ "initial_capital": c.initial_capital,
+ "position_sizing": c.position_sizing,
+ "mode": c.mode,
+ "holding_days": c.holding_days,
+ }
+
+ @staticmethod
+ def _apply_score(
+ panel: pl.DataFrame,
+ s: StrategyDef,
+ 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}
+
+ work = panel
+ 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"))
+
+ def _value_in_universe(col: str) -> pl.Expr:
+ if has_universe:
+ return pl.when(pl.col("_score_universe")).then(pl.col(col)).otherwise(None)
+ return pl.col(col)
+
+ def _finish(df: pl.DataFrame) -> pl.DataFrame:
+ return df.drop("_score_universe") if "_score_universe" in df.columns else df
+
+ if scoring:
+ total_weight = sum(scoring.values())
+ if total_weight > 0:
+ score_parts: list[pl.Expr] = []
+ for col, weight in scoring.items():
+ if col not in work.columns:
+ continue
+ w = weight / total_weight
+ value = _value_in_universe(col)
+ col_min = value.min().over("date")
+ col_max = value.max().over("date")
+ col_range = col_max - col_min
+ normalized = pl.when(col_range > 0).then(
+ (pl.col(col) - col_min) / col_range
+ ).otherwise(pl.lit(0.5))
+ if has_universe:
+ normalized = pl.when(pl.col("_score_universe")).then(normalized).otherwise(0.0)
+ score_parts.append(normalized * w)
+ if score_parts:
+ score_expr = score_parts[0]
+ for part in score_parts[1:]:
+ score_expr = score_expr + part
+ return _finish(work.with_columns((score_expr * 100).fill_null(0).alias("score")))
+
+ order_by = s.meta.get("order_by")
+ if order_by and order_by != "score" and order_by in work.columns:
+ direction = 1 if s.meta.get("descending", True) else -1
+ score_expr = pl.col(order_by).fill_null(0) * direction
+ if has_universe:
+ score_expr = pl.when(pl.col("_score_universe")).then(score_expr).otherwise(0.0)
+ return _finish(work.with_columns(score_expr.alias("score")))
+ return _finish(work.with_columns(pl.lit(0.0).alias("score")))
diff --git a/backend/app/config.py b/backend/app/config.py
new file mode 100644
index 0000000..8468076
--- /dev/null
+++ b/backend/app/config.py
@@ -0,0 +1,61 @@
+"""全局配置 — 从环境变量 / .env 读取。"""
+from __future__ import annotations
+
+from pathlib import Path
+
+from pydantic import Field, model_validator
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+# 项目根目录 = backend/ 的父目录
+_BACKEND_DIR = Path(__file__).resolve().parent.parent
+_PROJECT_ROOT = _BACKEND_DIR.parent
+
+
+class Settings(BaseSettings):
+ model_config = SettingsConfigDict(
+ env_file=str(_PROJECT_ROOT / ".env"),
+ env_file_encoding="utf-8",
+ extra="ignore",
+ )
+
+ # TickFlow
+ tickflow_api_key: str = Field(default="", description="留空启用 free 模式")
+
+ # AI
+ ai_provider: str = "openai_compat"
+ ai_base_url: str = "https://api.alysc.top"
+ ai_api_key: str = ""
+ ai_model: str = "gpt-5.5"
+ ai_daily_token_budget: int = 5_000_000
+
+ # Server
+ host: str = "0.0.0.0"
+ port: int = 3018
+ log_level: str = "INFO"
+ backtest_range_guard: bool = False
+
+ # Data — 默认使用项目根目录的 data/,可通过 DATA_DIR 环境变量覆盖
+ data_dir: Path = _PROJECT_ROOT / "data"
+
+ # tiers.yaml 路径(项目根目录)
+ tiers_yaml: Path = _PROJECT_ROOT / "tiers.yaml"
+
+ # 静态文件(前端 dist) — 部署时只需 rsync 到 frontend/dist
+ static_dir: Path = _PROJECT_ROOT / "frontend" / "dist"
+
+ @model_validator(mode="after")
+ def _resolve_paths(self) -> Settings:
+ """确保 data_dir 是绝对路径(环境变量传入的相对路径基于项目根目录解析)。"""
+ if not self.data_dir.is_absolute():
+ # 相对路径基于项目根目录解析,而非 CWD
+ self.data_dir = (_PROJECT_ROOT / self.data_dir).resolve()
+ return self
+
+ @property
+ def use_free_mode(self) -> bool:
+ """是否走 Free 模式。优先看 secrets.json,其次看 .env。"""
+ from app import secrets_store
+ return not secrets_store.get_tickflow_key()
+
+
+settings = Settings()
diff --git a/backend/app/indicators/__init__.py b/backend/app/indicators/__init__.py
new file mode 100644
index 0000000..47c096f
--- /dev/null
+++ b/backend/app/indicators/__init__.py
@@ -0,0 +1 @@
+"""技术指标 — Polars 实现(§7.5 / §7.7)。"""
diff --git a/backend/app/indicators/pipeline.py b/backend/app/indicators/pipeline.py
new file mode 100644
index 0000000..dcab138
--- /dev/null
+++ b/backend/app/indicators/pipeline.py
@@ -0,0 +1,1494 @@
+"""enriched 表计算流水线(§7.5 / §7.7 Step 2)。
+
+存储层 (enriched parquet):
+ 仅存储基础行情窄表 (14 列), 指标和信号由各服务即时计算。
+
+ 存储列: symbol, date, OHLCV(前复权), volume, amount,
+ raw_close, raw_high, raw_low, turnover_rate,
+ consecutive_limit_ups, consecutive_limit_downs
+
+设计:
+ - 100% Polars 表达式(SQL 窗口无法表达递归 EMA)
+ - 每只标的独立计算(`.over("symbol")`)
+ - 有 adj_factor 时先应用前复权再算指标;无因子时直接用 raw
+ - streaming collect 控制内存
+"""
+from __future__ import annotations
+
+import logging
+from collections.abc import Callable
+from pathlib import Path
+
+import polars as pl
+
+from app.config import settings
+
+logger = logging.getLogger(__name__)
+
+
+# ── 自定义信号缓存 ─────────────────────────────────────
+# 从 data/user_data/custom_signals/*.json 加载并编译为 Polars 表达式。
+# 模块级缓存:首次调用时加载,invalidate_custom_signals() 后下次重载。
+_custom_signal_exprs: dict[str, pl.Expr] | None = None
+
+
+def _get_custom_signal_exprs() -> dict[str, pl.Expr]:
+ """懒加载自定义信号表达式(带模块级缓存)。"""
+ global _custom_signal_exprs
+ if _custom_signal_exprs is None:
+ from app.strategy import custom_signals
+ try:
+ sigs = custom_signals.load_all(settings.data_dir)
+ _custom_signal_exprs = custom_signals.build_expressions(sigs)
+ except Exception as e:
+ logger.warning("custom signals load failed: %s", e)
+ _custom_signal_exprs = {}
+ return _custom_signal_exprs
+
+
+def invalidate_custom_signals() -> None:
+ """失效自定义信号缓存(保存/删除信号后调用,下次计算重新加载)。"""
+ global _custom_signal_exprs
+ _custom_signal_exprs = None
+
+
+# enriched parquet 仅存储的列 (14 列)
+ENRICHED_STORAGE_COLS = [
+ "symbol", "date",
+ "open", "high", "low", "close", # 前复权
+ "volume", "amount",
+ "raw_close", "raw_high", "raw_low", # 不复权原始价
+ "turnover_rate", # 依赖当时的 float_shares, 不可回推
+ "consecutive_limit_ups", # 递推状态, 需从历史 cum_sum
+ "consecutive_limit_downs",
+]
+
+
+# ================================================================
+# enriched 完整列清单 (存储 + 运行时计算)
+# 供 AI 审查代码时参考: 策略/筛选/回测 可直接使用以下列名。
+# 分类: 存储列 → 指标列 → 信号列 → JOIN 列
+# ================================================================
+ENRICHED_COLUMNS: dict[str, dict[str, str]] = {
+ # ── 存储列 (parquet 持久化) ──────────────────────────
+ "symbol": "股票代码",
+ "date": "交易日期",
+ "open": "前复权开盘价",
+ "high": "前复权最高价",
+ "low": "前复权最低价",
+ "close": "前复权收盘价",
+ "volume": "成交量",
+ "amount": "成交额",
+ "raw_close": "原始收盘价(未复权)",
+ "raw_high": "原始最高价(未复权)",
+ "raw_low": "原始最低价(未复权)",
+ "turnover_rate": "换手率",
+ "consecutive_limit_ups": "连板数",
+ "consecutive_limit_downs": "连跌数",
+ # ── 基础指标 ─────────────────────────────────────────
+ "prev_close": "前收盘价",
+ "change_pct": "日涨跌幅(小数, 如 0.05 = 5%)",
+ "change_amount": "日涨跌额",
+ "amplitude": "日振幅 (最高-最低)/昨收",
+ # ── 均线 MA ──────────────────────────────────────────
+ "ma5": "5日简单均线",
+ "ma10": "10日简单均线",
+ "ma20": "20日简单均线",
+ "ma30": "30日简单均线",
+ "ma60": "60日简单均线(季线)",
+ # ── 指数均线 EMA ─────────────────────────────────────
+ "ema5": "5日指数均线",
+ "ema10": "10日指数均线",
+ "ema20": "20日指数均线",
+ "ema30": "30日指数均线",
+ "ema60": "60日指数均线",
+ # ── MACD ─────────────────────────────────────────────
+ "macd_dif": "MACD DIF线(快线-慢线)",
+ "macd_dea": "MACD DEA线(信号线)",
+ "macd_hist": "MACD柱状图 (DIF-DEA)×2",
+ # ── 布林带 BOLL ──────────────────────────────────────
+ "boll_upper": "布林带上轨 MA20+2σ",
+ "boll_lower": "布林带下轨 MA20-2σ",
+ # ── KDJ ──────────────────────────────────────────────
+ "kdj_k": "KDJ K值",
+ "kdj_d": "KDJ D值",
+ "kdj_j": "KDJ J值 (3K-2D)",
+ # ── ATR ──────────────────────────────────────────────
+ "atr_14": "14日平均真实波幅",
+ # ── 量价 ─────────────────────────────────────────────
+ "vol_ma5": "5日成交均量",
+ "vol_ma10": "10日成交均量",
+ "vol_ratio_5d": "量比 (成交量/5日均量)",
+ # ── 极值 ─────────────────────────────────────────────
+ "high_60d": "60日最高价",
+ "low_60d": "60日最低价",
+ # ── 动量 ─────────────────────────────────────────────
+ "momentum_5d": "5日动量(涨跌幅小数)",
+ "momentum_10d": "10日动量",
+ "momentum_20d": "20日动量",
+ "momentum_30d": "30日动量",
+ "momentum_60d": "60日动量",
+ # ── 波动率 ───────────────────────────────────────────
+ "annual_vol_20d": "20日年化波动率",
+ # ── RSI ──────────────────────────────────────────────
+ "rsi_6": "6日相对强弱指标",
+ "rsi_14": "14日相对强弱指标",
+ "rsi_24": "24日相对强弱指标",
+ # ── 信号列 (bool) ────────────────────────────────────
+ "signal_ma_golden_5_20": "MA5上穿MA20 (金叉)",
+ "signal_ma_dead_5_20": "MA5下穿MA20 (死叉)",
+ "signal_ma_golden_20_60": "MA20上穿MA60",
+ "signal_macd_golden": "MACD金叉 (DIF上穿DEA)",
+ "signal_macd_dead": "MACD死叉 (DIF下穿DEA)",
+ "signal_ma20_breakout": "收盘突破MA20上方",
+ "signal_ma20_breakdown": "收盘跌破MA20下方",
+ "signal_n_day_high": "创60日新高",
+ "signal_n_day_low": "创60日新低",
+ "signal_boll_breakout_upper": "突破布林上轨",
+ "signal_boll_breakdown_lower": "跌破布林下轨",
+ "signal_volume_surge": "放量 (量比≥2.0)",
+ "signal_limit_up": "涨停",
+ "signal_limit_down": "跌停",
+ "signal_limit_down_recovery": "跌停翘板(跌停后回升)",
+ "signal_broken_limit_up": "炸板(最高触及涨停但收盘未封住)",
+ # ── JOIN 列 (由 repository 从 instruments 表补充) ───
+ "name": "股票名称 (来自 instruments)",
+ "total_shares": "总股本 (来自 instruments)",
+ "float_shares": "流通股本 (来自 instruments)",
+}
+
+# 仅供 AI/开发者快速索引: 按类别的列名列表
+ENRICHED_COLUMNS_BY_CATEGORY: dict[str, list[str]] = {
+ "storage": [k for k in ENRICHED_COLUMNS if k in ENRICHED_STORAGE_COLS],
+ "basic": ["prev_close", "change_pct", "change_amount", "amplitude"],
+ "ma": ["ma5", "ma10", "ma20", "ma30", "ma60"],
+ "ema": ["ema5", "ema10", "ema20", "ema30", "ema60"],
+ "macd": ["macd_dif", "macd_dea", "macd_hist"],
+ "boll": ["boll_upper", "boll_lower"],
+ "kdj": ["kdj_k", "kdj_d", "kdj_j"],
+ "atr": ["atr_14"],
+ "volume": ["vol_ma5", "vol_ma10", "vol_ratio_5d"],
+ "extremes": ["high_60d", "low_60d"],
+ "momentum": ["momentum_5d", "momentum_10d", "momentum_20d", "momentum_30d", "momentum_60d"],
+ "volatility": ["annual_vol_20d"],
+ "rsi": ["rsi_6", "rsi_14", "rsi_24"],
+ "signals": [k for k in ENRICHED_COLUMNS if k.startswith("signal_")],
+ "join": ["name", "total_shares", "float_shares"],
+}
+
+
+def _ema_alpha(span: int) -> float:
+ return 2.0 / (span + 1)
+
+
+def _math_half_up(expr: pl.Expr, decimals: int = 2) -> pl.Expr:
+ """交易所四舍五入 (round half up),替代 Python round()(银行家舍入)。
+
+ round(2.625, 2) = 2.62 ← Python 银行家舍入
+ exchange_round(2.625) = 2.63 ← 交易所四舍五入
+ """
+ factor = 10 ** decimals
+ return (expr * factor + 0.5).floor() / factor
+
+
+def _limit_price(prev: pl.Expr, limit_pct: pl.Expr, up: bool) -> pl.Expr:
+ """用「分」为单位的整数算术计算涨跌停价,规避浮点精度问题。
+
+ 交易所涨跌停价 = round(prev × (1 ± limit), 2),标准四舍五入。
+ 若直接用浮点 prev × (1 ± limit) 会丢精度:
+ 18.90 × 0.95 = 17.955,浮点存储为 17.954999..., 四舍五入后得 17.95(错)。
+ 本函数先把 prev 转成整数「分」(round 到分避免输入含厘误差),
+ 再用整数系数 105/95、110/90、120/80、130/70 相乘后四舍五入回元,全程不丢精度。
+ """
+ sign = 1 if up else -1
+ # limit_pct ∈ {0.05, 0.10, 0.20, 0.30} → 系数分子 105/95、110/90、120/80、130/70
+ num = ((1 + sign * limit_pct) * 100).cast(pl.Int64) # 105, 110, 120, 130 等
+ cents = (prev * 100 + 0.5).floor().cast(pl.Int64) # 价格转「分」(四舍五入到分)
+ # cents × num / 100, 四舍五入到分(加 50)
+ return (((cents * num + 50) // 100) / 100)
+
+
+def _apply_adj_factor(raw: pl.DataFrame, factors: pl.DataFrame) -> pl.DataFrame:
+ """对 raw K 线应用前复权 (forward adjustment)。
+
+ adj_factor 结构: symbol, trade_date, ex_factor
+ ex_factor 含义: 每次除权事件的 pre/post 比值(个股级,非累积)。
+
+ 前复权原理:
+ - 保持最新价格不变,将历史价格向下调整以消除除权缺口
+ - adjusted = raw × cumprod_at_D / total_cumprod
+ - 等价于: adjusted = raw / (该日期之后所有事件的 ex_factor 乘积)
+ """
+ if factors.is_empty():
+ return raw
+
+ # 确保类型一致
+ factors = factors.with_columns(
+ pl.col("trade_date").cast(pl.Date, strict=False),
+ pl.col("ex_factor").cast(pl.Float64, strict=False),
+ ).select("symbol", "trade_date", "ex_factor").drop_nulls()
+
+ if factors.is_empty():
+ return raw
+
+ # 去重 + 排序 + 累积乘积 (一趟完成)
+ factors_sorted = (
+ factors.sort(["symbol", "trade_date"])
+ .unique(subset=["symbol", "trade_date"])
+ .sort(["symbol", "trade_date"])
+ .with_columns(
+ pl.col("ex_factor").cum_prod().over("symbol").alias("cum_factor"),
+ )
+ )
+
+ # 每个 symbol 的总累积因子
+ total_factors = (
+ factors_sorted
+ .group_by("symbol")
+ .agg(pl.col("cum_factor").last().alias("total_factor"))
+ )
+
+ raw_sorted = raw.sort(["symbol", "date"])
+
+ # join_asof backward: 每根 K 线取 <= 其 date 的最新累积因子
+ # 同时带 trade_date 列用于判断除权日标记
+ df = raw_sorted.join_asof(
+ factors_sorted.select("symbol", "trade_date", "cum_factor"),
+ left_on="date",
+ right_on="trade_date",
+ by="symbol",
+ strategy="backward",
+ )
+
+ # 补充 total_factor + 前复权 + 除权标记,一次 with_columns 完成
+ df = df.join(total_factors, on="symbol", how="left")
+
+ is_ex = pl.col("trade_date") == pl.col("date")
+ ratio = pl.col("cum_factor").fill_null(1.0) / pl.col("total_factor").fill_null(1.0)
+ price_cols = [c for c in ("open", "high", "low", "close") if c in df.columns]
+
+ df = df.with_columns(
+ [pl.col(c) * ratio for c in price_cols]
+ + [
+ is_ex.alias("ex_rights"),
+ ]
+ ).drop(["trade_date", "cum_factor", "total_factor"])
+
+ return df
+
+
+# ================================================================
+# 技术指标计算 (从 OHLCV 计算)
+# ================================================================
+
+def compute_indicators(df: pl.DataFrame) -> pl.DataFrame:
+ """从 OHLCV 数据计算全套技术指标。
+
+ 输入必须包含: symbol, date, open, high, low, close, volume
+ 返回添加了所有指标列的 DataFrame。
+ """
+ if df.is_empty():
+ return df
+
+ import time as _time
+ _t0 = _time.perf_counter()
+
+ df = df.sort(["symbol", "date"])
+
+ # Pass 1: 均线 + EMA + MACD 基础 + BOLL 基础 + KDJ 基础 + ATR 基础 + 量价 + 极值
+ prev_close = pl.col("close").shift(1).over("symbol")
+ df = df.with_columns([
+ # 前收盘价
+ prev_close.alias("prev_close"),
+ # MA (最大 MA60)
+ pl.col("close").rolling_mean(5).over("symbol").alias("ma5"),
+ pl.col("close").rolling_mean(10).over("symbol").alias("ma10"),
+ pl.col("close").rolling_mean(20).over("symbol").alias("ma20"),
+ pl.col("close").rolling_mean(30).over("symbol").alias("ma30"),
+ pl.col("close").rolling_mean(60).over("symbol").alias("ma60"),
+ # EMA (不含 ema12/ema26, MACD 内部自算)
+ pl.col("close").ewm_mean(alpha=_ema_alpha(5), adjust=False).over("symbol").alias("ema5"),
+ pl.col("close").ewm_mean(alpha=_ema_alpha(10), adjust=False).over("symbol").alias("ema10"),
+ pl.col("close").ewm_mean(alpha=_ema_alpha(20), adjust=False).over("symbol").alias("ema20"),
+ pl.col("close").ewm_mean(alpha=_ema_alpha(30), adjust=False).over("symbol").alias("ema30"),
+ pl.col("close").ewm_mean(alpha=_ema_alpha(60), adjust=False).over("symbol").alias("ema60"),
+ # MACD base (内部计算, 不存 ema12/ema26)
+ pl.col("close").ewm_mean(alpha=_ema_alpha(12), adjust=False).over("symbol").alias("_ema12"),
+ pl.col("close").ewm_mean(alpha=_ema_alpha(26), adjust=False).over("symbol").alias("_ema26"),
+ # BOLL base
+ pl.col("close").rolling_std(20).over("symbol").alias("_boll_std"),
+ # KDJ base
+ pl.col("low").rolling_min(9).over("symbol").alias("_kdj_ln"),
+ pl.col("high").rolling_max(9).over("symbol").alias("_kdj_hn"),
+ # ATR base
+ pl.max_horizontal(
+ pl.col("high") - pl.col("low"),
+ (pl.col("high") - prev_close).abs(),
+ (pl.col("low") - prev_close).abs(),
+ ).alias("_tr"),
+ # 量价 base
+ pl.col("volume").rolling_mean(5).over("symbol").alias("vol_ma5"),
+ pl.col("volume").rolling_mean(10).over("symbol").alias("vol_ma10"),
+ pl.col("volume").rolling_mean(5).over("symbol").alias("_vol_ma5"),
+ # 极值
+ pl.col("close").rolling_max(60).over("symbol").alias("high_60d"),
+ pl.col("close").rolling_min(60).over("symbol").alias("low_60d"),
+ ])
+
+ # Pass 2: MACD + BOLL (基于 Pass 1 基础列)
+ df = df.with_columns([
+ (pl.col("_ema12") - pl.col("_ema26")).alias("macd_dif"),
+ (pl.col("ma20") + 2 * pl.col("_boll_std")).alias("boll_upper"),
+ (pl.col("ma20") - 2 * pl.col("_boll_std")).alias("boll_lower"),
+ ]).with_columns(
+ pl.col("macd_dif").ewm_mean(alpha=_ema_alpha(9), adjust=False).over("symbol").alias("macd_dea"),
+ ).with_columns(
+ ((pl.col("macd_dif") - pl.col("macd_dea")) * 2).alias("macd_hist"),
+ )
+
+ # Pass 3: KDJ
+ _kdj_rsv = (
+ 100 * (pl.col("close") - pl.col("_kdj_ln"))
+ / (pl.col("_kdj_hn") - pl.col("_kdj_ln")).fill_null(1e-12)
+ )
+ df = df.with_columns([
+ _kdj_rsv.ewm_mean(alpha=1.0 / 3, adjust=False).over("symbol").alias("kdj_k"),
+ ]).with_columns([
+ pl.col("kdj_k").ewm_mean(alpha=1.0 / 3, adjust=False).over("symbol").alias("kdj_d"),
+ ]).with_columns([
+ (3 * pl.col("kdj_k") - 2 * pl.col("kdj_d")).alias("kdj_j"),
+ ])
+
+ # Pass 4: ATR + 量比 + 动量 + 波动 + 涨跌幅 + 涨跌额 + 振幅
+ df = df.with_columns(
+ pl.col("_tr").ewm_mean(alpha=1.0 / 14, adjust=False).over("symbol").alias("atr_14"),
+ ).with_columns(
+ (pl.col("volume") / pl.col("_vol_ma5")).alias("vol_ratio_5d"),
+ ).with_columns([
+ # 动量: 5d/10d/20d/30d/60d
+ (pl.col("close") / pl.col("close").shift(5).over("symbol") - 1).alias("momentum_5d"),
+ (pl.col("close") / pl.col("close").shift(10).over("symbol") - 1).alias("momentum_10d"),
+ (pl.col("close") / pl.col("close").shift(20).over("symbol") - 1).alias("momentum_20d"),
+ (pl.col("close") / pl.col("close").shift(30).over("symbol") - 1).alias("momentum_30d"),
+ (pl.col("close") / pl.col("close").shift(60).over("symbol") - 1).alias("momentum_60d"),
+ # 日涨跌幅
+ (pl.col("close") / pl.col("close").shift(1).over("symbol") - 1).alias("change_pct"),
+ ]).with_columns(
+ # 涨跌额
+ (pl.col("close") - pl.col("close").shift(1).over("symbol")).alias("change_amount"),
+ ).with_columns(
+ # 振幅 = (high - low) / prev_close
+ pl.when(pl.col("close").shift(1).over("symbol") > 0)
+ .then((pl.col("high") - pl.col("low")) / pl.col("close").shift(1).over("symbol"))
+ .otherwise(None)
+ .alias("amplitude"),
+ ).with_columns(
+ # 日涨跌幅 (用于波动率)
+ pl.col("close").pct_change().over("symbol").alias("_daily_pct"),
+ ).with_columns(
+ # 年化波动率
+ (pl.col("_daily_pct").rolling_std(20).over("symbol") * (252 ** 0.5))
+ .alias("annual_vol_20d"),
+ )
+
+ # Pass 5: RSI
+ df = df.with_columns(
+ pl.col("close").diff().over("symbol").alias("_delta"),
+ ).with_columns([
+ pl.when(pl.col("_delta") > 0).then(pl.col("_delta")).otherwise(0.0).alias("_gain"),
+ pl.when(pl.col("_delta") < 0).then(-pl.col("_delta")).otherwise(0.0).alias("_loss"),
+ ])
+ for n in (6, 14, 24):
+ a = 1.0 / n
+ df = df.with_columns([
+ pl.col("_gain").ewm_mean(alpha=a, adjust=False).over("symbol").alias(f"_rsi_avg_gain_{n}"),
+ pl.col("_loss").ewm_mean(alpha=a, adjust=False).over("symbol").alias(f"_rsi_avg_loss_{n}"),
+ ]).with_columns(
+ (100 - 100 / (1 + pl.col(f"_rsi_avg_gain_{n}") /
+ pl.when(pl.col(f"_rsi_avg_loss_{n}") == 0)
+ .then(1e-12)
+ .otherwise(pl.col(f"_rsi_avg_loss_{n}"))
+ )).alias(f"rsi_{n}"),
+ )
+
+ # Pass 6: 换手率 (需要 float_shares, 后续在 compute_all 中 JOIN instruments 后补充)
+
+ # 清理临时列
+ df = df.drop(["_boll_std", "_tr", "_ema12", "_ema26",
+ "_kdj_ln", "_kdj_hn", "_vol_ma5", "_daily_pct",
+ "_delta", "_gain", "_loss",
+ "_rsi_avg_gain_6", "_rsi_avg_loss_6",
+ "_rsi_avg_gain_14", "_rsi_avg_loss_14",
+ "_rsi_avg_gain_24", "_rsi_avg_loss_24"])
+
+ _elapsed = (_time.perf_counter() - _t0) * 1000
+ import logging as _logging
+ _logging.getLogger(__name__).debug("compute_indicators: %.1fms, %d rows", _elapsed, len(df))
+
+ return df
+
+
+def compute_signals(df: pl.DataFrame) -> pl.DataFrame:
+ """从已有指标列计算原子信号布尔列。
+
+ 输入必须包含 compute_indicators() 产出的指标列。
+ """
+ if df.is_empty():
+ return df
+
+ df = df.with_columns([
+ ((pl.col("ma5") > pl.col("ma20")) &
+ (pl.col("ma5").shift(1).over("symbol") <= pl.col("ma20").shift(1).over("symbol")))
+ .alias("signal_ma_golden_5_20"),
+ ((pl.col("ma5") < pl.col("ma20")) &
+ (pl.col("ma5").shift(1).over("symbol") >= pl.col("ma20").shift(1).over("symbol")))
+ .alias("signal_ma_dead_5_20"),
+ ((pl.col("ma20") > pl.col("ma60")) &
+ (pl.col("ma20").shift(1).over("symbol") <= pl.col("ma60").shift(1).over("symbol")))
+ .alias("signal_ma_golden_20_60"),
+ ((pl.col("macd_dif") > pl.col("macd_dea")) &
+ (pl.col("macd_dif").shift(1).over("symbol") <= pl.col("macd_dea").shift(1).over("symbol")))
+ .alias("signal_macd_golden"),
+ ((pl.col("macd_dif") < pl.col("macd_dea")) &
+ (pl.col("macd_dif").shift(1).over("symbol") >= pl.col("macd_dea").shift(1).over("symbol")))
+ .alias("signal_macd_dead"),
+ ((pl.col("close") > pl.col("ma20")) &
+ (pl.col("close").shift(1).over("symbol") <= pl.col("ma20").shift(1).over("symbol")))
+ .alias("signal_ma20_breakout"),
+ ((pl.col("close") < pl.col("ma20")) &
+ (pl.col("close").shift(1).over("symbol") >= pl.col("ma20").shift(1).over("symbol")))
+ .alias("signal_ma20_breakdown"),
+ (pl.col("close") >= pl.col("high_60d")).alias("signal_n_day_high"),
+ (pl.col("close") <= pl.col("low_60d")).alias("signal_n_day_low"),
+ (pl.col("close") > pl.col("boll_upper")).alias("signal_boll_breakout_upper"),
+ (pl.col("close") < pl.col("boll_lower")).alias("signal_boll_breakdown_lower"),
+ (pl.col("vol_ratio_5d") >= 2.0).alias("signal_volume_surge"),
+ ])
+
+ # 自定义信号(用户配置的字段+运算符+值组合,编译为布尔列)
+ from app.strategy import custom_signals
+ df = custom_signals.inject(df, _get_custom_signal_exprs())
+
+ return df
+
+
+def compute_limit_signals(df: pl.DataFrame, instruments: pl.DataFrame) -> pl.DataFrame:
+ """计算涨跌停相关信号。
+
+ 产出:
+ signal_limit_up, consecutive_limit_ups
+ signal_limit_down, consecutive_limit_downs
+ signal_limit_down_recovery (跌停翘板)
+ signal_broken_limit_up (炸板: 最高价触及涨停价但收盘未封住)
+
+ 输入必须包含: symbol, date, raw_close, raw_high, open, high, low, close,
+ change_pct, vol_ratio_5d。
+ """
+ if df.is_empty():
+ return df
+
+ # 从 instruments 取 ST 标记 + 流通股本(换手率用)
+ inst_cols = ["symbol"]
+ if "name" in instruments.columns:
+ inst_cols.append("name")
+ if "float_shares" in instruments.columns:
+ inst_cols.append("float_shares")
+ inst_subset = instruments.select(inst_cols).unique(subset=["symbol"])
+
+ if "name" in instruments.columns:
+ st_flag = (
+ instruments
+ .select("symbol", pl.col("name").str.contains("ST").alias("_is_st"))
+ .unique(subset=["symbol"])
+ )
+ inst_subset = inst_subset.join(st_flag, on="symbol", how="left")
+
+ df = df.join(inst_subset, on="symbol", how="left", suffix="_inst")
+
+ # 计算换手率(%) = volume(手) * 10000 / float_shares(股)
+ if "float_shares" in df.columns and "volume" in df.columns:
+ df = df.with_columns(
+ pl.when(pl.col("float_shares") > 0)
+ .then(pl.col("volume") * 10000.0 / pl.col("float_shares"))
+ .otherwise(None)
+ .alias("turnover_rate")
+ )
+ elif "turnover_rate" not in df.columns:
+ df = df.with_columns(pl.lit(None).cast(pl.Float64).alias("turnover_rate"))
+
+ # 前一日参考收盘价(交易所涨跌停基准价)
+ # 仅在 adj_factor 发生变化(除权除息 XD/DR)时使用前复权昨收作为交易所参考价;
+ # 否则使用原始 raw_close.shift(1) 以避免浮点精度误差。
+ _adj_today = pl.col("close") / pl.col("raw_close")
+ _adj_yesterday = pl.col("close").shift(1).over("symbol") / pl.col("raw_close").shift(1).over("symbol")
+ _adj_changed = (_adj_today - _adj_yesterday).abs() > 1e-6
+ df = df.with_columns(
+ pl.when(_adj_changed)
+ .then(pl.col("close").shift(1).over("symbol")) # 除权: 使用前复权昨收
+ .otherwise(pl.col("raw_close").shift(1).over("symbol")) # 正常: 使用原始昨收
+ .alias("_prev_raw_close")
+ )
+
+ # 板块涨跌停比例
+ is_chinext = pl.col("symbol").str.starts_with("300") | pl.col("symbol").str.starts_with("301")
+ is_star = pl.col("symbol").str.starts_with("688") | pl.col("symbol").str.starts_with("689")
+ is_bj = pl.col("symbol").str.ends_with(".BJ")
+
+ df = df.with_columns(
+ pl.when(is_chinext).then(0.20)
+ .when(is_star).then(0.20)
+ .when(is_bj).then(0.30)
+ .otherwise(0.10)
+ .alias("_board_pct")
+ )
+
+ # ST → 5%(覆盖板块默认值)
+ if "_is_st" in df.columns:
+ df = df.with_columns(
+ pl.when(pl.col("_is_st").fill_null(False))
+ .then(0.05)
+ .otherwise(pl.col("_board_pct"))
+ .alias("_limit_pct")
+ )
+ else:
+ df = df.with_columns(pl.col("_board_pct").alias("_limit_pct"))
+
+ # 理论涨停价 = prev_close × (1 + limit_pct) 整数算术,避免浮点误差
+ df = df.with_columns(
+ _limit_price(pl.col("_prev_raw_close"), pl.col("_limit_pct"), up=True)
+ .alias("_theoretical_limit_up")
+ )
+
+ # 理论跌停价 = prev_close × (1 - limit_pct)
+ df = df.with_columns(
+ _limit_price(pl.col("_prev_raw_close"), pl.col("_limit_pct"), up=False)
+ .alias("_theoretical_limit_down")
+ )
+
+ # ── signal_limit_up ──
+ df = df.with_columns(
+ pl.when(
+ pl.col("_prev_raw_close").is_not_null()
+ & (pl.col("_prev_raw_close") > 0)
+ & (pl.col("raw_close") > 0)
+ ).then(
+ (pl.col("raw_close") - pl.col("_theoretical_limit_up")).abs() < 0.005
+ ).otherwise(None).cast(pl.Boolean)
+ .alias("signal_limit_up")
+ )
+
+ # ── consecutive_limit_ups ──
+ df = df.with_columns(
+ (~pl.col("signal_limit_up").fill_null(False))
+ .cast(pl.UInt32)
+ .cum_sum()
+ .over("symbol")
+ .alias("_grp_up")
+ ).with_columns(
+ pl.col("signal_limit_up")
+ .cast(pl.UInt32)
+ .cum_sum()
+ .over("symbol", "_grp_up")
+ .cast(pl.UInt32)
+ .alias("consecutive_limit_ups")
+ ).with_columns(
+ pl.when(pl.col("signal_limit_up").fill_null(False))
+ .then(pl.col("consecutive_limit_ups"))
+ .otherwise(0)
+ .cast(pl.UInt32)
+ .alias("consecutive_limit_ups")
+ )
+
+ # ── signal_limit_down ──
+ df = df.with_columns(
+ pl.when(
+ pl.col("_prev_raw_close").is_not_null()
+ & (pl.col("_prev_raw_close") > 0)
+ & (pl.col("raw_close") > 0)
+ ).then(
+ (pl.col("raw_close") - pl.col("_theoretical_limit_down")).abs() < 0.005
+ ).otherwise(None).cast(pl.Boolean)
+ .alias("signal_limit_down")
+ )
+
+ # ── consecutive_limit_downs ──
+ df = df.with_columns(
+ (~pl.col("signal_limit_down").fill_null(False))
+ .cast(pl.UInt32)
+ .cum_sum()
+ .over("symbol")
+ .alias("_grp_down")
+ ).with_columns(
+ pl.col("signal_limit_down")
+ .cast(pl.UInt32)
+ .cum_sum()
+ .over("symbol", "_grp_down")
+ .cast(pl.UInt32)
+ .alias("consecutive_limit_downs")
+ ).with_columns(
+ pl.when(pl.col("signal_limit_down").fill_null(False))
+ .then(pl.col("consecutive_limit_downs"))
+ .otherwise(0)
+ .cast(pl.UInt32)
+ .alias("consecutive_limit_downs")
+ )
+
+ # ── signal_limit_down_recovery (跌停翘板) ──
+ # 条件: 当日最低价曾触及跌停价 + 最终没有跌停 + 收阳
+ df = df.with_columns(
+ pl.when(
+ pl.col("_prev_raw_close").is_not_null()
+ & (pl.col("_prev_raw_close") > 0)
+ ).then(
+ (~pl.col("signal_limit_down").fill_null(False)) # 最终没跌停
+ & (pl.col("low") <= pl.col("_theoretical_limit_down") + 0.005) # 曾触及跌停
+ & (pl.col("close") > pl.col("open")) # 收阳
+ ).otherwise(None).cast(pl.Boolean)
+ .alias("signal_limit_down_recovery")
+ )
+
+ # ── signal_broken_limit_up (炸板) ──
+ # 条件: 最高价曾触及涨停价 + 最终没有封住涨停
+ df = df.with_columns(
+ pl.when(
+ pl.col("_prev_raw_close").is_not_null()
+ & (pl.col("_prev_raw_close") > 0)
+ & (pl.col("raw_high") > 0)
+ ).then(
+ (~pl.col("signal_limit_up").fill_null(False)) # 最终没封住涨停
+ & (pl.col("raw_high") >= pl.col("_theoretical_limit_up") - 0.005) # 曾触及涨停价
+ ).otherwise(None).cast(pl.Boolean)
+ .alias("signal_broken_limit_up")
+ )
+
+ # 清理临时列 + JOIN 引入的 instruments 列 (不存入 enriched)
+ cleanup = ["_prev_raw_close", "_board_pct", "_limit_pct",
+ "_theoretical_limit_up", "_theoretical_limit_down",
+ "_grp_up", "_grp_down"]
+ if "_is_st" in df.columns:
+ cleanup.append("_is_st")
+ # 清理 join 产生的重复列
+ for c in df.columns:
+ if c.endswith("_inst"):
+ cleanup.append(c)
+ # name 和 float_shares 只用于计算, 不存入 enriched
+ for c in ["name", "float_shares"]:
+ if c in df.columns and c != "turnover_rate":
+ cleanup.append(c)
+ df = df.drop([c for c in cleanup if c in df.columns])
+
+ return df
+
+
+def compute_all(df: pl.DataFrame, instruments: pl.DataFrame | None = None) -> pl.DataFrame:
+ """从 OHLCV 计算全套指标 + 信号。一站式调用。
+
+ 输入: symbol, date, open, high, low, close, volume, amount, raw_close
+ """
+ df = compute_indicators(df)
+ df = compute_signals(df)
+ if instruments is not None and not instruments.is_empty():
+ df = compute_limit_signals(df, instruments)
+
+ # 清理 NaN / Inf
+ float_cols = [c for c in df.columns if df[c].dtype.is_float()]
+ if float_cols:
+ df = df.with_columns([
+ pl.when(pl.col(c).is_nan() | pl.col(c).is_infinite())
+ .then(None)
+ .otherwise(pl.col(c))
+ .alias(c)
+ for c in float_cols
+ ])
+
+ return df
+
+
+def filter_halt_days(df: pl.DataFrame) -> pl.DataFrame:
+ """过滤停牌日。
+
+ 停牌日的 open/high 必然为 0 (无集合竞价)。注意 close 可能被数据源
+ 填充为前收盘价而非 0, 因此不能用 "OHLC 全零" 判断, 否则会漏过这类
+ 停牌记录 (如 *ST 撤销风险警示的停牌日), 污染 MA/ATR 等指标。
+ """
+ if df.is_empty() or "open" not in df.columns or "high" not in df.columns:
+ return df
+ return df.filter(~((pl.col("open") == 0) & (pl.col("high") == 0)))
+
+
+# ================================================================
+# Pipeline: 盘后全量计算 + 写入
+# ================================================================
+
+def compute_enriched(
+ raw: pl.DataFrame,
+ factors: pl.DataFrame | None = None,
+ instruments: pl.DataFrame | None = None,
+) -> pl.DataFrame:
+ """对原始日 K 应用前复权 + 全量计算指标 + 信号, 产出完整 enriched (含全部指标列)。
+
+ 输入应包含至少: symbol, date, open, high, low, close, volume (可选 amount)。
+ 如果提供了 factors, 先应用前复权再算指标。
+ 如果提供了 instruments, 计算涨跌停信号和换手率。
+ """
+ if raw.is_empty():
+ return raw
+
+ # 过滤停牌日 (会污染指标计算)
+ raw = filter_halt_days(raw)
+
+ if raw.is_empty():
+ return raw
+
+ # 保留不复权原始价格(涨停/炸板/跌停判断需用不复权价格)
+ raw = raw.with_columns(
+ pl.col("close").alias("raw_close"),
+ pl.col("high").alias("raw_high"),
+ pl.col("low").alias("raw_low"),
+ )
+
+ # 应用前复权(只改 open/high/low/close,raw_close 不受影响)
+ if factors is not None and not factors.is_empty():
+ raw = _apply_adj_factor(raw, factors)
+
+ # 排序
+ df = raw.sort(["symbol", "date"])
+
+ # 全量计算指标 + 信号
+ df = compute_all(df, instruments=instruments)
+
+ return df
+
+
+def _select_storage_cols(df: pl.DataFrame) -> pl.DataFrame:
+ """写入 parquet 前裁剪到存储列 (14 列)。"""
+ cols = [c for c in ENRICHED_STORAGE_COLS if c in df.columns]
+ return df.select(cols)
+
+
+def run_pipeline(data_dir: Path | None = None,
+ symbols: list[str] | None = None,
+ new_dates_only: bool = False,
+ on_batch_done: Callable[[int, int], None] | None = None) -> int:
+ """运行盘后管道:读 kline_daily + adj_factor → 前复权 + 计算存储列 → 写 enriched。
+
+ enriched 表仅存储 14 列基础行情窄表 (OHLCV + raw_close/high/low + turnover_rate + 连板数)。
+
+ 模式:
+ - 全量 (symbols=None, new_dates_only=False):
+ 读全部 kline_daily, 全部重写 enriched 分区。
+ 用于首次同步、往前扩展历史。
+ - 向后增量 (new_dates_only=True):
+ 只读 enriched 中尚不存在的日期分区对应的 daily 数据,
+ 为所有标的生成新的 enriched 分区;
+ 若同时传 symbols, 还会对这些个股的全部已有日期做重算
+ (因为除权因子链变了,历史数据的复权比例也要更新)。
+ - 除权因子增量 (symbols 指定, new_dates_only=False):
+ 只对指定 symbol 做局部重算并合并回已有 enriched。
+ 用于无新日K数据、仅除权因子变更的场景。
+ 返回写入的行数。
+ """
+ import time as _t
+ t0 = _t.perf_counter()
+
+ d = Path(data_dir or settings.data_dir)
+ daily_dir = d / "kline_daily"
+ enriched_base = d / "kline_daily_enriched"
+ factor_path = d / "adj_factor" / "all.parquet"
+ inst_glob = str(d / "instruments" / "**" / "*.parquet")
+
+ if not daily_dir.exists() or not any(daily_dir.rglob("*.parquet")):
+ logger.info("无日K数据, 跳过管道")
+ return 0
+
+ daily_glob = (daily_dir / "**" / "*.parquet").as_posix()
+ _cast = pl.ScanCastOptions(integer_cast="allow-float")
+ written = 0
+
+ # 加载 instruments (涨跌停+换手率需要)
+ instruments = pl.DataFrame()
+ try:
+ instruments = pl.scan_parquet(inst_glob, cast_options=_cast).collect()
+ except Exception as e: # noqa: BLE001
+ logger.warning("instruments 读取失败: %s", e)
+
+ if new_dates_only:
+ # ── 向后增量模式 ──
+ # 1. 找出 daily 有但 enriched 还没有的日期
+ enriched_dates = set()
+ if enriched_base.exists():
+ enriched_dates = {p.stem.split("=")[1] for p in enriched_base.glob("date=*")}
+
+ # 读新增日期的 daily 数据 (所有标的)
+ new_date_dirs = sorted(
+ p for p in daily_dir.glob("date=*")
+ if p.stem.split("=")[1] not in enriched_dates
+ )
+ if not new_date_dirs and not symbols:
+ logger.info("增量模式: 无新日期, 无需重算")
+ return 0
+
+ # 加载复权因子 (全量,因为所有标的都可能需要)
+ factors = _load_factors(factor_path)
+
+ # 2. 为新日期计算 enriched (所有标的)
+ if new_date_dirs:
+ raw_new = pl.scan_parquet(new_date_dirs[0] / "*.parquet", cast_options=_cast)
+ for nd in new_date_dirs[1:]:
+ raw_new = pl.concat([raw_new, pl.scan_parquet(nd / "*.parquet", cast_options=_cast)], how="diagonal_relaxed")
+ raw_new = raw_new.sort(["symbol", "date"]).collect(streaming=True)
+
+ # 增量模式: 只算新日期, 但指标需要历史窗口
+ # 读已有 enriched 最近 60 天作为历史前缀
+ sym_list = raw_new["symbol"].unique().to_list()
+ hist_df = _load_recent_history(enriched_base, sym_list, days=60)
+
+ # 合并历史 + 新数据
+ if not hist_df.is_empty():
+ # 只取基础行情列做历史前缀
+ hist_cols = [c for c in ["symbol", "date", "open", "high", "low", "close",
+ "volume", "amount", "raw_close", "raw_high", "raw_low"]
+ if c in hist_df.columns]
+ raw_full = pl.concat([hist_df.select(hist_cols), raw_new], how="diagonal_relaxed")
+ else:
+ raw_full = raw_new
+
+ enriched_new = compute_enriched(raw_full, factors=factors, instruments=instruments)
+
+ # 只保留新日期的行
+ new_date_set = set()
+ for nd in new_date_dirs:
+ ds = nd.stem.split("=")[1]
+ new_date_set.add(ds)
+ enriched_new = enriched_new.filter(
+ pl.col("date").map_elements(lambda x: x.isoformat(), return_dtype=pl.Utf8).is_in(list(new_date_set))
+ )
+
+ t_new = _t.perf_counter()
+ logger.info("增量计算: %d 个新日期, %d 行, 耗时 %.2fs",
+ len(new_date_dirs), enriched_new.height, t_new - t0)
+
+ if not enriched_new.is_empty():
+ for date_df in enriched_new.partition_by("date"):
+ dt = date_df["date"][0]
+ ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt)
+ 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)
+ written += date_df.height
+ t_write_new = _t.perf_counter()
+ logger.info("增量写入: %.2fs, %d 行", t_write_new - t_new, written)
+
+ # 3. 受除权因子影响的个股: 重算全部已有日期 (累积因子链变了)
+ if symbols:
+ sym_set = set(symbols)
+ raw_sym = pl.scan_parquet(daily_glob, cast_options=_cast).sort(["symbol", "date"])
+ raw_sym = raw_sym.filter(pl.col("symbol").is_in(list(sym_set)))
+ raw_sym = raw_sym.collect(streaming=True)
+ if not raw_sym.is_empty():
+ factors_sym = factors.filter(pl.col("symbol").is_in(list(sym_set))) if not factors.is_empty() else factors
+ inst_sym = instruments.filter(pl.col("symbol").is_in(list(sym_set))) if not instruments.is_empty() else instruments
+ enriched_sym = compute_enriched(raw_sym, factors=factors_sym, instruments=inst_sym)
+ for date_df in enriched_sym.partition_by("date"):
+ dt = date_df["date"][0]
+ ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt)
+ out = enriched_base / f"date={ds}" / "part.parquet"
+ out.parent.mkdir(parents=True, exist_ok=True)
+ date_df_storage = _select_storage_cols(date_df)
+ if out.exists():
+ existing = pl.read_parquet(out)
+ 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)
+ written += date_df.height
+ logger.info("除权重算: %d 只, 共写入 %d 行", len(sym_set), written)
+
+ t_done = _t.perf_counter()
+ logger.info("增量管道完成: %.2fs, %d 行", t_done - t0, written)
+ return written
+
+ # ── 全量 或 除权因子增量 模式 ──
+ mode = f"incremental ({len(symbols)} symbols)" if symbols else "full"
+ base = d / "kline_daily_enriched"
+
+ # 加载复权因子 (全量加载一次,每批复用)
+ factors = _load_factors(factor_path)
+
+ # 局部模式: 过滤 instruments
+ inst_use = instruments
+
+ import gc
+
+ # ── 按 symbol 分批处理: 每只股只有 ~244 行, 无冗余计算 ──
+ # 先获取全部 symbol 列表
+ lf_all = pl.scan_parquet(daily_glob, cast_options=_cast)
+ if symbols:
+ sym_set = set(symbols)
+ lf_all = lf_all.filter(pl.col("symbol").is_in(list(sym_set)))
+
+ all_symbols = (
+ lf_all.select("symbol").unique().sort("symbol")
+ .collect(streaming=True)["symbol"].to_list()
+ )
+ if not all_symbols:
+ logger.info("无日K数据, 跳过管道")
+ return 0
+
+ total_syms = len(all_symbols)
+ logger.info("全量计算: %d 只标的, 按 symbol 分批 [%s]", total_syms, mode)
+
+ if not factors.is_empty() and symbols:
+ factors = factors.filter(pl.col("symbol").is_in(list(sym_set)))
+ if not factors.is_empty():
+ logger.info("读取复权因子: %d 行", factors.height)
+ if not instruments.is_empty() and symbols:
+ inst_use = instruments.filter(pl.col("symbol").is_in(list(sym_set)))
+
+ from app.services import preferences as prefs_mod
+ SYM_BATCH = prefs_mod.get_enriched_batch_size() # 每批 N 只 × ~244 天, 可在设置中调整
+ total_batches = (total_syms + SYM_BATCH - 1) // SYM_BATCH
+
+ # 全量模式: 先清理旧 enriched 目录, 最后一次性按日期写入
+ # 收集所有批次结果, 按日期分区写入
+ from collections import defaultdict
+ date_buffers: dict[str, list[pl.DataFrame]] = defaultdict(list)
+
+ for batch_start in range(0, total_syms, SYM_BATCH):
+ batch_end = min(batch_start + SYM_BATCH, total_syms)
+ batch_syms = all_symbols[batch_start:batch_end]
+
+ # 只读取本批 symbol 的数据
+ lf_batch = pl.scan_parquet(daily_glob, cast_options=_cast)
+ lf_batch = lf_batch.filter(pl.col("symbol").is_in(batch_syms))
+ raw = lf_batch.sort(["symbol", "date"]).collect(streaming=True)
+
+ if raw.is_empty():
+ continue
+
+ # 本批的 factors / instruments
+ batch_factors = (
+ factors.filter(pl.col("symbol").is_in(batch_syms))
+ if not factors.is_empty() else factors
+ )
+ batch_inst = (
+ inst_use.filter(pl.col("symbol").is_in(batch_syms))
+ if not inst_use.is_empty() else inst_use
+ )
+
+ # 计算
+ enriched = compute_enriched(raw, factors=batch_factors, instruments=batch_inst)
+
+ if not enriched.is_empty():
+ if symbols:
+ # 局部模式: 直接按日期合并写入
+ for date_df in enriched.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)
+ date_df_storage = _select_storage_cols(date_df)
+ if out.exists():
+ existing = pl.read_parquet(out)
+ 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)
+ written += date_df_storage.height
+ else:
+ # 全量模式: 缓冲到 date_buffers, 最后一次性写入
+ for date_df in enriched.partition_by("date"):
+ dt = date_df["date"][0]
+ ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt)
+ date_buffers[ds].append(_select_storage_cols(date_df).sort(["symbol"]))
+ written += date_df.height
+
+ del raw, enriched, batch_factors, batch_inst
+ gc.collect()
+
+ logger.info("symbol 批次 %d/%d (%s ~ %s), 已处理 %d 行",
+ batch_start // SYM_BATCH + 1,
+ total_batches,
+ batch_syms[0], batch_syms[-1], written)
+
+ # 通知进度
+ if on_batch_done:
+ on_batch_done(batch_start // SYM_BATCH + 1, total_batches)
+
+ # 全量模式: 按日期分区写入
+ if not symbols and date_buffers:
+ if base.exists():
+ import shutil
+ shutil.rmtree(base)
+ base.mkdir(parents=True, exist_ok=True)
+
+ for ds, dfs in date_buffers.items():
+ 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)
+
+ date_buffers.clear()
+ gc.collect()
+
+ t_done = _t.perf_counter()
+ adj_label = "含复权" if not factors.is_empty() else "无复权"
+ logger.info("enriched 完成 [%s]: %.2fs, 共 %d 行, %s",
+ mode, t_done - t0, written, adj_label)
+ return written
+
+
+def _load_factors(factor_path: Path) -> pl.DataFrame:
+ """加载复权因子文件。"""
+ if not factor_path.exists():
+ return pl.DataFrame()
+ try:
+ return pl.read_parquet(factor_path)
+ except Exception as e: # noqa: BLE001
+ logger.warning("复权因子读取失败: %s", e)
+ return pl.DataFrame()
+
+
+def _load_recent_history(enriched_base: Path, symbols: list[str], days: int) -> pl.DataFrame:
+ """从已有 enriched parquet 加载最近 N 天的历史数据(用于增量模式的指标计算窗口)。
+
+ 只读基础行情列, 作为指标计算的历史前缀。
+ """
+ from datetime import date, timedelta
+ cutoff = date.today() - timedelta(days=days + 30) # 多读 30 天余量
+
+ try:
+ lf = (
+ pl.scan_parquet(str(enriched_base / "**" / "*.parquet"), cast_options=_cast)
+ .filter(
+ (pl.col("symbol").is_in(symbols))
+ & (pl.col("date") >= cutoff)
+ )
+ .sort(["symbol", "date"])
+ )
+ hist_cols = [c for c in ["symbol", "date", "open", "high", "low", "close",
+ "volume", "amount", "raw_close", "raw_high", "raw_low"]
+ if c in lf.schema]
+ return lf.select(hist_cols).collect()
+ except Exception as e: # noqa: BLE001
+ logger.warning("历史数据加载失败: %s", e)
+ return pl.DataFrame()
+
+
+def compute_enriched_single(daily_for_symbol: pl.DataFrame) -> pl.DataFrame:
+ """单股版本 — Free 用户用,拉下来单股 K 后即时计算全部指标+信号返回给前端。"""
+ if daily_for_symbol.is_empty():
+ return daily_for_symbol
+
+ # 过滤停牌
+ daily_for_symbol = filter_halt_days(daily_for_symbol)
+ if daily_for_symbol.is_empty():
+ return daily_for_symbol
+
+ # 保留 raw_close 用于涨停判断
+ daily_for_symbol = daily_for_symbol.with_columns(pl.col("close").alias("raw_close"))
+
+ # 即时计算全套指标 + 信号 (无复权因子, 无 instruments)
+ return compute_all(daily_for_symbol)
+
+
+# ================================================================
+# 盘中增量计算: 只算今天 5500 行 (不复算历史)
+# ================================================================
+
+def compute_enriched_today(
+ live_agg: pl.DataFrame,
+ prev_enriched: pl.DataFrame,
+ today_ohlcv: pl.DataFrame,
+ instruments: pl.DataFrame | None = None,
+) -> pl.DataFrame:
+ """用昨天的递推状态 + 今天的 OHLCV 增量计算今天的 enriched 数据。
+
+ 只处理 ~5500 行, 耗时 ~10-50ms (替代全量 compute_enriched 的 1.5-2s)。
+
+ 参数:
+ live_agg: repo.get_live_agg() — 包含所有递推状态 + 窗口聚合
+ prev_enriched: repo.get_enriched_latest() — 昨天的完整 enriched (用于信号交叉判断)
+ today_ohlcv: 今天的 OHLCV (symbol, date, open, high, low, close, volume, amount)
+ instruments: 维表 (涨跌停/换手率需要)
+
+ 返回:
+ 今天的 enriched DataFrame (~5500 行, 64 列)
+ """
+ if today_ohlcv.is_empty() or live_agg.is_empty():
+ return pl.DataFrame()
+
+ alpha = _ema_alpha
+
+ # ---- JOIN: 今天的 OHLCV + 昨天的递推状态 ----
+ df = today_ohlcv.join(live_agg, on="symbol", how="inner")
+
+ # ---- 前复权: 保存原始价 → 调整 OHLCV ----
+ df = df.with_columns([
+ pl.col("close").alias("raw_close"),
+ pl.col("high").alias("raw_high"),
+ pl.col("low").alias("raw_low"),
+ ])
+ if "_adj_factor" in df.columns:
+ af = pl.col("_adj_factor").fill_null(1.0)
+ df = df.with_columns([
+ (pl.col("open") * af).alias("open"),
+ (pl.col("high") * af).alias("high"),
+ (pl.col("low") * af).alias("low"),
+ (pl.col("close") * af).alias("close"),
+ ])
+
+ # ---- volume 统一 Float64 ----
+ df = df.with_columns(pl.col("volume").cast(pl.Float64))
+
+ # ---- ex_rights: 盘中除权极罕见, 直接 false ----
+ df = df.with_columns(pl.lit(False).alias("ex_rights"))
+
+ # ---- 基础涨跌 ----
+ # prev_close: 有则直接用 (来自 API quote_extra, raw), 需要乘 adj_factor 对齐复权价
+ if "prev_close" not in df.columns:
+ prev_close = pl.col("close_right") if "close_right" in df.columns else pl.col("close")
+ df = df.with_columns(prev_close.alias("prev_close"))
+ elif "_adj_factor" in df.columns:
+ # 保存 API 原始前收盘价 (用于涨跌停价计算)
+ df = df.with_columns(pl.col("prev_close").alias("_prev_close_raw"))
+ # API 返回的 prev_close 是原始价, 乘复权因子对齐复权价 (用于 change_pct)
+ df = df.with_columns((pl.col("prev_close") * pl.col("_adj_factor").fill_null(1.0)).alias("prev_close"))
+
+ # change_pct / change_amount / amplitude: 有则直接用, 无则计算
+ if "change_pct" not in df.columns:
+ df = df.with_columns((pl.col("close") / pl.col("prev_close") - 1).alias("change_pct"))
+ if "change_amount" not in df.columns:
+ df = df.with_columns((pl.col("close") - pl.col("prev_close")).alias("change_amount"))
+ if "amplitude" not in df.columns:
+ df = df.with_columns(
+ pl.when(pl.col("prev_close") > 0)
+ .then((pl.col("high") - pl.col("low")) / pl.col("prev_close"))
+ .otherwise(None)
+ .alias("amplitude"),
+ )
+
+ # ---- EMA (递推) ----
+ df = df.with_columns([
+ (alpha(5) * pl.col("close") + (1 - alpha(5)) * pl.col("ema5")).alias("ema5"),
+ (alpha(10) * pl.col("close") + (1 - alpha(10)) * pl.col("ema10")).alias("ema10"),
+ (alpha(20) * pl.col("close") + (1 - alpha(20)) * pl.col("ema20")).alias("ema20"),
+ (alpha(30) * pl.col("close") + (1 - alpha(30)) * pl.col("ema30")).alias("ema30"),
+ (alpha(60) * pl.col("close") + (1 - alpha(60)) * pl.col("ema60")).alias("ema60"),
+ ])
+
+ # ---- MACD (递推) ----
+ ema12 = alpha(12) * pl.col("close") + (1 - alpha(12)) * pl.col("_ema12")
+ ema26 = alpha(26) * pl.col("close") + (1 - alpha(26)) * pl.col("_ema26")
+ dif = ema12 - ema26
+ dea = alpha(9) * dif + (1 - alpha(9)) * pl.col("macd_dea")
+ df = df.with_columns([
+ dif.alias("macd_dif"),
+ dea.alias("macd_dea"),
+ ((dif - dea) * 2).alias("macd_hist"),
+ ])
+
+ # ---- MA (用部分和) ----
+ df = df.with_columns([
+ ((pl.col("_ma5_partial_sum") + pl.col("close")) / 5).alias("ma5"),
+ ((pl.col("_ma10_partial_sum") + pl.col("close")) / 10).alias("ma10"),
+ ((pl.col("_ma20_partial_sum") + pl.col("close")) / 20).alias("ma20"),
+ ((pl.col("_ma30_partial_sum") + pl.col("close")) / 30).alias("ma30"),
+ ((pl.col("_ma60_partial_sum") + pl.col("close")) / 60).alias("ma60"),
+ ])
+
+ # ---- Bollinger ----
+ boll_sum = pl.col("_boll_partial_sum") + pl.col("close")
+ boll_sq_sum = pl.col("_boll_partial_sq_sum") + pl.col("close") ** 2
+ boll_ma = boll_sum / 20
+ boll_var = boll_sq_sum / 20 - boll_ma ** 2
+ boll_std = pl.when(boll_var > 0).then(boll_var.sqrt()).otherwise(0.0)
+ df = df.with_columns([
+ (boll_ma + 2 * boll_std).alias("boll_upper"),
+ (boll_ma - 2 * boll_std).alias("boll_lower"),
+ ])
+
+ # ---- KDJ (递推) ----
+ kdj_ln = pl.min_horizontal(pl.col("_kdj_8d_low"), pl.col("low"))
+ kdj_hn = pl.max_horizontal(pl.col("_kdj_8d_high"), pl.col("high"))
+ rsv = (pl.col("close") - kdj_ln) / (kdj_hn - kdj_ln).fill_null(1e-12) * 100
+ k_today = rsv / 3 + pl.col("kdj_k") * 2 / 3
+ d_today = k_today / 3 + pl.col("kdj_d") * 2 / 3
+ df = df.with_columns([
+ k_today.alias("kdj_k"),
+ d_today.alias("kdj_d"),
+ (3 * k_today - 2 * d_today).alias("kdj_j"),
+ ])
+
+ # ---- ATR (递推) ----
+ tr = pl.max_horizontal(
+ pl.col("high") - pl.col("low"),
+ (pl.col("high") - pl.col("prev_close")).abs(),
+ (pl.col("low") - pl.col("prev_close")).abs(),
+ )
+ df = df.with_columns(
+ (tr / 14 + pl.col("atr_14") * 13 / 14).alias("atr_14"),
+ )
+
+ # ---- RSI (递推, n=6,14,24) ----
+ delta = pl.col("close") - pl.col("prev_close")
+ gain = pl.when(delta > 0).then(delta).otherwise(0.0)
+ loss = pl.when(delta < 0).then(-delta).otherwise(0.0)
+ for n in (6, 14, 24):
+ a = 1.0 / n
+ avg_gain = (1 - a) * pl.col(f"_rsi_avg_gain_{n}") + a * gain
+ avg_loss = (1 - a) * pl.col(f"_rsi_avg_loss_{n}") + a * loss
+ df = df.with_columns([
+ avg_gain.alias(f"_rsi_avg_gain_{n}"),
+ avg_loss.alias(f"_rsi_avg_loss_{n}"),
+ (100 - 100 / (1 + avg_gain / pl.when(avg_loss == 0).then(1e-12).otherwise(avg_loss)))
+ .alias(f"rsi_{n}"),
+ ])
+
+ # ---- 量比 ----
+ vol_ma5 = (pl.col("_vol_ma5_partial_sum") + pl.col("volume")) / 5
+ vol_ma10 = (pl.col("_vol_ma10_partial_sum") + pl.col("volume")) / 10
+ df = df.with_columns([
+ vol_ma5.alias("vol_ma5"),
+ vol_ma10.alias("vol_ma10"),
+ (pl.col("volume") / vol_ma5).alias("vol_ratio_5d"),
+ ])
+
+ # ---- 极值 60 日 ----
+ df = df.with_columns([
+ pl.max_horizontal(pl.col("_high_59d"), pl.col("high")).alias("high_60d"),
+ pl.min_horizontal(pl.col("_low_59d"), pl.col("low")).alias("low_60d"),
+ ])
+
+ # ---- 动量 (5d/10d/20d/30d/60d) ----
+ df = df.with_columns([
+ (pl.col("close") / pl.col("_close_5d_ago") - 1).alias("momentum_5d"),
+ (pl.col("close") / pl.col("_close_10d_ago") - 1).alias("momentum_10d"),
+ (pl.col("close") / pl.col("_close_20d_ago") - 1).alias("momentum_20d"),
+ (pl.col("close") / pl.col("_close_30d_ago") - 1).alias("momentum_30d"),
+ (pl.col("close") / pl.col("_close_60d_ago") - 1).alias("momentum_60d"),
+ ])
+
+ # ---- 年化波动率 20d (递推) ----
+ # 用 Welford 简化: sum + sum_sq of 19 historical returns + today's return
+ today_ret = pl.col("close") / pl.col("prev_close") - 1
+ total_sum = pl.col("_vol_19d_pct_sum").fill_null(0.0) + today_ret
+ total_sq_sum = pl.col("_vol_19d_pct_sq_sum").fill_null(0.0) + today_ret ** 2
+ vol_mean = total_sum / 20
+ vol_var = total_sq_sum / 20 - vol_mean ** 2
+ df = df.with_columns(
+ pl.when(vol_var > 0)
+ .then(vol_var.sqrt() * (252 ** 0.5))
+ .otherwise(None)
+ .alias("annual_vol_20d"),
+ )
+
+ # ---- 信号 (需要昨天的指标值判断交叉) ----
+ if not prev_enriched.is_empty():
+ sig_prev = prev_enriched.select(
+ "symbol",
+ pl.col("ma5").alias("_prev_ma5"),
+ pl.col("ma20").alias("_prev_ma20"),
+ pl.col("ma60").alias("_prev_ma60"),
+ pl.col("macd_dif").alias("_prev_dif"),
+ pl.col("macd_dea").alias("_prev_dea"),
+ pl.col("boll_upper").alias("_prev_boll_upper"),
+ pl.col("boll_lower").alias("_prev_boll_lower"),
+ pl.col("close").alias("_prev_close_enriched"),
+ )
+ df = df.join(sig_prev, on="symbol", how="left")
+
+ df = df.with_columns([
+ # MA 金叉/死叉
+ ((pl.col("ma5") > pl.col("ma20")) & (pl.col("_prev_ma5") <= pl.col("_prev_ma20")))
+ .alias("signal_ma_golden_5_20"),
+ ((pl.col("ma5") < pl.col("ma20")) & (pl.col("_prev_ma5") >= pl.col("_prev_ma20")))
+ .alias("signal_ma_dead_5_20"),
+ ((pl.col("ma20") > pl.col("ma60")) & (pl.col("_prev_ma20") <= pl.col("_prev_ma60")))
+ .alias("signal_ma_golden_20_60"),
+ # MACD 金叉/死叉
+ ((pl.col("macd_dif") > pl.col("macd_dea")) & (pl.col("_prev_dif") <= pl.col("_prev_dea")))
+ .alias("signal_macd_golden"),
+ ((pl.col("macd_dif") < pl.col("macd_dea")) & (pl.col("_prev_dif") >= pl.col("_prev_dea")))
+ .alias("signal_macd_dead"),
+ # MA20 突破/跌破
+ ((pl.col("close") > pl.col("ma20")) & (pl.col("_prev_close_enriched") <= pl.col("_prev_ma20")))
+ .alias("signal_ma20_breakout"),
+ ((pl.col("close") < pl.col("ma20")) & (pl.col("_prev_close_enriched") >= pl.col("_prev_ma20")))
+ .alias("signal_ma20_breakdown"),
+ # BOLL 突破
+ (pl.col("close") >= pl.col("boll_upper")).alias("signal_boll_breakout_upper"),
+ (pl.col("close") <= pl.col("boll_lower")).alias("signal_boll_breakdown_lower"),
+ ])
+
+ df = df.drop([
+ c for c in df.columns
+ if c.startswith("_prev_") and c not in {"_prev_consec_up", "_prev_consec_down"}
+ ])
+
+ # N日新高/新低 + 放量
+ df = df.with_columns([
+ (pl.col("close") >= pl.col("high_60d")).alias("signal_n_day_high"),
+ (pl.col("close") <= pl.col("low_60d")).alias("signal_n_day_low"),
+ (pl.col("vol_ratio_5d") >= 2.0).alias("signal_volume_surge"),
+ ])
+
+ # ---- 涨跌停 + 换手率 + 炸板 + 连板 ----
+ if instruments is not None and not instruments.is_empty():
+ df = _compute_limit_signals_today(df, instruments)
+
+ # ---- 清理内部列 ----
+ drop_cols = [
+ "close_right", "high_right", "low_right", "_prev_close_raw",
+ "_ma5_partial_sum", "_ma10_partial_sum", "_ma20_partial_sum",
+ "_ma30_partial_sum", "_ma60_partial_sum",
+ "_boll_partial_sum", "_boll_partial_sq_sum",
+ "_high_59d", "_low_59d",
+ "_close_5d_ago", "_close_10d_ago", "_close_20d_ago",
+ "_close_30d_ago", "_close_60d_ago",
+ "_vol_ma5_partial_sum", "_vol_ma10_partial_sum",
+ "_kdj_8d_low", "_kdj_8d_high",
+ "_window_len",
+ "_rsi_avg_gain_6", "_rsi_avg_loss_6",
+ "_rsi_avg_gain_14", "_rsi_avg_loss_14",
+ "_rsi_avg_gain_24", "_rsi_avg_loss_24",
+ "_ema12", "_ema26",
+ "_adj_factor",
+ "_vol_19d_pct_sum", "_vol_19d_pct_sq_sum",
+ "_prev_consec_up", "_prev_consec_down",
+ ]
+ df = df.drop([c for c in drop_cols if c in df.columns])
+
+ # 自定义信号(日级实时路径同样注入)
+ from app.strategy import custom_signals
+ df = custom_signals.inject(df, _get_custom_signal_exprs())
+
+ # 清理 NaN / Inf
+ float_cols = [c for c in df.columns if df[c].dtype.is_float()]
+ if float_cols:
+ df = df.with_columns([
+ pl.when(pl.col(c).is_nan() | pl.col(c).is_infinite())
+ .then(None)
+ .otherwise(pl.col(c))
+ .alias(c)
+ for c in float_cols
+ ])
+
+ return df
+
+
+def _compute_limit_signals_today(df: pl.DataFrame, instruments: pl.DataFrame) -> pl.DataFrame:
+ """盘中增量版的涨跌停/换手率/炸板/连板计算。"""
+ inst_cols = ["symbol"]
+ if "float_shares" in instruments.columns:
+ inst_cols.append("float_shares")
+ inst_subset = instruments.select(inst_cols).unique(subset=["symbol"])
+ if "name" in instruments.columns:
+ st_flag = (
+ instruments
+ .select("symbol", pl.col("name").str.contains("ST").alias("_is_st"))
+ .unique(subset=["symbol"])
+ )
+ inst_subset = inst_subset.join(st_flag, on="symbol", how="left")
+
+ df = df.join(inst_subset, on="symbol", how="left", suffix="_inst")
+
+ # 换手率: API 有则直接用, 无则从 float_shares 计算
+ if "turnover_rate" not in df.columns:
+ if "float_shares" in df.columns and "volume" in df.columns:
+ df = df.with_columns(
+ pl.when(pl.col("float_shares") > 0)
+ .then(pl.col("volume") * 10000.0 / pl.col("float_shares"))
+ .otherwise(None)
+ .alias("turnover_rate")
+ )
+
+ # 涨跌停 (用 raw_close / raw_high 和前一日原始收盘价)
+ # 优先用 API 原始前收盘价, 回退到 close_right, 最后回退到 raw_close
+ if "_prev_close_raw" in df.columns:
+ if "close_right" in df.columns:
+ prev_raw = pl.when(pl.col("_prev_close_raw").is_not_null()).then(pl.col("_prev_close_raw")).otherwise(pl.col("close_right"))
+ else:
+ prev_raw = pl.col("_prev_close_raw")
+ elif "close_right" in df.columns:
+ prev_raw = pl.col("close_right")
+ else:
+ prev_raw = pl.col("raw_close")
+ is_chinext = pl.col("symbol").str.starts_with("300") | pl.col("symbol").str.starts_with("301")
+ is_star = pl.col("symbol").str.starts_with("688") | pl.col("symbol").str.starts_with("689")
+ is_bj = pl.col("symbol").str.ends_with(".BJ")
+ limit_pct = (
+ pl.when(is_chinext).then(0.20)
+ .when(is_star).then(0.20)
+ .when(is_bj).then(0.30)
+ .otherwise(0.10)
+ )
+ if "_is_st" in df.columns:
+ limit_pct = pl.when(pl.col("_is_st").fill_null(False)).then(0.05).otherwise(limit_pct)
+ limit_pct = limit_pct.alias("_limit_pct")
+
+ limit_up_price = _limit_price(prev_raw, limit_pct, up=True)
+ limit_down_price = _limit_price(prev_raw, limit_pct, up=False)
+
+ is_limit_up = (
+ pl.when((prev_raw > 0) & (pl.col("raw_close") > 0))
+ .then((pl.col("raw_close") - limit_up_price).abs() < 0.005)
+ .otherwise(None).cast(pl.Boolean)
+ )
+ is_limit_down = (
+ pl.when((prev_raw > 0) & (pl.col("raw_close") > 0))
+ .then((pl.col("raw_close") - limit_down_price).abs() < 0.005)
+ .otherwise(None).cast(pl.Boolean)
+ )
+
+ df = df.with_columns([
+ is_limit_up.alias("signal_limit_up"),
+ is_limit_down.alias("signal_limit_down"),
+ # 跌停翘板
+ pl.when(prev_raw > 0)
+ .then(
+ (~is_limit_down.fill_null(True))
+ & (pl.col("low") <= limit_down_price + 0.005)
+ & (pl.col("close") > pl.col("open"))
+ ).otherwise(None).cast(pl.Boolean)
+ .alias("signal_limit_down_recovery"),
+ # 炸板: 最高价曾触及涨停价 + 最终未封住
+ pl.when((prev_raw > 0) & (pl.col("raw_high") > 0))
+ .then(
+ (~is_limit_up.fill_null(True))
+ & (pl.col("raw_high") >= limit_up_price - 0.005)
+ ).otherwise(None).cast(pl.Boolean)
+ .alias("signal_broken_limit_up"),
+ ])
+
+ # 连板数: 同向 +1, 不同向归零
+ # _prev_consec_up / _prev_consec_down 来自 live_agg (昨日 enriched)
+ if "_prev_consec_up" not in df.columns:
+ df = df.with_columns(pl.lit(0).cast(pl.UInt32).alias("_prev_consec_up"))
+ if "_prev_consec_down" not in df.columns:
+ df = df.with_columns(pl.lit(0).cast(pl.UInt32).alias("_prev_consec_down"))
+ prev_up = pl.col("_prev_consec_up").fill_null(0).cast(pl.UInt32)
+ prev_down = pl.col("_prev_consec_down").fill_null(0).cast(pl.UInt32)
+ df = df.with_columns([
+ pl.when(is_limit_up.fill_null(False))
+ .then((prev_up + 1).cast(pl.UInt32))
+ .otherwise(pl.lit(0).cast(pl.UInt32))
+ .alias("consecutive_limit_ups"),
+ pl.when(is_limit_down.fill_null(False))
+ .then((prev_down + 1).cast(pl.UInt32))
+ .otherwise(pl.lit(0).cast(pl.UInt32))
+ .alias("consecutive_limit_downs"),
+ ])
+
+ # 清理
+ cleanup = ["_limit_pct", "_is_st"]
+ for c in df.columns:
+ if c.endswith("_inst"):
+ cleanup.append(c)
+ for c in ["name", "float_shares"]:
+ if c in df.columns:
+ cleanup.append(c)
+ df = df.drop([c for c in cleanup if c in df.columns])
+
+ return df
diff --git a/backend/app/jobs/__init__.py b/backend/app/jobs/__init__.py
new file mode 100644
index 0000000..3daa442
--- /dev/null
+++ b/backend/app/jobs/__init__.py
@@ -0,0 +1 @@
+"""APScheduler 任务。"""
diff --git a/backend/app/jobs/daily_pipeline.py b/backend/app/jobs/daily_pipeline.py
new file mode 100644
index 0000000..fe8c13c
--- /dev/null
+++ b/backend/app/jobs/daily_pipeline.py
@@ -0,0 +1,499 @@
+"""盘后管道 + 盘前维表同步。
+
+调度:
+ 09:10 盘前 — 同步标的维表 instruments (全量覆盖)
+ 15:30 盘后 — 日K同步 + 增量除权因子 + enriched 计算 + 刷新视图
+
+盘后同步策略:
+ 日 K: QuoteService 交易时段已实时落盘 → 有数据时跳过 batch,首次拉 1 年区间
+ 除权因子: 从已有数据最新日期的下一天开始增量获取,避免重复拉取和计算
+"""
+from __future__ import annotations
+
+import logging
+from collections.abc import Callable
+from pathlib import Path
+
+import polars as pl
+from apscheduler.schedulers.asyncio import AsyncIOScheduler
+from apscheduler.triggers.cron import CronTrigger
+
+from app.indicators.pipeline import run_pipeline
+from app.config import settings
+from app.services import index_sync, instrument_sync, kline_sync
+from app.tickflow.capabilities import Cap, CapabilitySet
+from app.tickflow.pools import DEMO_SYMBOLS, get_pool
+from app.tickflow.repository import KlineRepository
+
+logger = logging.getLogger(__name__)
+
+ProgressCb = Callable[..., None]
+
+
+def _noop(stage: str, pct: int, msg: str, **kwargs) -> None: # noqa: ARG001
+ pass
+
+
+def _invalidate(table: str | None = None) -> None:
+ """stage 写完调用,让 /api/data/status 只重算被影响的那张表。"""
+ from app.api.data import invalidate_data_cache
+ invalidate_data_cache(table)
+
+
+def _resolve_universe(capset: CapabilitySet) -> list[str]:
+ """解析标的池 — 以 CN_Equity_A (沪深京A股 ~5522只) 为主。
+
+ 有 batch 能力 → 直接拉 CN_Equity_A universe
+ 其他用户 → 用 instruments parquet + watchlist 兜底
+ """
+ if capset.has(Cap.KLINE_DAILY_BATCH):
+ try:
+ all_a = get_pool("CN_Equity_A", refresh=True)
+ if all_a:
+ return sorted(all_a)
+ except Exception as e: # noqa: BLE001
+ logger.warning("CN_Equity_A pool unavailable, fallback: %s", e)
+
+ # Free 用户兜底: instruments parquet + watchlist + demo
+ base: set[str] = set(DEMO_SYMBOLS)
+ base.update(get_pool("watchlist"))
+ d = Path(settings.data_dir)
+ inst_path = d / "instruments" / "instruments.parquet"
+ if inst_path.exists():
+ try:
+ inst = pl.read_parquet(inst_path, columns=["symbol"])
+ base.update(inst["symbol"].to_list())
+ except Exception as e: # noqa: BLE001
+ logger.warning("instruments supplement failed: %s", e)
+ return sorted(base)
+
+
+def run_instruments_sync(repo: KlineRepository) -> dict:
+ """盘前同步标的维表。"""
+ rows = instrument_sync.sync_instruments(repo.store.data_dir)
+ _refresh_instruments_view(repo)
+ _invalidate("instruments")
+ return {"instruments_rows": rows}
+
+
+def run_now(
+ repo: KlineRepository,
+ capset: CapabilitySet,
+ on_progress: ProgressCb | None = None,
+) -> dict:
+ """立即执行一次盘后管道,支持进度回调。
+
+ 跳过的 stage **不 emit**,避免前端把"无 capability"的卡片错误标记为 active/done。
+ result 里带 skipped_stages 列表供前端展示。
+ """
+ emit = on_progress or _noop
+ skipped: list[str] = []
+
+ # Step 0: 先同步标的维表, 再解析标的池 — 确保标的池基于最新 instruments
+ emit("sync_instruments", 2, "同步标的维表…")
+ inst_rows = instrument_sync.sync_instruments(repo.store.data_dir)
+ if inst_rows > 0:
+ _refresh_instruments_view(repo)
+ emit("sync_instruments", 8, f"标的维表同步完成,{inst_rows} 只标的")
+ _invalidate("instruments")
+
+ emit("resolve_universe", 9, "解析标的池…")
+ universe = _resolve_universe(capset)
+ emit("resolve_universe", 10, f"标的池规模:{len(universe)} 只")
+
+ # Step 1: 日 K 同步
+ # 今天有数据 → 实时行情接口拉一次覆写(1请求全市场)
+ # 今天没数据 → batch K-line API 补齐
+ # 无任何数据 → batch K-line API 拉首次 1 年
+ from datetime import date as _date, timedelta as _td, datetime as _dt
+ latest_daily = repo.latest_daily_date()
+ today = _date.today()
+ today_exists = latest_daily and latest_daily >= today
+ new_daily_days = 0
+
+ if today_exists:
+ # 今天有数据(QuoteService 已落盘)→ 实时行情覆写,确保最新
+ emit("sync_daily", 12, f"获取日K [{today} ~ {today}] 实时行情…")
+ written_daily = kline_sync.sync_daily_by_quotes(repo)
+ new_daily_days = 1
+ emit("sync_daily", 45, f"日K 完成,{written_daily} 只标的")
+ logger.info("sync_daily: [%s ~ %s] live quotes, %d symbols", today, today, written_daily)
+ elif latest_daily:
+ # 有历史但今天没数据 → batch 补齐缺口
+ start_date = latest_daily
+ emit("sync_daily", 12, f"获取日K [{start_date} ~ {today}]…")
+ logger.info("sync_daily: [%s ~ %s] gap fill", start_date, today)
+
+ def _daily_chunk_progress(cur: int, tot: int) -> None:
+ emit("sync_daily", 12 + int(33 * cur / tot),
+ f"日K 批次 {cur}/{tot}", stage_pct=int(100 * cur / tot), skip_log=True)
+ written_daily = kline_sync.sync_and_persist_daily_batch(
+ universe, repo, capset,
+ start_date=_dt.combine(start_date, _dt.min.time()),
+ end_date=_dt.combine(today, _dt.min.time()),
+ on_chunk_done=_daily_chunk_progress,
+ )
+ gap_days = (today - start_date).days
+ new_daily_days = gap_days
+ emit("sync_daily", 45, f"日K 完成,覆盖 {gap_days} 天")
+ logger.info("sync_daily: [%s ~ %s] done, %d days", start_date, today, gap_days)
+ else:
+ # 首次:无任何数据 → batch 拉 1 年
+ start_date = today - _td(days=365)
+ emit("sync_daily", 12, f"获取日K [{start_date} ~ {today}]…")
+ logger.info("sync_daily: [%s ~ %s] initial fetch", start_date, today)
+
+ def _daily_chunk_progress(cur: int, tot: int) -> None:
+ emit("sync_daily", 12 + int(33 * cur / tot),
+ f"日K 批次 {cur}/{tot}", stage_pct=int(100 * cur / tot), skip_log=True)
+ written_daily = kline_sync.sync_and_persist_daily_batch(
+ universe, repo, capset,
+ start_date=_dt.combine(start_date, _dt.min.time()),
+ end_date=_dt.combine(today, _dt.min.time()),
+ on_chunk_done=_daily_chunk_progress,
+ )
+ new_daily_days = 365
+ emit("sync_daily", 45, "日K 完成")
+ logger.info("sync_daily: [%s ~ %s] done", start_date, today)
+ _invalidate("daily")
+
+ # Step 1.5: 增量同步除权因子 — 从已有数据最新日期的下一天开始获取
+ written_adj = 0
+ affected_symbols: list[str] = []
+ if capset.has(Cap.ADJ_FACTOR):
+ from datetime import datetime, timedelta
+ adj_end = datetime.now()
+ # 从已有除权因子数据的最新日期开始获取,避免重复拉取
+ adj_factor_path = repo.store.data_dir / "adj_factor" / "all.parquet"
+ fallback_start = adj_end - timedelta(days=30)
+ if adj_factor_path.exists():
+ try:
+ from datetime import date as date_cls
+ max_date = pl.scan_parquet(adj_factor_path).select(
+ pl.col("trade_date").max()
+ ).collect().item()
+ if max_date is not None:
+ # trade_date 可能是 date / datetime / string 类型
+ if isinstance(max_date, str):
+ td = date_cls.fromisoformat(max_date)
+ elif isinstance(max_date, datetime):
+ td = max_date.date()
+ else:
+ td = max_date
+ adj_start = datetime.combine(td, datetime.min.time())
+ else:
+ adj_start = fallback_start
+ except Exception:
+ adj_start = fallback_start
+ else:
+ adj_start = fallback_start
+ adj_start_str = adj_start.strftime("%Y-%m-%d")
+ adj_end_str = adj_end.strftime("%Y-%m-%d")
+ emit("sync_adj", 50, f"获取除权因子 [{adj_start_str} ~ {adj_end_str}]…")
+ logger.info("sync_adj: [%s ~ %s] start", adj_start_str, adj_end_str)
+
+ def _adj_chunk_progress(cur: int, tot: int) -> None:
+ emit("sync_adj", 50 + int(10 * cur / tot),
+ f"除权因子批次 {cur}/{tot}", stage_pct=int(100 * cur / tot), skip_log=True)
+ written_adj, affected_symbols = kline_sync.sync_adj_factor(
+ universe, repo, capset,
+ start_time=adj_start, end_time=adj_end,
+ on_chunk_done=_adj_chunk_progress,
+ )
+ if affected_symbols:
+ _refresh_single_view(repo, "adj_factor")
+ emit("sync_adj", 60, f"除权因子完成,新增 {len(affected_symbols)} 只个股")
+ logger.info("sync_adj: [%s ~ %s] done, %d symbols", adj_start_str, adj_end_str, len(affected_symbols))
+ else:
+ emit("sync_adj", 60, "除权因子完成,无新增")
+ logger.info("sync_adj: [%s ~ %s] no new factors", adj_start_str, adj_end_str)
+ _invalidate("adj_factor")
+ else:
+ skipped.append("sync_adj")
+ logger.info("sync_adj skipped: no ADJ_FACTOR capability")
+
+ # Step 2: 计算 enriched
+ # 判断策略:
+ # - 首次 (enriched 目录不存在) → 全量
+ # - 往前扩展历史 (新日期 < enriched 已有最早日期) → 全量
+ # 前面的除权因子会改变累积因子链,影响后面所有日期的复权价格
+ # - 往后新增日期 (新日期 > enriched 已有最晚日期)
+ # → 增量补新区块(所有标的) + 受除权影响个股全日期重算
+ # - 无新日期 + 有新除权因子 → 增量: 只重算受影响个股的全部日期
+ # - 无新日期 + 无变化 → 跳过
+ enriched_dir = repo.store.data_dir / "kline_daily_enriched"
+ enriched_exists = enriched_dir.exists() and any(enriched_dir.glob("date=*"))
+ daily_dir = repo.store.data_dir / "kline_daily"
+ daily_days = len(list(daily_dir.glob("date=*"))) if daily_dir.exists() else 0
+ prev_enriched_days = len(list(enriched_dir.glob("date=*"))) if enriched_exists else 0
+
+ # 判断新日期方向: 找 daily 和 enriched 的日期集合做比较
+ forward_incremental = False
+ backward_extension = False
+
+ if daily_days > prev_enriched_days and enriched_exists:
+ daily_dates = sorted(d.stem.split("=")[1] for d in daily_dir.glob("date=*"))
+ enriched_dates = sorted(d.stem.split("=")[1] for d in enriched_dir.glob("date=*"))
+ earliest_enriched = enriched_dates[0]
+ latest_enriched = enriched_dates[-1]
+ new_dates = set(daily_dates) - set(enriched_dates)
+ if new_dates:
+ # 有新日期早于 enriched 最早日期 → 往前扩展
+ if any(d < earliest_enriched for d in new_dates):
+ backward_extension = True
+ # 有新日期晚于 enriched 最晚日期 → 往后新增
+ if any(d > latest_enriched for d in new_dates):
+ forward_incremental = True
+
+ def _enriched_batch_progress(cur: int, tot: int) -> None:
+ emit("compute_enriched", 65 + int(23 * cur / tot),
+ f"计算指标 批次 {cur}/{tot}", stage_pct=int(100 * cur / tot), skip_log=True)
+
+ if not enriched_exists or backward_extension:
+ # 首次 或 往前扩展 → 全量
+ emit("compute_enriched", 65, "全量计算 enriched…")
+ logger.info("compute_enriched: full rebuild (first=%s, backward=%s, daily=%d, enriched=%d)",
+ not enriched_exists, backward_extension, daily_days, prev_enriched_days)
+ written_enriched = run_pipeline(on_batch_done=_enriched_batch_progress)
+ new_enriched_days = len(list(enriched_dir.glob("date=*")))
+ emit("compute_enriched", 88, f"enriched 完成,覆盖 {new_enriched_days} 天")
+ logger.info("compute_enriched: full rebuild done, %d days", new_enriched_days)
+ elif forward_incremental:
+ # 往后新增日期: 增量补新区块 + 受影响个股全日期重算
+ symbols_to_recompute = list(set(affected_symbols)) if affected_symbols else []
+ emit("compute_enriched", 65,
+ f"增量计算 enriched (新日期 + {len(symbols_to_recompute)} 只个股重算)…"
+ if symbols_to_recompute else "增量计算 enriched (新日期)…")
+ logger.info("compute_enriched: forward incremental, %d symbols to recompute",
+ len(symbols_to_recompute))
+ written_enriched = run_pipeline(
+ new_dates_only=True,
+ symbols=symbols_to_recompute or None,
+ on_batch_done=_enriched_batch_progress,
+ )
+ new_enriched_days = len(list(enriched_dir.glob("date=*")))
+ emit("compute_enriched", 88, f"enriched 完成,覆盖 {new_enriched_days} 天")
+ logger.info("compute_enriched: forward incremental done, %d days", new_enriched_days)
+ elif affected_symbols:
+ # 无新日期,仅除权因子变更 → 只重算受影响个股的全部日期
+ emit("compute_enriched", 65, f"增量计算 enriched ({len(affected_symbols)} 只个股)…")
+ logger.info("compute_enriched: adj_factor incremental, %d symbols", len(affected_symbols))
+ written_enriched = run_pipeline(symbols=affected_symbols, on_batch_done=_enriched_batch_progress)
+ emit("compute_enriched", 88, f"enriched 完成,{len(affected_symbols)} 只个股")
+ else:
+ written_enriched = 0
+ logger.info("compute_enriched: skip (no new daily, no adj_factor changes)")
+ _refresh_single_view(repo, "kline_enriched")
+ _invalidate("enriched")
+
+ # Step 2.3: 指数同步 — 独立 kline_index_* 存储,不进入股票选股/策略链路。
+ written_index_daily = 0
+ index_count = 0
+ if capset.has(Cap.KLINE_DAILY_BATCH):
+ emit("sync_index", 88, "同步指数列表与日K…")
+ try:
+ index_count = index_sync.sync_index_instruments(repo)
+ index_dir = repo.store.data_dir / "kline_index_enriched"
+ index_dates = sorted(
+ d.name[5:] for d in index_dir.glob("date=*")
+ if d.is_dir() and d.name.startswith("date=")
+ ) if index_dir.exists() else []
+ index_start = _date.fromisoformat(index_dates[-1]) if index_dates else today - _td(days=365)
+ written_index_daily = index_sync.sync_and_persist_index_daily(
+ repo,
+ capset,
+ start_date=_dt.combine(index_start, _dt.min.time()),
+ end_date=_dt.combine(today, _dt.min.time()),
+ )
+ repo.refresh_index_views()
+ _invalidate("index_instruments")
+ _invalidate("index_daily")
+ _invalidate("index_enriched")
+ emit("sync_index", 89, f"指数完成,{index_count} 只指数,{written_index_daily} 行日K")
+ except Exception as e: # noqa: BLE001
+ logger.warning("sync_index failed: %s", e)
+ emit("sync_index", 89, f"指数同步失败:{e}")
+ else:
+ skipped.append("sync_index")
+
+ # Step 2.5: 分钟 K 同步(可选) — 未启用或无 capability 时静默跳过(不 emit)
+ from app.services import preferences
+ minute_on = preferences.get_minute_sync_enabled()
+ minute_days = preferences.get_minute_sync_days()
+ written_minute = 0
+ if minute_on and capset.has(Cap.KLINE_MINUTE_BATCH):
+ minute_start = today - _td(days=minute_days)
+ emit("sync_minute", 90, f"获取分钟K [{minute_start} ~ {today}]…")
+ logger.info("sync_minute: [%s ~ %s] start", minute_start, today)
+ minute_symbols = _resolve_minute_symbols(capset)
+ def _minute_chunk_progress(cur: int, tot: int) -> None:
+ emit("sync_minute", 90 + int(3 * cur / tot),
+ f"分钟K 批次 {cur}/{tot}", stage_pct=int(100 * cur / tot), skip_log=True)
+ written_minute = kline_sync.sync_and_persist_minute(
+ minute_symbols, repo, capset, days=minute_days,
+ on_chunk_done=_minute_chunk_progress,
+ )
+ minute_dir = repo.store.data_dir / "kline_minute"
+ minute_cover_days = len(list(minute_dir.glob("date=*"))) if minute_dir.exists() else 0
+ emit("sync_minute", 93, f"分钟K完成,覆盖 {minute_cover_days} 天")
+ logger.info("sync_minute: [%s ~ %s] done, %d days", minute_start, today, minute_cover_days)
+ _invalidate("minute")
+ else:
+ skipped.append("sync_minute")
+ if minute_on:
+ logger.info("sync_minute skipped: no KLINE_MINUTE_BATCH capability")
+ else:
+ logger.info("sync_minute skipped: user disabled")
+
+ # Step 3: 刷新视图
+ emit("refresh_views", 95, "刷新 DuckDB 视图…")
+ _refresh_views(repo)
+
+ emit("done", 100, "完成")
+ _invalidate(None) # 兜底:全清
+
+ return {
+ "universe_size": len(universe),
+ "daily_days": new_daily_days,
+ "adj_factor_symbols": len(affected_symbols),
+ "enriched_days": written_enriched,
+ "index_count": index_count,
+ "index_daily_rows": written_index_daily,
+ "minute_rows": written_minute,
+ "skipped_stages": skipped,
+ }
+
+
+def _refresh_views(repo: KlineRepository) -> None:
+ """刷新所有 DuckDB 视图。"""
+ d = repo.store.data_dir.as_posix()
+ views = {
+ "kline_daily": f"{d}/kline_daily/**/*.parquet",
+ "kline_enriched": f"{d}/kline_daily_enriched/**/*.parquet",
+ "kline_index_daily": f"{d}/kline_index_daily/**/*.parquet",
+ "kline_index_enriched": f"{d}/kline_index_enriched/**/*.parquet",
+ "kline_minute": f"{d}/kline_minute/**/*.parquet",
+ "adj_factor": f"{d}/adj_factor/**/*.parquet",
+ "instruments": f"{d}/instruments/**/*.parquet",
+ "instruments_index": f"{d}/instruments_index/**/*.parquet",
+ }
+ for name, path in views.items():
+ try:
+ repo.db.execute(
+ f"CREATE OR REPLACE VIEW {name} AS "
+ f"SELECT * FROM read_parquet('{path}', union_by_name=true)"
+ )
+ except Exception as e: # noqa: BLE001
+ logger.warning("refresh view %s failed: %s", name, e)
+
+
+def _refresh_single_view(repo: KlineRepository, name: str) -> None:
+ """刷新单个 DuckDB 视图。"""
+ d = repo.store.data_dir.as_posix()
+ paths = {
+ "kline_daily": f"{d}/kline_daily/**/*.parquet",
+ "kline_enriched": f"{d}/kline_daily_enriched/**/*.parquet",
+ "kline_index_daily": f"{d}/kline_index_daily/**/*.parquet",
+ "kline_index_enriched": f"{d}/kline_index_enriched/**/*.parquet",
+ "kline_minute": f"{d}/kline_minute/**/*.parquet",
+ "adj_factor": f"{d}/adj_factor/**/*.parquet",
+ "instruments": f"{d}/instruments/**/*.parquet",
+ "instruments_index": f"{d}/instruments_index/**/*.parquet",
+ }
+ path = paths.get(name)
+ if not path:
+ return
+ try:
+ repo.db.execute(
+ f"CREATE OR REPLACE VIEW {name} AS "
+ f"SELECT * FROM read_parquet('{path}', union_by_name=true)"
+ )
+ except Exception as e: # noqa: BLE001
+ logger.warning("refresh view %s failed: %s", name, e)
+
+
+def _resolve_minute_symbols(capset: CapabilitySet) -> list[str]:
+ """分钟 K 同步标的 — 与日K共用同一标的池。"""
+ return _resolve_universe(capset)
+
+
+def _refresh_instruments_view(repo: KlineRepository) -> None:
+ """单独刷新 instruments 视图。"""
+ d = repo.store.data_dir.as_posix()
+ try:
+ repo.db.execute(
+ f"CREATE OR REPLACE VIEW instruments AS "
+ f"SELECT * FROM read_parquet('{d}/instruments/**/*.parquet', union_by_name=true)"
+ )
+ except Exception as e: # noqa: BLE001
+ logger.warning("refresh instruments view failed: %s", e)
+
+
+def _run_tracked(fn, job_label: str) -> None:
+ """调度触发时包装 JobStore 跟踪,确保同步历史有记录。"""
+ from app.services.pipeline_jobs import job_store
+
+ job_id = job_store.create()
+ job_store.start(job_id)
+
+ 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)
+
+ try:
+ result = fn(on_progress=progress)
+ job_store.succeed(job_id, result)
+ 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")
+
+
+def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOScheduler:
+ """启动调度器。
+
+ 工作日 09:10 — 同步标的维表
+ 工作日 HH:MM — 盘后管道(时间由用户偏好决定,默认 15:30)
+ """
+ from app.services import preferences
+ sched = preferences.get_pipeline_schedule()
+ inst_sched = preferences.get_instruments_schedule()
+
+ scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
+
+ # 盘前: 同步 instruments(时间由偏好决定)
+ def _instruments_task(on_progress=None):
+ emit = on_progress or _noop
+ emit("sync_instruments", 0, "同步标的维表…")
+ result = run_instruments_sync(repo)
+ emit("done", 100, f"标的维表同步完成,{result.get('instruments_rows', 0)} 只标的")
+ return result
+
+ scheduler.add_job(
+ lambda: _run_tracked(_instruments_task, "instruments_sync"),
+ trigger=CronTrigger(day_of_week="mon-fri",
+ hour=inst_sched["hour"], minute=inst_sched["minute"],
+ timezone="Asia/Shanghai"),
+ id="pre_market_instruments",
+ misfire_grace_time=1800,
+ replace_existing=True,
+ )
+
+ # 盘后: 日 K + enriched(时间由偏好决定)
+ scheduler.add_job(
+ lambda: _run_tracked(
+ lambda on_progress=None: run_now(repo, capset, on_progress=on_progress),
+ "daily_pipeline",
+ ),
+ trigger=CronTrigger(day_of_week="mon-fri",
+ hour=sched["hour"], minute=sched["minute"],
+ timezone="Asia/Shanghai"),
+ id="daily_pipeline",
+ misfire_grace_time=3600,
+ replace_existing=True,
+ )
+
+ scheduler.start()
+ logger.info("scheduler started; instruments@%02d:%02d, pipeline@%02d:%02d mon-fri",
+ inst_sched["hour"], inst_sched["minute"], sched["hour"], sched["minute"])
+ return scheduler
diff --git a/backend/app/main.py b/backend/app/main.py
new file mode 100644
index 0000000..6bf6110
--- /dev/null
+++ b/backend/app/main.py
@@ -0,0 +1,170 @@
+"""FastAPI 入口。"""
+from __future__ import annotations
+
+import logging
+from contextlib import asynccontextmanager
+from pathlib import Path
+
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import FileResponse
+from fastapi.staticfiles import StaticFiles
+
+from app import __version__
+from app.api import analysis, backtest, data, ext_data, financials, indices, intraday, kline, overview, pipeline, screener, settings as settings_api, signals, strategy, watchlist
+from app.api.routes import router as core_router
+from app.config import settings
+from app.jobs import daily_pipeline
+from app.services.quote_service import QuoteService
+from app.tickflow.policy import detect_capabilities
+from app.tickflow.repository import DataStore, KlineRepository
+
+logging.basicConfig(
+ level=settings.log_level,
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+)
+logger = logging.getLogger(__name__)
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ logger.info(
+ "TF-Stocks-Panel v%s starting (mode=%s)",
+ __version__, "free" if settings.use_free_mode else "api_key",
+ )
+
+ # 数据层
+ store = DataStore()
+ repo = KlineRepository(store)
+ app.state.datastore = store
+ app.state.repo = repo
+
+ # Polars 缓存预热
+ repo.refresh_cache()
+
+ # 能力探测
+ capset = detect_capabilities()
+ app.state.capabilities = capset
+ logger.info("ready; %d capabilities active", len(capset.all()))
+
+ # 全局行情服务
+ qs = QuoteService()
+ app.state.quote_service = qs
+ qs.set_repo(repo)
+ qs.boot_check()
+
+ # QuoteService 需要访问 strategy_monitor 等单例
+ # 先创建 strategy_monitor,再注入 app.state
+ from app.strategy.monitor import StrategyMonitorService
+ strategy_monitor = StrategyMonitorService()
+ app.state.strategy_monitor = strategy_monitor
+ qs.set_app_state(app.state)
+
+ # 启动调度器(若 enriched 数据为空,首次启动可手动 POST /api/pipeline/run)
+ try:
+ scheduler = daily_pipeline.start_scheduler(repo, capset)
+ app.state.scheduler = scheduler
+ except Exception as e: # noqa: BLE001
+ logger.warning("scheduler not started: %s", e)
+ app.state.scheduler = None
+
+ # 扩展数据定时拉取
+ from app.services.ext_pull import pull_scheduler
+ pull_scheduler.start(store.data_dir)
+ pull_scheduler.refresh(store.data_dir)
+ app.state.pull_scheduler = pull_scheduler
+
+ # 财务数据独立调度 (需 Expert 套餐)
+ from app.services.financial_sync import financial_scheduler
+ financial_scheduler.start(store.data_dir, capset)
+ app.state.financial_scheduler = financial_scheduler
+
+ # 策略引擎
+ from app.strategy.engine import StrategyEngine
+ from app.strategy.monitor import StrategyMonitorService
+ from app.services.screener import ScreenerService
+
+ _screener_svc = ScreenerService(repo)
+ strategy_dirs = [
+ Path(__file__).resolve().parent / "strategy" / "builtin",
+ store.data_dir / "strategies" / "custom",
+ store.data_dir / "strategies" / "ai",
+ ]
+ strategy_engine = StrategyEngine(
+ enriched_loader=_screener_svc._load_enriched_for_date,
+ enriched_history_loader=_screener_svc._load_enriched_history,
+ strategy_dirs=strategy_dirs,
+ )
+ app.state.strategy_engine = strategy_engine
+ logger.info("strategy engine loaded: %d strategies", len(strategy_engine.list_strategies()))
+
+ yield
+
+ 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()
+ logger.info("shutdown")
+
+
+app = FastAPI(
+ title="TF-Stocks-Panel",
+ version=__version__,
+ description="A 股选股 + 回测面板 — TickFlow 适配",
+ lifespan=lifespan,
+)
+
+# 开发期 CORS 允许 Vite dev server
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["http://localhost:3011", "http://127.0.0.1:3011"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+# 路由
+app.include_router(core_router)
+app.include_router(kline.router)
+app.include_router(watchlist.router)
+app.include_router(screener.router)
+app.include_router(backtest.router)
+app.include_router(intraday.router)
+app.include_router(indices.router)
+app.include_router(overview.router)
+app.include_router(analysis.router)
+app.include_router(pipeline.router)
+app.include_router(data.router)
+app.include_router(ext_data.router)
+app.include_router(financials.router)
+app.include_router(settings_api.router)
+app.include_router(strategy.router)
+app.include_router(signals.router)
+
+# 生产期静态文件(前端 dist)
+_static = Path(settings.static_dir)
+if _static.exists():
+ if (_static / "assets").exists():
+ app.mount("/assets", StaticFiles(directory=_static / "assets"), name="assets")
+
+ @app.get("/{full_path:path}", include_in_schema=False)
+ def spa_fallback(full_path: str): # noqa: ARG001
+ """所有未匹配路径回退到 index.html — React Router 接管。
+
+ index.html 禁止缓存 (Cache-Control: no-store), 确保浏览器每次拿到
+ 最新版本引用的 JS/CSS 文件名 (assets 带 hash, 可长缓存)。
+ """
+ index = _static / "index.html"
+ if index.exists():
+ return FileResponse(
+ index,
+ headers={"Cache-Control": "no-store, must-revalidate"},
+ )
+ return {"error": "frontend not built"}
diff --git a/backend/app/secrets_store.py b/backend/app/secrets_store.py
new file mode 100644
index 0000000..28d2c1b
--- /dev/null
+++ b/backend/app/secrets_store.py
@@ -0,0 +1,96 @@
+"""Key / 凭据本地存储(§14)。
+
+存储位置:`data/user_data/secrets.json`,权限 0600。
+优先级:secrets.json > .env > 空(Free 模式)。
+
+UI 改 Key 时只动这个文件,不动 .env。
+"""
+from __future__ import annotations
+
+import json
+import logging
+import os
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+
+def _path() -> Path:
+ from app.config import settings
+ p = settings.data_dir / "user_data" / "secrets.json"
+ p.parent.mkdir(parents=True, exist_ok=True)
+ return p
+
+
+def load() -> dict:
+ p = _path()
+ if p.exists():
+ try:
+ return json.loads(p.read_text(encoding="utf-8"))
+ except Exception as e: # noqa: BLE001
+ logger.warning("secrets.json malformed: %s", e)
+ return {}
+
+
+def save(updates: dict) -> dict:
+ """合并写入(不会清掉未提及的字段)。返回新内容。"""
+ current = load()
+ current.update({k: v for k, v in updates.items() if v is not None})
+ p = _path()
+ p.write_text(json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8")
+ try:
+ os.chmod(p, 0o600)
+ except OSError:
+ pass
+ return current
+
+
+def clear(*keys: str) -> dict:
+ """清掉指定字段(留空清全部)。"""
+ p = _path()
+ if not p.exists():
+ return {}
+ if not keys:
+ p.unlink()
+ return {}
+ current = load()
+ for k in keys:
+ current.pop(k, None)
+ p.write_text(json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8")
+ return current
+
+
+def get_tickflow_key() -> str:
+ """取当前 TickFlow Key:secrets.json 优先,否则 .env。"""
+ val = load().get("tickflow_api_key")
+ if val:
+ return val
+ from app.config import settings
+ return settings.tickflow_api_key or ""
+
+
+def get_ai_key() -> str:
+ """取当前 AI Key:secrets.json 优先,否则 .env。"""
+ val = load().get("ai_api_key")
+ if val:
+ return val
+ from app.config import settings
+ return settings.ai_api_key or ""
+
+
+def get_ai_config(key: str, default: str = "") -> str:
+ """取 AI 配置项:secrets.json 优先,否则 config。"""
+ val = load().get(key)
+ if val:
+ return val
+ from app.config import settings
+ return getattr(settings, key, default) or default
+
+
+def mask(key: str, prefix: int = 4, suffix: int = 4) -> str:
+ """脱敏显示。"""
+ if not key:
+ return ""
+ if len(key) <= prefix + suffix:
+ return "•" * len(key)
+ return f"{key[:prefix]}{'•' * 6}{key[-suffix:]}"
diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py
new file mode 100644
index 0000000..829d0ed
--- /dev/null
+++ b/backend/app/services/__init__.py
@@ -0,0 +1 @@
+"""业务服务层。"""
diff --git a/backend/app/services/backtest.py b/backend/app/services/backtest.py
new file mode 100644
index 0000000..806541c
--- /dev/null
+++ b/backend/app/services/backtest.py
@@ -0,0 +1,392 @@
+"""回测服务(§6.7)。
+
+包 vectorbt — 全项目唯一一处出现 pandas。
+"""
+from __future__ import annotations
+
+import logging
+import uuid
+from dataclasses import dataclass, field
+from datetime import date
+from typing import Literal
+
+import numpy as np
+import pandas as pd
+import polars as pl
+
+from app.config import settings
+from app.tickflow.repository import KlineRepository
+
+logger = logging.getLogger(__name__)
+
+# vectorbt 是 optional extras(见 pyproject.toml).未装时只有 backtest 不可用,其他功能正常.
+_vbt = None
+_vbt_unavailable_reason: str | None = None
+
+
+class VectorbtUnavailable(RuntimeError):
+ """vectorbt 未安装 — 提示用户 `uv sync --extra backtest`."""
+
+
+def _get_vbt():
+ global _vbt, _vbt_unavailable_reason
+ if _vbt is not None:
+ return _vbt
+ if _vbt_unavailable_reason is not None:
+ raise VectorbtUnavailable(_vbt_unavailable_reason)
+ try:
+ import vectorbt as vbt
+ _vbt = vbt
+ return _vbt
+ except ImportError as e:
+ _vbt_unavailable_reason = (
+ "vectorbt 未安装 — 它是回测的可选依赖.macOS Intel 用户先 `brew install cmake` "
+ "然后 `uv sync --extra backtest`"
+ )
+ logger.warning("vectorbt unavailable: %s", e)
+ raise VectorbtUnavailable(_vbt_unavailable_reason) from e
+
+
+def is_available() -> bool:
+ """供 API 层快速检测."""
+ try:
+ _get_vbt()
+ return True
+ except VectorbtUnavailable:
+ return False
+
+
+SignalKind = Literal[
+ "macd_golden", "macd_dead",
+ "ma_golden_5_20", "ma_dead_5_20",
+ "ma_golden_20_60",
+ "ma20_breakout", "ma20_breakdown",
+ "n_day_high", "n_day_low",
+ "boll_breakout_upper", "boll_breakdown_lower",
+ "volume_surge",
+ "rsi_oversold", "rsi_overbought",
+ "stop_loss", "trailing_stop", "max_hold",
+]
+
+
+@dataclass
+class BacktestConfig:
+ symbols: list[str]
+ start: date
+ end: date
+ # 买入信号(任一触发即买)
+ entries: list[str] = field(default_factory=list)
+ # 卖出信号(任一触发即卖)
+ exits: list[str] = field(default_factory=list)
+ # 其他参数
+ stop_loss_pct: float | None = None # 例 -0.05 = -5%
+ max_hold_days: int | None = None
+ fees_pct: float = 0.0002 # 万二佣金
+ slippage_bps: float = 5 # 5 bps
+ # 撮合
+ matching: Literal["close_t", "open_t+1"] = "close_t"
+ rsi_oversold_threshold: float = 30
+ rsi_overbought_threshold: float = 70
+
+
+@dataclass
+class BacktestResult:
+ run_id: str
+ config: dict
+ stats: dict
+ equity_curve: list[dict] # [{date, value}]
+ trades: list[dict] # [{symbol, entry_date, exit_date, pnl_pct, ...}]
+ per_symbol_stats: list[dict] # 每只股票的统计
+
+
+# enriched 表里的信号列名映射
+_SIGNAL_COLS: dict[SignalKind, str] = {
+ "macd_golden": "signal_macd_golden",
+ "macd_dead": "signal_macd_dead",
+ "ma_golden_5_20": "signal_ma_golden_5_20",
+ "ma_dead_5_20": "signal_ma_dead_5_20",
+ "ma_golden_20_60": "signal_ma_golden_20_60",
+ "ma20_breakout": "signal_ma20_breakout",
+ "ma20_breakdown": "signal_ma20_breakdown",
+ "n_day_high": "signal_n_day_high",
+ "n_day_low": "signal_n_day_low",
+ "boll_breakout_upper": "signal_boll_breakout_upper",
+ "boll_breakdown_lower": "signal_boll_breakdown_lower",
+ "volume_surge": "signal_volume_surge",
+}
+
+
+class BacktestService:
+ def __init__(self, repo: KlineRepository) -> None:
+ self.repo = repo
+
+ def _load_panel(
+ self,
+ symbols: list[str],
+ start: date,
+ end: date,
+ ) -> pd.DataFrame:
+ """加载 [date × symbol] 价格面板 — Polars scan_parquet + 即时计算指标。
+
+ **全项目唯一从 Polars 转 pandas 的边界**(§7.4 / ADR-19)。
+ """
+ try:
+ enriched_glob = str(self.repo.store.data_dir / "kline_daily_enriched" / "**" / "*.parquet")
+ df = (
+ pl.scan_parquet(enriched_glob)
+ .filter(
+ (pl.col("symbol").is_in(symbols))
+ & (pl.col("date") >= start)
+ & (pl.col("date") <= end)
+ )
+ .sort(["date", "symbol"])
+ .collect()
+ )
+ except Exception as e: # noqa: BLE001
+ logger.warning("backtest load failed: %s", e)
+ return pd.DataFrame()
+
+ if df.is_empty():
+ return pd.DataFrame()
+
+ # 即时计算指标 + 信号
+ from app.indicators.pipeline import compute_all
+ df = compute_all(df)
+
+ # 选择需要的列
+ needed_cols = [
+ "date", "symbol", "open", "high", "low", "close", "volume",
+ "rsi_14", "signal_macd_golden", "signal_macd_dead",
+ "signal_ma_golden_5_20", "signal_ma_dead_5_20",
+ "signal_ma_golden_20_60",
+ "signal_ma20_breakout", "signal_ma20_breakdown",
+ "signal_n_day_high", "signal_n_day_low",
+ "signal_boll_breakout_upper", "signal_boll_breakdown_lower",
+ "signal_volume_surge",
+ ]
+ existing = [c for c in needed_cols if c in df.columns]
+ df = df.select(existing)
+
+ # to_pandas 边界
+ return df.to_pandas(use_pyarrow_extension_array=False)
+
+ def _build_signal_matrix(
+ self,
+ panel: pd.DataFrame,
+ kinds: list[str],
+ config: BacktestConfig,
+ ) -> pd.DataFrame:
+ """从面板构造 [date × symbol] 的布尔信号矩阵。"""
+ if not kinds or panel.empty:
+ return pd.DataFrame()
+
+ # pivot 成 [date × symbol] 形式
+ result = None
+ for kind in kinds:
+ mat = None
+ if kind in _SIGNAL_COLS:
+ col = _SIGNAL_COLS[kind]
+ mat = panel.pivot(index="date", columns="symbol", values=col).fillna(False).astype(bool)
+ elif kind == "rsi_oversold":
+ mat = (panel.pivot(index="date", columns="symbol", values="rsi_14")
+ < config.rsi_oversold_threshold)
+ elif kind == "rsi_overbought":
+ mat = (panel.pivot(index="date", columns="symbol", values="rsi_14")
+ > config.rsi_overbought_threshold)
+ # stop_loss / trailing / max_hold 通过 vectorbt 参数处理,不参与信号矩阵
+
+ if mat is not None:
+ result = mat if result is None else (result | mat)
+ return result if result is not None else pd.DataFrame()
+
+ def run(self, config: BacktestConfig) -> BacktestResult:
+ vbt = _get_vbt()
+ run_id = uuid.uuid4().hex[:10]
+
+ panel = self._load_panel(config.symbols, config.start, config.end)
+ if panel.empty:
+ return BacktestResult(
+ run_id=run_id,
+ config=_config_to_dict(config),
+ stats={"error": "no data"},
+ equity_curve=[],
+ trades=[],
+ per_symbol_stats=[],
+ )
+
+ # 价格面板
+ close = panel.pivot(index="date", columns="symbol", values="close")
+
+ # 信号矩阵
+ entries = self._build_signal_matrix(panel, config.entries, config)
+ exits = self._build_signal_matrix(panel, config.exits, config)
+
+ # 对齐 index/columns
+ if not entries.empty:
+ entries = entries.reindex_like(close).fillna(False).astype(bool)
+ else:
+ entries = pd.DataFrame(False, index=close.index, columns=close.columns)
+ if not exits.empty:
+ exits = exits.reindex_like(close).fillna(False).astype(bool)
+ else:
+ exits = pd.DataFrame(False, index=close.index, columns=close.columns)
+
+ if not entries.any().any():
+ return BacktestResult(
+ run_id=run_id,
+ config=_config_to_dict(config),
+ stats={"error": "no buy signals"},
+ equity_curve=[],
+ trades=[],
+ per_symbol_stats=[],
+ )
+
+ # T+1 适配:vectorbt 默认信号当根 K 撮合
+ # close_t 撮合:维持默认
+ # open_t+1 撮合:shift 信号 1 根 + 用 open 作为价
+ if config.matching == "open_t+1":
+ entries = entries.shift(1).fillna(False).astype(bool)
+ exits = exits.shift(1).fillna(False).astype(bool)
+ price = panel.pivot(index="date", columns="symbol", values="open")
+ else:
+ price = close
+
+ # 跑回测
+ try:
+ pf_kwargs = dict(
+ close=close,
+ entries=entries,
+ exits=exits,
+ price=price,
+ fees=config.fees_pct,
+ slippage=config.slippage_bps / 10000.0,
+ freq="1D",
+ )
+ if config.stop_loss_pct is not None:
+ pf_kwargs["sl_stop"] = abs(config.stop_loss_pct)
+ if config.max_hold_days is not None:
+ # vectorbt 没有内置 max-hold;用时间退出近似:
+ # 在 max_hold_days 后强制 exit
+ exits_idx = entries.copy()
+ for col in entries.columns:
+ entry_rows = np.where(entries[col].values)[0]
+ for i in entry_rows:
+ end_i = min(i + config.max_hold_days, len(entries) - 1)
+ if end_i > i:
+ exits_idx.iloc[end_i][col] = True
+ pf_kwargs["exits"] = (exits | exits_idx).astype(bool)
+
+ pf = vbt.Portfolio.from_signals(**pf_kwargs)
+ except Exception as e: # noqa: BLE001
+ logger.exception("vectorbt backtest failed")
+ return BacktestResult(
+ run_id=run_id,
+ config=_config_to_dict(config),
+ stats={"error": str(e)},
+ equity_curve=[],
+ trades=[],
+ per_symbol_stats=[],
+ )
+
+ # 提取结果
+ try:
+ stats_series = pf.stats(silence_warnings=True)
+ if isinstance(stats_series, pd.DataFrame):
+ # 多列时取 agg
+ stats_dict = stats_series.mean(numeric_only=True).to_dict()
+ else:
+ stats_dict = stats_series.to_dict()
+ except Exception: # noqa: BLE001
+ stats_dict = {}
+
+ # 净值曲线(组合平均)
+ equity = pf.value().mean(axis=1) if isinstance(pf.value(), pd.DataFrame) else pf.value()
+ equity_curve = [
+ {"date": str(idx.date() if hasattr(idx, "date") else idx), "value": float(v)}
+ for idx, v in equity.items() if pd.notna(v)
+ ]
+
+ # 交易记录
+ try:
+ trades_df = pf.trades.records_readable
+ trades = trades_df.to_dict(orient="records") if not trades_df.empty else []
+ # 字段名美化
+ trades = [
+ {
+ "symbol": t.get("Column", t.get("Symbol", "")),
+ "entry_date": str(t.get("Entry Timestamp", t.get("Entry Date", ""))),
+ "exit_date": str(t.get("Exit Timestamp", t.get("Exit Date", ""))),
+ "entry_price": float(t.get("Avg Entry Price", t.get("Avg. Entry Price", 0))),
+ "exit_price": float(t.get("Avg Exit Price", t.get("Avg. Exit Price", 0))),
+ "pnl_pct": float(t.get("Return", t.get("PnL %", 0))),
+ "duration": str(t.get("Duration", "")),
+ }
+ for t in trades
+ ]
+ except Exception: # noqa: BLE001
+ trades = []
+
+ # 每标的统计
+ per_symbol = []
+ try:
+ total_ret = pf.total_return()
+ if isinstance(total_ret, pd.Series):
+ for sym, ret in total_ret.items():
+ if pd.notna(ret):
+ per_symbol.append({"symbol": sym, "total_return": float(ret)})
+ except Exception: # noqa: BLE001
+ pass
+
+ result = BacktestResult(
+ run_id=run_id,
+ config=_config_to_dict(config),
+ stats={k: _json_safe(v) for k, v in stats_dict.items()},
+ equity_curve=equity_curve,
+ trades=trades,
+ per_symbol_stats=per_symbol,
+ )
+
+ # 落盘
+ self._persist(result)
+ return result
+
+ def _persist(self, result: BacktestResult) -> None:
+ out_dir = settings.data_dir / "backtest_results"
+ out_dir.mkdir(parents=True, exist_ok=True)
+ # 用 polars 写一份汇总
+ summary = pl.DataFrame({
+ "run_id": [result.run_id],
+ "stats_json": [str(result.stats)],
+ "n_trades": [len(result.trades)],
+ })
+ summary.write_parquet(out_dir / f"run_id={result.run_id}.parquet")
+
+ def get_result(self, run_id: str) -> BacktestResult | None:
+ # Phase 1:只保留近似落盘,完整结果保存在内存的近期 cache 中
+ # 简化:重新 run 比缓存复杂结果代价小,暂不实现 get_result
+ return None
+
+
+def _config_to_dict(c: BacktestConfig) -> dict:
+ return {
+ "symbols": c.symbols,
+ "start": str(c.start),
+ "end": str(c.end),
+ "entries": c.entries,
+ "exits": c.exits,
+ "stop_loss_pct": c.stop_loss_pct,
+ "max_hold_days": c.max_hold_days,
+ "fees_pct": c.fees_pct,
+ "slippage_bps": c.slippage_bps,
+ "matching": c.matching,
+ }
+
+
+def _json_safe(v):
+ if isinstance(v, (int, float, str, bool)) or v is None:
+ return v
+ if isinstance(v, (np.floating, np.integer)):
+ return float(v) if not np.isnan(float(v)) else None
+ if hasattr(v, "isoformat"):
+ return v.isoformat()
+ return str(v)
diff --git a/backend/app/services/ext_data.py b/backend/app/services/ext_data.py
new file mode 100644
index 0000000..1ce4ad5
--- /dev/null
+++ b/backend/app/services/ext_data.py
@@ -0,0 +1,483 @@
+"""扩展数据服务 — 配置管理 + 文件解析 + Parquet 存储。"""
+from __future__ import annotations
+
+import json
+import logging
+from datetime import date, datetime
+from pathlib import Path
+from typing import Literal
+
+import polars as pl
+
+logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# 配置模型
+# ---------------------------------------------------------------------------
+
+class ExtField:
+ """扩展字段定义。"""
+ __slots__ = ("name", "dtype", "label")
+
+ def __init__(self, name: str, dtype: str = "string", label: str = "") -> None:
+ self.name = name
+ self.dtype = dtype # string | int | float | bool
+ self.label = label or name
+
+ def to_dict(self) -> dict:
+ return {"name": self.name, "dtype": self.dtype, "label": self.label}
+
+ @classmethod
+ def from_dict(cls, d: dict) -> ExtField:
+ return cls(d["name"], d.get("dtype", "string"), d.get("label", ""))
+
+
+class PullConfig:
+ """定时拉取配置。"""
+ __slots__ = (
+ "url", "method", "headers", "body", "response_path",
+ "field_map", "schedule_minutes", "enabled",
+ "last_run", "last_status", "last_message", "last_rows",
+ )
+
+ def __init__(
+ self,
+ url: str = "",
+ method: str = "GET",
+ headers: dict[str, str] | None = None,
+ body: str | None = None,
+ response_path: str = "",
+ field_map: dict[str, str] | None = None,
+ schedule_minutes: int = 1440,
+ enabled: bool = False,
+ last_run: str | None = None,
+ last_status: str | None = None,
+ last_message: str | None = None,
+ last_rows: int | None = None,
+ ) -> None:
+ self.url = url
+ self.method = method # GET | POST
+ self.headers = headers or {}
+ self.body = body # JSON string (POST body template)
+ self.response_path = response_path # dot-path to rows array, e.g. "data.list"
+ self.field_map = field_map or {} # external_name → config_field_name
+ self.schedule_minutes = schedule_minutes
+ self.enabled = enabled
+ self.last_run = last_run
+ self.last_status = last_status # "success" | "error"
+ self.last_message = last_message
+ self.last_rows = last_rows
+
+ def to_dict(self) -> dict:
+ return {
+ "url": self.url,
+ "method": self.method,
+ "headers": self.headers,
+ "body": self.body,
+ "response_path": self.response_path,
+ "field_map": self.field_map,
+ "schedule_minutes": self.schedule_minutes,
+ "enabled": self.enabled,
+ "last_run": self.last_run,
+ "last_status": self.last_status,
+ "last_message": self.last_message,
+ "last_rows": self.last_rows,
+ }
+
+ @classmethod
+ def from_dict(cls, d: dict) -> PullConfig:
+ if not d:
+ return cls()
+ return cls(
+ url=d.get("url", ""),
+ method=d.get("method", "GET"),
+ headers=d.get("headers"),
+ body=d.get("body"),
+ response_path=d.get("response_path", ""),
+ field_map=d.get("field_map"),
+ schedule_minutes=d.get("schedule_minutes", 1440),
+ enabled=d.get("enabled", False),
+ last_run=d.get("last_run"),
+ last_status=d.get("last_status"),
+ last_message=d.get("last_message"),
+ last_rows=d.get("last_rows"),
+ )
+
+
+class ExtConfig:
+ """一个扩展数据源的完整配置。"""
+ __slots__ = (
+ "id", "label", "mode", "fields", "description",
+ "symbol_map", "code_map",
+ "created_at", "updated_at", "pull",
+ )
+
+ def __init__(
+ self,
+ id: str,
+ label: str,
+ mode: Literal["snapshot", "timeseries"],
+ fields: list[ExtField],
+ description: str = "",
+ symbol_map: dict | None = None,
+ code_map: dict | None = None,
+ created_at: str | None = None,
+ updated_at: str | None = None,
+ pull: PullConfig | None = None,
+ ) -> None:
+ self.id = id
+ self.label = label
+ self.mode = mode
+ self.fields = fields
+ self.description = description
+ # 映射关系: {"type": "mapped", "col": "原始列名"} 或 {"type": "computed", "from": "symbol|code", "method": "strip_exchange|append_exchange"}
+ self.symbol_map = symbol_map or {}
+ self.code_map = code_map or {}
+ self.created_at = created_at or datetime.now().isoformat()
+ self.updated_at = updated_at or datetime.now().isoformat()
+ self.pull = pull
+
+ def to_dict(self) -> dict:
+ d = {
+ "id": self.id,
+ "label": self.label,
+ "mode": self.mode,
+ "fields": [f.to_dict() for f in self.fields],
+ "description": self.description,
+ "symbol_map": self.symbol_map,
+ "code_map": self.code_map,
+ "created_at": self.created_at,
+ "updated_at": self.updated_at,
+ }
+ if self.pull:
+ d["pull"] = self.pull.to_dict()
+ return d
+
+ @classmethod
+ def from_dict(cls, d: dict) -> ExtConfig:
+ return cls(
+ id=d["id"],
+ label=d["label"],
+ mode=d["mode"],
+ fields=[ExtField.from_dict(f) for f in d.get("fields", [])],
+ description=d.get("description", ""),
+ symbol_map=d.get("symbol_map"),
+ code_map=d.get("code_map"),
+ created_at=d.get("created_at"),
+ updated_at=d.get("updated_at"),
+ pull=PullConfig.from_dict(d["pull"]) if d.get("pull") else None,
+ )
+
+
+# ---------------------------------------------------------------------------
+# 配置持久化
+# ---------------------------------------------------------------------------
+
+class ExtConfigStore:
+ """扩展数据配置文件读写 — 每个表独立目录 data/ext/{config_id}/config.json。"""
+
+ def __init__(self, data_dir: Path) -> None:
+ self._base = data_dir / "ext_data"
+
+ def _config_path(self, config_id: str) -> Path:
+ return self._base / config_id / "config.json"
+
+ def load_all(self) -> list[ExtConfig]:
+ # 兼容旧版: 如果目录为空且旧配置文件存在则迁移
+ if not self._base.exists() or not any(self._base.iterdir()):
+ old = self._base.parent / "ext_configs.json"
+ if not old.exists():
+ old = self._base.parent / "ext_configs.json.bak"
+ if old.exists():
+ self._migrate_legacy(old)
+ if not self._base.exists():
+ return []
+ configs = []
+ for d in sorted(self._base.iterdir()):
+ cp = d / "config.json"
+ if d.is_dir() and cp.exists():
+ try:
+ raw = json.loads(cp.read_text(encoding="utf-8"))
+ configs.append(ExtConfig.from_dict(raw))
+ except Exception as e:
+ logger.warning("扩展表配置解析失败 %s: %s", cp, e)
+ return configs
+
+ def get(self, config_id: str) -> ExtConfig | None:
+ cp = self._config_path(config_id)
+ if not cp.exists():
+ return None
+ try:
+ raw = json.loads(cp.read_text(encoding="utf-8"))
+ return ExtConfig.from_dict(raw)
+ except Exception:
+ return None
+
+ def upsert(self, config: ExtConfig) -> None:
+ config.updated_at = datetime.now().isoformat()
+ cp = self._config_path(config.id)
+ cp.parent.mkdir(parents=True, exist_ok=True)
+ cp.write_text(
+ json.dumps(config.to_dict(), ensure_ascii=False, indent=2),
+ encoding="utf-8",
+ )
+
+ def delete(self, config_id: str) -> bool:
+ import shutil
+ cp = self._config_path(config_id)
+ if not cp.exists():
+ return False
+ shutil.rmtree(cp.parent, ignore_errors=True)
+ return True
+
+ def _migrate_legacy(self, old_path: Path) -> None:
+ """一次性迁移旧版 ext_configs.json 到独立目录结构。"""
+ try:
+ raw = json.loads(old_path.read_text(encoding="utf-8"))
+ configs = [ExtConfig.from_dict(d) for d in raw]
+ for c in configs:
+ cp = self._config_path(c.id)
+ cp.parent.mkdir(parents=True, exist_ok=True)
+ cp.write_text(
+ json.dumps(c.to_dict(), ensure_ascii=False, indent=2),
+ encoding="utf-8",
+ )
+ # 迁移完成后重命名旧文件作为备份
+ backup = old_path.with_suffix(".json.bak")
+ old_path.rename(backup)
+ logger.info("ext_configs.json 已迁移至 ext/ (备份: %s)", backup.name)
+ except Exception as e:
+ logger.warning("ext_configs 迁移失败: %s", e)
+
+
+# ---------------------------------------------------------------------------
+# CSV / Excel 解析 → Parquet 写入
+# ---------------------------------------------------------------------------
+
+_POLARS_DTYPE_MAP = {
+ "string": pl.Utf8,
+ "int": pl.Int64,
+ "float": pl.Float64,
+ "bool": pl.Boolean,
+}
+
+
+def build_code_lookup(data_dir: Path) -> dict[str, str]:
+ """从 instruments 维表构建 code → symbol 映射。"""
+ path = data_dir / "instruments" / "instruments.parquet"
+ if not path.exists():
+ return {}
+ try:
+ df = pl.read_parquet(path, columns=["code", "symbol"])
+ return dict(zip(df["code"].to_list(), df["symbol"].to_list()))
+ except Exception:
+ return {}
+
+
+def normalize_symbol(series: pl.Series, lookup: dict[str, str] | None = None) -> pl.Series:
+ """将 symbol 列标准化为 代码.交易所 格式。
+
+ 优先使用 instruments 维表查找 code → symbol,确保 100% 准确。
+ 查不到时按规则兜底:6开头 → .SH,其余 → .SZ。
+ """
+ _lookup = lookup or {}
+
+ def _fix_one(val: str) -> str:
+ if not val:
+ return val
+ val = val.strip()
+ # 已经是标准格式(含 .),直接返回
+ if "." in val:
+ return val
+ # 纯6位数字代码 → 优先查维表
+ if len(val) == 6 and val.isdigit():
+ mapped = _lookup.get(val)
+ if mapped:
+ return mapped
+ # 兜底规则
+ if val.startswith(("6",)):
+ return f"{val}.SH"
+ else:
+ return f"{val}.SZ"
+ return val
+
+ return series.map_elements(_fix_one, return_dtype=pl.Utf8)
+
+
+def parse_upload_file(file_path: Path, symbol_col: str = "symbol", data_dir: Path | None = None) -> pl.DataFrame:
+ """解析上传的 CSV / Excel 文件为 Polars DataFrame。"""
+ suffix = file_path.suffix.lower()
+ if suffix == ".csv":
+ df = pl.read_csv(file_path, infer_schema_length=10000)
+ elif suffix in (".xlsx", ".xls"):
+ df = pl.read_excel(file_path)
+ else:
+ raise ValueError(f"不支持的文件格式: {suffix}")
+
+ if symbol_col not in df.columns:
+ # 尝试模糊匹配
+ candidates = [c for c in df.columns if c.lower() in ("symbol", "code", "代码", "标的")]
+ if candidates:
+ df = df.rename({candidates[0]: symbol_col})
+ else:
+ raise ValueError(f"未找到标的代码列 (symbol),可选列: {df.columns}")
+
+ # 确保 symbol 列为字符串并标准化
+ lookup = build_code_lookup(data_dir) if data_dir else None
+ df = df.with_columns(normalize_symbol(df[symbol_col].cast(pl.Utf8), lookup))
+ return df
+
+
+def cast_df_to_schema(df: pl.DataFrame, fields: list[ExtField]) -> pl.DataFrame:
+ """按配置的字段类型转换 DataFrame 列类型。"""
+ for f in fields:
+ if f.name in df.columns:
+ target = _POLARS_DTYPE_MAP.get(f.dtype, pl.Utf8)
+ df = df.with_columns(pl.col(f.name).cast(target))
+ return df
+
+
+def _config_dir(config_id: str, data_dir: Path) -> Path:
+ """返回扩展配置的根目录 data/ext_data/{config_id}/。"""
+ return data_dir / "ext_data" / config_id
+
+
+def write_ext_parquet(
+ df: pl.DataFrame,
+ config: ExtConfig,
+ data_dir: Path,
+ snapshot_date: date | None = None,
+) -> int:
+ """将 DataFrame 写入扩展数据 Parquet。
+
+ 目录结构:
+ - snapshot: data/ext_data/{id}/part.parquet(与 config.json 同级,覆盖写)
+ - timeseries: data/ext_data/{id}/timeseries/date=xxx/part.parquet(按日分区)
+
+ Returns:
+ 写入行数。
+ """
+ snap = snapshot_date or date.today()
+ cfg_dir = _config_dir(config.id, data_dir)
+
+ # 标准化 symbol 列: 用维表查找 → 准确匹配交易所
+ if "symbol" in df.columns:
+ lookup = build_code_lookup(data_dir)
+ df = df.with_columns(normalize_symbol(df["symbol"], lookup))
+
+ if config.mode == "snapshot":
+ # 快照: 与 config.json 同级,直接覆盖
+ cfg_dir.mkdir(parents=True, exist_ok=True)
+ out_path = cfg_dir / "part.parquet"
+
+ # 如果已有文件,合并去重后覆盖
+ if out_path.exists():
+ try:
+ existing = pl.read_parquet(out_path)
+ key = "symbol" if "symbol" in df.columns else df.columns[0]
+ df = pl.concat([existing, df]).unique(subset=[key], keep="last")
+ except Exception:
+ pass
+ else:
+ # 时序: timeseries/ 下按日期分区
+ out_dir = cfg_dir / "timeseries" / f"date={snap}"
+ out_dir.mkdir(parents=True, exist_ok=True)
+ out_path = out_dir / "part.parquet"
+
+ # 如果已有文件,合并去重
+ if out_path.exists():
+ try:
+ existing = pl.read_parquet(out_path)
+ key = "symbol" if "symbol" in df.columns else df.columns[0]
+ df = pl.concat([existing, df]).unique(subset=[key], keep="last")
+ except Exception:
+ pass
+
+ df = cast_df_to_schema(df, config.fields)
+ df.write_parquet(out_path)
+ logger.info("扩展表写入: %s → %s (%d 行)", config.id, out_path, len(df))
+ return len(df)
+
+
+def delete_ext_parquet(config_id: str, data_dir: Path) -> None:
+ """删除扩展数据源关联的所有 Parquet 数据(保留 config.json)。
+
+ - snapshot: 删除 ext_data/{id}/part.parquet
+ - timeseries: 删除 ext_data/{id}/timeseries/ 目录
+ """
+ cfg_dir = _config_dir(config_id, data_dir)
+ # 删除快照文件
+ snap = cfg_dir / "part.parquet"
+ if snap.exists():
+ snap.unlink()
+ # 删除时序目录
+ ts_dir = cfg_dir / "timeseries"
+ if ts_dir.exists():
+ import shutil
+ shutil.rmtree(ts_dir, ignore_errors=True)
+
+
+def fix_symbol_format(config: ExtConfig, data_dir: Path) -> int:
+ """扫描该扩展配置的所有 Parquet 文件,将 symbol 列标准化为 代码.交易所 格式。
+
+ - snapshot: 扫描 ext_data/{id}/part.parquet
+ - timeseries: 扫描 ext_data/{id}/timeseries/date=xxx/part.parquet
+
+ Returns:
+ 修复的文件数。
+ """
+ cfg_dir = _config_dir(config.id, data_dir)
+ if not cfg_dir.exists():
+ return 0
+
+ # 收集需要扫描的 parquet 文件列表
+ parquet_files: list[Path] = []
+ if config.mode == "snapshot":
+ p = cfg_dir / "part.parquet"
+ if p.exists():
+ parquet_files.append(p)
+ else:
+ ts_dir = cfg_dir / "timeseries"
+ if ts_dir.exists():
+ for part_dir in sorted(ts_dir.iterdir()):
+ if not part_dir.is_dir() or not part_dir.name.startswith("date="):
+ continue
+ p = part_dir / "part.parquet"
+ if p.exists():
+ parquet_files.append(p)
+
+ fixed = 0
+ lookup = build_code_lookup(data_dir)
+ for parquet_path in parquet_files:
+ try:
+ df = pl.read_parquet(parquet_path)
+ if "symbol" not in df.columns:
+ continue
+ old = df["symbol"].to_list()
+ df = df.with_columns(normalize_symbol(df["symbol"], lookup))
+ new = df["symbol"].to_list()
+ if old != new:
+ df.write_parquet(parquet_path)
+ fixed += 1
+ logger.info("代码格式修复: %s/%s (%d 行)", config.id, parquet_path.parent.name, len(df))
+ except Exception as e:
+ logger.warning("代码格式修复跳过 %s: %s", parquet_path, e)
+
+ return fixed
+
+
+def rows_to_parquet(
+ rows: list[dict],
+ config: ExtConfig,
+ data_dir: Path,
+ snapshot_date: date | None = None,
+) -> int:
+ """将 JSON 行列表转为 DataFrame 写入 Parquet,复用 write_ext_parquet 的存储逻辑。
+
+ Returns:
+ 写入行数。
+ """
+ df = pl.DataFrame(rows)
+ if "symbol" in df.columns:
+ df = df.with_columns(pl.col("symbol").cast(pl.Utf8))
+ return write_ext_parquet(df, config, data_dir, snapshot_date=snapshot_date)
diff --git a/backend/app/services/ext_pull.py b/backend/app/services/ext_pull.py
new file mode 100644
index 0000000..d15a638
--- /dev/null
+++ b/backend/app/services/ext_pull.py
@@ -0,0 +1,216 @@
+"""扩展数据定时拉取引擎 — 从外部 API 拉取数据写入 Parquet。"""
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import threading
+from datetime import date, datetime, timezone
+from functools import reduce
+from typing import Any
+
+import httpx
+
+from app.services.ext_data import (
+ ExtConfig,
+ ExtConfigStore,
+ PullConfig,
+ rows_to_parquet,
+)
+
+logger = logging.getLogger(__name__)
+
+
+# ---------------------------------------------------------------------------
+# 响应解析
+# ---------------------------------------------------------------------------
+
+def _extract_rows(data: Any, path: str) -> list[dict]:
+ """按 dot-path 从 JSON 响应中提取行数组。
+
+ 例: path="data.list" → response["data"]["list"]
+ 如果 path 为空,直接将 data 视为数组。
+ """
+ if not path:
+ if isinstance(data, list):
+ return data
+ raise ValueError("response_path 为空但响应不是数组")
+
+ keys = path.split(".")
+ current = data
+ for key in keys:
+ if isinstance(current, dict):
+ if key not in current:
+ raise ValueError(f"响应中不存在路径 '{path}',缺失键 '{key}'")
+ current = current[key]
+ elif isinstance(current, list):
+ try:
+ current = current[int(key)]
+ except (ValueError, IndexError) as e:
+ raise ValueError(f"响应路径 '{path}' 解析失败: {e}") from e
+ else:
+ raise ValueError(f"响应路径 '{path}' 中间值不是 dict/list: {type(current)}")
+
+ if not isinstance(current, list):
+ raise ValueError(f"路径 '{path}' 指向的不是数组,而是 {type(current)}")
+
+ return current
+
+
+def _apply_field_map(rows: list[dict], field_map: dict[str, str]) -> list[dict]:
+ """将外部字段名映射为内部配置字段名。field_map: {外部名: 内部名}。"""
+ if not field_map:
+ return rows
+ mapped = []
+ for row in rows:
+ new_row: dict = {}
+ for k, v in row.items():
+ mapped_key = field_map.get(k, k)
+ new_row[mapped_key] = v
+ mapped.append(new_row)
+ return mapped
+
+
+# ---------------------------------------------------------------------------
+# 拉取执行
+# ---------------------------------------------------------------------------
+
+async def fetch_and_ingest(
+ config: ExtConfig,
+ data_dir,
+) -> tuple[int, str]:
+ """执行一次拉取: 请求外部 API → 解析响应 → 写入 Parquet。
+
+ Returns:
+ (rows_written, date_str)
+ """
+ pull = config.pull
+ if not pull or not pull.url:
+ raise ValueError("拉取未配置或 URL 为空")
+
+ async with httpx.AsyncClient(timeout=30) as client:
+ headers = pull.headers or {}
+ kwargs: dict[str, Any] = {"headers": headers}
+
+ if pull.method.upper() == "POST" and pull.body:
+ kwargs["content"] = pull.body
+ if "content-type" not in {k.lower() for k in headers}:
+ kwargs["headers"]["Content-Type"] = "application/json"
+
+ resp = await client.request(pull.method.upper(), pull.url, **kwargs)
+ resp.raise_for_status()
+
+ # 解析 JSON
+ try:
+ data = resp.json()
+ except Exception as e:
+ raise ValueError(f"响应不是有效 JSON: {e}") from e
+
+ # 提取行
+ rows = _extract_rows(data, pull.response_path)
+ if not rows:
+ raise ValueError("提取到的行数为 0")
+
+ # 字段映射
+ rows = _apply_field_map(rows, pull.field_map)
+
+ # 校验 symbol 列
+ if rows and "symbol" not in rows[0]:
+ raise ValueError("数据行中缺少 symbol 字段,请配置 field_map 映射")
+
+ # 写入
+ snap = date.today()
+ n = rows_to_parquet(rows, config, data_dir, snapshot_date=snap)
+ return n, snap.isoformat()
+
+
+# ---------------------------------------------------------------------------
+# 调度器
+# ---------------------------------------------------------------------------
+
+class PullScheduler:
+ """后台调度器:为每个启用了 pull 的 ExtConfig 维护定时任务。"""
+
+ def __init__(self) -> None:
+ self._tasks: dict[str, asyncio.Task] = {}
+ self._running = False
+ self._lock = threading.Lock()
+
+ def start(self, data_dir) -> None:
+ """启动调度(在 lifespan startup 调用)。"""
+ self._running = True
+ self._data_dir = data_dir
+ logger.info("PullScheduler started")
+
+ def stop(self) -> None:
+ """停止所有任务。"""
+ self._running = False
+ for task in self._tasks.values():
+ task.cancel()
+ self._tasks.clear()
+ logger.info("PullScheduler stopped")
+
+ def refresh(self, data_dir) -> None:
+ """重新加载配置,更新调度任务(增/删/改)。"""
+ self._data_dir = data_dir
+ store = ExtConfigStore(data_dir)
+ configs = store.load_all()
+
+ active_ids: set[str] = set()
+
+ for config in configs:
+ if not config.pull or not config.pull.enabled or not config.pull.url:
+ continue
+ active_ids.add(config.id)
+ if config.id not in self._tasks:
+ # 新增调度
+ task = asyncio.create_task(self._run_loop(config))
+ self._tasks[config.id] = task
+ logger.info("PullScheduler: scheduled %s (every %d min)", config.id, config.pull.schedule_minutes)
+
+ # 移除不再活跃的
+ for cid in list(self._tasks):
+ if cid not in active_ids:
+ self._tasks[cid].cancel()
+ del self._tasks[cid]
+ logger.info("PullScheduler: removed %s", cid)
+
+ async def _run_loop(self, config: ExtConfig) -> None:
+ """单个配置的定时拉取循环。"""
+ try:
+ while self._running:
+ pull = config.pull
+ if not pull:
+ break
+ interval = max(pull.schedule_minutes * 60, 60) # 至少 60s
+ await asyncio.sleep(interval)
+ if not self._running:
+ break
+ try:
+ # 重新加载最新配置(用户可能中途修改)
+ store = ExtConfigStore(self._data_dir)
+ fresh = store.get(config.id)
+ if not fresh or not fresh.pull or not fresh.pull.enabled:
+ break
+ n, d = await fetch_and_ingest(fresh, self._data_dir)
+ fresh.pull.last_run = datetime.now(timezone.utc).isoformat()
+ fresh.pull.last_status = "success"
+ fresh.pull.last_message = f"{n} rows @ {d}"
+ fresh.pull.last_rows = n
+ store.upsert(fresh)
+ logger.info("PullScheduler: %s success, %d rows", config.id, n)
+ except Exception as e:
+ store = ExtConfigStore(self._data_dir)
+ fresh = store.get(config.id)
+ if fresh and fresh.pull:
+ fresh.pull.last_run = datetime.now(timezone.utc).isoformat()
+ fresh.pull.last_status = "error"
+ fresh.pull.last_message = str(e)[:200]
+ store.upsert(fresh)
+ logger.warning("PullScheduler: %s error: %s", config.id, e)
+ except asyncio.CancelledError:
+ pass
+
+
+# 全局单例
+pull_scheduler = PullScheduler()
diff --git a/backend/app/services/extend_history.py b/backend/app/services/extend_history.py
new file mode 100644
index 0000000..1339a52
--- /dev/null
+++ b/backend/app/services/extend_history.py
@@ -0,0 +1,224 @@
+"""向前扩展历史数据 — 完全独立于 daily_pipeline 的盘后管道。
+
+用户从日 K 卡片手动触发,指定往前补的时长 (x 天/月/年)。
+流程:
+ 1. 获取当前最早日期
+ 2. 向前拉日 K batch (start = 最早日期 - offset, end = 最早日期)
+ 3. 向前拉除权因子 (同范围)
+ 4. 全量重算 enriched
+ 5. 刷新视图 + 缓存
+
+⚠️ 本模块不导入 daily_pipeline 的任何函数,只复用基础设施:
+ - kline_sync.sync_and_persist_daily_batch / sync_adj_factor
+ - indicators.pipeline.run_pipeline
+ - pipeline_jobs.JobStore
+ - tickflow.repository.KlineRepository
+"""
+from __future__ import annotations
+
+import logging
+from collections.abc import Callable
+from datetime import date, datetime, timedelta
+
+from app.services import kline_sync
+from app.services.pipeline_jobs import job_store
+from app.tickflow.capabilities import Cap, CapabilitySet
+from app.tickflow.repository import KlineRepository
+
+logger = logging.getLogger(__name__)
+
+
+def _noop(stage: str, pct: int, msg: str, **kwargs) -> None: # noqa: ARG001
+ pass
+
+
+def _invalidate(table: str | None = None) -> None:
+ from app.api.data import invalidate_data_cache
+ invalidate_data_cache(table)
+
+
+def _resolve_universe(capset: CapabilitySet) -> list[str]:
+ """解析标的池 — 与 daily_pipeline 独立的副本。"""
+ if capset.has(Cap.KLINE_DAILY_BATCH):
+ try:
+ from app.tickflow.pools import get_pool
+ all_a = get_pool("CN_Equity_A", refresh=True)
+ if all_a:
+ return sorted(all_a)
+ except Exception as e:
+ logger.warning("CN_Equity_A pool unavailable: %s", e)
+
+ from app.tickflow.pools import DEMO_SYMBOLS, get_pool as _get_pool
+ from app.config import settings
+ from pathlib import Path
+ import polars as pl
+ base: set[str] = set(DEMO_SYMBOLS)
+ base.update(_get_pool("watchlist"))
+ d = Path(settings.data_dir)
+ inst_path = d / "instruments" / "instruments.parquet"
+ if inst_path.exists():
+ try:
+ inst = pl.read_parquet(inst_path, columns=["symbol"])
+ base.update(inst["symbol"].to_list())
+ except Exception as e:
+ logger.warning("instruments supplement failed: %s", e)
+ return sorted(base)
+
+
+def _refresh_single_view(repo: KlineRepository, name: str) -> None:
+ """刷新单个 DuckDB 视图。"""
+ d = repo.store.data_dir.as_posix()
+ paths = {
+ "kline_daily": f"{d}/kline_daily/**/*.parquet",
+ "kline_enriched": f"{d}/kline_daily_enriched/**/*.parquet",
+ "kline_minute": f"{d}/kline_minute/**/*.parquet",
+ "adj_factor": f"{d}/adj_factor/**/*.parquet",
+ "instruments": f"{d}/instruments/**/*.parquet",
+ }
+ path = paths.get(name)
+ if not path:
+ return
+ try:
+ repo.db.execute(
+ f"CREATE OR REPLACE VIEW {name} AS "
+ f"SELECT * FROM read_parquet('{path}', union_by_name=true)"
+ )
+ except Exception as e:
+ logger.warning("refresh view %s failed: %s", name, e)
+
+
+def compute_offset(value: int, unit: str) -> timedelta:
+ """将用户输入的 value + unit 转成 timedelta。"""
+ if unit == "day":
+ return timedelta(days=value)
+ elif unit == "month":
+ return timedelta(days=value * 30)
+ elif unit == "year":
+ return timedelta(days=value * 365)
+ else:
+ raise ValueError(f"不支持的单位: {unit}")
+
+
+def run_extend_history(
+ repo: KlineRepository,
+ capset: CapabilitySet,
+ value: int,
+ unit: str,
+ on_progress: Callable | None = None,
+) -> dict:
+ """向前扩展历史数据的主函数。
+
+ 完全独立于 daily_pipeline.run_now(),不调用其任何逻辑。
+ 返回结果 dict 供 job_store 记录。
+ """
+ emit = on_progress or _noop
+
+ # 0. 计算时间偏移
+ offset = compute_offset(value, unit)
+ today = date.today()
+
+ # 1. 获取当前最早日期
+ emit("extend_history", 2, "检查当前数据范围…")
+ earliest = repo.earliest_daily_date()
+
+ if not earliest:
+ return {"error": "本地无日K数据,请先执行一次完整同步"}
+
+ new_start = earliest - offset
+ # 不能超过今天
+ if new_start >= earliest:
+ return {"error": "扩展范围无效,请增大时间跨度"}
+
+ # 2. 解析标的池
+ emit("extend_history", 5, "解析标的池…")
+ universe = _resolve_universe(capset)
+ if not universe:
+ return {"error": "标的池为空"}
+ emit("extend_history", 8, f"标的池: {len(universe)} 只")
+
+ start_str = new_start.strftime("%Y-%m-%d")
+ end_str = earliest.strftime("%Y-%m-%d")
+
+ # 3. 拉日 K
+ emit("extend_history", 10, f"获取日K [{start_str} ~ {end_str}]…")
+ logger.info("extend_history: daily K [%s ~ %s], %d symbols", start_str, end_str, len(universe))
+
+ def _daily_chunk(cur: int, tot: int) -> None:
+ emit("extend_history", 10 + int(35 * cur / tot),
+ f"日K 批次 {cur}/{tot}", stage_pct=int(100 * cur / tot), skip_log=True)
+
+ written_daily = kline_sync.sync_and_persist_daily_batch(
+ universe, repo, capset,
+ start_date=datetime.combine(new_start, datetime.min.time()),
+ end_date=datetime.combine(earliest, datetime.min.time()),
+ on_chunk_done=_daily_chunk,
+ )
+ emit("extend_history", 45, f"日K 完成,写入 {written_daily} 行")
+ logger.info("extend_history: daily K done, %d rows", written_daily)
+ _refresh_single_view(repo, "kline_daily")
+ _invalidate("daily")
+
+ # 4. 拉除权因子 (新范围)
+ written_adj = 0
+ adj_start = datetime.combine(new_start, datetime.min.time())
+ adj_end = datetime.combine(today, datetime.min.time())
+ adj_start_str = new_start.strftime("%Y-%m-%d")
+ adj_end_str = today.strftime("%Y-%m-%d")
+
+ if capset.has(Cap.ADJ_FACTOR):
+ emit("extend_history", 48, f"获取除权因子 [{adj_start_str} ~ {adj_end_str}]…")
+ logger.info("extend_history: adj_factor [%s ~ %s]", adj_start_str, adj_end_str)
+
+ def _adj_chunk(cur: int, tot: int) -> None:
+ emit("extend_history", 48 + int(10 * cur / tot),
+ f"除权因子批次 {cur}/{tot}", stage_pct=int(100 * cur / tot), skip_log=True)
+
+ written_adj, _affected = kline_sync.sync_adj_factor(
+ universe, repo, capset,
+ start_time=adj_start, end_time=adj_end,
+ on_chunk_done=_adj_chunk,
+ )
+ emit("extend_history", 60, f"除权因子完成,{written_adj} 行")
+ logger.info("extend_history: adj_factor done, %d rows", written_adj)
+ _refresh_single_view(repo, "adj_factor")
+ _invalidate("adj_factor")
+ else:
+ emit("extend_history", 60, "除权因子跳过(无权限)")
+ logger.info("extend_history: adj_factor skipped, no ADJ_FACTOR capability")
+
+ # 5. 全量重算 enriched
+ emit("extend_history", 65, "全量计算 enriched…")
+ logger.info("extend_history: full enriched rebuild start")
+
+ from app.indicators.pipeline import run_pipeline
+ written_enriched = run_pipeline()
+
+ enriched_dir = repo.store.data_dir / "kline_daily_enriched"
+ enriched_days = len(list(enriched_dir.glob("date=*"))) if enriched_dir.exists() else 0
+ emit("extend_history", 92, f"enriched 完成,覆盖 {enriched_days} 天")
+ logger.info("extend_history: enriched done, %d days", enriched_days)
+ _refresh_single_view(repo, "kline_enriched")
+ _invalidate("enriched")
+
+ # 6. 刷新视图
+ emit("extend_history", 95, "刷新视图…")
+ _refresh_single_view(repo, "kline_daily")
+ _refresh_single_view(repo, "kline_enriched")
+ _refresh_single_view(repo, "adj_factor")
+ _invalidate(None)
+
+ # 7. 统计结果
+ daily_dir = repo.store.data_dir / "kline_daily"
+ daily_days = len(list(daily_dir.glob("date=*"))) if daily_dir.exists() else 0
+
+ emit("extend_history", 100, f"完成,已扩展至 {new_start}")
+
+ return {
+ "earliest_before": earliest.isoformat(),
+ "earliest_after": new_start.isoformat(),
+ "daily_rows": written_daily,
+ "daily_days": daily_days,
+ "adj_factor_rows": written_adj,
+ "enriched_days": enriched_days,
+ "universe_size": len(universe),
+ }
diff --git a/backend/app/services/financial_sync.py b/backend/app/services/financial_sync.py
new file mode 100644
index 0000000..da19ed1
--- /dev/null
+++ b/backend/app/services/financial_sync.py
@@ -0,0 +1,273 @@
+"""财务数据独立同步服务。
+
+解耦于 K-line 管道, 自有调度 + 自有存储。
+能力门控: Cap.FINANCIAL (Expert 套餐)
+"""
+from __future__ import annotations
+
+import asyncio
+import logging
+import threading
+from datetime import date, datetime, timezone
+from pathlib import Path
+from typing import Any
+
+import polars as pl
+
+from app.tickflow.capabilities import Cap, CapabilitySet
+
+logger = logging.getLogger(__name__)
+
+# 每个 API 请求最多 100 个标的
+_BATCH_SIZE = 100
+
+# 4 张财务表
+FINANCIAL_TABLES = ("metrics", "income", "balance_sheet", "cash_flow")
+
+
+# ================================================================
+# 同步函数
+# ================================================================
+
+def _get_symbols(data_dir: Path) -> list[str]:
+ """从 instruments 表获取标的列表。"""
+ inst_path = data_dir / "instruments" / "instruments.parquet"
+ if not inst_path.exists():
+ return []
+ try:
+ df = pl.read_parquet(inst_path, columns=["symbol"])
+ return df["symbol"].to_list()
+ except Exception as e:
+ logger.warning("读取 instruments 失败: %s", e)
+ return []
+
+
+def _sync_table(
+ table: str,
+ symbols: list[str],
+ data_dir: Path,
+ capset: CapabilitySet,
+ latest_only: bool = True,
+) -> int:
+ """同步单张财务表。返回写入的行数。"""
+ if not capset.has(Cap.FINANCIAL):
+ logger.info("sync_%s skipped: no FINANCIAL capability", table)
+ return 0
+ if not symbols:
+ logger.warning("sync_%s skipped: no symbols", table)
+ return 0
+
+ from app.tickflow.client import get_client
+ tf = get_client()
+
+ # 分批拉取
+ api_method = {
+ "metrics": tf.financials.metrics,
+ "income": tf.financials.income,
+ "balance_sheet": tf.financials.balance_sheet,
+ "cash_flow": tf.financials.cash_flow,
+ }[table]
+
+ all_records: list[dict] = []
+ total_batches = (len(symbols) + _BATCH_SIZE - 1) // _BATCH_SIZE
+
+ for i in range(0, len(symbols), _BATCH_SIZE):
+ chunk = symbols[i : i + _BATCH_SIZE]
+ batch_num = i // _BATCH_SIZE + 1
+ try:
+ data = api_method(chunk, latest=latest_only)
+ # data 格式: { "600519.SH": [record, ...], ... }
+ if isinstance(data, dict):
+ for sym, records in data.items():
+ if isinstance(records, list):
+ for rec in records:
+ if isinstance(rec, dict):
+ rec["symbol"] = sym
+ all_records.append(rec)
+ logger.debug("sync_%s batch %d/%d: %d records", table, batch_num, total_batches, len(data) if isinstance(data, dict) else 0)
+ except Exception as e:
+ logger.warning("sync_%s batch %d/%d failed: %s", table, batch_num, total_batches, e)
+
+ if not all_records:
+ return 0
+
+ df = pl.DataFrame(all_records)
+ if df.is_empty():
+ return 0
+
+ # 确保 symbol 列存在
+ if "symbol" not in df.columns:
+ return 0
+
+ # 写入 Parquet (全量覆盖)
+ out_dir = data_dir / "financials" / table
+ out_dir.mkdir(parents=True, exist_ok=True)
+ out_file = out_dir / "part.parquet"
+ df.write_parquet(out_file)
+
+ logger.info("sync_%s done: %d records written", table, len(df))
+ return len(df)
+
+
+def sync_metrics(data_dir: Path, capset: CapabilitySet) -> int:
+ """同步核心财务指标 (metrics)。"""
+ symbols = _get_symbols(data_dir)
+ return _sync_table("metrics", symbols, data_dir, capset, latest_only=True)
+
+
+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)
+
+
+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)
+
+
+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)
+
+
+def sync_all(data_dir: Path, capset: CapabilitySet) -> dict[str, int]:
+ """同步所有财务表。返回 {table: rows}。"""
+ if not capset.has(Cap.FINANCIAL):
+ logger.info("sync_all financials skipped: no FINANCIAL capability")
+ return {}
+
+ symbols = _get_symbols(data_dir)
+ results: dict[str, int] = {}
+ for table in FINANCIAL_TABLES:
+ results[table] = _sync_table(table, symbols, data_dir, capset, latest_only=True)
+
+ # 同步完成后注册 DuckDB 视图
+ _refresh_financials_views(data_dir)
+
+ return results
+
+
+# ================================================================
+# DuckDB 视图
+# ================================================================
+
+def _refresh_financials_views(data_dir: Path) -> None:
+ """刷新财务表 DuckDB 视图 (在 DataStore.db 上注册)。"""
+ d = data_dir.as_posix()
+ views = {
+ "financials_metrics": f"{d}/financials/metrics/*.parquet",
+ "financials_income": f"{d}/financials/income/*.parquet",
+ "financials_balance_sheet": f"{d}/financials/balance_sheet/*.parquet",
+ "financials_cash_flow": f"{d}/financials/cash_flow/*.parquet",
+ }
+ for name, path in views.items():
+ out = data_dir / "financials" / name.replace("financials_", "") / "part.parquet"
+ if not out.exists():
+ continue
+ # 视图注册需要由 DataStore 完成,这里只做日志
+ logger.debug("financial parquet ready: %s (%d rows)", name, out.stat().st_size)
+
+
+def get_financial_df(data_dir: Path, table: str) -> pl.DataFrame:
+ """读取本地财务 Parquet。"""
+ path = data_dir / "financials" / table / "part.parquet"
+ if not path.exists():
+ return pl.DataFrame()
+ try:
+ return pl.read_parquet(path)
+ except Exception as e:
+ logger.warning("读取 financials/%s 失败: %s", table, e)
+ return pl.DataFrame()
+
+
+# ================================================================
+# 调度器
+# ================================================================
+
+class FinancialScheduler:
+ """独立调度器: 每周同步 metrics, 每季度同步三张报表。"""
+
+ def __init__(self) -> None:
+ self._task: asyncio.Task | None = None
+ self._running = False
+ self._data_dir: Path | None = None
+ self._capset: CapabilitySet | None = None
+ self._lock = threading.Lock()
+ self._last_sync: dict[str, str] = {} # {table: iso_timestamp}
+
+ def start(self, data_dir: Path, capset: CapabilitySet) -> None:
+ if not capset.has(Cap.FINANCIAL):
+ logger.info("FinancialScheduler skipped: no FINANCIAL capability")
+ return
+ self._data_dir = data_dir
+ self._capset = capset
+ self._running = True
+ self._task = asyncio.create_task(self._run_loop())
+ logger.info("FinancialScheduler started")
+
+ def stop(self) -> None:
+ self._running = False
+ if self._task:
+ self._task.cancel()
+ self._task = None
+ logger.info("FinancialScheduler stopped")
+
+ async def _run_loop(self) -> None:
+ """每周执行一次 metrics 同步。"""
+ try:
+ while self._running:
+ # 首次启动等 60s, 之后每 7 天执行一次
+ await asyncio.sleep(60)
+ if not self._running:
+ break
+
+ # 每周: 只同步 metrics
+ try:
+ rows = sync_metrics(self._data_dir, self._capset)
+ self._last_sync["metrics"] = datetime.now(timezone.utc).isoformat()
+ logger.info("FinancialScheduler: metrics synced, %d rows", rows)
+ except Exception as e:
+ logger.warning("FinancialScheduler: metrics sync failed: %s", e)
+
+ # 等待下一次 (7天)
+ for _ in range(7 * 24 * 60): # 每分钟检查一次 _running
+ if not self._running:
+ break
+ await asyncio.sleep(60)
+
+ except asyncio.CancelledError:
+ pass
+
+ def run_now(self, table: str | None = None) -> dict[str, int]:
+ """手动触发同步。table=None 同步全部。"""
+ if not self._capset or not self._capset.has(Cap.FINANCIAL):
+ return {}
+ if table:
+ fn = {
+ "metrics": sync_metrics,
+ "income": sync_income,
+ "balance_sheet": sync_balance_sheet,
+ "cash_flow": sync_cash_flow,
+ }.get(table)
+ if not fn:
+ return {}
+ rows = fn(self._data_dir, self._capset)
+ self._last_sync[table] = datetime.now(timezone.utc).isoformat()
+ return {table: rows}
+ else:
+ result = sync_all(self._data_dir, self._capset)
+ now = datetime.now(timezone.utc).isoformat()
+ for t in result:
+ self._last_sync[t] = now
+ return result
+
+ @property
+ def last_sync(self) -> dict[str, str]:
+ return dict(self._last_sync)
+
+
+# 全局单例
+financial_scheduler = FinancialScheduler()
diff --git a/backend/app/services/index_sync.py b/backend/app/services/index_sync.py
new file mode 100644
index 0000000..b7ee11a
--- /dev/null
+++ b/backend/app/services/index_sync.py
@@ -0,0 +1,146 @@
+"""指数数据同步服务。"""
+from __future__ import annotations
+
+import logging
+import gc
+from datetime import datetime, timedelta
+
+import polars as pl
+
+from app.indicators.pipeline import compute_enriched
+from app.services import kline_sync, preferences
+from app.tickflow.capabilities import Cap, CapabilitySet
+from app.tickflow.client import get_client
+from app.tickflow.repository import KlineRepository
+
+logger = logging.getLogger(__name__)
+
+
+def _quotes_to_index_instruments(resp) -> pl.DataFrame:
+ """将 TickFlow quotes 响应规范为指数 instruments。"""
+ if resp is None:
+ return pl.DataFrame()
+
+ if isinstance(resp, pl.DataFrame):
+ df = resp
+ elif hasattr(resp, "columns"):
+ df = pl.from_pandas(resp.reset_index() if hasattr(resp, "reset_index") else resp)
+ else:
+ rows: list[dict] = []
+ for q in resp or []:
+ item = q if isinstance(q, dict) else {}
+ ext = item.get("ext") or {}
+ symbol = item.get("symbol")
+ if not symbol:
+ continue
+ rows.append({
+ "symbol": str(symbol),
+ "name": ext.get("name") or item.get("name") or str(symbol),
+ })
+ df = pl.DataFrame(rows)
+
+ if df.is_empty() or "symbol" not in df.columns:
+ return pl.DataFrame()
+
+ rename = {"ts_code": "symbol"}
+ df = df.rename({k: v for k, v in rename.items() if k in df.columns})
+
+ if "name" not in df.columns:
+ if "ext" in df.columns:
+ df = df.with_columns(pl.col("symbol").cast(pl.Utf8).alias("name"))
+ else:
+ df = df.with_columns(pl.col("symbol").cast(pl.Utf8).alias("name"))
+
+ result = df.select([
+ pl.col("symbol").cast(pl.Utf8),
+ pl.col("name").cast(pl.Utf8),
+ ]).with_columns([
+ pl.col("symbol").str.split(".").list.first().alias("code"),
+ pl.lit("index").alias("asset_type"),
+ ])
+ return result.unique(subset=["symbol"], keep="last").sort("symbol")
+
+
+def sync_index_instruments(repo: KlineRepository) -> int:
+ """同步 CN_Index 指数标的维表,返回指数数量。"""
+ tf = get_client()
+ resp = None
+ errors: list[str] = []
+ for kwargs in (
+ {"universes": ["CN_Index"]},
+ {"universes": ["CN_Index"], "as_dataframe": False},
+ ):
+ try:
+ resp = tf.quotes.get_by_universes(**kwargs)
+ if resp is not None and len(resp) > 0:
+ break
+ except Exception as e: # noqa: BLE001
+ errors.append(str(e))
+ resp = None
+
+ if resp is None or len(resp) == 0:
+ logger.warning("CN_Index universe returned empty: %s", "; ".join(errors))
+ return 0
+
+ instruments = _quotes_to_index_instruments(resp)
+ if instruments.is_empty():
+ return 0
+ repo.save_index_instruments(instruments)
+ repo.refresh_index_views()
+ return instruments.height
+
+
+def sync_and_persist_index_daily(
+ repo: KlineRepository,
+ capset: CapabilitySet,
+ count: int | None = None,
+ start_date: datetime | None = None,
+ end_date: datetime | None = None,
+) -> int:
+ """同步指数日K到独立 parquet,并计算指数 enriched。"""
+ if not capset.has(Cap.KLINE_DAILY_BATCH):
+ return 0
+
+ instruments = repo.get_index_instruments()
+ if instruments.is_empty():
+ sync_index_instruments(repo)
+ instruments = repo.get_index_instruments()
+ if instruments.is_empty() or "symbol" not in instruments.columns:
+ return 0
+
+ symbols = sorted(set(instruments["symbol"].to_list()))
+ lim = capset.limits(Cap.KLINE_DAILY_BATCH)
+ batch_size = preferences.get_index_daily_batch_size()
+ if lim and lim.batch:
+ batch_size = min(batch_size, lim.batch)
+ rpm = lim.rpm if lim else None
+
+ end_time = end_date or datetime.now()
+ start_time = start_date or (end_time - timedelta(days=365))
+
+ total_rows = 0
+ interval = (60.0 / rpm) if rpm else 0
+ chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)]
+ for i, chunk in enumerate(chunks):
+ if i > 0 and interval > 0 and len(chunks) > rpm:
+ import time
+ time.sleep(interval)
+ raw = kline_sync.sync_daily_batch(
+ chunk,
+ count=count,
+ batch_size=None,
+ start_time=start_time,
+ end_time=end_time,
+ )
+ if raw.is_empty():
+ continue
+
+ repo.append_index_daily(raw)
+ enriched = compute_enriched(raw, factors=None, instruments=None)
+ repo.append_index_enriched(enriched)
+ total_rows += raw.height
+ logger.info("index daily synced: %d/%d chunks, +%d rows", i + 1, len(chunks), raw.height)
+ del raw, enriched
+ gc.collect()
+ repo.refresh_index_views()
+ return total_rows
diff --git a/backend/app/services/instrument_sync.py b/backend/app/services/instrument_sync.py
new file mode 100644
index 0000000..2abae49
--- /dev/null
+++ b/backend/app/services/instrument_sync.py
@@ -0,0 +1,122 @@
+"""标的维表同步服务。
+
+盘前 9:10 调用 tf.exchanges.get_instruments("SH"/"SZ"/"BJ", type="stock")
+获取全量标的元数据,flatten ext 字段,写入 instruments.parquet。
+
+Starter+ 盘后可用 quotes.get(universes) 顺便补充 name。
+"""
+from __future__ import annotations
+
+import logging
+from datetime import date
+from pathlib import Path
+
+import polars as pl
+
+from app.tickflow.client import get_client
+
+logger = logging.getLogger(__name__)
+
+_EXCHANGES = ["SH", "SZ", "BJ"]
+
+
+def _flatten_instruments(items: list[dict]) -> list[dict]:
+ """把 SDK 返回的 Instrument 列表 flatten 成扁平行。"""
+ rows = []
+ for item in items:
+ row = {
+ "symbol": item.get("symbol"),
+ "name": item.get("name"),
+ "code": item.get("code"),
+ "exchange": item.get("exchange"),
+ "region": item.get("region"),
+ "type": item.get("type"),
+ }
+ ext = item.get("ext") or {}
+ row["listing_date"] = ext.get("listing_date")
+ row["total_shares"] = ext.get("total_shares")
+ row["float_shares"] = ext.get("float_shares")
+ row["tick_size"] = ext.get("tick_size")
+ row["limit_up"] = ext.get("limit_up")
+ row["limit_down"] = ext.get("limit_down")
+ rows.append(row)
+ return rows
+
+
+def sync_instruments(data_dir: Path) -> int:
+ """全量同步标的维表 → data/instruments/instruments.parquet。
+
+ 返回写入的行数。
+ """
+ tf = get_client()
+ all_rows: list[dict] = []
+
+ for ex in _EXCHANGES:
+ try:
+ items = tf.exchanges.get_instruments(ex, instrument_type="stock")
+ if items:
+ all_rows.extend(_flatten_instruments(items))
+ logger.info("instruments %s: %d stocks", ex, len(items))
+ except Exception as e:
+ logger.warning("get_instruments(%s) failed: %s", ex, e)
+
+ if not all_rows:
+ return 0
+
+ df = pl.DataFrame(all_rows)
+ df = df.with_columns(pl.lit(date.today()).alias("as_of"))
+
+ out = data_dir / "instruments" / "instruments.parquet"
+ out.parent.mkdir(parents=True, exist_ok=True)
+ df.write_parquet(out)
+
+ logger.info("instruments synced: %d rows → %s", df.height, out)
+ return df.height
+
+
+def enrich_names_from_quotes(
+ data_dir: Path,
+ quotes_data: list[dict],
+) -> int:
+ """从 quotes 响应中提取 name,更新 instruments 维表(兜底补充)。
+
+ 盘后 quotes.get(universes) 返回的数据中包含 ext.name,
+ 用来补充 instruments 中可能缺失的 name。
+ """
+ if not quotes_data:
+ return 0
+
+ # 构建 symbol → name 映射
+ name_map: dict[str, str] = {}
+ for q in quotes_data:
+ symbol = q.get("symbol", "")
+ ext = q.get("ext") or {}
+ name = ext.get("name") or q.get("name", "")
+ if symbol and name:
+ name_map[symbol] = name
+
+ if not name_map:
+ return 0
+
+ inst_path = data_dir / "instruments" / "instruments.parquet"
+ if not inst_path.exists():
+ return 0
+
+ df = pl.read_parquet(inst_path)
+
+ # 只更新空 name 的行
+ updates = pl.DataFrame({
+ "symbol": list(name_map.keys()),
+ "_new_name": list(name_map.values()),
+ })
+ df = df.join(updates, on="symbol", how="left")
+ df = df.with_columns(
+ pl.when(pl.col("name").is_null() | (pl.col("name") == ""))
+ .then(pl.col("_new_name"))
+ .otherwise(pl.col("name"))
+ .alias("name"),
+ ).drop("_new_name")
+
+ df.write_parquet(inst_path)
+ logger.info("instruments name enriched from quotes: %d names", len(name_map))
+ return len(name_map)
diff --git a/backend/app/services/kline_sync.py b/backend/app/services/kline_sync.py
new file mode 100644
index 0000000..e1ac71d
--- /dev/null
+++ b/backend/app/services/kline_sync.py
@@ -0,0 +1,617 @@
+"""日 K 同步服务(§7.7 Step 1)。
+
+调度器在 capability 允许下,把符号集合的日 K 批量同步到本地 Parquet。
+策略:
+ - 日 K 仅使用 `kline.daily.batch`
+ - 除权因子仅使用 `adj_factor`
+"""
+from __future__ import annotations
+
+import logging
+import time
+from collections.abc import Callable
+from datetime import datetime, timedelta
+
+import polars as pl
+
+from app.indicators.pipeline import filter_halt_days
+from app.tickflow.capabilities import Cap, CapabilitySet
+from app.tickflow.client import get_client
+from app.tickflow.repository import KlineRepository
+
+logger = logging.getLogger(__name__)
+
+
+# 标准列(无论 SDK 返回什么形状,我们把它规范成这套)
+CANONICAL_DAILY_COLS = [
+ "symbol", "date", "open", "high", "low", "close", "volume", "amount",
+]
+
+
+def _normalize_daily(df_in, default_symbol: str | None = None) -> pl.DataFrame:
+ """把 SDK 返回的 pandas/任意 DataFrame 规范成 canonical 列。"""
+ if df_in is None or len(df_in) == 0:
+ return pl.DataFrame()
+
+ if not isinstance(df_in, pl.DataFrame):
+ df = pl.from_pandas(df_in.reset_index() if hasattr(df_in, "reset_index") else df_in)
+ else:
+ df = df_in
+
+ # 兼容字段名差异
+ rename_map = {
+ "ts_code": "symbol",
+ "trade_date": "date",
+ "vol": "volume",
+ "amt": "amount",
+ "datetime": "date",
+ }
+ df = df.rename({k: v for k, v in rename_map.items() if k in df.columns})
+
+ if "symbol" not in df.columns and default_symbol is not None:
+ df = df.with_columns(pl.lit(default_symbol).alias("symbol"))
+
+ # 类型规范
+ if "date" in df.columns and df.schema["date"] != pl.Date:
+ df = df.with_columns(pl.col("date").cast(pl.Date, strict=False))
+
+ for col in ("open", "high", "low", "close"):
+ if col in df.columns:
+ df = df.with_columns(pl.col(col).cast(pl.Float64, strict=False))
+ for col in ("volume", "amount"):
+ if col in df.columns:
+ df = df.with_columns(pl.col(col).cast(pl.Float64, strict=False))
+
+ # 过滤停牌日 (open/high 为 0; close 可能被填充为前收盘价, 不能用全零判断)
+ df = filter_halt_days(df)
+
+ # 只保留 canonical 列
+ keep = [c for c in CANONICAL_DAILY_COLS if c in df.columns]
+ return df.select(keep)
+
+
+def sync_daily_batch(symbols: list[str],
+ count: int | None = None,
+ batch_size: int | None = None,
+ rpm: int | None = None,
+ start_time: datetime | None = None,
+ end_time: datetime | None = None,
+ on_chunk_done: Callable[[int, int], None] | None = None) -> pl.DataFrame:
+ """批量拉取多股日 K。
+
+ 优先使用 start_time / end_time 区间 + count=10000,确保覆盖完整时间段。
+ 仅传 count 时按条数回溯。
+ """
+ tf = get_client()
+ out: list[pl.DataFrame] = []
+ interval = (60.0 / rpm) if rpm else 0
+
+ if batch_size is None:
+ chunks = [symbols]
+ else:
+ chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)]
+
+ for i, chunk in enumerate(chunks):
+ if i > 0 and interval > 0 and len(chunks) > rpm:
+ time.sleep(interval)
+ try:
+ if start_time and end_time:
+ raw = tf.klines.batch(
+ chunk, period="1d", adjust="none",
+ start_time=_datetime_to_ms(start_time),
+ end_time=_datetime_to_ms(end_time),
+ count=10000,
+ as_dataframe=True, show_progress=False,
+ )
+ else:
+ raw = tf.klines.batch(chunk, period="1d", count=count or 250, adjust="none",
+ as_dataframe=True, show_progress=False)
+ except Exception as e: # noqa: BLE001
+ logger.warning("batch fetch failed for %d symbols: %s", len(chunk), e)
+ continue
+
+ # 兼容两种形态:dict[sym → df] 和扁平 df
+ if isinstance(raw, dict):
+ for sym, sub in raw.items():
+ if sub is None or len(sub) == 0:
+ continue
+ out.append(_normalize_daily(sub, default_symbol=sym))
+ elif raw is not None and len(raw) > 0:
+ out.append(_normalize_daily(raw))
+
+ if on_chunk_done:
+ on_chunk_done(i + 1, len(chunks))
+
+ if not out:
+ return pl.DataFrame()
+ return pl.concat(out, how="diagonal_relaxed")
+
+
+def sync_and_persist_daily_batch(
+ symbols: list[str],
+ repo: KlineRepository,
+ capset: CapabilitySet,
+ count: int | None = None,
+ start_date: datetime | None = None,
+ end_date: datetime | None = None,
+ on_chunk_done: Callable[[int, int], None] | None = None,
+) -> int:
+ """批量同步日 K 并落到 Parquet。返回写入的行数。
+
+ start_date/end_date: 外部传入的时间范围(由 pipeline 根据已有数据计算)。
+ 未传入时默认拉最近 1 年。
+ """
+ if not symbols or not capset.has(Cap.KLINE_DAILY_BATCH):
+ return 0
+
+ lim = capset.limits(Cap.KLINE_DAILY_BATCH)
+ batch_size = lim.batch if lim and lim.batch else 100
+ rpm = lim.rpm if lim else None
+
+ end_time = end_date or datetime.now()
+ start_time = start_date or (end_time - timedelta(days=365))
+
+ df = sync_daily_batch(
+ symbols, count=count, batch_size=batch_size, rpm=rpm,
+ start_time=start_time, end_time=end_time,
+ on_chunk_done=on_chunk_done,
+ )
+
+ if df.is_empty():
+ return 0
+
+ repo.append_daily(df)
+
+ try:
+ d = repo.store.data_dir.as_posix()
+ repo.db.execute(
+ f"""CREATE OR REPLACE VIEW kline_daily AS
+ SELECT * FROM read_parquet('{d}/kline_daily/**/*.parquet', union_by_name=true)"""
+ )
+ except Exception as e: # noqa: BLE001
+ logger.warning("refresh view failed: %s", e)
+
+ return df.height
+
+
+def sync_daily_by_quotes(repo: KlineRepository) -> int:
+ """用实时行情接口拉全市场当日数据,覆写 kline_daily 今天分区。
+
+ 一个请求覆盖 ~5500 只股票,比 batch K-line 快几个数量级。
+ 返回写入的行数。
+ """
+ from datetime import date as _date
+
+ from app.tickflow.client import get_client
+
+ tf = get_client()
+ try:
+ resp = tf.quotes.get_by_universes(universes=["CN_Equity_A"])
+ except Exception as e:
+ logger.warning("get_by_universes failed: %s", e)
+ return 0
+
+ if not resp:
+ logger.warning("get_by_universes returned empty")
+ return 0
+
+ records = []
+ for q in resp:
+ ext = q.get("ext") or {}
+ records.append({
+ "symbol": q.get("symbol"),
+ "open": q.get("open"),
+ "high": q.get("high"),
+ "low": q.get("low"),
+ "close": q.get("last_price"),
+ "volume": q.get("volume"),
+ "amount": q.get("amount"),
+ })
+
+ df = pl.DataFrame(records)
+ if df.is_empty():
+ return 0
+
+ today = _date.today()
+ daily_df = df.with_columns(pl.lit(today).cast(pl.Date).alias("date"))
+
+ # 过滤停牌 (open/high 为 0; close 可能被填充为前收盘价, 不能用全零判断)
+ daily_df = filter_halt_days(daily_df)
+
+ repo.flush_live_daily(daily_df)
+ logger.info("sync_daily_by_quotes: %d symbols flushed for %s", daily_df.height, today)
+ return daily_df.height
+
+
+def sync_adj_factor(symbols: list[str], repo: KlineRepository,
+ capset: CapabilitySet,
+ start_time: datetime | None = None,
+ end_time: datetime | None = None,
+ on_chunk_done: Callable[[int, int], None] | None = None) -> tuple[int, list[str]]:
+ """同步除权因子(Starter+)。SDK 接口:`tf.klines.ex_factors(symbols=...)`。
+
+ 支持增量: 传 start_time/end_time 只拉取该时间范围内的新除权事件。
+ 返回 (写入行数, 受影响的 symbol 列表) — 供 enriched 局部重算使用。
+ """
+ if not capset.has(Cap.ADJ_FACTOR) or not symbols:
+ return 0, []
+
+ tf = get_client()
+ lim = capset.limits(Cap.ADJ_FACTOR)
+ batch_size = lim.batch if lim and lim.batch else 50
+ rpm = lim.rpm if lim else 30
+ interval = 60.0 / rpm if rpm else 0
+
+ # 构建 SDK 参数
+ sdk_kwargs: dict = {"as_dataframe": True, "batch_size": batch_size, "show_progress": False}
+ if start_time:
+ sdk_kwargs["start_time"] = _datetime_to_ms(start_time)
+ if end_time:
+ sdk_kwargs["end_time"] = _datetime_to_ms(end_time)
+
+ chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)]
+ all_dfs: list[pl.DataFrame] = []
+
+ for i, chunk in enumerate(chunks):
+ if i > 0 and interval > 0 and len(chunks) > rpm:
+ time.sleep(interval)
+ try:
+ raw = tf.klines.ex_factors(chunk, **sdk_kwargs)
+ if raw is not None and len(raw) > 0:
+ all_dfs.append(pl.from_pandas(
+ raw.reset_index() if hasattr(raw, "reset_index") else raw
+ ))
+ logger.debug("adj_factor chunk %d/%d: %d symbols", i + 1, len(chunks), len(chunk))
+ except Exception as e: # noqa: BLE001
+ logger.warning("adj_factor chunk %d failed: %s", i + 1, e)
+
+ if on_chunk_done:
+ on_chunk_done(i + 1, len(chunks))
+
+ if not all_dfs:
+ return 0, []
+
+ new_data = pl.concat(all_dfs, how="diagonal_relaxed") if len(all_dfs) > 1 else all_dfs[0]
+
+ # 提取受影响的 symbol 列表(合并前)
+ affected = new_data["symbol"].unique().to_list()
+
+ out = repo.store.data_dir / "adj_factor" / "all.parquet"
+ out.parent.mkdir(parents=True, exist_ok=True)
+
+ if out.exists():
+ existing = pl.read_parquet(out)
+ before = existing.height
+ merged = pl.concat([existing, new_data]).unique(
+ subset=["symbol", "trade_date"], keep="last",
+ ).sort(["symbol", "trade_date"])
+ merged.write_parquet(out)
+ added = merged.height - before
+ logger.info("adj_factor merged: %d total (+%d new), %d/%d symbols",
+ merged.height, added, new_data.height, len(symbols))
+ return added, affected
+ else:
+ new_data.sort(["symbol", "trade_date"]).write_parquet(out)
+ logger.info("adj_factor synced: %d rows (%d symbols)", new_data.height, len(symbols))
+ return new_data.height, affected
+
+
+# ===== 分钟 K 同步 =====
+
+CANONICAL_MINUTE_COLS = [
+ "symbol", "datetime", "open", "high", "low", "close", "volume", "amount",
+]
+
+
+def _normalize_minute(df_in, default_symbol: str | None = None) -> pl.DataFrame:
+ """把 SDK 返回的分钟 K 数据规范成 canonical 列。"""
+ if df_in is None or len(df_in) == 0:
+ return pl.DataFrame()
+
+ if not isinstance(df_in, pl.DataFrame):
+ df = pl.from_pandas(df_in.reset_index() if hasattr(df_in, "reset_index") else df_in)
+ else:
+ df = df_in
+
+ rename_map = {
+ "ts_code": "symbol",
+ "vol": "volume",
+ "amt": "amount",
+ }
+ df = df.rename({k: v for k, v in rename_map.items() if k in df.columns})
+
+ # datetime 列:优先用 timestamp(毫秒精度),其次 trade_time
+ if "timestamp" in df.columns:
+ df = df.with_columns(
+ pl.from_epoch("timestamp", time_unit="ms").alias("datetime"),
+ ).drop("timestamp")
+ for drop_col in ("trade_time", "trade_date"):
+ if drop_col in df.columns:
+ df = df.drop(drop_col)
+ elif "trade_time" in df.columns:
+ df = df.rename({"trade_time": "datetime"})
+ if "trade_date" in df.columns:
+ df = df.drop("trade_date")
+ elif "trade_date" in df.columns:
+ df = df.rename({"trade_date": "datetime"})
+
+ if "symbol" not in df.columns and default_symbol is not None:
+ df = df.with_columns(pl.lit(default_symbol).alias("symbol"))
+
+ # 类型规范:统一转 Datetime('us')
+ if "datetime" in df.columns:
+ dt_type = df.schema["datetime"]
+ if not isinstance(dt_type, pl.Datetime) or dt_type.time_unit != "us":
+ df = df.with_columns(pl.col("datetime").cast(pl.Datetime("us"), strict=False))
+
+ for col in ("open", "high", "low", "close"):
+ if col in df.columns:
+ df = df.with_columns(pl.col(col).cast(pl.Float64, strict=False))
+ for col in ("volume", "amount"):
+ if col in df.columns:
+ df = df.with_columns(pl.col(col).cast(pl.Float64, strict=False))
+
+ keep = [c for c in CANONICAL_MINUTE_COLS if c in df.columns]
+ return df.select(keep)
+
+
+def _datetime_to_ms(dt: datetime) -> int:
+ """datetime → 毫秒时间戳 (供 SDK start_time / end_time 使用)。"""
+ return int(dt.timestamp() * 1000)
+
+
+def sync_minute_batch(
+ symbols: list[str],
+ start_time: datetime | None = None,
+ end_time: datetime | None = None,
+ count: int | None = None,
+ batch_size: int | None = None,
+ rpm: int | None = None,
+ on_chunk_done: Callable[[int, int], None] | None = None,
+) -> pl.DataFrame:
+ """批量拉取多股分钟 K。
+
+ 优先使用 start_time / end_time 区间, 确保所有标的覆盖同一时间段。
+ count 仅作为 fallback 保留。
+ on_chunk_done(current, total) 每个 chunk 完成后回调。
+ """
+ tf = get_client()
+ out: list[pl.DataFrame] = []
+ interval = (60.0 / rpm) if rpm else 0
+
+ if batch_size is None:
+ chunks = [symbols]
+ else:
+ chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)]
+
+ for i, chunk in enumerate(chunks):
+ if i > 0 and interval > 0 and len(chunks) > rpm:
+ time.sleep(interval)
+ try:
+ if start_time and end_time:
+ raw = tf.klines.batch(
+ chunk, period="1m",
+ start_time=_datetime_to_ms(start_time),
+ end_time=_datetime_to_ms(end_time),
+ count=10000,
+ as_dataframe=True, show_progress=False,
+ )
+ else:
+ raw = tf.klines.batch(chunk, period="1m", count=count or 1200,
+ as_dataframe=True, show_progress=False)
+ except Exception as e: # noqa: BLE001
+ logger.warning("minute batch fetch failed for %d symbols: %s", len(chunk), e)
+ continue
+
+ if isinstance(raw, dict):
+ for sym, sub in raw.items():
+ if sub is None or len(sub) == 0:
+ continue
+ out.append(_normalize_minute(sub, default_symbol=sym))
+ elif raw is not None and len(raw) > 0:
+ out.append(_normalize_minute(raw))
+
+ if on_chunk_done:
+ on_chunk_done(i + 1, len(chunks))
+
+ if not out:
+ return pl.DataFrame()
+ return pl.concat(out, how="diagonal_relaxed")
+
+
+def fetch_minute_single(symbol: str, trade_date: date) -> pl.DataFrame:
+ """从 TickFlow 实时拉取单股单日分钟 K(不写入本地)。"""
+ 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)
+ tf = get_client()
+ try:
+ raw = tf.klines.batch(
+ [symbol], period="1m",
+ start_time=_datetime_to_ms(start_time),
+ end_time=_datetime_to_ms(end_time),
+ count=10000,
+ as_dataframe=True, show_progress=False,
+ )
+ except Exception as e:
+ logger.warning("fetch_minute_single(%s, %s) failed: %s", symbol, trade_date, e)
+ return pl.DataFrame()
+
+ if isinstance(raw, dict):
+ sub = raw.get(symbol)
+ return _normalize_minute(sub) if sub is not None and len(sub) > 0 else pl.DataFrame()
+ if raw is not None and len(raw) > 0:
+ return _normalize_minute(raw)
+ return pl.DataFrame()
+
+
+def _latest_minute_datetime(repo: KlineRepository) -> datetime | None:
+ """本地分钟 K 数据的最新时间。"""
+ try:
+ res = repo.execute_one("SELECT max(datetime) FROM kline_minute")
+ if res and res[0]:
+ d = res[0]
+ if isinstance(d, datetime):
+ return d
+ return datetime.fromisoformat(str(d))
+ except Exception: # noqa: BLE001
+ pass
+ return None
+
+
+def _cleanup_null_datetime_minute(repo: KlineRepository) -> None:
+ """检测并清除 datetime 全为 null 的旧版分钟 K 数据(迁移用)。"""
+ minute_dir = repo.store.data_dir / "kline_minute"
+ if not minute_dir.exists():
+ return
+ try:
+ row = repo.execute_one(
+ "SELECT count(*) AS total, count(datetime) AS non_null FROM kline_minute"
+ )
+ if row and row[0] > 0 and (row[1] is None or row[1] == 0):
+ # 全部 datetime 为 null — 清除所有分钟 K parquet
+ n = 0
+ for f in minute_dir.rglob("*.parquet"):
+ f.unlink()
+ n += 1
+ logger.info("cleaned %d corrupted minute-K parquet files (null datetime)", n)
+ except Exception as e: # noqa: BLE001
+ logger.debug("minute cleanup check failed: %s", e)
+
+
+def _migrate_symbol_to_date_partition(repo: KlineRepository) -> None:
+ """将旧版 symbol= 分区迁移为 date= 分区。迁移完成后删除旧目录。"""
+ minute_dir = repo.store.data_dir / "kline_minute"
+ if not minute_dir.exists():
+ return
+
+ old_dirs = [d for d in minute_dir.iterdir() if d.is_dir() and d.name.startswith("symbol=")]
+ if not old_dirs:
+ return
+
+ logger.info("migrating %d symbol-partitioned minute-K dirs to date partition…", len(old_dirs))
+
+ all_frames: list[pl.DataFrame] = []
+ for sym_dir in old_dirs:
+ for pq in sym_dir.glob("*.parquet"):
+ try:
+ df = pl.read_parquet(pq)
+ if "datetime" in df.columns:
+ df = df.filter(pl.col("datetime").is_not_null())
+ if not df.is_empty():
+ all_frames.append(df)
+ except Exception: # noqa: BLE001
+ pass
+
+ if not all_frames:
+ # 数据全部不可用,直接删旧目录
+ for d in old_dirs:
+ d.mkdir(parents=True, exist_ok=True)
+ for f in d.rglob("*"):
+ if f.is_file():
+ f.unlink()
+ d.rmdir()
+ return
+
+ combined = pl.concat(all_frames, how="diagonal_relaxed")
+ combined = combined.unique(subset=["symbol", "datetime"], keep="last")
+
+ # 按日期写新分区
+ combined = combined.with_columns(pl.col("datetime").dt.date().alias("_trade_date"))
+ for day_df in combined.partition_by("_trade_date"):
+ trade_date = day_df["_trade_date"][0]
+ out = minute_dir / f"date={trade_date}" / "part.parquet"
+ out.parent.mkdir(parents=True, exist_ok=True)
+ day_df = day_df.drop("_trade_date").sort("symbol", "datetime")
+ day_df.write_parquet(out)
+
+ # 删旧目录
+ for d in old_dirs:
+ for f in d.rglob("*"):
+ if f.is_file():
+ f.unlink()
+ # 移除空目录
+ try:
+ d.rmdir()
+ except OSError:
+ pass
+
+ logger.info("minute-K migration done: %d rows migrated", combined.height)
+
+
+def sync_and_persist_minute(
+ symbols: list[str],
+ repo: KlineRepository,
+ capset: CapabilitySet,
+ days: int = 5,
+ on_chunk_done: Callable[[int, int], None] | None = None,
+) -> int:
+ """同步分钟 K 并存到 Parquet(仅 raw,不前复权)。返回写入行数。
+
+ 使用 start_time / end_time 区间拉取, 确保所有标的覆盖同一时间段。
+ on_chunk_done(current, total) 每个 chunk 完成后回调。
+ """
+ if not symbols or not capset.has(Cap.KLINE_MINUTE_BATCH):
+ return 0
+
+ # 迁移:旧版 _normalize_minute 未转换 timestamp→datetime,导致全部 datetime 为 null
+ # 检测到后直接清除(这些数据无法使用)
+ _cleanup_null_datetime_minute(repo)
+
+ # 迁移:旧版按 symbol= 分区转为 date= 分区
+ _migrate_symbol_to_date_partition(repo)
+
+ now = datetime.now()
+
+ # 计算时间区间: 首次拉取回溯 N 天, 增量从最后数据时间开始
+ last_dt = _latest_minute_datetime(repo)
+ if last_dt:
+ start_time = last_dt
+ else:
+ start_time = now - timedelta(days=days)
+ end_time = now
+
+ lim = capset.limits(Cap.KLINE_MINUTE_BATCH)
+ batch_size = lim.batch if lim and lim.batch else 100
+ rpm = lim.rpm if lim else 30
+
+ df = sync_minute_batch(symbols, start_time=start_time, end_time=end_time,
+ batch_size=batch_size, rpm=rpm,
+ on_chunk_done=on_chunk_done)
+ if df.is_empty():
+ return 0
+
+ # 按日期分区写: data/kline_minute/date={YYYY-MM-DD}/part.parquet
+ df = df.with_columns(
+ pl.col("datetime").dt.date().alias("_trade_date")
+ )
+ written = 0
+ for day_df in df.partition_by("_trade_date"):
+ trade_date = day_df["_trade_date"][0]
+ out = repo.store.data_dir / "kline_minute" / f"date={trade_date}" / "part.parquet"
+ out.parent.mkdir(parents=True, exist_ok=True)
+ if out.exists():
+ existing = pl.read_parquet(out)
+ if "datetime" in existing.columns:
+ existing = existing.filter(pl.col("datetime").is_not_null())
+ day_df = pl.concat([existing, day_df.drop("_trade_date")]).unique(
+ subset=["symbol", "datetime"], keep="last",
+ )
+ else:
+ day_df = day_df.drop("_trade_date")
+ day_df = day_df.sort("symbol", "datetime")
+ day_df.write_parquet(out)
+ written += day_df.height
+
+ # 刷新视图
+ try:
+ d = repo.store.data_dir.as_posix()
+ repo.db.execute(
+ f"""CREATE OR REPLACE VIEW kline_minute AS
+ SELECT * FROM read_parquet('{d}/kline_minute/**/*.parquet', union_by_name=true)"""
+ )
+ except Exception as e: # noqa: BLE001
+ logger.warning("refresh kline_minute view failed: %s", e)
+
+ logger.info("minute K synced: %d rows (%d symbols)", written, len(symbols))
+ return written
diff --git a/backend/app/services/pipeline_jobs.py b/backend/app/services/pipeline_jobs.py
new file mode 100644
index 0000000..979eddb
--- /dev/null
+++ b/backend/app/services/pipeline_jobs.py
@@ -0,0 +1,250 @@
+"""异步盘后管道任务注册表 — 每个 job 独立 JSON 文件。
+
+设计:
+ - job_store/ 文件夹,每个 job 一个 {id}.json,最多保留 max_jobs 个文件
+ - running/pending 状态的 job 仅存内存(高频读写)
+ - succeeded/failed 后写入独立文件并从内存释放
+ - 列表查询 = 内存中的活跃 job + 磁盘文件扫描,按时间排序
+ - 单个查询 = 内存优先,没有则读磁盘
+ - 创建新 job 前检查文件数量,>= max_jobs 时删除最老的文件
+"""
+from __future__ import annotations
+
+import json
+import logging
+import os
+import threading
+import uuid
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Literal
+
+logger = logging.getLogger(__name__)
+
+JobStatus = Literal["pending", "running", "succeeded", "failed"]
+
+
+def _default_store_dir() -> Path:
+ from app.config import settings
+ return settings.data_dir / "job_store"
+
+
+_STORE_DIR = _default_store_dir()
+
+
+class JobStore:
+ def __init__(self, max_jobs: int = 50, store_dir: Path = _STORE_DIR) -> None:
+ self._max_jobs = max_jobs
+ self._store_dir = store_dir
+ self._active_jobs: dict[str, dict[str, Any]] = {} # running/pending
+ self._active_id: str | None = None
+ self._lock = threading.Lock()
+ self._store_dir.mkdir(parents=True, exist_ok=True)
+
+ # ===== persistence =====
+
+ def _write_file(self, job: dict[str, Any]) -> None:
+ """将终态 job 写入独立 JSON 文件。"""
+ path = self._store_dir / f"{job['id']}.json"
+ try:
+ path.write_text(
+ json.dumps(job, ensure_ascii=False, indent=None),
+ encoding="utf-8",
+ )
+ except Exception:
+ logger.warning("failed to write job file %s", path)
+
+ def _read_file(self, job_id: str) -> dict[str, Any] | None:
+ """从磁盘读取单个 job 文件。"""
+ path = self._store_dir / f"{job_id}.json"
+ if not path.exists():
+ return None
+ try:
+ return json.loads(path.read_text("utf-8"))
+ except Exception:
+ logger.warning("failed to read job file %s", path)
+ return None
+
+ def _delete_oldest(self) -> None:
+ """删除最老的 job 文件,保持文件数量 < max_jobs。"""
+ try:
+ files = sorted(self._store_dir.glob("*.json"), key=lambda f: f.stat().st_mtime)
+ except Exception:
+ return
+ while len(files) >= self._max_jobs:
+ oldest = files.pop(0)
+ try:
+ oldest.unlink()
+ except Exception:
+ logger.warning("failed to delete old job file %s", oldest)
+
+ def _job_files_sorted(self) -> list[dict[str, Any]]:
+ """扫描磁盘上所有 job 文件,按 started_at 从新到旧排序。"""
+ jobs: list[dict[str, Any]] = []
+ for f in self._store_dir.glob("*.json"):
+ try:
+ jobs.append(json.loads(f.read_text("utf-8")))
+ except Exception:
+ continue
+ jobs.sort(key=lambda j: j.get("started_at") or "", reverse=True)
+ return jobs
+
+ # ===== lifecycle =====
+
+ def create(self) -> str:
+ with self._lock:
+ if self._active_id and self._active_jobs.get(self._active_id, {}).get("status") == "running":
+ return self._active_id
+
+ job_id = uuid.uuid4().hex[:10]
+ self._active_jobs[job_id] = {
+ "id": job_id,
+ "status": "pending",
+ "stage": "init",
+ "progress": 0,
+ "stage_pct": 0,
+ "log": [],
+ "started_at": None,
+ "finished_at": None,
+ "duration_s": None,
+ "result": None,
+ "error": None,
+ }
+ self._active_id = job_id
+ return job_id
+
+ def start(self, job_id: str) -> None:
+ with self._lock:
+ j = self._active_jobs.get(job_id)
+ if not j:
+ return
+ j["status"] = "running"
+ j["started_at"] = datetime.utcnow().isoformat(timespec="seconds") + "Z"
+
+ def succeed(self, job_id: str, result: Any) -> None:
+ with self._lock:
+ j = self._active_jobs.pop(job_id, None)
+ if not j:
+ return
+ j["status"] = "succeeded"
+ j["finished_at"] = datetime.utcnow().isoformat(timespec="seconds") + "Z"
+ j["progress"] = 100
+ j["result"] = result
+ j["duration_s"] = _duration_s(j)
+ if self._active_id == job_id:
+ self._active_id = None
+ self._delete_oldest()
+ self._write_file(j)
+
+ def fail(self, job_id: str, error: str) -> None:
+ with self._lock:
+ j = self._active_jobs.pop(job_id, None)
+ if not j:
+ return
+ j["status"] = "failed"
+ j["finished_at"] = datetime.utcnow().isoformat(timespec="seconds") + "Z"
+ j["error"] = error
+ j["duration_s"] = _duration_s(j)
+ if self._active_id == job_id:
+ self._active_id = None
+ self._delete_oldest()
+ self._write_file(j)
+
+ # ===== progress =====
+
+ def progress(self, job_id: str, stage: str, pct: int, msg: str,
+ stage_pct: int | None = None, skip_log: bool = False) -> None:
+ with self._lock:
+ j = self._active_jobs.get(job_id)
+ if not j:
+ return
+ j["stage"] = stage
+ j["progress"] = max(0, min(100, int(pct)))
+ if stage_pct is not None:
+ j["stage_pct"] = max(0, min(100, int(stage_pct)))
+ elif j["stage"] != stage:
+ j["stage_pct"] = 0
+ entry = {
+ "ts": datetime.utcnow().isoformat(timespec="seconds") + "Z",
+ "stage": stage,
+ "msg": msg,
+ }
+ if skip_log:
+ entry["_skip"] = True
+ if skip_log and j["log"] and j["log"][-1].get("stage") == stage and j["log"][-1].get("_skip"):
+ j["log"][-1] = entry
+ else:
+ j["log"].append(entry)
+ if len(j["log"]) > 200:
+ j["log"] = j["log"][-200:]
+
+ # ===== query =====
+
+ def get(self, job_id: str) -> dict[str, Any] | None:
+ # 内存中的活跃 job 优先
+ j = self._active_jobs.get(job_id)
+ if j:
+ return j
+ # 否则从磁盘读
+ return self._read_file(job_id)
+
+ def list_recent(self, limit: int = 20) -> list[dict[str, Any]]:
+ # 合并: 内存中的活跃 job + 磁盘文件
+ all_jobs: list[dict[str, Any]] = list(self._active_jobs.values())
+ all_jobs.extend(self._job_files_sorted())
+ # 按 started_at 从新到旧排序,去重(理论上不会有重复)
+ seen: set[str] = set()
+ result: list[dict[str, Any]] = []
+ for j in sorted(all_jobs, key=lambda x: x.get("started_at") or "", reverse=True):
+ jid = j["id"]
+ if jid in seen:
+ continue
+ seen.add(jid)
+ result.append(_summary(j))
+ if len(result) >= limit:
+ break
+ return result
+
+ def active_id(self) -> str | None:
+ return self._active_id
+
+ def clear(self) -> None:
+ """清空所有任务(内存 + 磁盘文件)。"""
+ with self._lock:
+ self._active_jobs.clear()
+ self._active_id = None
+ for f in self._store_dir.glob("*.json"):
+ try:
+ f.unlink()
+ except Exception:
+ pass
+
+
+def _summary(j: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "id": j["id"],
+ "status": j["status"],
+ "stage": j["stage"],
+ "progress": j["progress"],
+ "stage_pct": j.get("stage_pct", 0),
+ "started_at": j["started_at"],
+ "finished_at": j["finished_at"],
+ "duration_s": j["duration_s"],
+ "result": j["result"],
+ "error": j["error"],
+ }
+
+
+def _duration_s(j: dict[str, Any]) -> float | None:
+ if not j.get("started_at") or not j.get("finished_at"):
+ return None
+ try:
+ s = datetime.fromisoformat(j["started_at"])
+ e = datetime.fromisoformat(j["finished_at"])
+ return round((e - s).total_seconds(), 2)
+ except Exception: # noqa: BLE001
+ return None
+
+
+# 进程内单例
+job_store = JobStore()
diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py
new file mode 100644
index 0000000..29b1714
--- /dev/null
+++ b/backend/app/services/preferences.py
@@ -0,0 +1,250 @@
+"""用户偏好设置持久化。
+
+存储位置: data/user_data/preferences.json
+沿用 secrets_store 的 merge-write 模式,但不做 chmod 0600 (非敏感数据)。
+"""
+from __future__ import annotations
+
+import json
+import logging
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+
+def _path() -> Path:
+ from app.config import settings
+ p = settings.data_dir / "user_data" / "preferences.json"
+ p.parent.mkdir(parents=True, exist_ok=True)
+ return p
+
+
+def load() -> dict:
+ 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 {}
+
+
+def save(updates: dict) -> dict:
+ """合并写入。返回新内容。"""
+ current = load()
+ current.update(updates)
+ _path().write_text(
+ json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8",
+ )
+ return current
+
+
+def get_realtime_quotes_enabled() -> bool:
+ return load().get("realtime_quotes_enabled", False)
+
+
+def get_indices_nav_pinned() -> bool:
+ """侧栏指数报价卡片是否固定显示。默认 True(常驻)。
+ 关闭后,卡片跟随实时行情开关(仅实时开时显示)。"""
+ return load().get("indices_nav_pinned", True)
+
+
+def get_realtime_quote_interval() -> float:
+ return load().get("realtime_quote_interval", 10.0)
+
+
+def set_realtime_quote_interval(interval: float) -> float:
+ """保存行情轮询间隔(不在此做 min/max 校验,由调用方按档位限制)。"""
+ current = load()
+ current["realtime_quote_interval"] = interval
+ _path().write_text(
+ json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8",
+ )
+ return interval
+
+
+def get_minute_sync_enabled() -> bool:
+ return load().get("minute_sync_enabled", False)
+
+
+def get_minute_sync_days() -> int:
+ return max(1, min(30, load().get("minute_sync_days", 5)))
+
+
+def get_pipeline_schedule() -> dict:
+ """返回盘后管道调度时间 {"hour": 15, "minute": 30}。"""
+ d = load().get("pipeline_schedule", {"hour": 15, "minute": 30})
+ return {"hour": d.get("hour", 15), "minute": d.get("minute", 30)}
+
+
+def set_pipeline_schedule(hour: int, minute: int) -> dict:
+ h = max(0, min(23, hour))
+ m = max(0, min(59, minute))
+ # 盘后不早于 15:00
+ if h * 60 + m < 15 * 60:
+ h, m = 15, 0
+ save({"pipeline_schedule": {"hour": h, "minute": m}})
+ return {"hour": h, "minute": m}
+
+
+def get_instruments_schedule() -> dict:
+ """返回盘前标的维表调度时间 {"hour": 9, "minute": 10}。"""
+ d = load().get("instruments_schedule", {"hour": 9, "minute": 10})
+ return {"hour": d.get("hour", 9), "minute": d.get("minute", 10)}
+
+
+def set_instruments_schedule(hour: int, minute: int) -> dict:
+ h = max(0, min(23, hour))
+ m = max(0, min(59, minute))
+ # 盘前不晚于 09:15
+ if h * 60 + m > 9 * 60 + 15:
+ h, m = 9, 15
+ save({"instruments_schedule": {"hour": h, "minute": m}})
+ return {"hour": h, "minute": m}
+
+
+def get_enriched_batch_size() -> int:
+ """返回 enriched 全量计算每批 symbol 数量。"""
+ return max(1, min(10000, load().get("enriched_batch_size", 1000)))
+
+
+def set_enriched_batch_size(size: int) -> int:
+ """保存 enriched 全量计算批次大小。"""
+ size = max(10, min(6000, size))
+ save({"enriched_batch_size": size})
+ return size
+
+
+def get_index_daily_batch_size() -> int:
+ """返回指数日 K 同步每批 symbol 数量。"""
+ return max(1, min(10000, load().get("index_daily_batch_size", 100)))
+
+
+def set_index_daily_batch_size(size: int) -> int:
+ """保存指数日 K 同步批次大小。"""
+ size = max(1, min(10000, size))
+ save({"index_daily_batch_size": size})
+ return size
+
+
+# ===== 实时监控 =====
+
+# 页面 SSE 刷新配置: { "watchlist": true, "monitor": true, ... }
+# 可刷新的页面列表及其默认值
+SSE_REFRESH_PAGES_DEFAULT = {
+ "watchlist": True,
+ "limit-ladder": False,
+}
+
+SIDEBAR_INDEX_SYMBOLS_DEFAULT = ["000001.SH", "399001.SZ", "399006.SZ", "000680.SH"]
+
+
+def get_sse_refresh_pages() -> dict[str, bool]:
+ """返回每个页面的 SSE 刷新开关。"""
+ stored = load().get("sse_refresh_pages", {})
+ # 合并默认值 (新增页面自动出现)
+ result = dict(SSE_REFRESH_PAGES_DEFAULT)
+ result.update(stored)
+ return result
+
+
+def set_sse_refresh_pages(pages: dict[str, bool]) -> dict[str, bool]:
+ """保存页面 SSE 刷新配置。"""
+ save({"sse_refresh_pages": pages})
+ return get_sse_refresh_pages()
+
+
+def get_sidebar_index_symbols() -> list[str]:
+ """返回左侧菜单显示的指数代码。"""
+ stored = load().get("sidebar_index_symbols", SIDEBAR_INDEX_SYMBOLS_DEFAULT)
+ allowed = set(SIDEBAR_INDEX_SYMBOLS_DEFAULT)
+ return [s for s in stored if s in allowed]
+
+
+def get_strategy_monitor_enabled() -> bool:
+ """策略告警评估总开关。"""
+ return load().get("strategy_monitor_enabled", False)
+
+
+def get_screener_auto_run() -> bool:
+ """选股页进入时是否自动运行所有策略 (获取命中数)。默认开。"""
+ return load().get("screener_auto_run", True)
+
+
+def get_strategy_monitor_ids() -> list[str]:
+ """返回监控池中的策略 ID。"""
+ return load().get("strategy_monitor_ids", [])
+
+
+def set_realtime_monitor_config(cfg: dict) -> dict:
+ """批量更新实时监控配置。"""
+ updates = {}
+ if "sse_refresh_pages" in cfg:
+ updates["sse_refresh_pages"] = cfg["sse_refresh_pages"]
+ if "strategy_monitor_enabled" in cfg:
+ updates["strategy_monitor_enabled"] = cfg["strategy_monitor_enabled"]
+ if "strategy_monitor_ids" in cfg:
+ updates["strategy_monitor_ids"] = cfg["strategy_monitor_ids"]
+ if "sidebar_index_symbols" in cfg:
+ allowed = set(SIDEBAR_INDEX_SYMBOLS_DEFAULT)
+ updates["sidebar_index_symbols"] = [s for s in cfg["sidebar_index_symbols"] if s in allowed]
+ if "screener_auto_run" in cfg:
+ updates["screener_auto_run"] = bool(cfg["screener_auto_run"])
+ if updates:
+ save(updates)
+ return get_realtime_monitor_config()
+
+
+def get_realtime_monitor_config() -> dict:
+ """返回完整的实时监控配置。"""
+ return {
+ "sse_refresh_pages": get_sse_refresh_pages(),
+ "strategy_monitor_enabled": get_strategy_monitor_enabled(),
+ "strategy_monitor_ids": get_strategy_monitor_ids(),
+ "sidebar_index_symbols": get_sidebar_index_symbols(),
+ "screener_auto_run": get_screener_auto_run(),
+ }
+
+
+def get_nav_order() -> list[str]:
+ """返回左侧菜单的自定义排序(内置页面 path + 扩展分析菜单 id)。"""
+ return load().get("nav_order", [])
+
+
+def set_nav_order(order: list[str]) -> list[str]:
+ """保存左侧菜单排序。"""
+ save({"nav_order": order})
+ return get_nav_order()
+
+
+def get_nav_hidden() -> list[str]:
+ """返回左侧菜单中隐藏的项 id 列表。"""
+ return load().get("nav_hidden", [])
+
+
+def set_nav_hidden(hidden: list[str]) -> list[str]:
+ """保存左侧菜单隐藏项。"""
+ save({"nav_hidden": hidden})
+ return get_nav_hidden()
+
+
+def get_watchlist_columns() -> list[dict] | None:
+ """返回自选列表列配置。"""
+ return load().get("watchlist_columns")
+
+
+def set_watchlist_columns(columns: list[dict]) -> list[dict]:
+ """保存自选列表列配置。"""
+ save({"watchlist_columns": columns})
+ return columns
+
+
+def get_screener_result_columns() -> list[dict] | None:
+ """返回策略结果列表列配置。"""
+ return load().get("screener_result_columns")
+
+
+def set_screener_result_columns(columns: list[dict]) -> list[dict]:
+ """保存策略结果列表列配置。"""
+ save({"screener_result_columns": columns})
+ return columns
diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py
new file mode 100644
index 0000000..8d0e731
--- /dev/null
+++ b/backend/app/services/quote_service.py
@@ -0,0 +1,673 @@
+"""全局实时行情服务。
+
+集中管理全市场行情拉取 + enriched 缓存,供盘中选股、自选股等所有模块复用。
+
+架构:
+ - 后台线程轮询 TickFlow get_by_universes(["CN_Equity_A", "CN_Index"])
+ - 拉取行情 → 写 kline_daily (不复权) + 增量计算 enriched → 写盘 + 更新缓存
+ - _enriched_cache 是唯一的盘中数据源 (OHLCV + 全套技术指标)
+ - _live_agg_cache 是递推状态 (只加载一次, 盘中不变)
+
+数据流 (每轮 ~15s):
+ 1. API 拉取 → raw_records (临时变量)
+ 2. raw_records → 写 kline_daily (不复权原始价格)
+ 3. raw_records → 更新 _enriched_cache 的 OHLCV
+ 4. 增量计算 enriched 指标 (~50ms)
+ 5. 写 kline_daily_enriched + 替换 _enriched_cache
+ 6. 通知 SSE
+
+生命周期:
+ - 服务启动时读取 preferences,若 enabled 则自动启动线程
+ - 运行中可通过 API 切换开关
+ - 关闭时停止线程
+"""
+from __future__ import annotations
+
+import logging
+import threading
+import time
+from datetime import date, datetime, time as dt_time
+
+import polars as pl
+
+logger = logging.getLogger(__name__)
+
+
+class QuoteService:
+ """全局实时行情服务 — 单例。"""
+
+ CORE_INDEX_SYMBOLS = ("000001.SH", "399001.SZ", "399006.SZ", "000680.SH")
+
+ # 档位 → 最小轮询间隔 (秒)
+ TIER_MIN_INTERVAL = {
+ "expert": 0.5,
+ "pro": 1.0,
+ "starter": 3.0,
+ }
+ DEFAULT_INTERVAL = 10.0
+ MAX_INTERVAL = 60.0
+
+ def __init__(self) -> None:
+ self._lock = threading.Lock()
+ self._running = False
+ self._enabled = False # 全局开关 (持久化到 preferences)
+ self._interval = self.DEFAULT_INTERVAL
+ self._thread: threading.Thread | None = None
+ self._repo = None # 延迟注入, 避免循环导入
+ self._update_event = threading.Event() # SSE 通知: 行情更新后 set
+ self._alert_event = threading.Event() # SSE 通知: 有告警时 set
+ self._pending_alerts: list[dict] = [] # 待推送的告警
+ self._strategy_monitor = None # 延迟注入
+ self._app_state = None # 延迟注入 (FastAPI app.state)
+
+ # 拉取元信息 (给 SSE / status 用)
+ self._fetch_time: float = 0.0 # perf_counter (用于计算 quote_age_ms)
+ self._fetch_ms: float = 0.0 # 拉取耗时 (毫秒)
+ self._fetched_at: float = 0.0 # 拉取完成的 Unix 时间戳 (毫秒)
+ self._symbol_count: int = 0
+ self._index_symbol_count: int = 0
+ self._index_quotes_cache: pl.DataFrame | None = None
+
+ # ================================================================
+ # 生命周期
+ # ================================================================
+
+ def start(self, interval: float = 0.0) -> None:
+ """启动后台行情轮询线程。"""
+ if self._running:
+ return
+ if interval <= 0:
+ from app.services import preferences
+ interval = preferences.get_realtime_quote_interval()
+ self._interval = self._clamp_interval(interval)
+ self._running = True
+ self._enabled = True
+ self._thread = threading.Thread(target=self._poll_loop, daemon=True)
+ self._thread.start()
+ self._save_enabled(True)
+ logger.info("行情服务已启动, 轮询间隔 %.1fs", self._interval)
+
+ def stop(self) -> None:
+ """停止后台行情轮询线程。"""
+ self._running = False
+ self._enabled = False
+ if self._thread:
+ self._thread.join(timeout=10)
+ self._thread = None
+ self._save_enabled(False)
+ logger.info("行情服务已停止")
+
+ def enable(self) -> None:
+ """开启自动行情 (不立即启动线程,等下一个交易时段)。"""
+ self._enabled = True
+ self._save_enabled(True)
+ if not self._running:
+ from app.services import preferences
+ self._interval = self._clamp_interval(preferences.get_realtime_quote_interval())
+ self._running = True
+ self._thread = threading.Thread(target=self._poll_loop, daemon=True)
+ self._thread.start()
+ logger.info("行情服务已启用, 轮询间隔 %.1fs", self._interval)
+
+ def disable(self) -> None:
+ """关闭自动行情。"""
+ self.stop()
+ logger.info("行情服务已关闭")
+
+ def boot_check(self) -> None:
+ """启动时检查 preferences,若 enabled 则自动启动。"""
+ from app.services import preferences
+ if preferences.get_realtime_quotes_enabled():
+ self.start()
+
+ def set_repo(self, repo) -> None:
+ """注入 KlineRepository, 用于实时落盘。"""
+ self._repo = repo
+
+ def set_app_state(self, app_state) -> None:
+ """注入 FastAPI app.state, 用于获取 strategy_monitor 等单例。"""
+ self._app_state = app_state
+
+ def set_interval(self, interval: float) -> float:
+ """运行时更新轮询间隔(立即生效)。"""
+ clamped = self._clamp_interval(interval)
+ self._interval = clamped
+ from app.services import preferences
+ preferences.set_realtime_quote_interval(clamped)
+ logger.info("轮询间隔已更新为 %.1fs", clamped)
+ return clamped
+
+ def get_min_interval(self) -> float:
+ """返回当前档位允许的最小间隔。"""
+ return self._tier_min_interval()
+
+ def wait_for_update(self, timeout: float = 30.0) -> bool:
+ """阻塞等待下一次行情更新 (供 SSE 线程使用)。"""
+ self._update_event.clear()
+ return self._update_event.wait(timeout=timeout)
+
+ def wait_for_alert(self, timeout: float = 30.0) -> bool:
+ """阻塞等待告警 (供 SSE 线程使用)。"""
+ self._alert_event.clear()
+ return self._alert_event.wait(timeout=timeout)
+
+ def pop_alerts(self) -> list[dict]:
+ """取走所有待推送的告警 (线程安全)。"""
+ with self._lock:
+ alerts = self._pending_alerts
+ self._pending_alerts = []
+ return alerts
+
+ # ================================================================
+ # 档位感知间隔限制
+ # ================================================================
+
+ @staticmethod
+ def _current_tier() -> str:
+ """获取当前档位名(小写)。"""
+ from app.tickflow.policy import tier_label
+ return tier_label().split()[0].split("+")[0].strip().lower()
+
+ @classmethod
+ def _tier_min_interval(cls) -> float:
+ tier = cls._current_tier()
+ return cls.TIER_MIN_INTERVAL.get(tier, cls.DEFAULT_INTERVAL)
+
+ def _clamp_interval(self, interval: float) -> float:
+ return max(self._tier_min_interval(), min(self.MAX_INTERVAL, interval))
+
+ # ================================================================
+ # 行情数据访问
+ # ================================================================
+
+ def get_enriched_today(self) -> tuple[pl.DataFrame, date | None]:
+ """返回今天 enriched 数据 + 日期 (线程安全)。
+
+ 所有页面统一通过此方法获取实时行情 + 技术指标。
+ """
+ if not self._repo:
+ return pl.DataFrame(), None
+ return self._repo.get_enriched_latest()
+
+ def get_quotes_compat(self) -> pl.DataFrame:
+ """兼容接口: 返回行情 DataFrame (用于盘中选股等需要 last_price/prev_close 的场景)。
+
+ 从 _enriched_cache 取 today 的数据, 只选行情基础列, 补上 last_price 别名。
+ 不返回指标列, 避免 JOIN live_agg 时列名冲突。
+ """
+ df, _ = self.get_enriched_today()
+ if df.is_empty():
+ return df
+
+ # 只取盘中选股需要的行情基础列
+ keep = [c for c in [
+ "symbol", "close", "open", "high", "low", "volume", "amount",
+ "prev_close", "change_pct", "change_amount", "amplitude", "turnover_rate",
+ ] if c in df.columns]
+ df = df.select(keep)
+
+ # enriched 的 close 等价于 last_price
+ if "close" in df.columns and "last_price" not in df.columns:
+ df = df.with_columns(pl.col("close").alias("last_price"))
+ return df
+
+ def get_index_quotes(self, symbols: list[str] | None = None) -> pl.DataFrame:
+ """返回实时指数行情缓存。不会触发 TickFlow 请求。"""
+ with self._lock:
+ df = self._index_quotes_cache.clone() if self._index_quotes_cache is not None else pl.DataFrame()
+ if df.is_empty():
+ return df
+ if symbols:
+ return df.filter(pl.col("symbol").is_in(symbols))
+ return df
+
+ def status(self) -> dict:
+ """返回行情服务状态。"""
+ age = (time.perf_counter() - self._fetch_time) * 1000 if self._fetch_time else -1
+ return {
+ "enabled": self._enabled,
+ "running": self._running,
+ "interval_s": self._interval,
+ "symbol_count": self._symbol_count,
+ "index_symbol_count": self._index_symbol_count,
+ "quote_age_ms": round(age, 0) if age >= 0 else None,
+ "is_trading_hours": self._is_trading_hours(),
+ "last_fetch_ms": round(self._fetched_at, 0) if self._fetched_at else None,
+ }
+
+ def refresh(self) -> dict:
+ """手动触发一次行情拉取。"""
+ self._fetch_quotes()
+ return self.status()
+
+ # ================================================================
+ # 后台轮询
+ # ================================================================
+
+ def _poll_loop(self) -> None:
+ while self._running and self._enabled:
+ try:
+ if self._is_trading_hours():
+ self._fetch_quotes()
+ else:
+ logger.debug("非交易时段, 跳过行情轮询")
+ except Exception as e: # noqa: BLE001
+ logger.warning("行情轮询异常: %s", e)
+
+ waited = 0.0
+ while self._running and self._enabled and waited < self._interval:
+ time.sleep(0.5)
+ waited += 0.5
+
+ def _fetch_quotes(self) -> None:
+ """拉取全市场行情 → 写 daily + 计算 enriched + 更新缓存。"""
+ from app.tickflow.client import get_client
+
+ tf = get_client()
+ t0 = time.perf_counter()
+ now_ts = time.perf_counter()
+
+ try:
+ all_index_symbols = set(self._repo.get_index_symbol_set()) if self._repo else set()
+ all_index_symbols.update(self.CORE_INDEX_SYMBOLS)
+ resp = tf.quotes.get_by_universes(universes=["CN_Equity_A", "CN_Index"])
+ except Exception as e: # noqa: BLE001
+ logger.warning("行情拉取失败: %s", e)
+ return
+
+ if not resp:
+ logger.warning("行情数据为空")
+ return
+
+ # ---- 解析 API 响应 (临时变量, 用完丢弃) ----
+ records = []
+ for q in resp:
+ ext = q.get("ext") or {}
+ last_price = q.get("last_price")
+ prev_close = q.get("prev_close")
+ change_amount = ext.get("change_amount")
+ change_pct = ext.get("change_pct")
+ if change_amount is None and last_price is not None and prev_close is not None:
+ change_amount = float(last_price) - float(prev_close)
+ if change_pct is None and change_amount is not None and prev_close not in (None, 0):
+ change_pct = float(change_amount) / float(prev_close) * 100
+ records.append({
+ "symbol": q.get("symbol"),
+ "name": q.get("name") or ext.get("name"),
+ "last_price": last_price,
+ "prev_close": prev_close,
+ "open": q.get("open"),
+ "high": q.get("high"),
+ "low": q.get("low"),
+ "volume": q.get("volume"),
+ "amount": q.get("amount"),
+ "change_pct": change_pct,
+ "change_amount": change_amount,
+ "amplitude": ext.get("amplitude"),
+ "turnover_rate": ext.get("turnover_rate"),
+ "timestamp": q.get("timestamp"),
+ "session": q.get("session"),
+ })
+
+ index_records = [r for r in records if r.get("symbol") in all_index_symbols]
+ stock_records = [r for r in records if r.get("symbol") not in all_index_symbols]
+
+ fetch_ms = (time.perf_counter() - t0) * 1000
+ fetched_at = time.time() * 1000
+
+ # ---- 更新元信息 ----
+ with self._lock:
+ self._fetch_time = now_ts
+ self._fetch_ms = fetch_ms
+ self._fetched_at = fetched_at
+ self._symbol_count = len(stock_records)
+ self._index_symbol_count = len(index_records)
+ self._index_quotes_cache = self._build_index_quotes(index_records)
+
+ logger.info("行情刷新: %d 只股票, %d 只指数, 耗时 %.0fms", len(stock_records), len(index_records), fetch_ms)
+
+ # ---- 写 kline_daily (不复权原始价格, 只有 OHLCV) ----
+ daily_df = self._build_daily(stock_records)
+ if not daily_df.is_empty() and self._repo:
+ try:
+ self._repo.flush_live_daily(daily_df)
+ except Exception as e: # noqa: BLE001
+ logger.warning("日K写盘失败: %s", e)
+
+ # ---- 构建 API 直接值的补充表 (不写 daily, 只用于 enriched 计算) ----
+ quote_extra = self._build_quote_extra(stock_records)
+
+ # ---- 增量计算 enriched + 写盘 + 更新缓存 ----
+ if not daily_df.is_empty() and self._repo:
+ self._flush_live_enriched(daily_df, quote_extra)
+
+ # ---- 通知 SSE ----
+ self._update_event.set()
+
+ # ---- 策略监控 + 告警评估 ----
+ self._evaluate_monitors(daily_df, quote_extra)
+
+ # ================================================================
+ # 工具
+ # ================================================================
+
+ @staticmethod
+ def _build_daily(records: list[dict]) -> pl.DataFrame:
+ """将 API records 转为日K格式 DataFrame (只有 OHLCV, 写 kline_daily 用)。"""
+ if not records:
+ return pl.DataFrame()
+ df = pl.DataFrame(records)
+ cols_map = {
+ "symbol": "symbol",
+ "last_price": "close",
+ "open": "open",
+ "high": "high",
+ "low": "low",
+ "volume": "volume",
+ "amount": "amount",
+ }
+ select_exprs = []
+ for src, dst in cols_map.items():
+ if src in df.columns:
+ select_exprs.append(pl.col(src).alias(dst))
+ if not select_exprs:
+ return pl.DataFrame()
+ result = df.select(select_exprs).with_columns(
+ pl.lit(date.today()).cast(pl.Date).alias("date"),
+ )
+ # 修复: API 在非交易时段可能返回 open/high/low=0 或 null,
+ # 导致蜡烛从 0 开始。用 close 填充这些异常值。
+ for col in ("open", "high", "low"):
+ if col in result.columns:
+ result = result.with_columns(
+ pl.when((pl.col(col) == 0) | pl.col(col).is_null())
+ .then(pl.col("close"))
+ .otherwise(pl.col(col))
+ .alias(col)
+ )
+ return result
+
+ @staticmethod
+ def _build_quote_extra(records: list[dict]) -> pl.DataFrame:
+ """构建 API 直接提供的补充字段 (不写 daily, 只传给 enriched 计算)。
+
+ 包含: prev_close, change_pct, change_amount, amplitude, turnover_rate。
+ """
+ if not records:
+ return pl.DataFrame()
+ df = pl.DataFrame(records)
+ keep = [c for c in [
+ "symbol", "prev_close", "change_pct", "change_amount",
+ "amplitude", "turnover_rate",
+ ] if c in df.columns]
+ if not keep or "symbol" not in keep:
+ return pl.DataFrame()
+ return df.select(keep)
+
+ @staticmethod
+ def _build_index_quotes(records: list[dict]) -> pl.DataFrame:
+ """构建指数实时行情缓存,不落股票 parquet。
+
+ 注意: API 返回的 change_pct/amplitude 是小数 (0.0366 = 3.66%),
+ 统一转成百分比输出, 与 _fallback_index_quotes_from_daily 口径一致
+ (前端指数侧不×100, 直接 toFixed(2)% 展示)。
+ """
+ if not records:
+ return pl.DataFrame()
+ df = pl.DataFrame(records)
+ keep = [c for c in [
+ "symbol", "name", "last_price", "prev_close", "open", "high", "low",
+ "volume", "amount", "change_pct", "change_amount", "amplitude", "timestamp", "session",
+ ] if c in df.columns]
+ if not keep or "symbol" not in keep:
+ return pl.DataFrame()
+ df = df.select(keep)
+ # change_pct / amplitude: 小数 → 百分比 (统一指数展示口径)
+ for col in ("change_pct", "amplitude"):
+ if col in df.columns:
+ df = df.with_columns((pl.col(col).cast(pl.Float64) * 100).alias(col))
+ if "last_price" in df.columns and "close" not in df.columns:
+ df = df.with_columns(pl.col("last_price").alias("close"))
+ return df
+
+ @staticmethod
+ def _is_trading_hours() -> bool:
+ now = datetime.now()
+ t = now.time()
+ morning = dt_time(9, 15) <= t <= dt_time(11, 35)
+ afternoon = dt_time(12, 55) <= t <= dt_time(15, 5)
+ return now.weekday() < 5 and (morning or afternoon)
+
+ @staticmethod
+ def _save_enabled(enabled: bool) -> None:
+ from app.services import preferences
+ preferences.save({"realtime_quotes_enabled": enabled})
+
+ # ================================================================
+ # 策略监控
+ # ================================================================
+
+ def _evaluate_monitors(self, daily_df: pl.DataFrame, quote_extra: pl.DataFrame | None) -> None:
+ """行情更新后评估策略监控,并刷新策略结果缓存。"""
+ from app.services import preferences
+
+ try:
+ # 获取 enriched 数据 (刚算好的)
+ enriched_today, enriched_date = self.get_enriched_today()
+ if enriched_today.is_empty():
+ return
+
+ all_alerts: list[dict] = []
+
+ # 1. 策略监控评估
+ if preferences.get_strategy_monitor_enabled():
+ monitor = getattr(self._app_state, "strategy_monitor", None) if self._app_state else None
+ if monitor and monitor.watching:
+ strategy_alerts = monitor.on_quote_update(enriched_today)
+ for a in strategy_alerts:
+ all_alerts.append({
+ "source": "strategy",
+ "type": a.type,
+ "strategy_id": a.strategy_id,
+ "symbol": a.symbol,
+ "name": a.name,
+ "message": a.message,
+ "price": a.price,
+ "change_pct": a.change_pct,
+ "signals": a.signals,
+ })
+
+ # 2. 刷新策略结果缓存 (实时行情开启时,每轮行情更新后自动重算)
+ if self._enabled and self._app_state:
+ self._refresh_strategy_cache(enriched_today, enriched_date)
+
+ # 推入待推送队列 + 通知 SSE
+ if all_alerts:
+ with self._lock:
+ self._pending_alerts.extend(all_alerts)
+ self._alert_event.set()
+ logger.info("策略监控评估完成: %d 条通知", len(all_alerts))
+
+ except Exception as e: # noqa: BLE001
+ logger.warning("监控评估失败: %s", e)
+
+ def _refresh_strategy_cache(self, enriched_today: pl.DataFrame, enriched_date: date | None) -> None:
+ """利用已计算好的 enriched 数据,运行策略池并写入缓存。"""
+ import math
+ from dataclasses import asdict
+ from app.services import strategy_cache
+ from app.services.screener import PRESET_STRATEGIES, ScreenerService
+ from app.strategy import config as strategy_config
+
+ try:
+ if enriched_date is None:
+ return
+ as_of = enriched_date
+ data_dir = self._repo.store.data_dir
+ svc = ScreenerService(self._repo)
+ engine = getattr(self._app_state, "strategy_engine", None)
+
+ # 确定要运行的策略: 策略监控池中的策略
+ monitor_ids = self._get_monitor_pool_ids()
+ if not monitor_ids:
+ return
+
+ # 一次加载所有 override
+ all_overrides = strategy_config.list_overrides(data_dir)
+
+ # 历史策略: 只在需要时加载
+ shared_history = None
+ history_strats = []
+ if engine:
+ id_set = set(monitor_ids)
+ history_strats = [
+ (sid, s) for sid, s in engine._strategies.items()
+ if s.filter_history_fn and sid in id_set
+ ]
+ if history_strats:
+ max_lb = max(s.lookback_days for _, s in history_strats)
+ shared_history = svc._load_enriched_history(as_of, max(1, max_lb))
+
+ results: dict[str, dict] = {}
+ for sid in monitor_ids:
+ try:
+ overrides = all_overrides.get(sid, {})
+ bf = overrides.get("basic_filter") if overrides else None
+ dl = overrides.get("display_limit") if overrides else None
+ if dl is None and overrides and "display_limit" in overrides:
+ dl = 0
+
+ if sid in PRESET_STRATEGIES:
+ r = svc.run_preset(sid, as_of=as_of, precomputed=enriched_today, basic_filter=bf, display_limit=dl)
+ elif engine:
+ r = engine.run(
+ sid, as_of, overrides=overrides or None,
+ precomputed=enriched_today, precomputed_history=shared_history,
+ )
+ if dl is not None and dl > 0:
+ r.rows = r.rows[:dl]
+ r.total = min(r.total, dl)
+ else:
+ continue
+
+ # sanitize NaN/Inf
+ rows = []
+ for row_dict in asdict(r).get("rows", []):
+ for k, v in list(row_dict.items()):
+ if isinstance(v, float) and not math.isfinite(v):
+ row_dict[k] = None
+ rows.append(row_dict)
+ results[sid] = {"total": r.total, "as_of": str(as_of), "rows": rows}
+ except Exception: # noqa: BLE001
+ continue
+
+ if results:
+ strategy_cache.write_cache(data_dir, str(as_of), results)
+
+ except Exception as e: # noqa: BLE001
+ logger.warning("策略缓存刷新失败: %s", e)
+
+ def _get_monitor_pool_ids(self) -> list[str]:
+ """获取策略监控池中的策略 ID 列表。"""
+ from app.services import preferences
+ ids = preferences.get_strategy_monitor_ids()
+ if not ids:
+ return []
+ return [sid for sid in ids if sid]
+
+ @staticmethod
+ def _get_strategy_monitor():
+ """获取 StrategyMonitorService — 不再使用, 改用 _app_state 注入。"""
+ return None
+
+ # ================================================================
+ # enriched 增量计算
+ # ================================================================
+
+ def _flush_live_enriched(self, daily_df: pl.DataFrame, quote_extra: pl.DataFrame = None) -> None:
+ """增量计算今天的 enriched: 用昨天的递推状态 + 今天 OHLCV → 只算今天 5500 行。
+
+ quote_extra: API 直接提供的补充字段 (prev_close, change_pct 等),
+ 不写 daily, 直接传给 compute_enriched_today 避免重复计算。
+ """
+ try:
+ today = date.today()
+ t0 = time.perf_counter()
+
+ # ---- 尝试增量路径 ----
+ live_agg = self._repo.get_live_agg()
+ prev_enriched, prev_date = self._repo.get_enriched_latest()
+
+ use_incremental = (
+ not live_agg.is_empty()
+ and not prev_enriched.is_empty()
+ and prev_date is not None
+ )
+
+ if use_incremental:
+ from app.indicators.pipeline import compute_enriched_today
+ instruments = self._repo.get_instruments()
+ # 将 API 直接提供的补充字段 JOIN 到 daily_df
+ today_ohlcv = daily_df
+ if quote_extra is not None and not quote_extra.is_empty():
+ today_ohlcv = daily_df.join(quote_extra, on="symbol", how="left")
+ enriched_today = compute_enriched_today(
+ live_agg=live_agg,
+ prev_enriched=prev_enriched,
+ today_ohlcv=today_ohlcv,
+ instruments=instruments,
+ )
+ if enriched_today.is_empty():
+ logger.warning("增量计算结果为空, 回退到全量计算")
+ use_incremental = False
+
+ # ---- 全量回退路径 ----
+ if not use_incremental:
+ from datetime import timedelta
+ from app.indicators.pipeline import compute_enriched
+
+ logger.info("enriched 全量计算 (live_agg=%s, 上次日期=%s)",
+ "ok" if not live_agg.is_empty() else "空", prev_date)
+
+ cutoff = today - timedelta(days=90)
+ daily_glob = str(self._repo.store.data_dir / "kline_daily" / "**" / "*.parquet")
+ ohlcv_cols = ["symbol", "date", "open", "high", "low", "close", "volume", "amount"]
+ hist_df = (
+ pl.scan_parquet(daily_glob)
+ .filter(pl.col("date") >= cutoff)
+ .sort(["symbol", "date"])
+ .collect()
+ )
+ if hist_df.is_empty():
+ return
+
+ hist_cols = [c for c in ohlcv_cols if c in hist_df.columns]
+ hist_df = hist_df.select(hist_cols).filter(pl.col("date") != today)
+ daily_ohlcv = daily_df.select([c for c in ohlcv_cols if c in daily_df.columns])
+ full_df = pl.concat([hist_df, daily_ohlcv], how="diagonal_relaxed")
+ full_df = full_df.sort(["symbol", "date"])
+
+ factor_path = self._repo.store.data_dir / "adj_factor" / "all.parquet"
+ factors = pl.DataFrame()
+ if factor_path.exists():
+ try:
+ factors = pl.read_parquet(factor_path)
+ except Exception:
+ pass
+ instruments = self._repo.get_instruments()
+
+ enriched_full = compute_enriched(full_df, factors=factors, instruments=instruments)
+ enriched_today = enriched_full.filter(pl.col("date") == today)
+
+ if enriched_today.is_empty():
+ return
+
+ # ---- 写盘 + 更新缓存 ----
+ self._repo.flush_live_enriched(enriched_today)
+
+ elapsed = time.perf_counter() - t0
+ mode_label = "增量" if use_incremental else "全量"
+ logger.info("enriched %s: %d 只, %s, 耗时 %.0fms",
+ mode_label, len(enriched_today), today, elapsed * 1000)
+ except Exception as e: # noqa: BLE001
+ logger.warning("enriched 计算失败: %s", e)
diff --git a/backend/app/services/screener.py b/backend/app/services/screener.py
new file mode 100644
index 0000000..8adfa1e
--- /dev/null
+++ b/backend/app/services/screener.py
@@ -0,0 +1,585 @@
+"""Screener 服务(§6.3)。
+
+性能优化:
+ - enriched parquet 仅存 14 列基础数据, 指标和信号即时计算
+ - preset 策略: 从内存缓存或即时计算获取完整指标, ~10-50ms
+ - custom SQL: DuckDB (用户传 SQL WHERE 字符串), ~10-50ms
+"""
+from __future__ import annotations
+
+import logging
+import time
+from dataclasses import dataclass, field
+from datetime import date, timedelta
+
+import polars as pl
+
+from app.tickflow.repository import KlineRepository
+
+logger = logging.getLogger(__name__)
+
+# ── 进程级历史数据缓存 (避免 run_all 每次重新扫描 parquet + 计算指标) ──
+_history_cache: dict[tuple[date, int], tuple[float, pl.DataFrame]] = {}
+_HISTORY_CACHE_TTL = 120.0 # 秒
+
+
+# 内置预设策略 — Polars 表达式方式
+PRESET_STRATEGIES: dict[str, dict] = {
+ "trend_breakout": {
+ "name": "趋势突破",
+ "description": "MA60 上方 + 60 日新高 + 量能 ≥ 2 倍均量",
+ "filter": (
+ (pl.col("close") > pl.col("ma60"))
+ & pl.col("signal_n_day_high").fill_null(False)
+ & (pl.col("vol_ratio_5d") >= 2.0)
+ ),
+ "order_by": "momentum_60d",
+ "descending": True,
+ "limit": 100,
+ },
+ "ma_golden_cross": {
+ "name": "MA 金叉",
+ "description": "MA5 上穿 MA20 当日触发,量能配合",
+ "filter": (
+ pl.col("signal_ma_golden_5_20").fill_null(False)
+ & (pl.col("vol_ratio_5d") >= 1.2)
+ & (pl.col("close") > pl.col("ma60"))
+ ),
+ "order_by": "momentum_20d",
+ "descending": True,
+ "limit": 100,
+ },
+ "macd_golden": {
+ "name": "MACD 金叉放量",
+ "description": "MACD 金叉当日 + 量能放大",
+ "filter": (
+ pl.col("signal_macd_golden").fill_null(False)
+ & (pl.col("vol_ratio_5d") >= 1.5)
+ ),
+ "order_by": "momentum_60d",
+ "descending": True,
+ "limit": 100,
+ },
+ "volume_price_surge": {
+ "name": "量价齐升",
+ "description": "突破 MA20 + 放量 + 收阳",
+ "filter": (
+ pl.col("signal_ma20_breakout").fill_null(False)
+ & (pl.col("vol_ratio_5d") >= 2.0)
+ & (pl.col("close") > pl.col("open"))
+ ),
+ "order_by": "vol_ratio_5d",
+ "descending": True,
+ "limit": 100,
+ },
+ "low_volatility_leader": {
+ "name": "低波动龙头",
+ "description": "20 日动量为正 + 年化波动 < 30% + MA20 上方",
+ "filter": (
+ (pl.col("momentum_20d") > 0)
+ & (pl.col("annual_vol_20d") < 0.30)
+ & (pl.col("close") > pl.col("ma20"))
+ ),
+ "order_by": "momentum_60d",
+ "descending": True,
+ "limit": 100,
+ },
+ "broken_board_recovery": {
+ "name": "断板反包",
+ "description": "连板 ≥2 后断板 1-2 天,出现放量反包信号",
+ "filter": (
+ pl.col("signal_limit_up").fill_null(False)
+ & (pl.col("vol_ratio_5d") >= 1.5)
+ & (pl.col("change_pct") > 0.03)
+ ),
+ "order_by": "change_pct",
+ "descending": True,
+ "limit": 100,
+ },
+ "oversold_bounce": {
+ "name": "超跌反弹",
+ "description": "RSI14 < 30 超卖区 + 当日收阳 + 放量,抄底信号",
+ "filter": (
+ (pl.col("rsi_14") < 30)
+ & (pl.col("close") > pl.col("open"))
+ & (pl.col("vol_ratio_5d") >= 1.2)
+ ),
+ "order_by": "rsi_14",
+ "descending": False,
+ "limit": 100,
+ },
+ "boll_breakout": {
+ "name": "布林突破",
+ "description": "突破布林上轨 + 放量,强势加速信号",
+ "filter": (
+ pl.col("signal_boll_breakout_upper").fill_null(False)
+ & (pl.col("vol_ratio_5d") >= 1.5)
+ ),
+ "order_by": "vol_ratio_5d",
+ "descending": True,
+ "limit": 100,
+ },
+ "bullish_alignment": {
+ "name": "均线多头",
+ "description": "MA5 > MA10 > MA20 > MA60 多头排列 + 短期动量为正",
+ "filter": (
+ (pl.col("ma5") > pl.col("ma10"))
+ & (pl.col("ma10") > pl.col("ma20"))
+ & (pl.col("ma20") > pl.col("ma60"))
+ & (pl.col("momentum_20d") > 0)
+ ),
+ "order_by": "momentum_60d",
+ "descending": True,
+ "limit": 100,
+ },
+ "consecutive_limit_ups": {
+ "name": "连板股",
+ "description": "当日涨停且连续涨停 ≥ 2 天,强势追涨",
+ "filter": (
+ pl.col("signal_limit_up").fill_null(False)
+ & (pl.col("consecutive_limit_ups") >= 2)
+ ),
+ "order_by": "consecutive_limit_ups",
+ "descending": True,
+ "limit": 100,
+ },
+ "pullback_to_support": {
+ "name": "缩量回踩",
+ "description": "回踩 MA20 附近 + 缩量 + 中期趋势向上",
+ "filter": (
+ (pl.col("close") > pl.col("ma20") * 0.98)
+ & (pl.col("close") < pl.col("ma20") * 1.02)
+ & (pl.col("vol_ratio_5d") < 0.8)
+ & (pl.col("close") > pl.col("ma60"))
+ & (pl.col("momentum_20d") > 0)
+ ),
+ "order_by": "momentum_60d",
+ "descending": True,
+ "limit": 100,
+ },
+ "n_day_low_reversal": {
+ "name": "新低反转",
+ "description": "触及 60 日新低后当日收阳放量,反转信号",
+ "filter": (
+ pl.col("signal_n_day_low").fill_null(False)
+ & (pl.col("close") > pl.col("open"))
+ & (pl.col("vol_ratio_5d") >= 1.5)
+ ),
+ "order_by": "change_pct",
+ "descending": True,
+ "limit": 100,
+ },
+}
+
+
+@dataclass
+class ScreenerResult:
+ as_of: date
+ strategy: str | None
+ rows: list[dict] = field(default_factory=list)
+ total: int = 0
+ elapsed_ms: float = 0.0
+
+
+class ScreenerService:
+ def __init__(self, repo: KlineRepository) -> None:
+ self.repo = repo
+
+ def _load_enriched_for_date(self, target_date: date) -> pl.DataFrame:
+ """从 enriched parquet 读取指定日期的基础数据并即时计算完整指标+信号。
+
+ enriched parquet 仅存 14 列。读取后需要即时计算 ma/ema/macd/kdj/rsi/boll/momentum/signal 等列。
+ 对于最新日, 优先使用内存缓存 (已包含完整指标)。
+ """
+ # 优先使用 repo 最新日缓存
+ cache, cache_date = self.repo.get_enriched_latest()
+ if cache is not None and not cache.is_empty() and cache_date == target_date:
+ df = cache
+ # JOIN instruments
+ df_i = self.repo.get_instruments()
+ if not df_i.is_empty():
+ inst_cols = [c for c in ["symbol", "name", "total_shares", "float_shares"] if c in df_i.columns]
+ if "name" not in df.columns:
+ df = df.join(df_i.select(inst_cols), on="symbol", how="left")
+ return df
+
+ # 尝试从 repo 级预计算历史缓存中提取目标日期
+ cached_hist = self.repo.get_enriched_history(target_date, 1)
+ if cached_hist is not None and not cached_hist.is_empty() and "date" in cached_hist.columns:
+ df = cached_hist.filter(pl.col("date") == target_date)
+ if not df.is_empty():
+ logger.debug("_load_enriched_for_date: repo history cache for %s", target_date)
+ # JOIN instruments
+ df_i = self.repo.get_instruments()
+ if not df_i.is_empty():
+ inst_cols = [c for c in ["symbol", "name", "total_shares", "float_shares"] if c in df_i.columns]
+ if "name" not in df.columns:
+ df = df.join(df_i.select(inst_cols), on="symbol", how="left")
+ return df
+
+ # 历史日期: 从 parquet 读取 14 列, 即时计算指标 (慢路径)
+ enriched_dir = self.repo.store.data_dir / "kline_daily_enriched"
+ ds = target_date.isoformat()
+ target_parquet = enriched_dir / f"date={ds}" / "part.parquet"
+
+ if not target_parquet.exists():
+ return pl.DataFrame()
+
+ try:
+ df = pl.read_parquet(target_parquet)
+ except Exception as e: # noqa: BLE001
+ logger.warning("load_enriched_for_date failed: %s", e)
+ return pl.DataFrame()
+
+ if df.is_empty():
+ return df
+
+ # 即时计算指标: 需要加载历史窗口作 warmup
+ df_full = self._compute_enriched_full(df, target_date)
+ return df_full
+
+ def _compute_enriched_full(self, df_target: pl.DataFrame, target_date: date) -> pl.DataFrame:
+ """从 14 列基础数据即时计算完整 enriched (含全部指标和信号)。
+
+ 读取历史数据作为指标计算的 warmup, 计算完成后只返回目标日期的行。
+ """
+ from app.indicators.pipeline import compute_indicators, compute_signals, compute_limit_signals
+
+ # 加载 warmup 历史 (目标日期前 ~120 天)
+ enriched_dir = self.repo.store.data_dir / "kline_daily_enriched"
+ start = target_date - timedelta(days=150)
+ read_cols = ["symbol", "date", "open", "high", "low", "close", "volume",
+ "amount", "raw_close", "raw_high", "raw_low"]
+
+ try:
+ lf = (
+ pl.scan_parquet(str(enriched_dir / "**" / "*.parquet"))
+ .filter(
+ (pl.col("date") >= start)
+ & (pl.col("date") <= target_date)
+ )
+ .sort(["symbol", "date"])
+ )
+ available = [c for c in read_cols if c in lf.schema]
+ df_hist = lf.select(available).collect()
+ except Exception as e: # noqa: BLE001
+ logger.warning("warmup history load failed: %s", e)
+ df_hist = df_target
+
+ if df_hist.is_empty():
+ df_hist = df_target
+
+ # 计算指标
+ df_full = compute_indicators(df_hist)
+ df_full = compute_signals(df_full)
+
+ # 计算涨跌停信号 (需要 instruments)
+ instruments = self.repo.get_instruments()
+ if instruments is not None and not instruments.is_empty():
+ df_full = compute_limit_signals(df_full, instruments)
+
+ # 只保留目标日期
+ df_result = df_full.filter(pl.col("date") == target_date)
+
+ # JOIN instruments (name, total_shares, float_shares)
+ if not instruments.is_empty():
+ inst_cols = [c for c in ["symbol", "name", "total_shares", "float_shares"] if c in instruments.columns]
+ if "name" not in df_result.columns:
+ df_result = df_result.join(instruments.select(inst_cols), on="symbol", how="left")
+
+ return df_result
+
+ def _load_enriched_history(self, target_date: date, lookback_days: int) -> pl.DataFrame:
+ """读取目标日期之前的基础行情数据, 供历史窗口策略使用。
+
+ 优先从 repo 内存缓存获取 (启动时已预计算), 命中时 0ms。
+ 缓存 miss 时走 scan_parquet + compute_indicators 慢路径。
+ """
+ # 优先级 1: repo 级预计算缓存 (启动时 _refresh_enriched 已计算完整历史)
+ t0 = time.perf_counter()
+ cached = self.repo.get_enriched_history(target_date, lookback_days)
+ if cached is not None and not cached.is_empty():
+ # JOIN instruments (repo 缓存不含 name 等列)
+ instruments = self.repo.get_instruments()
+ if instruments is not None and not instruments.is_empty() and "name" not in cached.columns:
+ inst_cols = [c for c in ["symbol", "name", "total_shares", "float_shares"]
+ if c in instruments.columns]
+ cached = cached.join(instruments.select(inst_cols), on="symbol", how="left")
+ elapsed = (time.perf_counter() - t0) * 1000
+ logger.info("_load_enriched_history(%s, %d): repo cache hit, %.1fms, %d rows",
+ target_date, lookback_days, elapsed, len(cached))
+ return cached
+
+ # 优先级 2: 进程级 history_cache (之前的 TTL 缓存)
+ cache_key = (target_date, lookback_days)
+ now = time.monotonic()
+ ttl_cached = _history_cache.get(cache_key)
+ if ttl_cached is not None:
+ ts, cached_df = ttl_cached
+ if now - ts < _HISTORY_CACHE_TTL:
+ logger.debug("history TTL cache hit: %s lookback=%d", target_date, lookback_days)
+ return cached_df
+ del _history_cache[cache_key]
+
+ # 优先级 3: scan_parquet + compute_indicators (慢路径, ~5s)
+ logger.warning("_load_enriched_history cache miss, computing indicators (%s, %d)...",
+ target_date, lookback_days)
+ from app.indicators.pipeline import compute_indicators, compute_signals, compute_limit_signals
+
+ warmup = 60
+ start = target_date - timedelta(days=min((lookback_days + warmup) * 2, 180))
+
+ enriched_dir = self.repo.store.data_dir / "kline_daily_enriched"
+ read_cols = ["symbol", "date", "open", "high", "low", "close", "volume",
+ "amount", "raw_close", "raw_high", "raw_low"]
+
+ try:
+ lf = (
+ pl.scan_parquet(str(enriched_dir / "**" / "*.parquet"))
+ .filter((pl.col("date") >= start) & (pl.col("date") <= target_date))
+ .sort(["symbol", "date"])
+ )
+ available = [c for c in read_cols if c in lf.collect_schema().names()]
+ df_hist = lf.select(available).collect()
+ except Exception as e: # noqa: BLE001
+ logger.warning("load_enriched_history failed: %s", e)
+ return pl.DataFrame()
+
+ if df_hist.is_empty():
+ return pl.DataFrame()
+
+ df_full = compute_indicators(df_hist)
+ df_full = compute_signals(df_full)
+
+ instruments = self.repo.get_instruments()
+ if instruments is not None and not instruments.is_empty():
+ df_full = compute_limit_signals(df_full, instruments)
+
+ if instruments is not None and not instruments.is_empty():
+ inst_cols = [c for c in ["symbol", "name", "total_shares", "float_shares"] if c in instruments.columns]
+ if "name" not in df_full.columns:
+ df_full = df_full.join(instruments.select(inst_cols), on="symbol", how="left")
+
+ # 裁剪掉 warmup 部分, 只保留 lookback 范围 (减少 group_by 开销)
+ lookback_start = target_date - timedelta(days=lookback_days)
+ if "date" in df_full.columns:
+ df_full = df_full.filter(pl.col("date") >= lookback_start)
+
+ df_full = df_full.sort(["symbol", "date"])
+
+ elapsed = (time.perf_counter() - t0) * 1000
+ logger.info("_load_enriched_history(%s, %d): computed in %.1fms, %d rows",
+ target_date, lookback_days, elapsed, len(df_full))
+
+ _history_cache[cache_key] = (now, df_full)
+ if len(_history_cache) > 10:
+ expired = [k for k, (ts, _) in _history_cache.items() if now - ts > _HISTORY_CACHE_TTL]
+ for k in expired:
+ del _history_cache[k]
+
+ return df_full
+
+ def run(
+ self,
+ as_of: date,
+ conditions: list[str],
+ order_by: str | None = None,
+ limit: int = 30,
+ pool: list[str] | None = None,
+ ) -> ScreenerResult:
+ """自定义 SQL 条件选股。
+
+ 先通过 Polars 即时计算完整指标, 再用 DuckDB 做 SQL WHERE 过滤。
+ kline_enriched DuckDB 视图只有 14 列, 不能直接用于指标过滤。
+ """
+ t0 = time.perf_counter()
+
+ if not conditions:
+ return ScreenerResult(as_of=as_of, strategy=None)
+
+ # 从即时计算获取完整 enriched 数据
+ df = self._load_enriched_for_date(as_of)
+ if df.is_empty():
+ return ScreenerResult(as_of=as_of, strategy=None)
+
+ # Pool 过滤
+ if pool:
+ df = df.filter(pl.col("symbol").is_in(pool))
+
+ # 用 DuckDB 做 SQL 过滤 (注册临时视图)
+ try:
+ import duckdb
+ con = duckdb.connect(database=":memory:")
+ con.register("enriched", df.to_arrow())
+ where = " AND ".join(f"({c})" for c in conditions)
+ sql = f"SELECT * FROM enriched WHERE {where}"
+ if order_by:
+ sql += f" ORDER BY {order_by}"
+ if limit:
+ sql += f" LIMIT {limit}"
+ df_result = con.execute(sql).pl()
+ con.close()
+ except Exception as e: # noqa: BLE001
+ logger.warning("screener SQL query failed: %s", e)
+ df_result = pl.DataFrame()
+
+ rows = df_result.to_dicts() if not df_result.is_empty() else []
+ elapsed = (time.perf_counter() - t0) * 1000
+
+ return ScreenerResult(
+ as_of=as_of,
+ strategy=None,
+ rows=rows,
+ total=len(rows),
+ elapsed_ms=elapsed,
+ )
+
+ def run_preset(
+ self,
+ strategy_id: str,
+ as_of: date,
+ pool: list[str] | None = None,
+ precomputed: pl.DataFrame | None = None,
+ basic_filter: dict | None = None,
+ display_limit: int | None = None,
+ ) -> ScreenerResult:
+ """预设策略选股 — 从 enriched 读取预计算好的指标列后过滤。
+
+ - precomputed 不为空: 直接复用(run_all 场景)
+ - precomputed 为空: 从 enriched 读目标日期
+ - basic_filter: 用户保存的基础参数过滤(boards、价格等)
+ """
+ t0 = time.perf_counter()
+
+ strat = PRESET_STRATEGIES.get(strategy_id)
+ if not strat:
+ raise ValueError(f"unknown strategy: {strategy_id}")
+
+ if precomputed is not None and not precomputed.is_empty():
+ df = precomputed
+ else:
+ df = self._load_enriched_for_date(as_of)
+ if df.is_empty():
+ return ScreenerResult(as_of=as_of, strategy=strategy_id)
+
+ # 应用用户基础参数过滤(boards、价格区间等)
+ if basic_filter and basic_filter.get("enabled", True):
+ df = self._apply_basic_filter(df, basic_filter)
+
+ # 应用策略过滤
+ df = df.filter(strat["filter"])
+
+ # 应用 pool
+ if pool:
+ df = df.filter(pl.col("symbol").is_in(pool))
+
+ # 排序 + 限制
+ order_col = strat["order_by"]
+ if order_col in df.columns:
+ df = df.sort(order_col, descending=strat.get("descending", True))
+
+ # display_limit: None=不限制, 0=全部, N=前N个
+ if display_limit == 0:
+ limit = None # 不限制
+ elif display_limit is not None:
+ limit = display_limit
+ else:
+ limit = None # 未配置时默认不限制
+ if limit is not None and limit > 0:
+ df = df.head(limit)
+
+ # 基于排序列生成 0-100 评分 (与 StrategyEngine 统一)
+ if order_col in df.columns and not df.is_empty():
+ col_vals = df[order_col].cast(pl.Float64)
+ col_min = col_vals.min()
+ col_max = col_vals.max()
+ col_range = col_max - col_min
+ if col_range and col_range > 0:
+ normalized = (col_vals - col_min) / col_range
+ else:
+ normalized = pl.Series("norm", [0.5] * len(df))
+ if not strat.get("descending", True):
+ normalized = 1.0 - normalized
+ df = df.with_columns((normalized * 100).alias("score"))
+
+ rows = df.to_dicts()
+ elapsed = (time.perf_counter() - t0) * 1000
+
+ # sanitize
+ for r in rows:
+ for k, v in list(r.items()):
+ if isinstance(v, float) and (v != v or abs(v) == float("inf")):
+ r[k] = None
+
+ return ScreenerResult(
+ as_of=as_of,
+ strategy=strategy_id,
+ rows=rows,
+ total=len(rows),
+ elapsed_ms=elapsed,
+ )
+
+ @staticmethod
+ def _apply_basic_filter(df: pl.DataFrame, bf: dict) -> pl.DataFrame:
+ """应用用户基础参数过滤(boards、价格区间、市值等)"""
+ exprs: list[pl.Expr] = []
+ if bf.get("price_min") is not None:
+ exprs.append(pl.col("close") >= bf["price_min"])
+ if bf.get("price_max") is not None:
+ exprs.append(pl.col("close") <= bf["price_max"])
+ if bf.get("float_cap_min") is not None and "float_shares" in df.columns:
+ exprs.append(pl.col("close") * pl.col("float_shares") >= bf["float_cap_min"])
+ if bf.get("float_cap_max") is not None and "float_shares" in df.columns:
+ exprs.append(pl.col("close") * pl.col("float_shares") <= bf["float_cap_max"])
+ if bf.get("amount_min") is not None:
+ exprs.append(pl.col("amount") >= bf["amount_min"])
+ if bf.get("amount_max") is not None:
+ exprs.append(pl.col("amount") <= bf["amount_max"])
+ if bf.get("turnover_min") is not None and "turnover_rate" in df.columns:
+ exprs.append(pl.col("turnover_rate") >= bf["turnover_min"])
+ if bf.get("turnover_max") is not None and "turnover_rate" in df.columns:
+ exprs.append(pl.col("turnover_rate") <= bf["turnover_max"])
+ if bf.get("exclude_st") and "name" in df.columns:
+ exprs.append(~pl.col("name").str.contains("(?i)ST|\\*ST|退"))
+ # 板块过滤
+ boards = bf.get("boards")
+ if boards and isinstance(boards, list) and len(boards) > 0:
+ board_exprs: list[pl.Expr] = []
+ for b in boards:
+ if b == "沪主板":
+ board_exprs.append(pl.col("symbol").str.starts_with("60"))
+ elif b == "深主板":
+ board_exprs.append(
+ pl.col("symbol").str.starts_with("00")
+ | pl.col("symbol").str.starts_with("001")
+ )
+ elif b == "创业板":
+ board_exprs.append(
+ pl.col("symbol").str.starts_with("300")
+ | pl.col("symbol").str.starts_with("301")
+ )
+ elif b == "科创板":
+ board_exprs.append(pl.col("symbol").str.starts_with("688"))
+ elif b == "北交所":
+ board_exprs.append(pl.col("symbol").str.contains(r"\.BJ$"))
+ if board_exprs:
+ exprs.append(pl.any_horizontal(board_exprs))
+ if exprs:
+ return df.filter(pl.all_horizontal(exprs))
+ return df
+
+ def latest_date(self) -> date | None:
+ d = self.repo.enriched_latest_date()
+ if d:
+ return d
+ # 回退 DuckDB
+ try:
+ res = self.repo.execute_one(
+ "SELECT max(date) FROM kline_enriched",
+ )
+ if res and res[0]:
+ d = res[0]
+ return d if isinstance(d, date) else date.fromisoformat(str(d))
+ except Exception: # noqa: BLE001
+ return None
+ return None
diff --git a/backend/app/services/strategy_cache.py b/backend/app/services/strategy_cache.py
new file mode 100644
index 0000000..7dc700f
--- /dev/null
+++ b/backend/app/services/strategy_cache.py
@@ -0,0 +1,150 @@
+"""策略结果缓存 — 写入本地文件,供策略页面秒加载。
+
+缓存结构:
+ {
+ "as_of": "2024-01-15",
+ "results": { strategy_id: { total, as_of, rows } },
+ "today_ever_matched": { strategy_id: [symbol, ...] }, // 今日曾命中 symbol 并集
+ "today_ever_rows": { strategy_id: { symbol: row_data } },// 今日曾命中的完整行数据
+ "updated_at": 1705324800000 # Unix ms
+ }
+
+文件路径: data/user_data/strategy_cache.json
+"""
+from __future__ import annotations
+
+import json
+import logging
+import time
+from datetime import date, datetime
+from pathlib import Path
+from typing import Any
+
+
+def _json_default(obj: Any) -> Any:
+ """处理 date/datetime 等 JSON 不认识的类型。"""
+ if isinstance(obj, date):
+ return obj.isoformat()
+ if isinstance(obj, datetime):
+ return obj.isoformat()
+ raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
+
+
+logger = logging.getLogger(__name__)
+
+_CACHE_FILENAME = "strategy_cache.json"
+
+
+def _cache_path(data_dir: Path) -> Path:
+ return data_dir / "user_data" / _CACHE_FILENAME
+
+
+def _enriched_parquet_path(data_dir: Path, as_of: str) -> Path:
+ """返回 enriched parquet 文件路径。"""
+ return data_dir / "kline_daily_enriched" / f"date={as_of}" / "part.parquet"
+
+
+def _get_enriched_mtime(data_dir: Path, as_of: str) -> float | None:
+ """返回 enriched parquet 文件的 mtime (秒)。文件不存在返回 None。"""
+ p = _enriched_parquet_path(data_dir, as_of)
+ try:
+ return p.stat().st_mtime
+ except FileNotFoundError:
+ return None
+
+
+def read_cache(data_dir: Path) -> dict | None:
+ """读取策略缓存文件。返回 None 表示无缓存、读取失败或 enriched 数据已更新导致缓存过期。"""
+ path = _cache_path(data_dir)
+ if not path.exists():
+ return None
+ try:
+ text = path.read_text(encoding="utf-8")
+ if not text.strip():
+ return None
+ cached = json.loads(text)
+ except Exception as e: # noqa: BLE001
+ logger.warning("读取策略缓存失败: %s", e)
+ return None
+
+ # 校验 enriched mtime: 数据文件变化 → 缓存过期
+ as_of = cached.get("as_of")
+ stored_mtime = cached.get("enriched_mtime")
+ if as_of and stored_mtime:
+ current_mtime = _get_enriched_mtime(data_dir, as_of)
+ if current_mtime is not None and current_mtime != stored_mtime:
+ logger.info("策略缓存过期: enriched 数据已更新 (as_of=%s)", as_of)
+ return None
+
+ return cached
+
+
+def _rows_to_symbol_map(rows: list[dict]) -> dict[str, dict]:
+ """将 rows 列表转为 {symbol: row_data} 映射。"""
+ result: dict[str, dict] = {}
+ for row in rows:
+ sym = row.get("symbol")
+ if sym:
+ result[sym] = row
+ return result
+
+
+def write_cache(
+ data_dir: Path,
+ as_of: str,
+ results: dict[str, Any],
+) -> None:
+ """将策略结果写入缓存文件,同时更新今日曾命中集合。
+
+ - 日期变更时重置 today_ever_matched 和 today_ever_rows
+ - 同一天内合并 (并集) 之前曾命中的 symbol,并用最新行数据更新
+ """
+ path = _cache_path(data_dir)
+ path.parent.mkdir(parents=True, exist_ok=True)
+
+ # 读取旧缓存
+ old = read_cache(data_dir)
+ old_as_of = old.get("as_of") if old else None
+ old_ever_rows: dict[str, dict[str, dict]] = old.get("today_ever_rows", {}) if old else {}
+
+ # 当前命中的行数据 → symbol 映射
+ current_row_maps: dict[str, dict[str, dict]] = {}
+ for sid, r in results.items():
+ current_row_maps[sid] = _rows_to_symbol_map(r.get("rows", []))
+
+ if old_as_of and old_as_of == as_of and old_ever_rows:
+ # 同一天: 合并 — 用当前行数据更新旧数据 (保持最新价格等)
+ merged_rows: dict[str, dict[str, dict]] = {}
+ all_keys = set(old_ever_rows.keys()) | set(current_row_maps.keys())
+ for sid in all_keys:
+ old_map = old_ever_rows.get(sid, {})
+ cur_map = current_row_maps.get(sid, {})
+ # 以旧数据为基础,用当前数据覆盖 (当前数据更新鲜)
+ combined = {**old_map, **cur_map}
+ merged_rows[sid] = combined
+ today_ever_rows = merged_rows
+ else:
+ # 新的一天或首次写入
+ today_ever_rows = current_row_maps
+
+ # 从 ever_rows 提取 symbol 列表 (用于快速计数)
+ today_ever_matched = {sid: sorted(maps.keys()) for sid, maps in today_ever_rows.items()}
+
+ # 记录 enriched parquet 文件的 mtime,用于后续校验缓存是否过期
+ enriched_mtime = _get_enriched_mtime(data_dir, as_of)
+
+ payload = {
+ "as_of": as_of,
+ "results": results,
+ "today_ever_matched": today_ever_matched,
+ "today_ever_rows": today_ever_rows,
+ "enriched_mtime": enriched_mtime,
+ "updated_at": int(time.time() * 1000),
+ }
+ try:
+ path.write_text(json.dumps(payload, ensure_ascii=False, default=_json_default), encoding="utf-8")
+ total_rows = sum(len(r.get("rows", [])) for r in results.values())
+ total_ever = sum(len(v) for v in today_ever_matched.values())
+ logger.info("策略缓存已写入: %s, %d 策略, %d 命中, %d 曾命中", as_of, len(results), total_rows, total_ever)
+ except Exception as e: # noqa: BLE001
+ logger.warning("写入策略缓存失败: %s", e)
diff --git a/backend/app/services/watchlist.py b/backend/app/services/watchlist.py
new file mode 100644
index 0000000..6b7a30e
--- /dev/null
+++ b/backend/app/services/watchlist.py
@@ -0,0 +1,126 @@
+"""自选股服务(§6.1)。
+
+存储:`data/user_data/watchlist.parquet`,字段 symbol + added_at + note。
+"""
+from __future__ import annotations
+
+import logging
+from datetime import datetime
+from pathlib import Path
+
+import polars as pl
+
+from app.config import settings
+from app.tickflow.capabilities import Cap, CapabilitySet
+from app.tickflow.client import get_client
+
+logger = logging.getLogger(__name__)
+
+
+def _path() -> Path:
+ p = settings.data_dir / "user_data" / "watchlist.parquet"
+ p.parent.mkdir(parents=True, exist_ok=True)
+ return p
+
+
+def list_symbols() -> list[dict]:
+ p = _path()
+ if not p.exists():
+ return []
+ df = pl.read_parquet(p)
+ if df.is_empty():
+ return []
+ return df.to_dicts()
+
+
+def add(symbol: str, note: str = "") -> list[dict]:
+ p = _path()
+ 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})
+
+ 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 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()
+
+
+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
+
+
+def fetch_quotes(symbols: list[str], capset: CapabilitySet, timeout_s: float = 8.0) -> list[dict]:
+ """拉取实时行情。
+
+ 优先用 quote.batch;否则降级为 quote.by_symbol 单股请求。
+ timeout_s: 单批次请求超时(秒),防止 API 卡死阻塞整个请求。
+ """
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
+
+ if not symbols:
+ return []
+
+ tf = get_client()
+ quotes: list[dict] = []
+
+ # 走 batch
+ batch_size = 5
+ if capset.has(Cap.QUOTE_BATCH):
+ lim = capset.limits(Cap.QUOTE_BATCH)
+ batch_size = lim.batch if lim and lim.batch else 50
+ elif capset.has(Cap.QUOTE_BY_SYMBOL):
+ lim = capset.limits(Cap.QUOTE_BY_SYMBOL)
+ batch_size = lim.batch if lim and lim.batch else 5
+
+ chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)]
+
+ # 用线程池为每个批次加超时保护
+ pool = ThreadPoolExecutor(max_workers=1)
+ for chunk in chunks:
+ try:
+ future = pool.submit(tf.quotes.get, symbols=chunk, as_dataframe=True)
+ raw = future.result(timeout=timeout_s)
+ if raw is None or len(raw) == 0:
+ continue
+ df = pl.from_pandas(raw)
+ rename_map = {
+ "last_price": "price",
+ "ext.change_pct": "pct",
+ "ext.name": "name",
+ }
+ df = df.rename({k: v for k, v in rename_map.items() if k in df.columns})
+ quotes.extend(df.to_dicts())
+ except FuturesTimeout:
+ logger.warning("quote fetch timeout (%.1fs) for %d symbols", timeout_s, len(chunk))
+ break # 超时后不再尝试后续批次
+ except Exception as e: # noqa: BLE001
+ logger.warning("quote fetch failed for %d symbols: %s", len(chunk), e)
+ pool.shutdown(wait=False)
+
+ return quotes
diff --git a/backend/app/strategy/__init__.py b/backend/app/strategy/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/strategy/ai_generator.py b/backend/app/strategy/ai_generator.py
new file mode 100644
index 0000000..0346a9f
--- /dev/null
+++ b/backend/app/strategy/ai_generator.py
@@ -0,0 +1,149 @@
+"""AI 策略生成器 — 读取策略开发文档 + 调用 LLM 生成策略代码。
+
+职责: 接收用户自然语言描述 → 读取 docs/strategy-guide.md → 调用 LLM → 返回策略代码。
+不知道: 引擎内部、API、前端、配置持久化、回测。
+"""
+from __future__ import annotations
+
+import ast
+import logging
+import re
+import tempfile
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+# 策略开发文档路径
+GUIDE_PATH = Path(__file__).resolve().parent.parent.parent.parent / "docs" / "strategy-guide.md"
+
+_SYSTEM_PREFIX = """你是A股量化策略设计专家。根据用户描述的需求,参考下方的《策略开发指南》生成一个完整的策略Python文件。
+
+要求:
+1. 用户可能调整的策略阈值通过 META["params"] 暴露;公式常数、固定窗口边界、布尔开关不必强行参数化
+2. 遵循指南中的文件结构模板,但优先贴合用户规则,不要为了套模板歪曲策略含义
+3. 优先使用 Polars 表达式、窗口函数、聚合和 with_columns/filter 实现,避免逐行/逐股 Python 循环;只有表达式难以描述的复杂状态机才使用 partition_by/to_dicts
+4. 只 import polars as pl,不 import 其他模块
+5. 直接输出Python代码,不要输出其他内容
+
+--- 策略开发指南 ---
+
+"""
+
+
+class AIStrategyGenerator:
+ """AI 策略生成器"""
+
+ def __init__(self) -> None:
+ self._guide_cache: str | None = None
+
+ def _get_guide(self) -> str:
+ if self._guide_cache is None:
+ if GUIDE_PATH.exists():
+ self._guide_cache = GUIDE_PATH.read_text(encoding="utf-8")
+ else:
+ logger.warning("strategy-guide.md not found at %s", GUIDE_PATH)
+ self._guide_cache = ""
+ return self._guide_cache
+
+ async def generate(self, user_prompt: str) -> dict:
+ """根据用户描述生成策略代码
+
+ Returns: {"code": str, "meta": dict, "valid": bool, "error": str | None}
+ """
+ guide = self._get_guide()
+
+ # 调用 LLM
+ code = await self._call_llm(user_prompt, guide)
+
+ # 验证
+ try:
+ self._validate_safety(code)
+ except ValueError as e:
+ return {"code": code, "meta": {}, "valid": False, "error": str(e)}
+
+ # 试加载获取 META
+ try:
+ meta = self._extract_meta(code)
+ except Exception as e:
+ return {"code": code, "meta": {}, "valid": False, "error": f"解析META失败: {e}"}
+
+ return {"code": code, "meta": meta, "valid": True, "error": None}
+
+ async def _call_llm(self, user_prompt: str, guide: str) -> str:
+ """调用 OpenAI 兼容 API(流式,避免 CDN 长连接超时)"""
+ from openai import AsyncOpenAI
+ from app import secrets_store
+
+ ai_key = secrets_store.get_ai_key()
+ if not ai_key:
+ raise RuntimeError("AI API Key 未配置,请在设置页面配置")
+
+ client = AsyncOpenAI(
+ api_key=ai_key,
+ base_url=secrets_store.get_ai_config("ai_base_url", "https://api.alysc.top"),
+ timeout=180.0,
+ max_retries=2,
+ )
+ # 使用流式请求:CDN 收到首个 token 后会持续转发,不会因等待超时
+ stream = await client.chat.completions.create(
+ model=secrets_store.get_ai_config("ai_model", "gpt-5.5"),
+ messages=[
+ {"role": "system", "content": _SYSTEM_PREFIX + guide},
+ {"role": "user", "content": user_prompt},
+ ],
+ temperature=0.3,
+ max_tokens=3000,
+ stream=True,
+ )
+ chunks: list[str] = []
+ async for chunk in stream:
+ delta = chunk.choices[0].delta if chunk.choices else None
+ if delta and delta.content:
+ chunks.append(delta.content)
+ content = "".join(chunks).strip()
+ # 提取代码块
+ if "```python" in content:
+ content = content.split("```python", 1)[1].split("```", 1)[0].strip()
+ elif "```" in content:
+ content = content.split("```", 1)[1].split("```", 1)[0].strip()
+ return content
+
+ @staticmethod
+ def _validate_safety(code: str) -> None:
+ """AST 级安全检查"""
+ tree = ast.parse(code)
+
+ forbidden_modules = {"os", "sys", "subprocess", "socket", "shutil",
+ "pathlib", "http", "urllib", "requests", "httpx"}
+ forbidden_calls = {"open", "exec", "eval", "compile", "__import__",
+ "globals", "locals", "vars", "dir", "getattr",
+ "setattr", "delattr", "type", "input"}
+
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ for alias in node.names:
+ if alias.name.split(".")[0] not in ("polars",):
+ if alias.name.split(".")[0] in forbidden_modules:
+ raise ValueError(f"禁止 import {alias.name}")
+ if isinstance(node, ast.ImportFrom):
+ if node.module and node.module.split(".")[0] not in ("polars",):
+ if node.module.split(".")[0] in forbidden_modules:
+ raise ValueError(f"禁止 from {node.module} import")
+ if isinstance(node, ast.Call):
+ if isinstance(node.func, ast.Name) and node.func.id in forbidden_calls:
+ raise ValueError(f"禁止调用 {node.func.id}()")
+
+ @staticmethod
+ def _extract_meta(code: str) -> dict:
+ """从代码字符串中提取 META 字典(不执行代码)"""
+ tree = ast.parse(code)
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Assign):
+ for target in node.targets:
+ if isinstance(target, ast.Name) and target.id == "META":
+ # 找到 META 赋值,用 compile+eval 安全提取
+ # 只允许字面量
+ meta_node = node.value
+ code_obj = compile(ast.Expression(meta_node), "", "eval")
+ return eval(code_obj, {"__builtins__": {}}) # noqa: S307
+ return {}
diff --git a/backend/app/strategy/builtin/__init__.py b/backend/app/strategy/builtin/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/strategy/builtin/boll_breakout.py b/backend/app/strategy/builtin/boll_breakout.py
new file mode 100644
index 0000000..cb86588
--- /dev/null
+++ b/backend/app/strategy/builtin/boll_breakout.py
@@ -0,0 +1,31 @@
+"""布林突破 — 突破布林上轨 + 放量"""
+import polars as pl
+
+META = {
+ "id": "boll_breakout",
+ "name": "布林突破",
+ "description": "突破布林上轨 + 放量, 强势加速信号",
+ "tags": ["布林", "突破"],
+ "params": [
+ {"id": "vol_ratio_min", "label": "最低量比", "type": "float",
+ "default": 1.5, "min": 0.5, "max": 5.0, "step": 0.1},
+ ],
+ "scoring": {"vol_ratio_5d": 0.4, "change_pct": 0.3, "momentum_20d": 0.3},
+ "order_by": "score",
+ "descending": True,
+ "limit": 100,
+}
+
+ENTRY_SIGNALS = ["signal_boll_breakout_upper"]
+EXIT_SIGNALS = ["signal_boll_breakdown_lower"]
+STOP_LOSS = -0.06
+MAX_HOLD_DAYS = 15
+ALERTS = []
+
+
+def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
+ vol_min = params.get("vol_ratio_min", 1.5)
+ return (
+ pl.col("signal_boll_breakout_upper").fill_null(False)
+ & (pl.col("vol_ratio_5d") >= vol_min)
+ )
diff --git a/backend/app/strategy/builtin/broken_board_recovery.py b/backend/app/strategy/builtin/broken_board_recovery.py
new file mode 100644
index 0000000..4fd9b5e
--- /dev/null
+++ b/backend/app/strategy/builtin/broken_board_recovery.py
@@ -0,0 +1,35 @@
+"""断板反包 — 涨停 + 放量 + 涨幅 >3%"""
+import polars as pl
+
+META = {
+ "id": "broken_board_recovery",
+ "name": "断板反包",
+ "description": "连板≥2后断板1-2天, 出现放量反包信号",
+ "tags": ["涨停", "反包"],
+ "params": [
+ {"id": "vol_ratio_min", "label": "最低量比", "type": "float",
+ "default": 1.5, "min": 0.5, "max": 5.0, "step": 0.1},
+ {"id": "change_pct_min", "label": "最低涨幅", "type": "float",
+ "default": 0.03, "min": 0.01, "max": 0.10, "step": 0.01},
+ ],
+ "scoring": {"change_pct": 0.4, "vol_ratio_5d": 0.3, "momentum_5d": 0.3},
+ "order_by": "score",
+ "descending": True,
+ "limit": 100,
+}
+
+ENTRY_SIGNALS = ["signal_limit_up"]
+EXIT_SIGNALS = ["signal_ma20_breakdown"]
+STOP_LOSS = -0.06
+MAX_HOLD_DAYS = 10
+ALERTS = []
+
+
+def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
+ vol_min = params.get("vol_ratio_min", 1.5)
+ chg_min = params.get("change_pct_min", 0.03)
+ return (
+ pl.col("signal_limit_up").fill_null(False)
+ & (pl.col("vol_ratio_5d") >= vol_min)
+ & (pl.col("change_pct") > chg_min)
+ )
diff --git a/backend/app/strategy/builtin/bullish_alignment.py b/backend/app/strategy/builtin/bullish_alignment.py
new file mode 100644
index 0000000..8dc3fc1
--- /dev/null
+++ b/backend/app/strategy/builtin/bullish_alignment.py
@@ -0,0 +1,29 @@
+"""均线多头 — MA5>MA10>MA20>MA60 + 短期动量为正"""
+import polars as pl
+
+META = {
+ "id": "bullish_alignment",
+ "name": "均线多头",
+ "description": "MA5>MA10>MA20>MA60多头排列 + 短期动量为正",
+ "tags": ["均线", "多头"],
+ "params": [],
+ "scoring": {"momentum_60d": 0.4, "momentum_20d": 0.3, "turnover_rate": 0.3},
+ "order_by": "score",
+ "descending": True,
+ "limit": 100,
+}
+
+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 = []
+
+
+def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
+ return (
+ (pl.col("ma5") > pl.col("ma10"))
+ & (pl.col("ma10") > pl.col("ma20"))
+ & (pl.col("ma20") > pl.col("ma60"))
+ & (pl.col("momentum_20d") > 0)
+ )
diff --git a/backend/app/strategy/builtin/consecutive_limit_ups.py b/backend/app/strategy/builtin/consecutive_limit_ups.py
new file mode 100644
index 0000000..4e864ac
--- /dev/null
+++ b/backend/app/strategy/builtin/consecutive_limit_ups.py
@@ -0,0 +1,31 @@
+"""连板股 — 涨停且连续涨停≥2天"""
+import polars as pl
+
+META = {
+ "id": "consecutive_limit_ups",
+ "name": "连板股",
+ "description": "当日涨停且连续涨停≥2天, 强势追涨",
+ "tags": ["涨停", "连板"],
+ "params": [
+ {"id": "min_boards", "label": "最少连板数", "type": "int",
+ "default": 2, "min": 1, "max": 20, "step": 1},
+ ],
+ "scoring": {"consecutive_limit_ups": 0.5, "change_pct": 0.3, "amount": 0.2},
+ "order_by": "score",
+ "descending": True,
+ "limit": 100,
+}
+
+ENTRY_SIGNALS = ["signal_limit_up"]
+EXIT_SIGNALS = []
+STOP_LOSS = -0.05
+MAX_HOLD_DAYS = 5
+ALERTS = []
+
+
+def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
+ min_boards = params.get("min_boards", 2)
+ return (
+ pl.col("signal_limit_up").fill_null(False)
+ & (pl.col("consecutive_limit_ups") >= min_boards)
+ )
diff --git a/backend/app/strategy/builtin/high_turnover_surge.py b/backend/app/strategy/builtin/high_turnover_surge.py
new file mode 100644
index 0000000..53b8276
--- /dev/null
+++ b/backend/app/strategy/builtin/high_turnover_surge.py
@@ -0,0 +1,34 @@
+"""高换手拉升 — 换手率 > 5% 且涨幅 > 3%, 资金活跃"""
+import polars as pl
+
+META = {
+ "id": "high_turnover_surge",
+ "name": "高换手拉升",
+ "description": "换手率 > 5% 且涨幅 > 3%, 资金活跃",
+ "tags": ["换手率", "放量", "资金"],
+ "params": [
+ {"id": "min_turnover", "label": "最低换手率%", "type": "float",
+ "default": 5.0, "min": 1.0, "max": 20.0, "step": 0.5},
+ {"id": "min_change", "label": "最低涨幅%", "type": "float",
+ "default": 3.0, "min": 1.0, "max": 10.0, "step": 0.5},
+ ],
+ "scoring": {"turnover_rate": 0.4, "change_pct": 0.3, "momentum_5d": 0.3},
+ "order_by": "score",
+ "descending": True,
+ "limit": 50,
+}
+
+ENTRY_SIGNALS = ["signal_volume_surge"]
+EXIT_SIGNALS = ["signal_ma20_breakdown"]
+STOP_LOSS = -0.05
+MAX_HOLD_DAYS = 10
+ALERTS = []
+
+
+def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
+ min_to = params.get("min_turnover", 5.0) / 100.0
+ min_chg = params.get("min_change", 3.0) / 100.0
+ return (
+ (pl.col("turnover_rate") > min_to)
+ & (pl.col("change_pct") > min_chg)
+ )
diff --git a/backend/app/strategy/builtin/limit_up_momentum.py b/backend/app/strategy/builtin/limit_up_momentum.py
new file mode 100644
index 0000000..65afe29
--- /dev/null
+++ b/backend/app/strategy/builtin/limit_up_momentum.py
@@ -0,0 +1,34 @@
+"""连板接力 — 近2日涨停且今日涨幅 > 5%, 连板股追踪"""
+import polars as pl
+
+META = {
+ "id": "limit_up_momentum",
+ "name": "连板接力",
+ "description": "连板股 + 今日涨幅 > 5%, 连板接力追踪",
+ "tags": ["涨停", "连板", "接力"],
+ "params": [
+ {"id": "min_change", "label": "最低涨幅%", "type": "float",
+ "default": 5.0, "min": 2.0, "max": 15.0, "step": 0.5},
+ {"id": "min_boards", "label": "最少连板", "type": "int",
+ "default": 1, "min": 1, "max": 10, "step": 1},
+ ],
+ "scoring": {"consecutive_limit_ups": 0.4, "change_pct": 0.3, "amount": 0.3},
+ "order_by": "score",
+ "descending": True,
+ "limit": 50,
+}
+
+ENTRY_SIGNALS = ["signal_limit_up"]
+EXIT_SIGNALS = []
+STOP_LOSS = -0.05
+MAX_HOLD_DAYS = 5
+ALERTS = []
+
+
+def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
+ min_chg = params.get("min_change", 5.0) / 100.0
+ min_boards = params.get("min_boards", 1)
+ return (
+ (pl.col("change_pct") > min_chg)
+ & (pl.col("consecutive_limit_ups") >= min_boards)
+ )
diff --git a/backend/app/strategy/builtin/low_volatility_leader.py b/backend/app/strategy/builtin/low_volatility_leader.py
new file mode 100644
index 0000000..f32a3a8
--- /dev/null
+++ b/backend/app/strategy/builtin/low_volatility_leader.py
@@ -0,0 +1,32 @@
+"""低波动龙头 — 正动量 + 低波动 + MA20上方"""
+import polars as pl
+
+META = {
+ "id": "low_volatility_leader",
+ "name": "低波动龙头",
+ "description": "20日动量为正 + 年化波动 < 30% + MA20上方",
+ "tags": ["低波动", "龙头"],
+ "params": [
+ {"id": "vol_max", "label": "最大年化波动", "type": "float",
+ "default": 0.30, "min": 0.05, "max": 1.0, "step": 0.01},
+ ],
+ "scoring": {"momentum_60d": 0.4, "momentum_20d": 0.3, "turnover_rate": 0.3},
+ "order_by": "score",
+ "descending": True,
+ "limit": 100,
+}
+
+ENTRY_SIGNALS = ["signal_ma20_breakout"]
+EXIT_SIGNALS = ["signal_ma20_breakdown"]
+STOP_LOSS = -0.05
+MAX_HOLD_DAYS = 30
+ALERTS = []
+
+
+def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
+ vol_max = params.get("vol_max", 0.30)
+ return (
+ (pl.col("momentum_20d") > 0)
+ & (pl.col("annual_vol_20d") < vol_max)
+ & (pl.col("close") > pl.col("ma20"))
+ )
diff --git a/backend/app/strategy/builtin/ma_golden_cross.py b/backend/app/strategy/builtin/ma_golden_cross.py
new file mode 100644
index 0000000..81b81fb
--- /dev/null
+++ b/backend/app/strategy/builtin/ma_golden_cross.py
@@ -0,0 +1,32 @@
+"""MA金叉 — MA5上穿MA20 + 量能配合 + MA60上方"""
+import polars as pl
+
+META = {
+ "id": "ma_golden_cross",
+ "name": "MA 金叉",
+ "description": "MA5上穿MA20当日触发, 量能配合",
+ "tags": ["均线", "金叉"],
+ "params": [
+ {"id": "vol_ratio_min", "label": "最低量比", "type": "float",
+ "default": 1.2, "min": 0.5, "max": 5.0, "step": 0.1},
+ ],
+ "scoring": {"momentum_20d": 0.5, "vol_ratio_5d": 0.3, "change_pct": 0.2},
+ "order_by": "score",
+ "descending": True,
+ "limit": 100,
+}
+
+ENTRY_SIGNALS = ["signal_ma_golden_5_20"]
+EXIT_SIGNALS = ["signal_ma_dead_5_20"]
+STOP_LOSS = -0.06
+MAX_HOLD_DAYS = 15
+ALERTS = []
+
+
+def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
+ vol_min = params.get("vol_ratio_min", 1.2)
+ return (
+ pl.col("signal_ma_golden_5_20").fill_null(False)
+ & (pl.col("vol_ratio_5d") >= vol_min)
+ & (pl.col("close") > pl.col("ma60"))
+ )
diff --git a/backend/app/strategy/builtin/macd_golden.py b/backend/app/strategy/builtin/macd_golden.py
new file mode 100644
index 0000000..2ea8006
--- /dev/null
+++ b/backend/app/strategy/builtin/macd_golden.py
@@ -0,0 +1,31 @@
+"""MACD金叉放量 — MACD金叉当日 + 量能放大"""
+import polars as pl
+
+META = {
+ "id": "macd_golden",
+ "name": "MACD 金叉放量",
+ "description": "MACD金叉当日 + 量能放大",
+ "tags": ["MACD", "金叉", "放量"],
+ "params": [
+ {"id": "vol_ratio_min", "label": "最低量比", "type": "float",
+ "default": 1.5, "min": 0.5, "max": 5.0, "step": 0.1},
+ ],
+ "scoring": {"momentum_60d": 0.4, "vol_ratio_5d": 0.3, "change_pct": 0.3},
+ "order_by": "score",
+ "descending": True,
+ "limit": 100,
+}
+
+ENTRY_SIGNALS = ["signal_macd_golden"]
+EXIT_SIGNALS = ["signal_macd_dead"]
+STOP_LOSS = -0.07
+MAX_HOLD_DAYS = 20
+ALERTS = []
+
+
+def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
+ vol_min = params.get("vol_ratio_min", 1.5)
+ return (
+ pl.col("signal_macd_golden").fill_null(False)
+ & (pl.col("vol_ratio_5d") >= vol_min)
+ )
diff --git a/backend/app/strategy/builtin/n_day_low_reversal.py b/backend/app/strategy/builtin/n_day_low_reversal.py
new file mode 100644
index 0000000..11219c2
--- /dev/null
+++ b/backend/app/strategy/builtin/n_day_low_reversal.py
@@ -0,0 +1,32 @@
+"""新低反转 — 60日新低后收阳放量"""
+import polars as pl
+
+META = {
+ "id": "n_day_low_reversal",
+ "name": "新低反转",
+ "description": "触及60日新低后当日收阳放量, 反转信号",
+ "tags": ["反转", "新低"],
+ "params": [
+ {"id": "vol_ratio_min", "label": "最低量比", "type": "float",
+ "default": 1.5, "min": 0.5, "max": 5.0, "step": 0.1},
+ ],
+ "scoring": {"change_pct": 0.4, "vol_ratio_5d": 0.3, "momentum_5d": 0.3},
+ "order_by": "score",
+ "descending": True,
+ "limit": 100,
+}
+
+ENTRY_SIGNALS = ["signal_n_day_low"]
+EXIT_SIGNALS = ["signal_ma20_breakdown"]
+STOP_LOSS = -0.06
+MAX_HOLD_DAYS = 15
+ALERTS = []
+
+
+def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
+ vol_min = params.get("vol_ratio_min", 1.5)
+ return (
+ pl.col("signal_n_day_low").fill_null(False)
+ & (pl.col("close") > pl.col("open"))
+ & (pl.col("vol_ratio_5d") >= vol_min)
+ )
diff --git a/backend/app/strategy/builtin/near_limit_up.py b/backend/app/strategy/builtin/near_limit_up.py
new file mode 100644
index 0000000..4442e06
--- /dev/null
+++ b/backend/app/strategy/builtin/near_limit_up.py
@@ -0,0 +1,55 @@
+"""逼近涨停 — 涨幅 > 7% 且距涨停 < 3%, 盘后选股"""
+import polars as pl
+
+
+def _limit_pct() -> pl.Expr:
+ """根据板块和 ST 动态计算涨跌幅限制 (小数)。
+ 创业板(300/301)/科创板(688): 20%
+ 北交所(.BJ): 30%
+ ST: 5%
+ 主板: 10%
+ """
+ is_st = pl.col("name").str.contains("(?i)ST").fill_null(False)
+ is_cyb = pl.col("symbol").str.starts_with("300") | pl.col("symbol").str.starts_with("301")
+ is_kcb = pl.col("symbol").str.starts_with("688")
+ is_bj = pl.col("symbol").str.contains(r"\.BJ$")
+ return (
+ pl.when(is_st).then(0.05)
+ .when(is_cyb | is_kcb).then(0.20)
+ .when(is_bj).then(0.30)
+ .otherwise(0.10)
+ )
+
+
+META = {
+ "id": "near_limit_up",
+ "name": "逼近涨停",
+ "description": "涨幅 > 7% 且距涨停 < 3%, 追涨信号",
+ "tags": ["涨停", "追涨"],
+ "params": [
+ {"id": "min_change", "label": "最低涨幅%", "type": "float",
+ "default": 7.0, "min": 3.0, "max": 15.0, "step": 1.0},
+ {"id": "limit_gap", "label": "距涨停空间%", "type": "float",
+ "default": 3.0, "min": 1.0, "max": 10.0, "step": 0.5},
+ ],
+ "scoring": {"change_pct": 0.5, "amount": 0.3, "momentum_5d": 0.2},
+ "order_by": "score",
+ "descending": True,
+ "limit": 50,
+}
+
+ENTRY_SIGNALS = []
+EXIT_SIGNALS = ["signal_ma20_breakdown"]
+STOP_LOSS = -0.05
+MAX_HOLD_DAYS = 5
+ALERTS = []
+
+
+def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
+ min_chg = params.get("min_change", 7.0) / 100.0
+ gap = params.get("limit_gap", 3.0) / 100.0
+ lp = _limit_pct()
+ return (
+ (pl.col("change_pct") > min_chg)
+ & (pl.col("change_pct") < lp - gap)
+ )
diff --git a/backend/app/strategy/builtin/oversold_bounce.py b/backend/app/strategy/builtin/oversold_bounce.py
new file mode 100644
index 0000000..8adc124
--- /dev/null
+++ b/backend/app/strategy/builtin/oversold_bounce.py
@@ -0,0 +1,37 @@
+"""超跌反弹 — RSI14 < 30 + 收阳 + 放量"""
+import polars as pl
+
+META = {
+ "id": "oversold_bounce",
+ "name": "超跌反弹",
+ "description": "RSI14 < 30超卖区 + 当日收阳 + 放量, 抄底信号",
+ "tags": ["超跌", "反弹", "RSI"],
+ "params": [
+ {"id": "rsi_max", "label": "RSI上限", "type": "float",
+ "default": 30.0, "min": 10.0, "max": 50.0, "step": 1.0},
+ {"id": "vol_ratio_min", "label": "最低量比", "type": "float",
+ "default": 1.2, "min": 0.5, "max": 5.0, "step": 0.1},
+ ],
+ "scoring": {"change_pct": 0.3, "vol_ratio_5d": 0.3, "momentum_5d": 0.2, "rsi_14": 0.2},
+ "order_by": "score",
+ "descending": True,
+ "limit": 100,
+}
+
+ENTRY_SIGNALS = []
+EXIT_SIGNALS = ["signal_ma20_breakdown"]
+STOP_LOSS = -0.05
+MAX_HOLD_DAYS = 15
+ALERTS = [
+ {"field": "rsi_14", "op": "<", "value": 25, "message": "RSI极度超卖"},
+]
+
+
+def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
+ rsi_max = params.get("rsi_max", 30.0)
+ vol_min = params.get("vol_ratio_min", 1.2)
+ return (
+ (pl.col("rsi_14") < rsi_max)
+ & (pl.col("close") > pl.col("open"))
+ & (pl.col("vol_ratio_5d") >= vol_min)
+ )
diff --git a/backend/app/strategy/builtin/oversold_reversal.py b/backend/app/strategy/builtin/oversold_reversal.py
new file mode 100644
index 0000000..ba31a6b
--- /dev/null
+++ b/backend/app/strategy/builtin/oversold_reversal.py
@@ -0,0 +1,37 @@
+"""超跌反弹 — RSI14 < 30 + 涨幅 > 1% + 站上 MA5, 超卖反弹信号"""
+import polars as pl
+
+META = {
+ "id": "oversold_reversal",
+ "name": "超跌反转",
+ "description": "RSI14 < 30超卖 + 涨幅 > 1% + 站上MA5, 超卖反转信号",
+ "tags": ["超跌", "反弹", "RSI"],
+ "params": [
+ {"id": "rsi_max", "label": "RSI上限", "type": "float",
+ "default": 30.0, "min": 10.0, "max": 50.0, "step": 1.0},
+ {"id": "min_change", "label": "最低涨幅%", "type": "float",
+ "default": 1.0, "min": 0.5, "max": 5.0, "step": 0.5},
+ ],
+ "scoring": {"change_pct": 0.4, "rsi_14": 0.3, "vol_ratio_5d": 0.3},
+ "order_by": "score",
+ "descending": True,
+ "limit": 50,
+}
+
+ENTRY_SIGNALS = []
+EXIT_SIGNALS = ["signal_ma20_breakdown"]
+STOP_LOSS = -0.05
+MAX_HOLD_DAYS = 15
+ALERTS = [
+ {"field": "rsi_14", "op": "<", "value": 25, "message": "RSI极度超卖"},
+]
+
+
+def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
+ rsi_max = params.get("rsi_max", 30.0)
+ min_chg = params.get("min_change", 1.0) / 100.0
+ return (
+ (pl.col("rsi_14") < rsi_max)
+ & (pl.col("change_pct") > min_chg)
+ & (pl.col("close") > pl.col("ma5"))
+ )
diff --git a/backend/app/strategy/builtin/pullback_ma20_bounce.py b/backend/app/strategy/builtin/pullback_ma20_bounce.py
new file mode 100644
index 0000000..9c56adb
--- /dev/null
+++ b/backend/app/strategy/builtin/pullback_ma20_bounce.py
@@ -0,0 +1,34 @@
+"""均线回踩反弹 — 价格在 MA20 附近(±2%)且 MA 多头排列, 回踩买入"""
+import polars as pl
+
+META = {
+ "id": "pullback_ma20_bounce",
+ "name": "均线回踩反弹",
+ "description": "价格在MA20附近(±2%)且MA5>MA20>MA60多头排列, 回踩买入",
+ "tags": ["回踩", "均线", "反弹"],
+ "params": [
+ {"id": "ma_proximity", "label": "MA偏离度%", "type": "float",
+ "default": 2.0, "min": 0.5, "max": 5.0, "step": 0.5},
+ ],
+ "scoring": {"momentum_60d": 0.4, "change_pct": 0.3, "momentum_20d": 0.3},
+ "order_by": "score",
+ "descending": True,
+ "limit": 50,
+}
+
+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 = []
+
+
+def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
+ proximity = params.get("ma_proximity", 2.0) / 100.0
+ return (
+ (pl.col("close") > pl.col("ma20") * (1 - proximity))
+ & (pl.col("close") < pl.col("ma20") * (1 + proximity))
+ & (pl.col("ma5") > pl.col("ma20"))
+ & (pl.col("ma20") > pl.col("ma60"))
+ & (pl.col("change_pct") > 0)
+ )
diff --git a/backend/app/strategy/builtin/pullback_to_support.py b/backend/app/strategy/builtin/pullback_to_support.py
new file mode 100644
index 0000000..36fc51a
--- /dev/null
+++ b/backend/app/strategy/builtin/pullback_to_support.py
@@ -0,0 +1,37 @@
+"""缩量回踩 — 回踩MA20附近 + 缩量 + 中期趋势向上"""
+import polars as pl
+
+META = {
+ "id": "pullback_to_support",
+ "name": "缩量回踩",
+ "description": "回踩MA20附近 + 缩量 + 中期趋势向上",
+ "tags": ["回踩", "支撑"],
+ "params": [
+ {"id": "ma_proximity", "label": "均线偏离度", "type": "float",
+ "default": 0.02, "min": 0.01, "max": 0.05, "step": 0.005},
+ {"id": "vol_ratio_max", "label": "最大量比", "type": "float",
+ "default": 0.8, "min": 0.2, "max": 1.5, "step": 0.1},
+ ],
+ "scoring": {"momentum_60d": 0.4, "momentum_20d": 0.3, "turnover_rate": 0.3},
+ "order_by": "score",
+ "descending": True,
+ "limit": 100,
+}
+
+ENTRY_SIGNALS = ["signal_ma_golden_5_20"]
+EXIT_SIGNALS = ["signal_ma20_breakdown"]
+STOP_LOSS = -0.05
+MAX_HOLD_DAYS = 20
+ALERTS = []
+
+
+def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
+ proximity = params.get("ma_proximity", 0.02)
+ vol_max = params.get("vol_ratio_max", 0.8)
+ return (
+ (pl.col("close") > pl.col("ma20") * (1 - proximity))
+ & (pl.col("close") < pl.col("ma20") * (1 + proximity))
+ & (pl.col("vol_ratio_5d") < vol_max)
+ & (pl.col("close") > pl.col("ma60"))
+ & (pl.col("momentum_20d") > 0)
+ )
diff --git a/backend/app/strategy/builtin/strong_open.py b/backend/app/strategy/builtin/strong_open.py
new file mode 100644
index 0000000..918842b
--- /dev/null
+++ b/backend/app/strategy/builtin/strong_open.py
@@ -0,0 +1,35 @@
+"""强势高开 — 高开 > 3% 且保持上涨, 集合竞价强势"""
+import polars as pl
+
+META = {
+ "id": "strong_open",
+ "name": "强势高开",
+ "description": "高开 > 3% 且收盘高于开盘价, 集合竞价强势",
+ "tags": ["高开", "强势"],
+ "params": [
+ {"id": "min_open_gap", "label": "最低高开%", "type": "float",
+ "default": 3.0, "min": 1.0, "max": 10.0, "step": 0.5},
+ {"id": "min_change", "label": "最低涨幅%", "type": "float",
+ "default": 3.0, "min": 1.0, "max": 10.0, "step": 0.5},
+ ],
+ "scoring": {"change_pct": 0.4, "amplitude": 0.2, "amount": 0.4},
+ "order_by": "score",
+ "descending": True,
+ "limit": 50,
+}
+
+ENTRY_SIGNALS = []
+EXIT_SIGNALS = ["signal_ma20_breakdown"]
+STOP_LOSS = -0.05
+MAX_HOLD_DAYS = 10
+ALERTS = []
+
+
+def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
+ min_gap = params.get("min_open_gap", 3.0) / 100.0
+ min_chg = params.get("min_change", 3.0) / 100.0
+ return (
+ (pl.col("open") > pl.col("prev_close") * (1 + min_gap))
+ & (pl.col("close") > pl.col("open"))
+ & (pl.col("change_pct") > min_chg)
+ )
diff --git a/backend/app/strategy/builtin/trend_breakout.py b/backend/app/strategy/builtin/trend_breakout.py
new file mode 100644
index 0000000..eb95cca
--- /dev/null
+++ b/backend/app/strategy/builtin/trend_breakout.py
@@ -0,0 +1,42 @@
+"""趋势突破 — MA60上方 + 60日新高 + 放量"""
+import polars as pl
+
+META = {
+ "id": "trend_breakout",
+ "name": "趋势突破",
+ "description": "MA60上方 + 60日新高 + 量能 ≥ 2倍均量",
+ "tags": ["趋势", "突破", "放量"],
+ "basic_filter": {
+ "price_min": 5,
+ "price_max": 200,
+ "market_cap_min": 20e8,
+ "amount_min": 1e8,
+ "exclude_st": True,
+ "exclude_new_days": 60,
+ },
+ "params": [
+ {"id": "vol_ratio_min", "label": "最低量比", "type": "float",
+ "default": 2.0, "min": 0.5, "max": 10.0, "step": 0.1},
+ ],
+ "scoring": {"momentum_60d": 0.4, "vol_ratio_5d": 0.3, "change_pct": 0.3},
+ "order_by": "score",
+ "descending": True,
+ "limit": 100,
+}
+
+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": "放量异动"},
+]
+
+
+def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
+ vol_min = params.get("vol_ratio_min", 2.0)
+ return (
+ (pl.col("close") > pl.col("ma60"))
+ & pl.col("signal_n_day_high").fill_null(False)
+ & (pl.col("vol_ratio_5d") >= vol_min)
+ )
diff --git a/backend/app/strategy/builtin/volume_price_surge.py b/backend/app/strategy/builtin/volume_price_surge.py
new file mode 100644
index 0000000..c3573e4
--- /dev/null
+++ b/backend/app/strategy/builtin/volume_price_surge.py
@@ -0,0 +1,32 @@
+"""量价齐升 — 突破MA20 + 放量 + 收阳"""
+import polars as pl
+
+META = {
+ "id": "volume_price_surge",
+ "name": "量价齐升",
+ "description": "突破MA20 + 放量 + 收阳",
+ "tags": ["量价", "突破"],
+ "params": [
+ {"id": "vol_ratio_min", "label": "最低量比", "type": "float",
+ "default": 2.0, "min": 0.5, "max": 10.0, "step": 0.1},
+ ],
+ "scoring": {"vol_ratio_5d": 0.4, "change_pct": 0.3, "momentum_20d": 0.3},
+ "order_by": "score",
+ "descending": True,
+ "limit": 100,
+}
+
+ENTRY_SIGNALS = ["signal_ma20_breakout"]
+EXIT_SIGNALS = ["signal_ma20_breakdown"]
+STOP_LOSS = -0.06
+MAX_HOLD_DAYS = 15
+ALERTS = []
+
+
+def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
+ vol_min = params.get("vol_ratio_min", 2.0)
+ return (
+ pl.col("signal_ma20_breakout").fill_null(False)
+ & (pl.col("vol_ratio_5d") >= vol_min)
+ & (pl.col("close") > pl.col("open"))
+ )
diff --git a/backend/app/strategy/config.py b/backend/app/strategy/config.py
new file mode 100644
index 0000000..3c594f7
--- /dev/null
+++ b/backend/app/strategy/config.py
@@ -0,0 +1,71 @@
+"""策略配置持久化 — 读写用户覆盖值。
+
+职责: 将每个策略的用户定制设置(基础参数、策略参数、评分、买卖信号)持久化到 JSON。
+不知道: 引擎、AI、前端、回测。
+存储: data/user_data/strategy_overrides/{strategy_id}.json
+"""
+from __future__ import annotations
+
+import json
+import logging
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+
+def _overrides_dir(data_dir: Path) -> Path:
+ d = data_dir / "user_data" / "strategy_overrides"
+ d.mkdir(parents=True, exist_ok=True)
+ return d
+
+
+def _path(data_dir: Path, strategy_id: str) -> Path:
+ return _overrides_dir(data_dir) / 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():
+ return {}
+ try:
+ data = json.loads(p.read_text(encoding="utf-8"))
+ # 清理 basic_filter 中值为 None/空的键(避免固化无意义的空值)
+ bf = data.get("basic_filter")
+ if isinstance(bf, dict):
+ cleaned = {k: v for k, v in bf.items() if v is not None}
+ if cleaned:
+ data["basic_filter"] = cleaned
+ else:
+ del data["basic_filter"]
+ return data
+ except Exception as e:
+ logger.warning("load override %s failed: %s", strategy_id, e)
+ return {}
+
+
+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")
+
+
+def delete_override(data_dir: Path, strategy_id: str) -> None:
+ """删除策略的用户覆盖配置(重置为默认值)"""
+ p = _path(data_dir, strategy_id)
+ if p.exists():
+ p.unlink()
+
+
+def list_overrides(data_dir: Path) -> dict[str, dict]:
+ """返回所有策略的覆盖配置 {strategy_id: overrides}"""
+ d = _overrides_dir(data_dir)
+ result: dict[str, dict] = {}
+ for f in d.glob("*.json"):
+ try:
+ sid = f.stem
+ result[sid] = json.loads(f.read_text(encoding="utf-8"))
+ except Exception:
+ continue
+ return result
diff --git a/backend/app/strategy/custom_signals.py b/backend/app/strategy/custom_signals.py
new file mode 100644
index 0000000..cc05a83
--- /dev/null
+++ b/backend/app/strategy/custom_signals.py
@@ -0,0 +1,207 @@
+"""自定义信号 — 用户用「字段 + 运算符 + 值」组合出的布尔信号。
+
+职责:
+ - 从 data/user_data/custom_signals/*.json 加载信号定义
+ - 把每个信号的 conditions 编译成一条 Polars 布尔表达式(AND 组合)
+ - 供 pipeline 在 compute_signals / compute_enriched_today 末尾注入为列
+
+不知道: 引擎、AI、API、回测、监控。纯函数 + 模块级缓存。
+
+设计:
+ - 信号列名加前缀 ``csg_`` 避免与内置 ``signal_`` 列冲突。
+ - 回测/选股/监控都按列名找信号,因此注入列后零特殊处理即可三处生效。
+ - 字段白名单 + 固定运算符集,杜绝任意表达式注入。
+ - 第一版只支持 AND(多条件同时满足)。
+"""
+from __future__ import annotations
+
+import json
+import logging
+import re
+from pathlib import Path
+
+import polars as pl
+
+logger = logging.getLogger(__name__)
+
+# ── 常量 ────────────────────────────────────────────────
+PREFIX = "csg_" # 自定义信号列名前缀
+ID_RE = re.compile(r"^[a-z0-9_]{1,40}$")
+OPS = {">", ">=", "<", "<=", "==", "!="}
+
+# 字段白名单:只允许这些列出现在条件里(防注入)。均为数值型。
+# 与 ENRICHED_COLUMNS 的数值列保持一致,排除 symbol/date/name 等非数值列。
+ALLOWED_FIELDS: frozenset[str] = frozenset({
+ # 行情
+ "open", "high", "low", "close", "volume", "amount", "turnover_rate",
+ "consecutive_limit_ups", "consecutive_limit_downs",
+ # 基础
+ "prev_close", "change_pct", "change_amount", "amplitude",
+ # 均线 / 指数均线
+ "ma5", "ma10", "ma20", "ma30", "ma60",
+ "ema5", "ema10", "ema20", "ema30", "ema60",
+ # MACD / BOLL / KDJ / ATR
+ "macd_dif", "macd_dea", "macd_hist",
+ "boll_upper", "boll_lower",
+ "kdj_k", "kdj_d", "kdj_j",
+ "atr_14",
+ # 量价 / 极值 / 动量 / 波动率 / RSI
+ "vol_ma5", "vol_ma10", "vol_ratio_5d",
+ "high_60d", "low_60d",
+ "momentum_5d", "momentum_10d", "momentum_20d", "momentum_30d", "momentum_60d",
+ "annual_vol_20d",
+ "rsi_6", "rsi_14", "rsi_24",
+})
+
+# 运算符 → Polars 表达式构造器(输入 col_expr, value)
+_OP_BUILDERS = {
+ ">": lambda c, v: c > v,
+ ">=": lambda c, v: c >= v,
+ "<": lambda c, v: c < v,
+ "<=": lambda c, v: c <= v,
+ "==": lambda c, v: c == v,
+ "!=": lambda c, v: c != v,
+}
+
+
+# ── 持久化(镜像 strategy/config.py 的写法)──────────────
+def _dir(data_dir: Path) -> Path:
+ d = data_dir / "user_data" / "custom_signals"
+ d.mkdir(parents=True, exist_ok=True)
+ return d
+
+
+def _path(data_dir: Path, signal_id: str) -> Path:
+ return _dir(data_dir) / f"{signal_id}.json"
+
+
+def load_all(data_dir: Path) -> list[dict]:
+ """读取全部自定义信号定义。损坏的文件被跳过。"""
+ d = _dir(data_dir)
+ out: list[dict] = []
+ for f in sorted(d.glob("*.json")):
+ try:
+ out.append(json.loads(f.read_text(encoding="utf-8")))
+ except Exception as e:
+ logger.warning("custom signal load failed %s: %s", f.name, e)
+ return out
+
+
+def save_one(data_dir: Path, sig: dict) -> None:
+ p = _path(data_dir, sig["id"])
+ p.parent.mkdir(parents=True, exist_ok=True)
+ p.write_text(json.dumps(sig, ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def delete_one(data_dir: Path, signal_id: str) -> bool:
+ p = _path(data_dir, signal_id)
+ if p.exists():
+ p.unlink()
+ return True
+ return False
+
+
+# ── 校验 ────────────────────────────────────────────────
+def _parse_right(right: str) -> tuple[str, object]:
+ """解析右值。返回 ('field', colname) 或 ('const', float)。"""
+ if isinstance(right, (int, float)):
+ return ("const", float(right))
+ if not isinstance(right, str):
+ raise ValueError(f"非法右值: {right!r}")
+ if right.startswith("field:"):
+ col = right[len("field:"):]
+ if col not in ALLOWED_FIELDS:
+ raise ValueError(f"右值字段不在白名单: {col}")
+ return ("field", col)
+ # 纯数字
+ try:
+ return ("const", float(right))
+ except ValueError:
+ raise ValueError(f"非法右值(应为 field:xxx 或数字): {right!r}")
+
+
+def validate(sig: dict) -> None:
+ """校验一个信号定义,非法则抛 ValueError(含中文信息)。"""
+ sid = sig.get("id", "")
+ if not isinstance(sid, str) or not ID_RE.match(sid):
+ raise ValueError(f"信号 id 非法(仅小写字母数字下划线,1-40字符): {sid!r}")
+ if not isinstance(sig.get("name"), str) or not sig["name"].strip():
+ raise ValueError("信号 name 不能为空")
+ if sig.get("kind") not in ("entry", "exit", "both"):
+ raise ValueError("kind 必须是 entry / exit / both")
+ conds = sig.get("conditions")
+ if not isinstance(conds, list) or len(conds) == 0:
+ raise ValueError("conditions 不能为空")
+ if len(conds) > 8:
+ raise ValueError("conditions 最多 8 条")
+ for i, c in enumerate(conds):
+ if not isinstance(c, dict):
+ raise ValueError(f"第 {i+1} 个条件格式错误")
+ left = c.get("left", "")
+ if left not in ALLOWED_FIELDS:
+ raise ValueError(f"第 {i+1} 个条件: 字段 {left!r} 不在白名单")
+ if c.get("op") not in OPS:
+ raise ValueError(f"第 {i+1} 个条件: 运算符 {c.get('op')!r} 非法")
+ _parse_right(c.get("right")) # 会校验右值字段/数字
+
+
+# ── 编译为 Polars 表达式 ─────────────────────────────────
+def column_name(signal_id: str) -> str:
+ """信号 id → DataFrame 列名(加前缀)。"""
+ return f"{PREFIX}{signal_id}"
+
+
+def build_expressions(signals: list[dict]) -> dict[str, pl.Expr]:
+ """把多个自定义信号编译成 {column_name: pl.Expr}。
+
+ - 只处理 enabled != False 的信号。
+ - 单个信号内多条件用 ``&`` 串联(AND)。
+ - 编译失败的信号被跳过并告警(不影响其它信号)。
+ """
+ out: dict[str, pl.Expr] = {}
+ for sig in signals:
+ if sig.get("enabled") is False:
+ continue
+ try:
+ conds = sig["conditions"]
+ col_name = column_name(sig["id"])
+ parts: list[pl.Expr] = []
+ for c in conds:
+ left = c["left"]
+ op = c["op"]
+ kind, val = _parse_right(c["right"])
+ right_expr = pl.col(val) if kind == "field" else val
+ parts.append(_OP_BUILDERS[op](pl.col(left), right_expr))
+ combined = parts[0]
+ for p in parts[1:]:
+ combined = combined & p
+ out[col_name] = combined
+ except Exception as e:
+ logger.warning("custom signal compile failed %s: %s", sig.get("id"), e)
+ return out
+
+
+def inject(df: pl.DataFrame, exprs: dict[str, pl.Expr]) -> pl.DataFrame:
+ """把编译好的信号表达式作为列加入 df。仅添加 df 已含其依赖列的信号。"""
+ if df.is_empty() or not exprs:
+ return df
+ cols = set(df.columns)
+ add: dict[str, pl.Expr] = {}
+ for name, expr in exprs.items():
+ # 提取该表达式引用的所有字段列,缺失则跳过(避免运行时报错)
+ needed = _expr_root_columns(expr)
+ if needed.issubset(cols):
+ add[name] = expr
+ if add:
+ df = df.with_columns([e.alias(n) for n, e in add.items()])
+ return df
+
+
+def _expr_root_columns(expr: pl.Expr) -> set[str]:
+ """尽力提取表达式里出现的列名。失败则返回空集(保守跳过)。"""
+ try:
+ # Polars 的 meta.root_names() 返回表达式引用的根列名
+ names = expr.meta.root_names()
+ return set(names)
+ except Exception:
+ return set()
diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py
new file mode 100644
index 0000000..67ef2ad
--- /dev/null
+++ b/backend/app/strategy/engine.py
@@ -0,0 +1,453 @@
+"""策略引擎 — 加载、执行、评分。
+
+职责: 从文件系统加载策略 Python 模块,执行两阶段过滤(基础+策略),
+ 通用评分排序。
+不知道: AI、API、前端、配置持久化、回测。
+"""
+from __future__ import annotations
+
+import importlib.util
+import logging
+import time
+from dataclasses import dataclass, field
+from datetime import date
+from pathlib import Path
+from typing import Any, Callable
+
+import polars as pl
+
+logger = logging.getLogger(__name__)
+
+# 引擎级默认基础过滤 — 策略未定义 BASIC_FILTER 时兜底
+DEFAULT_BASIC_FILTER: dict = {
+ "price_min": 3,
+ "price_max": 300,
+ "market_cap_min": 10e8,
+ "float_cap_min": None,
+ "float_cap_max": None,
+ "amount_min": 0.2e8,
+ "amount_max": None,
+ "turnover_min": None,
+ "turnover_max": None,
+ "exclude_st": True,
+ "exclude_new_days": 30,
+ "boards": ["沪主板", "深主板", "创业板", "科创板", "北交所"],
+}
+
+
+@dataclass
+class StrategyDef:
+ """加载后的策略定义(只读数据 + filter 函数引用)"""
+ meta: dict
+ basic_filter: dict
+ entry_signals: list[str]
+ exit_signals: list[str]
+ stop_loss: float | None
+ trailing_stop: float | None
+ 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
+ source: str # "builtin" | "custom" | "ai"
+ file_path: Path | None = None
+
+
+@dataclass
+class StrategyResult:
+ """策略执行结果"""
+ as_of: date
+ strategy_id: str
+ rows: list[dict] = field(default_factory=list)
+ total: int = 0
+ elapsed_ms: float = 0.0
+ scores: dict[str, float] = field(default_factory=dict)
+
+
+class StrategyEngine:
+ """策略引擎 — 策略加载 + 执行 + 评分"""
+
+ def __init__(self, enriched_loader: Callable[[date], pl.DataFrame],
+ enriched_history_loader: Callable[[date, int], pl.DataFrame] | None = None,
+ strategy_dirs: list[Path] | None = None):
+ """
+ Args:
+ enriched_loader: (date) -> pl.DataFrame, 加载指定日期的 enriched 数据
+ strategy_dirs: 策略文件搜索目录列表
+ """
+ self._loader = enriched_loader
+ self._history_loader = enriched_history_loader
+ self._strategies: dict[str, StrategyDef] = {}
+ self._strategy_dirs = strategy_dirs or []
+ self._load_all()
+
+ # ================================================================
+ # 加载
+ # ================================================================
+
+ def _load_all(self) -> None:
+ self._strategies.clear()
+ for d in self._strategy_dirs:
+ if not d.exists():
+ continue
+ for f in sorted(d.glob("*.py")):
+ if f.name.startswith("_"):
+ continue
+ try:
+ s = self._load_file(f)
+ self._strategies[s.meta["id"]] = s
+ logger.debug("loaded strategy: %s (%s)", s.meta["id"], s.source)
+ except Exception as e:
+ logger.warning("load strategy %s failed: %s", f.name, e)
+
+ @staticmethod
+ def _load_file(path: Path) -> StrategyDef:
+ """从 Python 文件加载策略定义"""
+ spec = importlib.util.spec_from_file_location(path.stem, path)
+ if spec is None or spec.loader is None:
+ raise ValueError(f"cannot load module from {path}")
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+
+ meta = getattr(mod, "META", {})
+ meta.setdefault("id", path.stem)
+ meta.setdefault("name", path.stem)
+ meta.setdefault("description", "")
+ meta.setdefault("tags", [])
+ meta.setdefault("params", [])
+ meta.setdefault("scoring", {})
+ meta.setdefault("order_by", "score")
+ meta.setdefault("descending", True)
+ meta.setdefault("limit", 100)
+
+ # 合并默认基础过滤
+ bf = {**DEFAULT_BASIC_FILTER}
+ strat_bf = getattr(mod, "BASIC_FILTER", None)
+ if strat_bf:
+ bf.update(strat_bf)
+ # meta 里的 basic_filter 也合并(优先级最高)
+ meta_bf = meta.get("basic_filter")
+ if meta_bf:
+ bf.update(meta_bf)
+
+ source = "custom"
+ if "builtin" in str(path).replace("\\", "/"):
+ source = "builtin"
+ elif "/ai/" in str(path).replace("\\", "/") or "\\ai\\" in str(path):
+ source = "ai"
+
+ return StrategyDef(
+ meta=meta,
+ basic_filter=bf,
+ entry_signals=getattr(mod, "ENTRY_SIGNALS", []),
+ exit_signals=getattr(mod, "EXIT_SIGNALS", []),
+ stop_loss=getattr(mod, "STOP_LOSS", None),
+ trailing_stop=getattr(mod, "TRAILING_STOP", None),
+ 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=getattr(mod, "filter", None),
+ filter_history_fn=getattr(mod, "filter_history", None),
+ lookback_days=int(getattr(mod, "LOOKBACK_DAYS", meta.get("lookback_days", 1)) or 1),
+ source=source,
+ file_path=path,
+ )
+
+ def reload(self) -> None:
+ """热重载所有策略"""
+ self._load_all()
+
+ # ================================================================
+ # 查询
+ # ================================================================
+
+ def list_strategies(self) -> list[dict]:
+ """返回所有策略的元信息"""
+ result = []
+ for s in self._strategies.values():
+ result.append({**s.meta, "source": s.source})
+ return result
+
+ def get(self, strategy_id: str) -> StrategyDef:
+ s = self._strategies.get(strategy_id)
+ if not s:
+ raise ValueError(f"unknown strategy: {strategy_id}")
+ return s
+
+ def has(self, strategy_id: str) -> bool:
+ return strategy_id in self._strategies
+
+ # ================================================================
+ # 执行
+ # ================================================================
+
+ def run(
+ self,
+ strategy_id: str,
+ as_of: date,
+ pool: list[str] | None = None,
+ params: dict | None = None,
+ overrides: dict | None = None,
+ precomputed: pl.DataFrame | None = None,
+ precomputed_history: pl.DataFrame | None = None,
+ ) -> StrategyResult:
+ """执行策略: 基础过滤 → 策略过滤 → 评分排序
+
+ Args:
+ strategy_id: 策略 ID
+ as_of: 选股日期
+ pool: 限定股票池
+ params: 策略参数 (用户在设置面板调的值)
+ overrides: 用户覆盖配置 (basic_filter/scoring/stop_loss 等)
+ precomputed: 已加载的 enriched 数据 (run_all 场景复用)
+ precomputed_history: 已加载的历史窗口数据 (run_all 场景复用)
+ """
+ t0 = time.perf_counter()
+
+ s = self.get(strategy_id)
+ params = params or {}
+ overrides = overrides or {}
+
+ # 加载数据。普通策略只读目标日期;声明 filter_history 的策略读取历史窗口。
+ if s.filter_history_fn:
+ if precomputed_history is not None and not precomputed_history.is_empty():
+ df = precomputed_history
+ elif self._history_loader:
+ df = self._history_loader(as_of, max(1, s.lookback_days))
+ else:
+ logger.warning("strategy %s requires history loader", strategy_id)
+ return StrategyResult(as_of=as_of, strategy_id=strategy_id)
+ if df.is_empty():
+ return StrategyResult(as_of=as_of, strategy_id=strategy_id)
+ df = s.filter_history_fn(df, params)
+ if df.is_empty():
+ return StrategyResult(as_of=as_of, strategy_id=strategy_id)
+ if "date" in df.columns:
+ df = df.filter(pl.col("date") == as_of)
+ elif precomputed is not None and not precomputed.is_empty():
+ df = precomputed
+ else:
+ df = self._loader(as_of)
+ if df.is_empty():
+ return StrategyResult(as_of=as_of, strategy_id=strategy_id)
+
+ # 基础过滤: 策略默认 basic_filter 兜底, 用户 override 优先覆盖。
+ # 这样策略文件里写的 exclude_st/price_min 等默认值即使前端没保存也能生效。
+ bf = dict(s.basic_filter) if s.basic_filter else {}
+ if overrides and overrides.get("basic_filter"):
+ bf.update(overrides["basic_filter"])
+
+ # Stage 1: 基础过滤(enabled 默认开启; 显式 enabled=false 才跳过)
+ if bf and bf.get("enabled", True):
+ df = self._apply_basic_filter(df, bf)
+
+ # Pool 过滤
+ if pool:
+ df = df.filter(pl.col("symbol").is_in(pool))
+
+ # Stage 2: 策略过滤
+ if s.filter_fn:
+ expr = s.filter_fn(df, params)
+ 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)
+
+ # 排序 + 限制
+ limit = s.meta.get("limit", 100)
+ order_desc = s.meta.get("descending", True)
+ if "score" in df.columns:
+ df = df.sort("score", descending=order_desc)
+ elif s.meta.get("order_by") and s.meta["order_by"] != "score":
+ ob = s.meta["order_by"]
+ if ob in df.columns:
+ df = df.sort(ob, descending=order_desc)
+ df = df.head(limit)
+
+ # 输出
+ rows = _sanitize(df.to_dicts())
+ elapsed = (time.perf_counter() - t0) * 1000
+
+ scores: dict[str, float] = {}
+ if "score" in df.columns:
+ for r in df.iter_rows(named=True):
+ scores[r["symbol"]] = float(r.get("score") or 0)
+
+ return StrategyResult(
+ as_of=as_of,
+ strategy_id=strategy_id,
+ rows=rows,
+ total=len(rows),
+ elapsed_ms=elapsed,
+ scores=scores,
+ )
+
+ def run_all(self, as_of: date, params_map: dict | None = None,
+ overrides_map: dict | None = None) -> dict[str, StrategyResult]:
+ """批量执行所有策略 (enriched 只加载一次,基础过滤按策略分组缓存,历史数据共享)"""
+ df = self._loader(as_of)
+ params_map = params_map or {}
+ overrides_map = overrides_map or {}
+
+ # 历史策略: 找最大 lookback,一次加载共享
+ history_strats = [(sid, s) for sid, s in self._strategies.items() if s.filter_history_fn]
+ if history_strats and self._history_loader:
+ max_lookback = max(s.lookback_days for _, s in history_strats)
+ shared_history = self._history_loader(as_of, max(1, max_lookback))
+ else:
+ shared_history = None
+
+ # 按 basic_filter hash 分组,避免重复过滤
+ bf_cache: dict[str, pl.DataFrame] = {}
+ results: dict[str, StrategyResult] = {}
+
+ for sid, strat in self._strategies.items():
+ try:
+ bf_key = _dict_hash(strat.basic_filter)
+ if bf_key not in bf_cache:
+ if strat.basic_filter.get("enabled", True):
+ bf_cache[bf_key] = self._apply_basic_filter(df, strat.basic_filter)
+ else:
+ bf_cache[bf_key] = df
+ base = bf_cache[bf_key]
+
+ # 从已过滤的 base 执行 (filter_history 策略使用共享历史)
+ results[sid] = self.run(
+ sid, as_of,
+ params=params_map.get(sid),
+ overrides=overrides_map.get(sid),
+ precomputed=base,
+ precomputed_history=shared_history,
+ )
+ except Exception as e:
+ logger.warning("run strategy %s failed: %s", sid, e)
+
+ return results
+
+ # ================================================================
+ # 内部: 基础过滤
+ # ================================================================
+
+ @staticmethod
+ def _basic_filter_expr(df: pl.DataFrame, bf: dict) -> pl.Expr | None:
+ """构建基础过滤表达式。回测可复用为买入候选 mask,不删除行情行。"""
+ exprs: list[pl.Expr] = []
+ if bf.get("price_min") is not None:
+ exprs.append(pl.col("close") >= bf["price_min"])
+ if bf.get("price_max") is not None:
+ exprs.append(pl.col("close") <= bf["price_max"])
+ if bf.get("market_cap_min") is not None and "total_shares" in df.columns:
+ exprs.append(
+ pl.col("close") * pl.col("total_shares") >= bf["market_cap_min"]
+ )
+ if bf.get("market_cap_max") is not None and "total_shares" in df.columns:
+ exprs.append(
+ pl.col("close") * pl.col("total_shares") <= bf["market_cap_max"]
+ )
+ # 流通市值
+ if bf.get("float_cap_min") is not None and "float_shares" in df.columns:
+ exprs.append(
+ pl.col("close") * pl.col("float_shares") >= bf["float_cap_min"]
+ )
+ if bf.get("float_cap_max") is not None and "float_shares" in df.columns:
+ exprs.append(
+ pl.col("close") * pl.col("float_shares") <= bf["float_cap_max"]
+ )
+ if bf.get("amount_min") is not None:
+ exprs.append(pl.col("amount") >= bf["amount_min"])
+ if bf.get("amount_max") is not None:
+ exprs.append(pl.col("amount") <= bf["amount_max"])
+ # 换手率
+ if bf.get("turnover_min") is not None and "turnover_rate" in df.columns:
+ exprs.append(pl.col("turnover_rate") >= bf["turnover_min"])
+ if bf.get("turnover_max") is not None and "turnover_rate" in df.columns:
+ exprs.append(pl.col("turnover_rate") <= bf["turnover_max"])
+ if bf.get("exclude_st") and "name" in df.columns:
+ exprs.append(~pl.col("name").str.contains("(?i)ST|\\*ST|退"))
+ # 板块过滤
+ boards = bf.get("boards")
+ if boards and isinstance(boards, list) and len(boards) > 0:
+ board_exprs: list[pl.Expr] = []
+ for b in boards:
+ if b == "沪主板":
+ board_exprs.append(pl.col("symbol").str.starts_with("60"))
+ elif b == "深主板":
+ board_exprs.append(
+ pl.col("symbol").str.starts_with("00")
+ | pl.col("symbol").str.starts_with("001")
+ )
+ elif b == "创业板":
+ board_exprs.append(
+ pl.col("symbol").str.starts_with("300")
+ | pl.col("symbol").str.starts_with("301")
+ )
+ elif b == "科创板":
+ board_exprs.append(pl.col("symbol").str.starts_with("688"))
+ elif b == "北交所":
+ board_exprs.append(pl.col("symbol").str.contains(r"\.BJ$"))
+ if board_exprs:
+ exprs.append(pl.any_horizontal(board_exprs))
+ if exprs:
+ return pl.all_horizontal(exprs)
+ return None
+
+ @staticmethod
+ def _apply_basic_filter(df: pl.DataFrame, bf: dict) -> pl.DataFrame:
+ """Stage 1: 基础参数过滤"""
+ expr = StrategyEngine._basic_filter_expr(df, bf)
+ if expr is not None:
+ return df.filter(expr)
+ return df
+
+ # ================================================================
+ # 内部: 评分
+ # ================================================================
+
+ @staticmethod
+ def _apply_scoring(df: pl.DataFrame, weights: dict) -> pl.DataFrame:
+ """通用评分: min-max 归一化 → 加权求和 → 0~100 分"""
+ if not weights:
+ return df
+ total_weight = sum(weights.values())
+ if total_weight <= 0:
+ return df
+
+ score_parts: list[pl.Expr] = []
+ for col, weight in weights.items():
+ if col not in df.columns:
+ continue
+ w = weight / total_weight
+ col_min = pl.col(col).min()
+ col_range = pl.col(col).max() - col_min
+ normalized = pl.when(col_range > 0).then(
+ (pl.col(col) - col_min) / col_range
+ ).otherwise(pl.lit(0.5))
+ score_parts.append(normalized * w)
+
+ if not score_parts:
+ return df
+
+ score_expr = score_parts[0]
+ for part in score_parts[1:]:
+ score_expr = score_expr + part
+ return df.with_columns((score_expr * 100).alias("score"))
+
+
+def _sanitize(rows: list[dict]) -> list[dict]:
+ for r in rows:
+ for k, v in list(r.items()):
+ if isinstance(v, float) and (v != v or abs(v) == float("inf")):
+ r[k] = None
+ return rows
+
+
+def _dict_hash(d: dict) -> str:
+ """用于 basic_filter 分组缓存"""
+ return str(sorted(d.items()))
diff --git a/backend/app/strategy/monitor.py b/backend/app/strategy/monitor.py
new file mode 100644
index 0000000..9b5b262
--- /dev/null
+++ b/backend/app/strategy/monitor.py
@@ -0,0 +1,204 @@
+"""策略实时监控 — 订阅行情更新,检查策略买卖信号和提醒条件。
+
+职责: 接收实时行情 DataFrame → 检查监控中策略的信号/提醒 → 推送告警。
+不知道: 策略加载逻辑、AI、API、配置持久化、回测。
+依赖: 外部调用 on_quote_update() 传入实时数据。
+"""
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass, field
+from typing import Any, Callable
+
+import polars as pl
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class StrategyAlert:
+ """策略告警"""
+ type: str # "entry" | "exit" | "alert"
+ strategy_id: str
+ symbol: str
+ name: str | None
+ message: str
+ price: float | None = None
+ change_pct: float | None = None
+ signals: list[str] = field(default_factory=list)
+
+
+class StrategyMonitorService:
+ """策略实时监控服务"""
+
+ def __init__(self, alert_handler: Callable[[StrategyAlert], None] | None = None):
+ """
+ Args:
+ alert_handler: 告警回调 (如推 SSE)
+ """
+ self._alert_handler = alert_handler
+ # strategy_id → 监控配置
+ self._watching: dict[str, dict] = {}
+
+ def start(self, strategy_id: str, config: dict) -> None:
+ """开始监控一个策略
+
+ config: {
+ "entry_signals": ["signal_n_day_high", ...],
+ "exit_signals": ["signal_ma20_breakdown", ...],
+ "alerts": [{"field": "rsi_14", "op": ">", "value": 80, "message": "..."}],
+ }
+ """
+ self._watching[strategy_id] = config
+ logger.info("strategy monitor started: %s", strategy_id)
+
+ def stop(self, strategy_id: str) -> None:
+ self._watching.pop(strategy_id, None)
+ logger.info("strategy monitor stopped: %s", strategy_id)
+
+ def stop_all(self) -> None:
+ self._watching.clear()
+
+ @property
+ def watching(self) -> dict[str, dict]:
+ return dict(self._watching)
+
+ def on_quote_update(self, df: pl.DataFrame) -> list[StrategyAlert]:
+ """行情更新后调用。向量化检查所有监控策略。
+
+ Args:
+ df: 实时 enriched 数据 (~5500行)
+ Returns:
+ 触发的告警列表
+ """
+ if not self._watching or df.is_empty():
+ return []
+
+ all_alerts: list[StrategyAlert] = []
+
+ for strategy_id, cfg in self._watching.items():
+ # 买入信号
+ entry_sigs = cfg.get("entry_signals", [])
+ if entry_sigs:
+ for sym, name, price, pct, hit_sigs in self._check_signals(df, entry_sigs):
+ alert = StrategyAlert(
+ type="entry",
+ strategy_id=strategy_id,
+ symbol=sym,
+ name=name,
+ message=f"买入信号触发",
+ price=price,
+ change_pct=pct,
+ signals=hit_sigs,
+ )
+ all_alerts.append(alert)
+ self._emit(alert)
+
+ # 卖出信号
+ exit_sigs = cfg.get("exit_signals", [])
+ if exit_sigs:
+ for sym, name, price, pct, hit_sigs in self._check_signals(df, exit_sigs):
+ alert = StrategyAlert(
+ type="exit",
+ strategy_id=strategy_id,
+ symbol=sym,
+ name=name,
+ message=f"卖出信号触发",
+ price=price,
+ change_pct=pct,
+ signals=hit_sigs,
+ )
+ 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:
+ if self._alert_handler:
+ try:
+ self._alert_handler(alert)
+ except Exception as e:
+ logger.warning("alert handler failed: %s", e)
+
+ @staticmethod
+ def _check_signals(
+ df: pl.DataFrame,
+ signals: list[str],
+ ) -> list[tuple[str, str | None, float | None, float | None, list[str]]]:
+ """检查信号列,返回 [(symbol, name, price, change_pct, [hit_signals])]。
+ 支持内置 signal_ 与自定义 csg_ 前缀。"""
+ cols = set(df.columns)
+ resolved: list[tuple[str, str]] = [] # (原值, 列名)
+ for s in signals:
+ col = s if (s.startswith("signal_") or s.startswith("csg_")) else f"signal_{s}"
+ if col in cols:
+ resolved.append((s, col))
+ if not resolved:
+ return []
+
+ mask = pl.any_horizontal(pl.col(c).fill_null(False) for _, c in resolved)
+ hit_df = df.filter(mask)
+
+ results = []
+ for row in hit_df.iter_rows(named=True):
+ sym = row.get("symbol", "")
+ name = row.get("name")
+ price = row.get("close")
+ pct = row.get("change_pct")
+ hit_sigs = [orig for orig, col in resolved if row.get(col)]
+ 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
diff --git a/backend/app/strategy/prompt_builder.py b/backend/app/strategy/prompt_builder.py
new file mode 100644
index 0000000..896e3ec
--- /dev/null
+++ b/backend/app/strategy/prompt_builder.py
@@ -0,0 +1,65 @@
+"""策略提示词组装器 — 两步定制流程的提示词生成。
+
+职责: 加载对应步骤的 Markdown 指南,拼接用户输入,组装 LLM 提示词。
+不知道: LLM 调用、API、前端、引擎执行。
+"""
+from __future__ import annotations
+
+from pathlib import Path
+
+_DOCS_DIR = Path(__file__).resolve().parent.parent.parent.parent / "docs"
+_cache: dict[str, str] = {}
+
+
+def _load_doc(name: str) -> str:
+ if name not in _cache:
+ path = _DOCS_DIR / name
+ _cache[name] = path.read_text(encoding="utf-8") if path.exists() else ""
+ return _cache[name]
+
+
+DIRECTION_CN = {"long": "做多", "short": "做空", "monitor": "监控"}
+
+
+def build_step1(name: str, description: str, direction: str, rules: str, strategy_id: str = "") -> str:
+ """步骤1:规则 → 完整策略代码(参数 + 信号 + 评分 + 告警)
+
+ 注意: strategy-guide.md 已在 ai_generator.py 的 system prompt 中加载,
+ 此处不再重复加载以节省 token。
+ """
+ guide = _load_doc("strategy-builder-step1.md")
+
+ id_line = f"\n策略ID(必须使用此ID):{strategy_id}" if strategy_id else ""
+
+ return f"""{guide}
+
+---
+
+请根据以下用户输入生成完整策略代码:
+
+策略名称:{name}{id_line}
+策略描述:{description}
+选股方向:{DIRECTION_CN.get(direction, direction)}
+策略规则:
+{rules}
+
+只输出 Python 代码。"""
+
+
+def build_step2(current_code: str, instruction: str) -> str:
+ """步骤2:修改策略任意部分"""
+ guide = _load_doc("strategy-builder-step2.md")
+
+ return f"""{guide}
+
+---
+
+当前策略代码:
+```python
+{current_code}
+```
+
+用户修改指令:
+{instruction}
+
+只输出修改后的完整 Python 代码。"""
diff --git a/backend/app/tickflow/__init__.py b/backend/app/tickflow/__init__.py
new file mode 100644
index 0000000..ff7675d
--- /dev/null
+++ b/backend/app/tickflow/__init__.py
@@ -0,0 +1 @@
+"""TickFlow 适配层 — 能力探测 / 调度 / Repository。"""
diff --git a/backend/app/tickflow/capabilities.py b/backend/app/tickflow/capabilities.py
new file mode 100644
index 0000000..7bee5e2
--- /dev/null
+++ b/backend/app/tickflow/capabilities.py
@@ -0,0 +1,75 @@
+"""Capability 定义(§5.1)。
+
+业务代码只依赖 CapabilitySet,不读 tiers.yaml,不感知"档位"。
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import StrEnum
+
+
+class Cap(StrEnum):
+ """所有 capability 的命名常量。新增能力时只在这里加一行。"""
+
+ QUOTE_BY_SYMBOL = "quote.by_symbol"
+ QUOTE_BATCH = "quote.batch"
+ QUOTE_POOL = "quote.pool"
+ KLINE_DAILY_BY_SYMBOL = "kline.daily.by_symbol"
+ KLINE_DAILY_BATCH = "kline.daily.batch"
+ KLINE_MINUTE_BY_SYMBOL = "kline.minute.by_symbol"
+ KLINE_MINUTE_BATCH = "kline.minute.batch"
+ INTRADAY = "intraday"
+ INTRADAY_BATCH = "intraday.batch"
+ DEPTH5 = "depth5"
+ WEBSOCKET = "websocket"
+ FINANCIAL = "financial"
+ ADJ_FACTOR = "adj_factor"
+
+
+@dataclass(slots=True, frozen=True)
+class CapabilityLimits:
+ """单个 capability 的运行时限制。"""
+ rpm: int | None = None # 次/分钟,None 表示未知或不限
+ batch: int | None = None # 标的/次
+ subscribe: int | None = None # WS 订阅上限
+
+
+class CapabilitySet:
+ """探测得到的"用户当前可用能力"。业务代码的唯一真理源。"""
+
+ def __init__(self, caps: dict[Cap, CapabilityLimits] | None = None) -> None:
+ self._caps: dict[Cap, CapabilityLimits] = dict(caps or {})
+
+ def has(self, cap: Cap) -> bool:
+ return cap in self._caps
+
+ def limits(self, cap: Cap) -> CapabilityLimits | None:
+ return self._caps.get(cap)
+
+ def require(self, cap: Cap) -> CapabilityLimits:
+ """断言可用,否则抛 CapabilityDenied。"""
+ if cap not in self._caps:
+ raise CapabilityDenied(cap)
+ return self._caps[cap]
+
+ def all(self) -> dict[Cap, CapabilityLimits]:
+ return dict(self._caps)
+
+ def to_dict(self) -> dict[str, dict]:
+ return {
+ str(cap): {
+ "rpm": lim.rpm,
+ "batch": lim.batch,
+ "subscribe": lim.subscribe,
+ }
+ for cap, lim in self._caps.items()
+ }
+
+
+class CapabilityDenied(Exception):
+ """请求的 capability 当前不可用。"""
+
+ def __init__(self, cap: Cap, suggestion: str | None = None) -> None:
+ self.cap = cap
+ self.suggestion = suggestion or f"加购『{cap}』能力可解锁"
+ super().__init__(f"capability not available: {cap}; {self.suggestion}")
diff --git a/backend/app/tickflow/client.py b/backend/app/tickflow/client.py
new file mode 100644
index 0000000..4351bf4
--- /dev/null
+++ b/backend/app/tickflow/client.py
@@ -0,0 +1,72 @@
+"""TickFlow SDK 封装(§5)。
+
+进程内单例;Key 来源(优先级):secrets.json > .env。
+用户改 Key 后需要 `reset_clients()`,然后 `get_client()` 会拿新的。
+"""
+from __future__ import annotations
+
+import os
+
+from tickflow import AsyncTickFlow, TickFlow
+
+from app import secrets_store
+
+_sync_client: TickFlow | None = None
+_async_client: AsyncTickFlow | None = None
+
+
+def _base_url() -> str | None:
+ """从 secrets.json 读取用户自定义端点,没有则返回 None(用 SDK 默认)。"""
+ return secrets_store.load().get("tickflow_base_url") or None
+
+
+def get_client() -> TickFlow:
+ """同步客户端。能力探测、盘后管道用。"""
+ global _sync_client
+ if _sync_client is None:
+ key = secrets_store.get_tickflow_key()
+ if not key:
+ # Free 模式:付费端点 URL 不可用,忽略 base_url 走 SDK 默认 free-api
+ _sync_client = TickFlow.free()
+ else:
+ _sync_client = TickFlow(api_key=key, base_url=_base_url())
+ return _sync_client
+
+
+def get_async_client() -> AsyncTickFlow:
+ """异步客户端。FastAPI 请求路径上用。"""
+ global _async_client
+ if _async_client is None:
+ key = secrets_store.get_tickflow_key()
+ if not key:
+ # Free 模式:付费端点 URL 不可用,忽略 base_url 走 SDK 默认 free-api
+ _async_client = AsyncTickFlow.free()
+ else:
+ _async_client = AsyncTickFlow(api_key=key, base_url=_base_url())
+ return _async_client
+
+
+def reset_clients() -> None:
+ """Key 变化后调用 — 让下一次 get_client() 拿新实例。"""
+ global _sync_client, _async_client
+ _sync_client = None
+ _async_client = None
+
+
+def current_mode() -> str:
+ """供 UI 显示当前模式。"""
+ return "api_key" if secrets_store.get_tickflow_key() else "free"
+
+
+def current_endpoint() -> str:
+ """返回当前显示用的端点 URL(对应 endpoints.json 列表项)。
+
+ 注:SDK 的 TickFlow.free() 内部实际走 free-api,但 UI 显示统一用默认
+ 节点(api.tickflow.org),使"当前使用"始终对得上端点列表里的某一项。
+ """
+ # 自定义端点(付费模式测速切换后):优先返回
+ base = _base_url()
+ if base:
+ return base.rstrip("/")
+ # Free 模式或未自定义:统一显示默认节点
+ return "https://api.tickflow.org"
diff --git a/backend/app/tickflow/policy.py b/backend/app/tickflow/policy.py
new file mode 100644
index 0000000..3307408
--- /dev/null
+++ b/backend/app/tickflow/policy.py
@@ -0,0 +1,390 @@
+"""能力探测 + CapabilitySet 持久化(§5.3)。
+
+探测策略:逐 capability 用最小代价请求试探。
+ - 成功 → 记录可用,优先取响应头 X-RateLimit-* 否则用 tiers.yaml 默认
+ - 抛权限错 → 不可用
+ - 抛其他错 → 不可用(谨慎,保留日志)
+
+Tier Label 算法见 §5.3:基线档 + 补丁能力。
+"""
+from __future__ import annotations
+
+import json
+import logging
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+from app.config import settings
+
+from .capabilities import Cap, CapabilityLimits, CapabilitySet
+
+logger = logging.getLogger(__name__)
+
+_CAPSET_CACHE_FILE = "capabilities.json"
+
+# 探测用最小代价请求:挑流通性最好的 1 只标的试
+_PROBE_SYMBOL = "600000.SH" # 浦发银行,长期不会退市
+
+
+def _load_tiers_yaml() -> dict[str, dict[str, dict[str, Any]]]:
+ for path in [settings.tiers_yaml, Path("/app/tiers.yaml"), Path("../tiers.yaml")]:
+ if path.exists():
+ with path.open(encoding="utf-8") as f:
+ return yaml.safe_load(f)
+ raise FileNotFoundError("tiers.yaml not found")
+
+
+def _tier_to_capset(tier_def: dict[str, dict[str, Any]]) -> CapabilitySet:
+ caps: dict[Cap, CapabilityLimits] = {}
+ for cap_name, limits_dict in tier_def.items():
+ try:
+ cap = Cap(cap_name)
+ except ValueError:
+ logger.warning("unknown cap in tiers.yaml: %s", cap_name)
+ continue
+ caps[cap] = CapabilityLimits(
+ rpm=limits_dict.get("rpm"),
+ batch=limits_dict.get("batch"),
+ subscribe=limits_dict.get("subscribe"),
+ )
+ return CapabilitySet(caps)
+
+
+def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str]]:
+ """逐 capability 试探。需要 API key。
+
+ 返回 (capset, probe_log)。
+ """
+ from .client import get_client
+
+ tf = get_client()
+ available: dict[Cap, CapabilityLimits] = {}
+ log: list[str] = []
+
+ def try_call(cap: Cap, fn, default_limits: dict[str, Any]) -> None:
+ try:
+ fn()
+ available[cap] = CapabilityLimits(
+ rpm=default_limits.get("rpm"),
+ batch=default_limits.get("batch"),
+ subscribe=default_limits.get("subscribe"),
+ )
+ log.append(f"✓ {cap}")
+ except Exception as e: # noqa: BLE001
+ msg = str(e).lower()
+ cls = e.__class__.__name__
+ # PermissionError 类名 / HTTP 403 / 中英文权限关键词都算"明确无权限"
+ is_perm_denied = (
+ cls in {"PermissionError", "AuthorizationError"}
+ or "permission" in msg or "unauthorized" in msg
+ or "403" in msg or "forbidden" in msg
+ or "套餐" in msg or "权限" in msg or "需要" in msg
+ )
+ if is_perm_denied:
+ log.append(f"✗ {cap}(无权限)")
+ else:
+ log.append(f"? {cap} ({cls}: {e})")
+
+ # 用各档默认上限作为占位(无 X-RateLimit-* 头时)
+ # 取所有档的并集,逐 cap 试探
+ all_caps_defaults: dict[str, dict[str, Any]] = {}
+ for tier in ("free", "starter", "pro", "expert"):
+ for cap_name, lim in tiers.get(tier, {}).items():
+ all_caps_defaults.setdefault(cap_name, lim)
+
+ def defaults(cap: Cap) -> dict[str, Any]:
+ return all_caps_defaults.get(str(cap), {})
+
+ # 全部用 keyword-only 形式调用,符合 SDK 真实签名
+ # quote.by_symbol
+ try_call(Cap.QUOTE_BY_SYMBOL,
+ lambda: tf.quotes.get(symbols=[_PROBE_SYMBOL], as_dataframe=False),
+ defaults(Cap.QUOTE_BY_SYMBOL))
+
+ # quote.pool — 用一个真实存在的 universe id 试探。
+ # universes.list() 在 Free 也开放,先拿任意一个 universe id 再用 get_by_universes 试。
+ def _probe_pool():
+ unis = tf.universes.list()
+ if not unis:
+ raise RuntimeError("no universes available")
+ first_id = unis[0]["id"] if isinstance(unis[0], dict) else getattr(unis[0], "id")
+ return tf.quotes.get_by_universes([first_id], as_dataframe=False)
+
+ try_call(Cap.QUOTE_POOL, _probe_pool, defaults(Cap.QUOTE_POOL))
+
+ # kline.daily.by_symbol — Free 也有
+ try_call(Cap.KLINE_DAILY_BY_SYMBOL,
+ lambda: tf.klines.get(_PROBE_SYMBOL, period="1d", count=1, as_dataframe=False),
+ defaults(Cap.KLINE_DAILY_BY_SYMBOL))
+
+ # kline.daily.batch
+ try_call(Cap.KLINE_DAILY_BATCH,
+ lambda: tf.klines.batch([_PROBE_SYMBOL], period="1d", count=1, as_dataframe=False),
+ defaults(Cap.KLINE_DAILY_BATCH))
+
+ # kline.minute.by_symbol
+ try_call(Cap.KLINE_MINUTE_BY_SYMBOL,
+ lambda: tf.klines.get(_PROBE_SYMBOL, period="1m", count=1, as_dataframe=False),
+ defaults(Cap.KLINE_MINUTE_BY_SYMBOL))
+
+ # kline.minute.batch
+ try_call(Cap.KLINE_MINUTE_BATCH,
+ lambda: tf.klines.batch([_PROBE_SYMBOL], period="1m", count=1, as_dataframe=False),
+ defaults(Cap.KLINE_MINUTE_BATCH))
+
+ # intraday
+ try_call(Cap.INTRADAY,
+ lambda: tf.klines.intraday(_PROBE_SYMBOL, count=1, as_dataframe=False),
+ defaults(Cap.INTRADAY))
+
+ # intraday.batch
+ try_call(Cap.INTRADAY_BATCH,
+ lambda: tf.klines.intraday_batch([_PROBE_SYMBOL], count=1, as_dataframe=False),
+ defaults(Cap.INTRADAY_BATCH))
+
+ # depth5
+ try_call(Cap.DEPTH5,
+ lambda: tf.depth.get(_PROBE_SYMBOL),
+ defaults(Cap.DEPTH5))
+
+ # financial — SDK 提供 income / balance_sheet / cash_flow / metrics / shares
+ # 用 metrics 探测(单据最小)
+ try_call(Cap.FINANCIAL,
+ lambda: tf.financials.metrics([_PROBE_SYMBOL], latest=True, as_dataframe=False),
+ defaults(Cap.FINANCIAL))
+
+ # adj_factor — 实际在 klines.ex_factors
+ try_call(Cap.ADJ_FACTOR,
+ lambda: tf.klines.ex_factors([_PROBE_SYMBOL], as_dataframe=False),
+ defaults(Cap.ADJ_FACTOR))
+
+ # websocket 不在探测期试连接(成本太高且阻塞),按档位默认推断
+ # 若 expert 的其他 cap 都通,则推断 websocket 也可用
+ if (Cap.FINANCIAL in available and Cap.INTRADAY_BATCH in available):
+ available[Cap.WEBSOCKET] = CapabilityLimits(
+ subscribe=defaults(Cap.WEBSOCKET).get("subscribe", 100),
+ )
+ log.append("✓ websocket (inferred from expert tier)")
+
+ return CapabilitySet(available), log
+
+
+def detect_capabilities(force: bool = False) -> CapabilitySet:
+ """探测当前 API Key 的能力集。"""
+ cache_path = settings.data_dir / _CAPSET_CACHE_FILE
+ if not force and cache_path.exists():
+ with cache_path.open(encoding="utf-8") as f:
+ cached = json.load(f)
+ return _capset_from_json(cached)
+
+ tiers = _load_tiers_yaml()
+ if settings.use_free_mode:
+ capset = _tier_to_capset(tiers["free"])
+ label, missing, extras = _compute_label_and_missing(capset, tiers)
+ _persist(capset, label, log=["Free 模式(无 API Key)"], missing=missing, extras=extras)
+ return capset
+
+ # 有 API key — 真实探测
+ try:
+ capset, probe_log = _probe_real(tiers)
+ if not capset.all():
+ logger.warning("probe returned no caps; falling back to free baseline")
+ capset = _tier_to_capset(tiers["free"])
+ probe_log.append("⚠ 所有探测均失败,降级为 Free 占位")
+ label, missing, extras = _compute_label_and_missing(capset, tiers)
+ # 探测时 limits 用了"任意档默认值",现在判档完成,用真实档位的 limits 覆盖
+ capset = _override_limits_with_detected_tier(capset, label, tiers)
+ _persist(capset, label, log=probe_log, missing=missing, extras=extras)
+ return capset
+ except Exception as e:
+ logger.exception("detect_capabilities failed; using free baseline: %s", e)
+ capset = _tier_to_capset(tiers["free"])
+ _persist(capset, "Free(探测失败)", log=[f"探测失败:{e}"], missing=[], extras=[])
+ return capset
+
+
+# ===== Tier 代表性 capability(signature caps)=====
+# 拥有**任意一个**即认作该档及以上。自上而下匹配。
+# 这套设计的好处:单个 capability 探测的 transient 失败不会把整体档位"误降"。
+TIER_SIGNATURES: dict[str, set[Cap]] = {
+ "expert": {Cap.FINANCIAL, Cap.INTRADAY_BATCH, Cap.WEBSOCKET},
+ "pro": {Cap.KLINE_MINUTE_BATCH, Cap.KLINE_MINUTE_BY_SYMBOL,
+ Cap.INTRADAY, Cap.DEPTH5},
+ "starter": {Cap.QUOTE_BATCH, Cap.KLINE_DAILY_BATCH,
+ Cap.ADJ_FACTOR, Cap.QUOTE_POOL},
+ # free 不需 signature — 默认兜底
+}
+
+# 补丁友好命名(label 后缀用)
+_CAP_ALIASES: dict[Cap, str] = {
+ Cap.KLINE_MINUTE_BATCH: "分钟K",
+ Cap.KLINE_MINUTE_BY_SYMBOL: "分钟K",
+ Cap.INTRADAY: "分时",
+ Cap.INTRADAY_BATCH: "批量分时",
+ Cap.DEPTH5: "五档",
+ Cap.WEBSOCKET: "WS",
+ Cap.FINANCIAL: "财务",
+ Cap.ADJ_FACTOR: "复权",
+ Cap.QUOTE_BATCH: "批量行情",
+ Cap.QUOTE_POOL: "标的池",
+ Cap.KLINE_DAILY_BATCH: "日K批量",
+}
+
+
+def _override_limits_with_detected_tier(
+ capset: CapabilitySet, label: str, tiers: dict,
+) -> CapabilitySet:
+ """探测完成后,用判档对应的 limits 覆盖每个 cap 的速率/批量。
+
+ 判档前每个 cap 用的是"所有档默认值的并集"(为了不漏数据),
+ 判档后才知道用户真实档位,limits 用该档的实际值更准。
+ label 可能是 "Pro" / "Pro + 分钟K" / "Pro+" 等组合形式 — 取第一个词当作基线档名。
+ """
+ base_name = label.split()[0].split("+")[0].strip().lower() # "Pro + 分钟K" → "pro"
+ tier_limits = tiers.get(base_name, {})
+ new_caps: dict[Cap, CapabilityLimits] = {}
+ for cap, _old_lim in capset.all().items():
+ spec = tier_limits.get(cap.value)
+ if spec:
+ new_caps[cap] = CapabilityLimits(
+ rpm=spec.get("rpm"),
+ batch=spec.get("batch"),
+ subscribe=spec.get("subscribe"),
+ )
+ else:
+ # 不在该档定义里(extras),用 expert 档兜底(最宽松)
+ expert_spec = tiers.get("expert", {}).get(cap.value, {})
+ new_caps[cap] = CapabilityLimits(
+ rpm=expert_spec.get("rpm"),
+ batch=expert_spec.get("batch"),
+ subscribe=expert_spec.get("subscribe"),
+ )
+ return CapabilitySet(new_caps)
+
+
+def _tier_caps_set(tiers: dict, tier_name: str) -> set[Cap]:
+ """读 tiers.yaml 的某档定义,转为 Cap 集合。"""
+ return {Cap(c) for c in tiers.get(tier_name, {}).keys() if c in {x.value for x in Cap}}
+
+
+def _compute_label_and_missing(
+ capset: CapabilitySet, tiers: dict,
+) -> tuple[str, list[str], list[str]]:
+ """返回 (label, missing_caps, extra_caps)。
+
+ label:档位标签。
+ missing_caps:本档**应有但未探测到**的 capability(用于诊断:可能是探测 bug 或权限丢失)。
+ extra_caps:超出本档的额外 capability(自定义组合)。
+ """
+ held = set(capset.all().keys())
+
+ # 1) 完全匹配 — 干净命中某档
+ for tier_name in ["free", "starter", "pro", "expert"]:
+ if held == _tier_caps_set(tiers, tier_name):
+ return tier_name.capitalize(), [], []
+
+ # 2) 按 signature 自上而下判档
+ if held & TIER_SIGNATURES["expert"]:
+ base = "expert"
+ elif held & TIER_SIGNATURES["pro"]:
+ base = "pro"
+ elif held & TIER_SIGNATURES["starter"]:
+ base = "starter"
+ else:
+ base = "free"
+
+ base_caps = _tier_caps_set(tiers, base)
+ missing = sorted(c.value for c in (base_caps - held))
+ extras = base_caps and (held - base_caps) or set() # extras 是超出该档的部分
+
+ # 实际超出 = held 中"既不属于本档、也不属于本档下方任何档"的 cap
+ # 简化:extras = held - base_caps
+ extras_set = held - base_caps
+
+ # 3) 拼 label
+ if not extras_set:
+ # 完全在本档内(可能缺一两项 — 由 missing 反映)
+ return base.capitalize(), missing, []
+
+ # 补丁过多 → 用 "≈" 形式
+ if len(extras_set) > 3:
+ return f"{base.capitalize()}+", missing, sorted(c.value for c in extras_set)
+
+ suffix = sorted({_CAP_ALIASES.get(e, str(e)) for e in extras_set})
+ return f"{base.capitalize()} + " + " + ".join(suffix), missing, sorted(c.value for c in extras_set)
+
+
+def _compute_label(capset: CapabilitySet, tiers: dict) -> str:
+ """对外简化签名 — 只要 label。"""
+ label, _missing, _extras = _compute_label_and_missing(capset, tiers)
+ return label
+
+
+def _persist(
+ capset: CapabilitySet,
+ label: str,
+ log: list[str] | None = None,
+ missing: list[str] | None = None,
+ extras: list[str] | None = None,
+) -> None:
+ settings.data_dir.mkdir(parents=True, exist_ok=True)
+ cache_path = settings.data_dir / _CAPSET_CACHE_FILE
+ payload = {
+ "label": label,
+ "capabilities": capset.to_dict(),
+ "probe_log": log or [],
+ "missing_caps": missing or [], # 本档应有但未探测到
+ "extras_caps": extras or [], # 超出本档的额外能力
+ }
+ with cache_path.open("w", encoding="utf-8") as f:
+ json.dump(payload, f, ensure_ascii=False, indent=2)
+
+
+def _capset_from_json(data: dict[str, Any]) -> CapabilitySet:
+ caps: dict[Cap, CapabilityLimits] = {}
+ for cap_name, lim in data.get("capabilities", {}).items():
+ try:
+ cap = Cap(cap_name)
+ except ValueError:
+ continue
+ caps[cap] = CapabilityLimits(
+ rpm=lim.get("rpm"),
+ batch=lim.get("batch"),
+ subscribe=lim.get("subscribe"),
+ )
+ return CapabilitySet(caps)
+
+
+def tier_label() -> str:
+ cache_path = settings.data_dir / _CAPSET_CACHE_FILE
+ if cache_path.exists():
+ with cache_path.open(encoding="utf-8") as f:
+ return json.load(f).get("label", "Unknown")
+ return "Unknown"
+
+
+def probe_log() -> list[str]:
+ cache_path = settings.data_dir / _CAPSET_CACHE_FILE
+ if cache_path.exists():
+ with cache_path.open(encoding="utf-8") as f:
+ return json.load(f).get("probe_log", [])
+ return []
+
+
+def missing_caps() -> list[str]:
+ """本档应有但未探测到的 capability — 通常意味着探测有 bug 或权限边界。"""
+ cache_path = settings.data_dir / _CAPSET_CACHE_FILE
+ if cache_path.exists():
+ with cache_path.open(encoding="utf-8") as f:
+ return json.load(f).get("missing_caps", [])
+ return []
+
+
+def extras_caps() -> list[str]:
+ cache_path = settings.data_dir / _CAPSET_CACHE_FILE
+ if cache_path.exists():
+ with cache_path.open(encoding="utf-8") as f:
+ return json.load(f).get("extras_caps", [])
+ return []
diff --git a/backend/app/tickflow/pools.py b/backend/app/tickflow/pools.py
new file mode 100644
index 0000000..77a45d9
--- /dev/null
+++ b/backend/app/tickflow/pools.py
@@ -0,0 +1,158 @@
+"""标的池(Universe)定义(§6.3)。
+
+Phase 1 实现:
+ - 常用指数成份(沪深 300 / 中证 500 / 上证 50)用 TickFlow `quote.pool` 端点拉取并缓存
+ - 全 A 通过 instruments.batch 获取
+ - 自选池 = 用户的 watchlist
+"""
+from __future__ import annotations
+
+import logging
+from datetime import date
+from pathlib import Path
+from typing import Literal
+
+import polars as pl
+
+from app.config import settings
+from app.tickflow.client import get_client
+
+logger = logging.getLogger(__name__)
+
+PoolId = Literal["CSI300", "CSI500", "SSE50", "CN_Equity_A", "CN_Index", "watchlist"]
+
+# TickFlow universe id 是它内部命名(见 tf.universes.list())。
+# 没有官方对照表,启动时按名称模糊匹配从 universes.list() 里找。
+# 常见名:沪深300 / 中证500 / 上证50 / 全 A
+_POOL_NAME_HINTS = {
+ "CSI300": ["沪深300", "HS300", "CSI300"],
+ "CSI500": ["中证500", "ZZ500", "CSI500"],
+ "SSE50": ["上证50", "SH50", "SSE50"],
+}
+
+
+def _find_universe_id(hints: list[str]) -> str | None:
+ """从 universes.list() 里按 name/id 子串匹配找一个 universe id。"""
+ try:
+ tf = get_client()
+ unis = tf.universes.list()
+ except Exception as e: # noqa: BLE001
+ logger.warning("universes.list failed: %s", e)
+ return None
+ for u in unis or []:
+ item = u if isinstance(u, dict) else {"id": getattr(u, "id", ""), "name": getattr(u, "name", "")}
+ haystack = (item.get("id", "") + " " + item.get("name", "")).lower()
+ for h in hints:
+ if h.lower() in haystack:
+ return item["id"]
+ return None
+
+
+def _pool_cache_path(pool_id: str) -> Path:
+ return settings.data_dir / "pools" / f"{pool_id}.parquet"
+
+
+def get_pool(pool_id: PoolId, refresh: bool = False) -> list[str]:
+ """返回标的池里的 symbol 列表。"""
+ if pool_id == "watchlist":
+ return _load_watchlist()
+
+ cache = _pool_cache_path(pool_id)
+ if cache.exists() and not refresh:
+ df = pl.read_parquet(cache)
+ return df["symbol"].to_list()
+
+ symbols = _fetch_pool(pool_id)
+ if symbols:
+ cache.parent.mkdir(parents=True, exist_ok=True)
+ pl.DataFrame({"symbol": symbols, "as_of": [date.today()] * len(symbols)}).write_parquet(cache)
+ return symbols
+
+
+def _fetch_pool(pool_id: PoolId) -> list[str]:
+ """从 TickFlow 拉取池成份。
+
+ 实现:先用 universes.list 找到 universe id,再 quotes.get_by_universes 拉成份。
+ """
+ tf = get_client()
+
+ if pool_id in _POOL_NAME_HINTS:
+ uid = _find_universe_id(_POOL_NAME_HINTS[pool_id])
+ if not uid:
+ logger.warning("无法在 TickFlow universes 列表里匹配到 %s", pool_id)
+ return []
+ try:
+ df = tf.quotes.get_by_universes([uid], as_dataframe=True)
+ if df is not None and len(df) > 0 and "symbol" in df.columns:
+ return df["symbol"].astype(str).tolist()
+ except Exception as e: # noqa: BLE001
+ logger.warning("fetch pool %s via universe %s failed: %s", pool_id, uid, e)
+
+ if pool_id == "CN_Equity_A":
+ # 全 A — 优先直接用 CN_Equity_A universe (包含沪深京三市)
+ uid = _find_universe_id(["CN_Equity_A", "沪深京A股", "全A"])
+ if uid:
+ try:
+ df = tf.quotes.get_by_universes([uid], as_dataframe=True)
+ if df is not None and len(df) > 0 and "symbol" in df.columns:
+ return sorted(set(df["symbol"].astype(str).tolist()))
+ except Exception as e: # noqa: BLE001
+ logger.warning("fetch CN_Equity_A via universe %s failed: %s", uid, e)
+
+ # fallback: 聚合申万一级行业 (覆盖度较低, 缺北交所/新股)
+ try:
+ unis = tf.universes.list()
+ except Exception as e: # noqa: BLE001
+ logger.warning("universes.list failed: %s", e)
+ unis = []
+ sw1_ids = []
+ for u in unis or []:
+ item = u if isinstance(u, dict) else {"id": getattr(u, "id", "")}
+ uid = item.get("id", "")
+ if "SW1_" in uid:
+ sw1_ids.append(uid)
+ if sw1_ids:
+ try:
+ df = tf.quotes.get_by_universes(sw1_ids, as_dataframe=True)
+ if df is not None and "symbol" in df.columns:
+ return sorted(set(df["symbol"].astype(str).tolist()))
+ except Exception as e: # noqa: BLE001
+ logger.warning("aggregate SW1 fetch failed: %s", e)
+
+ if pool_id == "CN_Index":
+ uid = _find_universe_id(["CN_Index", "沪深指数", "指数"])
+ ids = [uid] if uid else ["CN_Index"]
+ try:
+ df = tf.quotes.get_by_universes(ids, as_dataframe=True)
+ if df is not None and len(df) > 0 and "symbol" in df.columns:
+ return sorted(set(df["symbol"].astype(str).tolist()))
+ except Exception as e: # noqa: BLE001
+ logger.warning("fetch CN_Index via universe %s failed: %s", ids, e)
+
+ return []
+
+
+def _load_watchlist() -> list[str]:
+ """读取用户自选(由 watchlist service 维护)。"""
+ path = settings.data_dir / "user_data" / "watchlist.parquet"
+ if not path.exists():
+ return []
+ df = pl.read_parquet(path)
+ if df.is_empty() or "symbol" not in df.columns:
+ return []
+ return df["symbol"].to_list()
+
+
+# 兜底:Free 用户/无 API 时给一个小型可用集合,让 UI 不至于空白
+DEMO_SYMBOLS = [
+ "600000.SH", # 浦发银行
+ "600036.SH", # 招商银行
+ "600519.SH", # 贵州茅台
+ "601318.SH", # 中国平安
+ "601398.SH", # 工商银行
+ "000001.SZ", # 平安银行
+ "000333.SZ", # 美的集团
+ "000651.SZ", # 格力电器
+ "000858.SZ", # 五粮液
+ "002594.SZ", # 比亚迪
+]
diff --git a/backend/app/tickflow/repository.py b/backend/app/tickflow/repository.py
new file mode 100644
index 0000000..9c2f630
--- /dev/null
+++ b/backend/app/tickflow/repository.py
@@ -0,0 +1,965 @@
+"""Repository 层(§7.4)。
+
+数据分层:
+ - DuckDB 视图: 冷查询(统计、元数据、用户自定义SQL)
+ - Polars 缓存: 热路径(enriched 最新日 ~5500行 + instruments ~5500行)
+ - Polars scan_parquet: 分钟K/历史日K (predicate pushdown)
+
+缓存生命周期:
+ - startup 时不加载(数据可能为空)
+ - pipeline 完成后调用 refresh_cache()
+ - 服务层通过 get_enriched_latest() / get_instruments() 获取缓存
+"""
+from __future__ import annotations
+
+import logging
+import threading
+from datetime import date
+from pathlib import Path
+
+import duckdb
+import polars as pl
+
+from app.config import settings
+
+logger = logging.getLogger(__name__)
+
+
+class DataStore:
+ """唯一的存储入口 — 进程启动时创建。"""
+
+ def __init__(self, data_dir: Path | None = None) -> None:
+ self.data_dir = Path(data_dir or settings.data_dir)
+ self.data_dir.mkdir(parents=True, exist_ok=True)
+
+ # 关键子目录(§7.2)
+ for sub in (
+ "kline_daily",
+ "kline_daily_enriched",
+ "kline_index_daily",
+ "kline_index_enriched",
+ "kline_minute",
+ "adj_factor",
+ "financials",
+ "instruments",
+ "instruments_index",
+ "instruments_ext",
+ "kline_ext",
+ "pools",
+ "backtest_results",
+ "screener_results",
+ "ai_cache",
+ "user_data",
+ ):
+ (self.data_dir / sub).mkdir(parents=True, exist_ok=True)
+
+ # 财务数据子目录
+ for sub in ("metrics", "income", "balance_sheet", "cash_flow"):
+ (self.data_dir / "financials" / sub).mkdir(parents=True, exist_ok=True)
+
+ # DuckDB 内存模式 — 不建 .db 文件(§7.1)
+ self.db = duckdb.connect(database=":memory:")
+ self._register_views()
+
+ def _register_views(self) -> None:
+ """把 Parquet 目录挂载为 DuckDB 视图(§7.3)。"""
+ d = self.data_dir.as_posix()
+ statements = [
+ f"""CREATE OR REPLACE VIEW kline_daily AS
+ SELECT * FROM read_parquet('{d}/kline_daily/**/*.parquet', union_by_name=true)""",
+ f"""CREATE OR REPLACE VIEW kline_enriched AS
+ SELECT * FROM read_parquet('{d}/kline_daily_enriched/**/*.parquet', union_by_name=true)""",
+ f"""CREATE OR REPLACE VIEW kline_index_daily AS
+ SELECT * FROM read_parquet('{d}/kline_index_daily/**/*.parquet', union_by_name=true)""",
+ f"""CREATE OR REPLACE VIEW kline_index_enriched AS
+ SELECT * FROM read_parquet('{d}/kline_index_enriched/**/*.parquet', union_by_name=true)""",
+ f"""CREATE OR REPLACE VIEW kline_minute AS
+ SELECT * FROM read_parquet('{d}/kline_minute/**/*.parquet', union_by_name=true)""",
+ f"""CREATE OR REPLACE VIEW adj_factor AS
+ SELECT * FROM read_parquet('{d}/adj_factor/**/*.parquet', union_by_name=true)""",
+ f"""CREATE OR REPLACE VIEW instruments AS
+ SELECT * FROM read_parquet('{d}/instruments/**/*.parquet', union_by_name=true)""",
+ f"""CREATE OR REPLACE VIEW instruments_index AS
+ SELECT * FROM read_parquet('{d}/instruments_index/**/*.parquet', union_by_name=true)""",
+ f"""CREATE OR REPLACE VIEW instruments_ext AS
+ SELECT * FROM read_parquet('{d}/instruments_ext/**/*.parquet', union_by_name=true)""",
+ f"""CREATE OR REPLACE VIEW kline_ext AS
+ SELECT * FROM read_parquet('{d}/kline_ext/**/*.parquet', union_by_name=true)""",
+ # 财务数据视图
+ f"""CREATE OR REPLACE VIEW financials_metrics AS
+ SELECT * FROM read_parquet('{d}/financials/metrics/*.parquet', union_by_name=true)""",
+ f"""CREATE OR REPLACE VIEW financials_income AS
+ SELECT * FROM read_parquet('{d}/financials/income/*.parquet', union_by_name=true)""",
+ f"""CREATE OR REPLACE VIEW financials_balance_sheet AS
+ SELECT * FROM read_parquet('{d}/financials/balance_sheet/*.parquet', union_by_name=true)""",
+ f"""CREATE OR REPLACE VIEW financials_cash_flow AS
+ SELECT * FROM read_parquet('{d}/financials/cash_flow/*.parquet', union_by_name=true)""",
+ ]
+ for sql in statements:
+ try:
+ self.db.execute(sql)
+ except duckdb.IOException:
+ logger.debug("view registration skipped (no parquet yet): %s", sql[:60])
+
+
+class KlineRepository:
+ """日 K / 分钟 K 的读写入口。"""
+
+ def __init__(self, store: DataStore) -> None:
+ self.store = store
+ self.db = store.db
+ self._lock = threading.Lock()
+
+ # ---- Polars 缓存 ----
+ self._enriched_cache: pl.DataFrame | None = None # 最新一天 (~5500行)
+ self._enriched_cache_date: date | None = None
+ self._live_agg_cache: pl.DataFrame | None = None # 预计算聚合表 (~5500行)
+ self._live_agg_cache_date: date | None = None
+ self._instruments_cache: pl.DataFrame | None = None
+ # 完整 enriched 历史 (含所有指标, 供 filter_history 策略使用)
+ self._enriched_history_cache: pl.DataFrame | None = None # ~100万行
+ self._enriched_history_start: date | None = None
+ self._index_instruments_cache: pl.DataFrame | None = None
+
+ # parquet glob 路径
+ self._enriched_glob = str(store.data_dir / "kline_daily_enriched" / "**" / "*.parquet")
+ self._index_enriched_glob = str(store.data_dir / "kline_index_enriched" / "**" / "*.parquet")
+ self._minute_glob = str(store.data_dir / "kline_minute" / "**" / "*.parquet")
+ self._inst_glob = str(store.data_dir / "instruments" / "**" / "*.parquet")
+ self._index_inst_glob = str(store.data_dir / "instruments_index" / "**" / "*.parquet")
+
+ 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()
+
+ 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()
+
+ # ================================================================
+ # Polars 缓存管理
+ # ================================================================
+
+ def refresh_cache(self) -> None:
+ """刷新 Polars 缓存。在 pipeline 完成后、服务启动时调用。"""
+ self._refresh_instruments()
+ self._refresh_index_instruments()
+ self._refresh_enriched()
+
+ def _refresh_enriched(self) -> None:
+ """从 parquet 加载 enriched 最新日到内存 + 构建聚合表。
+
+ enriched parquet 仅存 14 列基础数据。启动时读入历史数据并即时计算完整指标,
+ 将结果缓存在内存中供各服务使用。
+
+ 优化: 扩大历史读取范围, 同时缓存完整历史 (含指标), 供 filter_history 策略直接复用。
+ """
+ try:
+ latest = self._latest_enriched_date_duckdb()
+ if not latest:
+ return
+
+ # Step 1: 直接读最新日期的分区文件 (仅 14 列)
+ enriched_dir = self.store.data_dir / "kline_daily_enriched"
+ ds = latest.isoformat() if hasattr(latest, "isoformat") else str(latest)
+ target_parquet = enriched_dir / f"date={ds}" / "part.parquet"
+
+ if not target_parquet.exists():
+ return
+
+ df_latest = pl.read_parquet(target_parquet)
+ if df_latest.is_empty():
+ return
+
+ # Step 2: 读近 300 天 14 列数据 → compute → filter(latest) → 缓存
+ # 300 日历天 ≈ 210 交易日, 覆盖 filter_history 最大 lookback(90) + warmup(60)
+ try:
+ from datetime import timedelta
+ from app.indicators.pipeline import compute_indicators, compute_signals, compute_limit_signals
+ start_full = latest - timedelta(days=300)
+ read_cols = [c for c in ["symbol", "date", "open", "high", "low", "close",
+ "volume", "amount", "raw_close", "raw_high", "raw_low"]
+ if c in df_latest.columns]
+ lf = (
+ pl.scan_parquet(self._enriched_glob)
+ .filter(pl.col("date") >= start_full)
+ .sort(["symbol", "date"])
+ )
+ df_hist = lf.select(read_cols).collect()
+ if not df_hist.is_empty():
+ instruments = self._instruments_cache if self._instruments_cache is not None else pl.DataFrame()
+ df_full = compute_indicators(df_hist)
+ df_full = compute_signals(df_full)
+ if instruments is not None and not instruments.is_empty():
+ df_full = compute_limit_signals(df_full, instruments)
+
+ # JOIN instruments 到完整历史 (filter_history/basic_filter 需要 name/股本等列)
+ if instruments is not None and not instruments.is_empty():
+ inst_cols = [c for c in ["name", "total_shares", "float_shares"]
+ if c in instruments.columns and c not in df_full.columns]
+ if inst_cols:
+ df_full = df_full.join(
+ instruments.select(["symbol", *inst_cols]).unique(subset=["symbol"]),
+ on="symbol",
+ how="left",
+ )
+
+ # 缓存完整历史 (含指标+必要基础信息) 供 filter_history/backtest 直接复用
+ self._enriched_history_cache = df_full
+ self._enriched_history_start = df_full["date"].min()
+ logger.info("enriched 历史缓存: %d rows, %s ~ %s",
+ len(df_full), self._enriched_history_start, latest)
+
+ # 只取最新一天作为 enriched_cache
+ df_today = df_full.filter(pl.col("date") == latest)
+ if not df_today.is_empty():
+ self._enriched_cache = df_today
+ self._enriched_cache_date = latest
+ # 构建盘中递推基准: 若最新分区是今天的实时盘中数据,
+ # 递推状态必须停在上一交易日, 不能把今天作为“昨日”。
+ self._build_live_agg(self._live_agg_baseline_date(latest))
+ logger.info("enriched 缓存已计算: %d 只, 日期 %s (即时计算)", len(df_today), latest)
+ return
+ except Exception as e: # noqa: BLE001
+ logger.warning("enriched 即时计算失败, 使用原始 14 列缓存: %s", e)
+
+ # 降级: 直接使用 14 列数据 + 构建 live_agg
+ self._enriched_cache = df_latest
+ self._enriched_cache_date = latest
+ self._build_live_agg(self._live_agg_baseline_date(latest))
+
+ logger.info("enriched 缓存已加载: %d 只, 日期 %s", len(df_latest), latest)
+ except Exception as e: # noqa: BLE001
+ logger.warning("enriched 缓存刷新失败: %s", e)
+
+ def _build_live_agg(self, latest: date) -> None:
+ """从 OHLCV 即时计算递推状态 + 窗口聚合, 构建盘中实时聚合表。
+
+ 优化: 优先使用 _enriched_history_cache (启动时已计算), 避免重复 compute_indicators。
+ """
+ from datetime import timedelta
+ from app.indicators.pipeline import _ema_alpha
+
+ start_60d = latest - timedelta(days=90) # 日历90天 ≈ 60个交易日
+
+ # 优先使用已有的历史缓存 (避免重复 scan_parquet + compute_indicators)
+ if self._enriched_history_cache is not None and not self._enriched_history_cache.is_empty():
+ hist_all = self._enriched_history_cache
+ if "date" in hist_all.columns and hist_all["date"].min() <= start_60d:
+ # 从历史缓存中提取所需列 (历史缓存已有指标列)
+ base_cols = ["symbol", "date", "open", "high", "low", "close", "volume",
+ "raw_close", "raw_high", "raw_low"]
+ needed = [c for c in base_cols if c in hist_all.columns]
+ df_hist = hist_all.filter(
+ (pl.col("date") >= start_60d) & (pl.col("date") <= latest)
+ ).select(needed).sort(["symbol", "date"])
+
+ # 用历史缓存的指标列提取最新日状态 (无需再次 compute_indicators)
+ state_source = hist_all.filter(pl.col("date") == latest)
+
+ state_cols = [
+ "symbol",
+ "ema5", "ema10", "ema20", "ema30", "ema60",
+ "macd_dea",
+ "kdj_k", "kdj_d",
+ "atr_14",
+ "close", "high", "low",
+ "annual_vol_20d",
+ ]
+ existing_state = [c for c in state_cols if c in state_source.columns]
+ agg_a = state_source.select(existing_state)
+ else:
+ df_hist = pl.DataFrame()
+ agg_a = pl.DataFrame()
+ else:
+ # 降级: 读 parquet + compute_indicators
+ df_hist, agg_a = self._build_live_agg_from_parquet(latest, start_60d)
+
+ if df_hist.is_empty():
+ self._live_agg_cache = pl.DataFrame()
+ self._live_agg_cache_date = None
+ return
+
+ if agg_a.is_empty():
+ self._live_agg_cache = pl.DataFrame()
+ self._live_agg_cache_date = None
+ return
+
+ # 单独计算 _ema12 / _ema26 (compute_indicators 内部会 drop 掉)
+ df_ema = df_hist.sort(["symbol", "date"]).with_columns([
+ pl.col("close").ewm_mean(alpha=_ema_alpha(12), adjust=False).over("symbol").alias("_ema12"),
+ pl.col("close").ewm_mean(alpha=_ema_alpha(26), adjust=False).over("symbol").alias("_ema26"),
+ ]).filter(pl.col("date") == latest).select("symbol", "_ema12", "_ema26")
+
+ agg_a = agg_a.join(df_ema, on="symbol", how="inner")
+
+ # 单独计算 RSI 状态列 (compute_indicators 内部会 drop 掉)
+ df_rsi_base = df_hist.sort(["symbol", "date"]).with_columns(
+ pl.col("close").diff().over("symbol").alias("_daily_delta")
+ )
+ gain = pl.when(pl.col("_daily_delta") > 0).then(pl.col("_daily_delta")).otherwise(0.0)
+ loss = pl.when(pl.col("_daily_delta") < 0).then(-pl.col("_daily_delta")).otherwise(0.0)
+ rsi_exprs = []
+ for n in (6, 14, 24):
+ a = 1.0 / n
+ rsi_exprs.append(gain.ewm_mean(alpha=a, adjust=False).over("symbol").alias(f"_rsi_avg_gain_{n}"))
+ rsi_exprs.append(loss.ewm_mean(alpha=a, adjust=False).over("symbol").alias(f"_rsi_avg_loss_{n}"))
+ df_rsi = (
+ df_rsi_base
+ .with_columns(rsi_exprs)
+ .filter(pl.col("date") == latest)
+ .select("symbol", *[f"_rsi_avg_gain_{n}" for n in (6, 14, 24)],
+ *[f"_rsi_avg_loss_{n}" for n in (6, 14, 24)])
+ )
+ agg_a = agg_a.join(df_rsi, on="symbol", how="inner")
+
+ # 前复权因子: adj_factor = close(复权) / raw_close(原始)
+ if "raw_close" in df_hist.columns:
+ adj_factor_df = (
+ df_hist.filter(pl.col("date") == latest)
+ .select("symbol", (pl.col("close") / pl.col("raw_close")).alias("_adj_factor"))
+ )
+ agg_a = agg_a.join(adj_factor_df, on="symbol", how="left")
+ if "_adj_factor" in agg_a.columns:
+ agg_a = agg_a.with_columns(pl.col("_adj_factor").fill_null(1.0))
+
+ # annual_vol_20d 递推状态: 最近 19 天日收益率的部分和 / 平方和
+ df_daily_pct = (
+ df_hist.sort(["symbol", "date"])
+ .with_columns(
+ pl.col("close").pct_change().over("symbol").alias("_daily_pct")
+ )
+ )
+ df_vol = df_daily_pct.group_by("symbol").agg([
+ pl.col("_daily_pct").tail(19).sum().alias("_vol_19d_pct_sum"),
+ (pl.col("_daily_pct") ** 2).tail(19).sum().alias("_vol_19d_pct_sq_sum"),
+ ])
+ agg_a = agg_a.join(df_vol, on="symbol", how="left")
+
+ # 昨日连板数: 从 enriched parquet 取 (用于增量计算同向 +1)
+ lf = pl.scan_parquet(self._enriched_glob).filter(pl.col("date") == latest)
+ consec_cols = [c for c in ["symbol", "consecutive_limit_ups", "consecutive_limit_downs"]
+ if c in lf.collect_schema().names()]
+ if len(consec_cols) == 3:
+ consec_df = lf.select(consec_cols).collect()
+ if not consec_df.is_empty():
+ consec = consec_df.select(
+ "symbol",
+ pl.col("consecutive_limit_ups").alias("_prev_consec_up"),
+ pl.col("consecutive_limit_downs").alias("_prev_consec_down"),
+ )
+ agg_a = agg_a.join(consec, on="symbol", how="left")
+
+ # B类: 按 symbol 分组聚合 — 窗口统计
+ agg_b = (
+ df_hist.sort(["symbol", "date"])
+ .group_by("symbol")
+ .agg([
+ pl.col("close").tail(4).sum().alias("_ma5_partial_sum"),
+ pl.col("close").tail(9).sum().alias("_ma10_partial_sum"),
+ pl.col("close").tail(19).sum().alias("_ma20_partial_sum"),
+ pl.col("close").tail(29).sum().alias("_ma30_partial_sum"),
+ pl.col("close").tail(59).sum().alias("_ma60_partial_sum"),
+
+ pl.col("close").tail(19).sum().alias("_boll_partial_sum"),
+ (pl.col("close").tail(19) ** 2).sum().alias("_boll_partial_sq_sum"),
+
+ pl.col("high").tail(59).max().alias("_high_59d"),
+ pl.col("low").tail(59).min().alias("_low_59d"),
+
+ 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"),
+ pl.col("close").tail(30).first().alias("_close_30d_ago"),
+ pl.col("close").tail(60).first().alias("_close_60d_ago"),
+
+ pl.col("volume").tail(4).sum().alias("_vol_ma5_partial_sum"),
+ pl.col("volume").tail(9).sum().alias("_vol_ma10_partial_sum"),
+
+ pl.col("low").tail(8).min().alias("_kdj_8d_low"),
+ pl.col("high").tail(8).max().alias("_kdj_8d_high"),
+
+ pl.col("close").tail(59).len().alias("_window_len"),
+ ])
+ )
+
+ self._live_agg_cache = agg_a.join(agg_b, on="symbol", how="inner")
+ self._live_agg_cache_date = latest
+
+ def _live_agg_baseline_date(self, latest: date) -> date:
+ """盘中递推基准日期。当天实时分区存在时使用上一可用交易日。"""
+ if latest != date.today():
+ return latest
+ try:
+ row = self.execute_one(
+ "SELECT max(date) FROM kline_enriched WHERE date < ?",
+ [latest],
+ )
+ if row and row[0]:
+ d = row[0]
+ return d if isinstance(d, date) else date.fromisoformat(str(d))
+ except Exception: # noqa: BLE001
+ pass
+ return latest
+
+ def _build_live_agg_from_parquet(self, latest: date, start_60d: date) -> tuple[pl.DataFrame, pl.DataFrame]:
+ """降级路径: 从 parquet 读取数据并计算指标 (当 _enriched_history_cache 不可用时)。"""
+ from app.indicators.pipeline import compute_indicators
+
+ lf = (
+ pl.scan_parquet(self._enriched_glob)
+ .filter(pl.col("date") >= start_60d)
+ .filter(pl.col("date") <= latest)
+ .sort(["symbol", "date"])
+ )
+
+ read_cols = [c for c in ["symbol", "date", "open", "high", "low", "close", "volume",
+ "raw_close", "raw_high", "raw_low"]
+ if c in lf.collect_schema().names()]
+ df_hist = lf.select(read_cols).collect()
+
+ if df_hist.is_empty():
+ return df_hist, pl.DataFrame()
+
+ df_with_indicators = compute_indicators(df_hist)
+
+ state_cols = [
+ "symbol",
+ "ema5", "ema10", "ema20", "ema30", "ema60",
+ "macd_dea",
+ "kdj_k", "kdj_d",
+ "atr_14",
+ "close", "high", "low",
+ "annual_vol_20d",
+ ]
+ existing_state = [c for c in state_cols if c in df_with_indicators.columns]
+ agg_a = df_with_indicators.filter(pl.col("date") == latest).select(existing_state)
+
+ return df_hist, agg_a
+
+ def _refresh_instruments(self) -> None:
+ """加载 instruments 到内存。"""
+ try:
+ df = pl.scan_parquet(self._inst_glob).collect()
+ if not df.is_empty():
+ self._instruments_cache = df
+ logger.info("instruments 缓存已加载: %d 只", len(df))
+ except Exception as e: # noqa: BLE001
+ logger.warning("instruments 缓存刷新失败: %s", e)
+
+ def _refresh_index_instruments(self) -> None:
+ """加载指数 instruments 到内存。"""
+ try:
+ df = pl.scan_parquet(self._index_inst_glob).collect()
+ if not df.is_empty():
+ self._index_instruments_cache = df
+ logger.info("index instruments 缓存已加载: %d 只", len(df))
+ except Exception as e: # noqa: BLE001
+ logger.debug("index instruments 缓存刷新跳过: %s", e)
+
+ def get_enriched_latest(self) -> tuple[pl.DataFrame, date | None]:
+ """返回缓存的 enriched 最新日 DataFrame + 日期。如无缓存则懒加载。"""
+ if self._enriched_cache is None:
+ self._refresh_enriched()
+ if self._enriched_cache is None:
+ return pl.DataFrame(), self._enriched_cache_date
+ return self._enriched_cache, self._enriched_cache_date
+
+ def get_enriched_history(self, target_date: date, lookback_days: int) -> pl.DataFrame | None:
+ """返回预计算的 enriched 历史数据 (仅 lookback 范围, 不含 warmup)。
+
+ warmup 部分在 _refresh_enriched 计算指标时已使用, 策略只需要最终的 lookback 窗口。
+ 返回 ~33万行 (90日历天) 而非 ~107万行, filter_history 策略的 group_by 快 20x+。
+ """
+ cache = self._enriched_history_cache
+ if cache is None or cache.is_empty():
+ return None
+ if "date" not in cache.columns:
+ return None
+ cache_max = cache["date"].max()
+ cache_min = cache["date"].min()
+ from datetime import timedelta
+ # 验证缓存覆盖完整范围 (含 warmup)
+ warmup_start = target_date - timedelta(days=(lookback_days + 60) * 2)
+ if cache_min > warmup_start or cache_max < target_date:
+ return None
+ # 只返回 lookback 范围 (日历天数 ≈ 2/3 交易日, 足够覆盖)
+ lookback_start = target_date - timedelta(days=lookback_days)
+ return cache.filter((pl.col("date") >= lookback_start) & (pl.col("date") <= target_date))
+
+ def get_enriched_range(
+ self,
+ start: date,
+ end: date,
+ symbols: list[str] | None = None,
+ columns: list[str] | None = None,
+ ) -> pl.DataFrame | None:
+ """从预计算 enriched 历史缓存返回完整区间;缓存不覆盖时返回 None。"""
+ if self._enriched_history_cache is None:
+ self._refresh_enriched()
+ cache = self._enriched_history_cache
+ if cache is None or cache.is_empty() or "date" not in cache.columns:
+ return None
+
+ cache_min = cache["date"].min()
+ cache_max = cache["date"].max()
+ if cache_min > start or cache_max < end:
+ return None
+
+ df = cache.filter((pl.col("date") >= start) & (pl.col("date") <= end))
+ if symbols is not None:
+ df = df.filter(pl.col("symbol").is_in(symbols))
+ if columns and not df.is_empty():
+ existing = [c for c in columns if c in df.columns]
+ if "symbol" not in existing and "symbol" in df.columns:
+ existing.insert(0, "symbol")
+ if "date" not in existing and "date" in df.columns:
+ existing.insert(1, "date")
+ df = df.select(existing)
+ return df.sort(["symbol", "date"])
+
+ def get_live_agg(self) -> pl.DataFrame:
+ """返回盘中实时指标预计算聚合表。如无缓存则懒加载。"""
+ if self._live_agg_cache is None:
+ self._refresh_enriched()
+ if self._live_agg_cache is None:
+ return pl.DataFrame()
+ return self._live_agg_cache
+
+ def get_instruments(self) -> pl.DataFrame:
+ """返回缓存的 instruments DataFrame。如无缓存则懒加载。"""
+ if self._instruments_cache is None:
+ self._refresh_instruments()
+ if self._instruments_cache is None:
+ return pl.DataFrame()
+ return self._instruments_cache
+
+ def get_index_instruments(self) -> pl.DataFrame:
+ """返回缓存的指数 instruments DataFrame。如无缓存则懒加载。"""
+ if self._index_instruments_cache is None:
+ self._refresh_index_instruments()
+ if self._index_instruments_cache is None:
+ return pl.DataFrame()
+ return self._index_instruments_cache
+
+ def get_index_symbol_set(self) -> set[str]:
+ """返回已缓存指数 symbol 集合。"""
+ df = self.get_index_instruments()
+ if df.is_empty() or "symbol" not in df.columns:
+ return set()
+ return set(df["symbol"].cast(pl.Utf8).to_list())
+
+ def enriched_latest_date(self) -> date | None:
+ """返回缓存中的 enriched 最新日期。"""
+ return self._enriched_cache_date
+
+ # ================================================================
+ # 热路径: Polars 查询 (Chart / Screener / Signals / Intraday)
+ # ================================================================
+
+ def get_daily(
+ self,
+ symbol: str,
+ start: date,
+ end: date,
+ columns: list[str] | None = None,
+ ) -> pl.DataFrame:
+ """单股日K查询 — 从14列parquet读取后即时计算指标。"""
+ from datetime import timedelta
+
+ # 扩展范围用于指标预热 (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)
+
+ # 尝试用缓存数据覆盖最新日 (盘中更准确)
+ cached, cache_date = self.get_enriched_latest()
+ if not df.is_empty() and cached is not None and not cached.is_empty() and cache_date:
+ if start <= cache_date <= end:
+ cached_part = self._filter_cached(cached, symbol, None)
+ if not cached_part.is_empty():
+ df = df.filter(pl.col("date") != cache_date)
+ common_cols = [c for c in df.columns if c in cached_part.columns]
+ df = pl.concat([df.select(common_cols), cached_part.select(common_cols)])
+
+ # 裁剪到请求范围
+ if not df.is_empty():
+ df = df.filter((pl.col("date") >= start) & (pl.col("date") <= end))
+
+ if columns and not df.is_empty():
+ existing = [c for c in columns if c in df.columns]
+ df = df.select(existing)
+
+ return df
+
+ def get_daily_batch(
+ self,
+ symbols: list[str],
+ start: date,
+ end: date,
+ columns: list[str] | None = None,
+ ) -> pl.DataFrame:
+ """批量日K查询。"""
+ cached, cache_date = self.get_enriched_latest()
+ if cached is not None and not cached.is_empty() and cache_date:
+ if start >= cache_date:
+ return self._filter_cached_batch(cached, symbols, columns)
+
+ # 回退 scan_parquet
+ return self._scan_daily_batch(symbols, start, end, columns)
+
+ def get_index_daily(
+ self,
+ symbol: str,
+ start: date,
+ end: date,
+ columns: list[str] | None = None,
+ ) -> pl.DataFrame:
+ """指数日K查询 — 从独立指数 enriched parquet 读取后即时计算通用指标。"""
+ from datetime import timedelta
+
+ warmup_start = start - timedelta(days=150)
+ df = self._scan_index_daily_symbol(symbol, warmup_start, end, None)
+ if not df.is_empty():
+ df = self._compute_index_enriched_range(df)
+ df = df.filter((pl.col("date") >= start) & (pl.col("date") <= end))
+ if columns and not df.is_empty():
+ existing = [c for c in columns if c in df.columns]
+ df = df.select(existing)
+ return df
+
+ def get_minute(
+ self,
+ symbol: str,
+ trade_date: date,
+ ) -> pl.DataFrame:
+ """分钟K查询 — Polars scan_parquet + predicate pushdown。"""
+ try:
+ return pl.scan_parquet(self._minute_glob).filter(
+ (pl.col("symbol") == symbol)
+ & (pl.col("datetime").dt.date() == trade_date)
+ ).sort("datetime").collect()
+ except Exception as e: # noqa: BLE001
+ logger.warning("分钟K查询失败: %s", e)
+ return pl.DataFrame()
+
+ # ================================================================
+ # Polars 查询内部方法
+ # ================================================================
+
+ def _compute_enriched_range(self, df: pl.DataFrame) -> pl.DataFrame:
+ """对14列enriched数据即时计算完整指标+信号。输入应含足够预热行数。"""
+ from app.indicators.pipeline import compute_indicators, compute_signals, compute_limit_signals, filter_halt_days
+ if df.is_empty() or df.height < 2:
+ return df
+ # 兜底过滤历史脏数据中的停牌日 (close 可能被填充为前收盘价)
+ df = filter_halt_days(df)
+ if df.is_empty() or df.height < 2:
+ return df
+ try:
+ df = compute_indicators(df)
+ df = compute_signals(df)
+ instruments = self.get_instruments()
+ df = compute_limit_signals(df, instruments)
+ except Exception as e: # noqa: BLE001
+ logger.warning("on-demand compute failed: %s", e)
+ return df
+
+ def _compute_index_enriched_range(self, df: pl.DataFrame) -> pl.DataFrame:
+ """指数只计算通用技术指标和通用信号,跳过涨跌停/股本/市值逻辑。"""
+ from app.indicators.pipeline import compute_indicators, compute_signals
+ if df.is_empty() or df.height < 2:
+ return df
+ try:
+ df = compute_indicators(df)
+ df = compute_signals(df)
+ except Exception as e: # noqa: BLE001
+ logger.warning("index on-demand compute failed: %s", e)
+ return df
+
+ def _filter_cached(self, cached: pl.DataFrame, symbol: str, columns: list[str] | None) -> pl.DataFrame:
+ df = cached.filter(pl.col("symbol") == symbol)
+ if columns and not df.is_empty():
+ existing = [c for c in columns if c in df.columns]
+ df = df.select(existing)
+ return df
+
+ def _filter_cached_batch(self, cached: pl.DataFrame, symbols: list[str], columns: list[str] | None) -> pl.DataFrame:
+ df = cached.filter(pl.col("symbol").is_in(symbols))
+ if columns and not df.is_empty():
+ existing = [c for c in columns if c in df.columns]
+ df = df.select(existing)
+ return df.sort(["symbol", "date"])
+
+ def _scan_daily_symbol(self, symbol: str, start: date, end: date, columns: list[str] | None) -> pl.DataFrame:
+ try:
+ lf = pl.scan_parquet(self._enriched_glob,
+ cast_options=pl.ScanCastOptions(integer_cast="allow-float")).filter(
+ (pl.col("symbol") == symbol)
+ & (pl.col("date") >= start)
+ & (pl.col("date") <= end)
+ ).sort("date")
+ if columns:
+ schema_names = lf.collect_schema().names()
+ existing = [c for c in columns if c in schema_names]
+ lf = lf.select(existing)
+ return lf.collect()
+ except Exception as e: # noqa: BLE001
+ logger.warning("日K查询失败: %s", e)
+ return pl.DataFrame()
+
+ def _scan_daily_batch(self, symbols: list[str], start: date, end: date, columns: list[str] | None) -> pl.DataFrame:
+ try:
+ lf = pl.scan_parquet(self._enriched_glob,
+ cast_options=pl.ScanCastOptions(integer_cast="allow-float")).filter(
+ (pl.col("symbol").is_in(symbols))
+ & (pl.col("date") >= start)
+ & (pl.col("date") <= end)
+ ).sort(["symbol", "date"])
+ if columns:
+ schema_names = lf.collect_schema().names()
+ existing = [c for c in columns if c in schema_names]
+ lf = lf.select(existing)
+ return lf.collect()
+ except Exception as e: # noqa: BLE001
+ logger.warning("日K批量查询失败: %s", e)
+ return pl.DataFrame()
+
+ def _scan_index_daily_symbol(self, symbol: str, start: date, end: date, columns: list[str] | None) -> pl.DataFrame:
+ try:
+ lf = pl.scan_parquet(self._index_enriched_glob,
+ cast_options=pl.ScanCastOptions(integer_cast="allow-float")).filter(
+ (pl.col("symbol") == symbol)
+ & (pl.col("date") >= start)
+ & (pl.col("date") <= end)
+ ).sort("date")
+ if columns:
+ schema_names = lf.collect_schema().names()
+ existing = [c for c in columns if c in schema_names]
+ lf = lf.select(existing)
+ return lf.collect()
+ except Exception as e: # noqa: BLE001
+ logger.warning("指数日K查询失败: %s", e)
+ return pl.DataFrame()
+
+ def _merge_cached_and_scan(
+ self,
+ cached: pl.DataFrame,
+ cache_date: date,
+ symbol: str,
+ start: date,
+ end: date,
+ columns: list[str] | None,
+ ) -> pl.DataFrame:
+ """合并缓存部分 + scan 历史部分。
+
+ 历史部分用 strict < cache_date, 避免与缓存重复。
+ 两部分 schema 可能不一致 (增量 vs 全量), concat 前对齐列。
+ """
+ hist = self._scan_daily_symbol(symbol, start, cache_date, columns)
+ cached_part = self._filter_cached(cached, symbol, columns)
+ if hist.is_empty():
+ return cached_part
+ if cached_part.is_empty():
+ return hist
+ # 去重: 历史部分可能包含 cache_date, 去掉后再合并
+ hist = hist.filter(pl.col("date") < cache_date)
+ # 对齐列: 取交集, 统一类型
+ common_cols = [c for c in hist.columns if c in cached_part.columns]
+ hist = hist.select(common_cols)
+ cached_part = cached_part.select(common_cols)
+ # 统一类型: 历史可能是 Float64, 缓存可能是 Int64, 统一为 cast
+ for c in common_cols:
+ if hist[c].dtype != cached_part[c].dtype:
+ # 统一到更宽的类型
+ target = hist[c].dtype if hist.height > cached_part.height else cached_part[c].dtype
+ hist = hist.with_columns(pl.col(c).cast(target))
+ cached_part = cached_part.with_columns(pl.col(c).cast(target))
+ return pl.concat([hist, cached_part])
+
+ # ================================================================
+ # DuckDB 查询 (冷路径: 统计/元数据/自定义SQL)
+ # ================================================================
+
+ def latest_minute_date(self, symbol: str) -> date | None:
+ try:
+ with self._lock:
+ row = self.db.execute(
+ "SELECT max(CAST(datetime AS DATE)) FROM kline_minute WHERE symbol = ?",
+ [symbol],
+ ).fetchone()
+ if row and row[0]:
+ return row[0] if isinstance(row[0], date) else date.fromisoformat(str(row[0]))
+ except duckdb.CatalogException:
+ pass
+ return None
+
+ def earliest_daily_date(self) -> date | None:
+ """本地日K数据的最早日期。"""
+ try:
+ with self._lock:
+ res = self.db.execute(
+ "SELECT min(date) FROM kline_daily",
+ ).fetchone()
+ if res and res[0]:
+ d = res[0]
+ return d if isinstance(d, date) else date.fromisoformat(str(d))
+ except Exception:
+ return None
+ return None
+
+ def earliest_minute_date(self) -> date | None:
+ """本地分钟K数据的最早日期。"""
+ try:
+ with self._lock:
+ res = self.db.execute(
+ "SELECT min(CAST(datetime AS DATE)) FROM kline_minute",
+ ).fetchone()
+ if res and res[0]:
+ d = res[0]
+ return d if isinstance(d, date) else date.fromisoformat(str(d))
+ except Exception:
+ return None
+ return None
+
+ def latest_daily_date(self) -> date | None:
+ """本地日K数据的最新日期。"""
+ try:
+ with self._lock:
+ res = self.db.execute(
+ "SELECT max(date) FROM kline_daily",
+ ).fetchone()
+ if res and res[0]:
+ d = res[0]
+ return d if isinstance(d, date) else date.fromisoformat(str(d))
+ except Exception:
+ return None
+ return None
+
+ def _latest_enriched_date_duckdb(self) -> date | None:
+ try:
+ with self._lock:
+ res = self.db.execute(
+ "SELECT max(date) FROM kline_enriched",
+ ).fetchone()
+ if res and res[0]:
+ d = res[0]
+ return d if isinstance(d, date) else date.fromisoformat(str(d))
+ except Exception: # noqa: BLE001
+ return None
+ return None
+
+ # ================================================================
+ # 写入 (Pipeline / Sync)
+ # ================================================================
+
+ def append_daily(self, df: pl.DataFrame) -> None:
+ """按日分区写入日K数据 (merge-upsert)。"""
+ if df.is_empty():
+ return
+ self._write_daily_partition(df, "kline_daily")
+
+ def append_enriched(self, df: pl.DataFrame) -> None:
+ """按日分区写入 enriched 数据 (merge-upsert)。磁盘仅写入 14 列存储列。"""
+ if df.is_empty():
+ return
+ 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)
+ self._write_daily_partition(df_storage, "kline_daily_enriched")
+
+ def append_index_daily(self, df: pl.DataFrame) -> None:
+ """按日分区写入指数日K数据 (merge-upsert)。"""
+ if df.is_empty():
+ return
+ self._write_daily_partition(df, "kline_index_daily")
+
+ def append_index_enriched(self, df: pl.DataFrame) -> None:
+ """按日分区写入指数 enriched 数据。磁盘仅写入通用基础行情窄表。"""
+ if df.is_empty():
+ return
+ 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)
+ self._write_daily_partition(df_storage, "kline_index_enriched")
+
+ def save_index_instruments(self, df: pl.DataFrame) -> None:
+ """保存指数标的维表。"""
+ if df.is_empty() or "symbol" not in df.columns:
+ return
+ out = self.store.data_dir / "instruments_index" / "instruments_index.parquet"
+ out.parent.mkdir(parents=True, exist_ok=True)
+ df.unique(subset=["symbol"], keep="last").sort("symbol").write_parquet(out)
+ self._index_instruments_cache = None
+ self._refresh_index_instruments()
+
+ def refresh_index_views(self) -> None:
+ """刷新指数相关 DuckDB 视图。"""
+ d = self.store.data_dir.as_posix()
+ statements = [
+ f"""CREATE OR REPLACE VIEW kline_index_daily AS
+ SELECT * FROM read_parquet('{d}/kline_index_daily/**/*.parquet', union_by_name=true)""",
+ f"""CREATE OR REPLACE VIEW kline_index_enriched AS
+ SELECT * FROM read_parquet('{d}/kline_index_enriched/**/*.parquet', union_by_name=true)""",
+ f"""CREATE OR REPLACE VIEW instruments_index AS
+ SELECT * FROM read_parquet('{d}/instruments_index/**/*.parquet', union_by_name=true)""",
+ ]
+ for sql in statements:
+ try:
+ with self._lock:
+ self.db.execute(sql)
+ except Exception as e: # noqa: BLE001
+ logger.debug("index view refresh skipped: %s", e)
+
+ def _write_daily_partition(self, df: pl.DataFrame, table: str) -> None:
+ """按 date 分区写入 parquet,每个日期一个文件,支持 merge-upsert。"""
+ base = self.store.data_dir / table
+ 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)
+ 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"])
+ date_df.write_parquet(out)
+
+ def flush_live_daily(self, df: pl.DataFrame) -> None:
+ """覆写当天 kline_daily 分区 (实时行情落盘, 非merge)。"""
+ if df.is_empty() or "date" not in df.columns:
+ return
+ base = self.store.data_dir / "kline_daily"
+ dt = 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)
+ df.sort(["symbol", "date"]).write_parquet(out)
+
+ def flush_live_enriched(self, df: pl.DataFrame) -> None:
+ """覆写当天 kline_daily_enriched 分区 (实时 enriched 落盘, 非merge)。
+
+ 内存缓存保留完整指标列供各服务使用,磁盘仅写入 14 列存储列。
+ """
+ if df.is_empty() or "date" not in df.columns:
+ return
+ # 内存缓存: 保留完整 66 列
+ self._enriched_cache = df.sort(["symbol"])
+ dt = df["date"][0]
+ self._enriched_cache_date = dt
+ # 磁盘写入: 仅 14 列存储列
+ 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 / "kline_daily_enriched"
+ 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)
+ df_storage.write_parquet(out)
diff --git a/backend/app/tickflow/scheduler.py b/backend/app/tickflow/scheduler.py
new file mode 100644
index 0000000..588b67c
--- /dev/null
+++ b/backend/app/tickflow/scheduler.py
@@ -0,0 +1,66 @@
+"""请求调度器(§5.6)。
+
+按 capability 分别维护令牌桶。Phase 0:基础实现;Phase 1 接入批量合并、优先级队列。
+"""
+from __future__ import annotations
+
+import asyncio
+import time
+from dataclasses import dataclass
+
+from .capabilities import Cap, CapabilitySet
+
+
+@dataclass
+class _Bucket:
+ capacity: int # 每分钟令牌数
+ tokens: float
+ last_refill: float # 单位:秒
+
+ def consume(self, n: int = 1) -> float:
+ """尝试消费 n 个令牌,返回需要等待的秒数(0 表示无需等待)。"""
+ now = time.monotonic()
+ elapsed = now - self.last_refill
+ # 60s 内补满 capacity,匀速补
+ refill = elapsed * (self.capacity / 60.0)
+ self.tokens = min(self.capacity, self.tokens + refill)
+ self.last_refill = now
+
+ if self.tokens >= n:
+ self.tokens -= n
+ return 0.0
+ deficit = n - self.tokens
+ # 还需多少秒才能补齐
+ wait = deficit / (self.capacity / 60.0)
+ # 不预扣,留给下一次再竞争(避免饿死优先级高的请求)
+ return wait
+
+
+class Scheduler:
+ """每个 capability 一个桶。"""
+
+ def __init__(self, capset: CapabilitySet) -> None:
+ self._capset = capset
+ self._buckets: dict[Cap, _Bucket] = {}
+ self._locks: dict[Cap, asyncio.Lock] = {}
+ for cap, lim in capset.all().items():
+ if lim.rpm:
+ self._buckets[cap] = _Bucket(
+ capacity=lim.rpm,
+ tokens=lim.rpm,
+ last_refill=time.monotonic(),
+ )
+ self._locks[cap] = asyncio.Lock()
+
+ async def acquire(self, cap: Cap, n: int = 1) -> None:
+ """阻塞直到拿到 n 个令牌。无桶 = 不限流(由调用方保证)。"""
+ bucket = self._buckets.get(cap)
+ if bucket is None:
+ return
+ lock = self._locks[cap]
+ async with lock:
+ while True:
+ wait = bucket.consume(n)
+ if wait == 0:
+ return
+ await asyncio.sleep(wait)
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
new file mode 100644
index 0000000..068f4b2
--- /dev/null
+++ b/backend/pyproject.toml
@@ -0,0 +1,65 @@
+[project]
+name = "tf-stocks-panel-backend"
+version = "0.1.0"
+description = "A 股选股 + 监控 + 回测面板 — TickFlow 适配"
+readme = "../README.md"
+requires-python = ">=3.11"
+license = { text = "MIT" }
+dependencies = [
+ # Web
+ "fastapi>=0.115",
+ "uvicorn[standard]>=0.30",
+ "pydantic>=2.7",
+ "pydantic-settings>=2.4",
+ "python-multipart>=0.0.6",
+ "sse-starlette>=2.0",
+ # Data
+ "polars>=1.0",
+ "duckdb>=1.0",
+ "pyarrow>=16.0",
+ "pandas>=2.2", # 仅在 BacktestService 边界使用,见 §7.4 / ADR-19
+ "fastexcel>=0.10", # Polars 读取 xlsx/xls
+ # TickFlow 官方 SDK
+ "tickflow[all]>=0.1.21",
+ # Scheduling
+ "apscheduler>=3.10",
+ # Config
+ "pyyaml>=6.0",
+ "python-dotenv>=1.0",
+ # AI(可选,但默认装上)
+ "openai>=1.40", # OpenAI 兼容适配器复用 openai SDK
+ "httpx>=0.27",
+]
+
+[project.optional-dependencies]
+# 回测依赖 vectorbt → numba → llvmlite,体积大且 macOS/Intel 上无预构建 wheel 时
+# 需要 brew install cmake 现场编译。挪到可选 extras,主依赖瘦身。
+# 启用:`uv sync --extra backtest`
+backtest = [
+ "vectorbt>=0.26",
+]
+
+dev = [
+ "pytest>=8.0",
+ "pytest-asyncio>=0.23",
+ "ruff>=0.5",
+ "mypy>=1.10",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["app"]
+
+[tool.ruff]
+line-length = 100
+target-version = "py311"
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "N", "UP", "B", "SIM", "RUF"]
+ignore = ["E501"] # 长行交给 fmt
+
+[tool.pytest.ini_options]
+asyncio_mode = "auto"
diff --git a/backend/scripts/__init__.py b/backend/scripts/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/scripts/cleanup_halt_days.py b/backend/scripts/cleanup_halt_days.py
new file mode 100644
index 0000000..43174f8
--- /dev/null
+++ b/backend/scripts/cleanup_halt_days.py
@@ -0,0 +1,96 @@
+#!/usr/bin/env python
+"""一次性清理脚本:移除已入库的停牌脏数据。
+
+背景:历史停牌过滤条件为 "OHLC 全零",会漏过 close 被数据源填充为
+前收盘价的停牌记录(如 *ST 撤销风险警示的停牌日),导致日 K 图出现
+开盘价为 0 的异常蜡烛。停牌过滤已改用 "open==0 且 high==0",本脚本
+负责清理既有脏数据,可重复执行(幂等)。
+
+用法(从 backend/ 目录运行):
+ .venv/bin/python -m scripts.cleanup_halt_days # 清理 + dry-run 关闭
+ .venv/bin/python -m scripts.cleanup_halt_days --dry-run # 仅扫描,不写盘
+"""
+from __future__ import annotations
+
+import argparse
+import logging
+from pathlib import Path
+
+import polars as pl
+
+logger = logging.getLogger(__name__)
+
+# kline_daily 原始表与 enriched 表的脏数据都在这些分区里
+HALT_TABLES = ["kline_daily", "kline_daily_enriched"]
+DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data"
+HALT_PRED = (pl.col("open") == 0) & (pl.col("high") == 0)
+
+
+def _scan_dirty(table: str) -> pl.DataFrame:
+ glob = str(DATA_DIR / table / "**" / "*.parquet")
+ cast = pl.ScanCastOptions(integer_cast="allow-float")
+ return (
+ pl.scan_parquet(glob, hive_partitioning=True, cast_options=cast)
+ .filter(HALT_PRED)
+ .select("symbol", "date")
+ .collect()
+ )
+
+
+def _clean_table(table: str, dry_run: bool) -> int:
+ """清理单张表所有脏分区,返回被删除的行数。"""
+ dirty = _scan_dirty(table)
+ if dirty.is_empty():
+ logger.info("[%s] 无脏数据", table)
+ return 0
+
+ removed = 0
+ base = DATA_DIR / table
+ for dt in dirty["date"].unique().sort():
+ part = base / f"date={dt}" / "part.parquet"
+ if not part.exists():
+ logger.warning("[%s] 分区文件不存在: %s", table, part)
+ continue
+ df = pl.read_parquet(part)
+ before = df.height
+ cleaned = df.filter(~HALT_PRED)
+ after = cleaned.height
+ diff = before - after
+ if diff == 0:
+ continue
+ removed += diff
+ if dry_run:
+ logger.info("[%s %s] 将删除 %d 行 (停牌), 剩余 %d 行 [dry-run]",
+ table, dt, diff, after)
+ continue
+ if after == 0:
+ part.unlink()
+ logger.info("[%s %s] 删除 %d 行后分区为空, 已移除文件", table, dt, diff)
+ else:
+ cleaned.write_parquet(part)
+ logger.info("[%s %s] 删除 %d 行 (停牌), 剩余 %d 行已重写",
+ table, dt, diff, after)
+ return removed
+
+
+def main() -> None:
+ ap = argparse.ArgumentParser(description="清理已入库的停牌脏数据")
+ ap.add_argument("--dry-run", action="store_true", help="仅扫描不写盘")
+ args = ap.parse_args()
+
+ logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s %(levelname)s %(message)s",
+ datefmt="%H:%M:%S",
+ )
+
+ total = 0
+ for table in HALT_TABLES:
+ total += _clean_table(table, args.dry_run)
+
+ mode = "dry-run 扫描" if args.dry_run else "已清理"
+ logger.info("完成: 共 %s %d 行停牌脏数据", mode, total)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/tests/backtest/test_engine_portfolio.py b/backend/tests/backtest/test_engine_portfolio.py
new file mode 100644
index 0000000..8a36cb6
--- /dev/null
+++ b/backend/tests/backtest/test_engine_portfolio.py
@@ -0,0 +1,312 @@
+from __future__ import annotations
+
+from datetime import date, timedelta
+
+import polars as pl
+
+from app.backtest.engine import BacktestEngine, MatcherConfig
+
+
+def _panel(symbols: list[str], days: int = 4, price: float = 10.0, overrides: dict[tuple[str, int], dict] | None = None) -> pl.DataFrame:
+ overrides = overrides or {}
+ start = date(2024, 1, 1)
+ rows = []
+ for sym in symbols:
+ for i in range(days):
+ patch = overrides.get((sym, i), {})
+ rows.append({
+ "symbol": sym,
+ "name": sym,
+ "date": start + timedelta(days=i),
+ "open": patch.get("open", price),
+ "high": patch.get("high", price),
+ "low": patch.get("low", price),
+ "close": patch.get("close", price),
+ "volume": patch.get("volume", 100_000),
+ "score": patch.get("score", {"A": 4, "B": 3, "C": 2, "D": 1}.get(sym, 0)),
+ "signal_limit_up": patch.get("signal_limit_up", False),
+ "signal_limit_down": patch.get("signal_limit_down", False),
+ })
+ return pl.DataFrame(rows).sort(["symbol", "date"])
+
+
+def _mask(panel: pl.DataFrame, marks: set[tuple[str, int]]) -> pl.Series:
+ values = []
+ base = date(2024, 1, 1)
+ for row in panel.select(["symbol", "date"]).iter_rows(named=True):
+ day = (row["date"] - base).days
+ values.append((row["symbol"], day) in marks)
+ return pl.Series(values, dtype=pl.Boolean)
+
+
+def _engine() -> BacktestEngine:
+ return BacktestEngine(repo=None) # simulate_portfolio 不访问 repo
+
+
+def test_max_exposure_sets_target_position_and_caps_count():
+ panel = _panel(["A", "B", "C", "D"], days=3)
+ entries = _mask(panel, {("A", 0), ("B", 0), ("C", 0), ("D", 0)})
+ exits = _mask(panel, set())
+
+ result = _engine().simulate_portfolio(
+ panel,
+ entries,
+ exits,
+ MatcherConfig(
+ matching="open_t+1",
+ fees_pct=0,
+ slippage_bps=0,
+ max_positions=3,
+ max_exposure_pct=0.6,
+ initial_capital=100_000,
+ ),
+ )
+
+ assert len(result.trades) == 3
+ assert {t.symbol for t in result.trades} == {"A", "B", "C"}
+ assert all(abs(t.position_pct - 0.2) < 0.001 for t in result.trades)
+ assert result.stats["max_exposure"] <= 0.61
+
+
+def test_one_price_limit_up_blocks_buy():
+ panel = _panel(
+ ["A"],
+ days=3,
+ overrides={
+ ("A", 1): {"open": 11, "high": 11, "low": 11, "close": 11, "signal_limit_up": True},
+ },
+ )
+ entries = _mask(panel, {("A", 0)})
+ exits = _mask(panel, set())
+
+ result = _engine().simulate_portfolio(
+ panel,
+ entries,
+ exits,
+ MatcherConfig(matching="open_t+1", fees_pct=0, slippage_bps=0, max_positions=1, initial_capital=100_000),
+ )
+
+ assert result.trades == []
+ assert result.stats["execution"]["buy_limit_up"] == 1
+
+
+def test_failed_open_exit_keeps_slot_and_blocks_replacement_buy():
+ panel = _panel(
+ ["A", "B", "C", "D"],
+ days=4,
+ overrides={
+ ("A", 2): {"open": 9, "high": 9, "low": 9, "close": 9, "signal_limit_down": True},
+ },
+ )
+ entries = _mask(panel, {
+ ("A", 0), ("B", 0), ("C", 0),
+ ("D", 1),
+ })
+ exits = _mask(panel, {("A", 1)})
+
+ result = _engine().simulate_portfolio(
+ panel,
+ entries,
+ exits,
+ MatcherConfig(
+ matching="open_t+1",
+ fees_pct=0,
+ slippage_bps=0,
+ max_positions=3,
+ max_exposure_pct=0.6,
+ initial_capital=100_000,
+ ),
+ )
+
+ assert "D" not in {t.symbol for t in result.trades}
+ assert result.stats["execution"]["sell_limit_down"] == 1
+ assert result.stats["execution"]["pending_exit"] == 1
+ assert result.stats["execution"]["buy_no_slot"] >= 1
+ a_trade = next(t for t in result.trades if t.symbol == "A")
+ assert a_trade.blocked_exit_days == 1
+ assert a_trade.exit_reason == "signal"
+
+
+def test_trailing_stop_uses_high_water_mark():
+ panel = _panel(
+ ["A"],
+ days=5,
+ overrides={
+ ("A", 2): {"open": 10, "high": 12, "low": 11.8, "close": 12},
+ ("A", 3): {"open": 12, "high": 12, "low": 11.3, "close": 11.3},
+ },
+ )
+ entries = _mask(panel, {("A", 0)})
+ exits = _mask(panel, set())
+
+ result = _engine().simulate_portfolio(
+ panel,
+ entries,
+ exits,
+ MatcherConfig(
+ matching="open_t+1",
+ fees_pct=0,
+ slippage_bps=0,
+ max_positions=1,
+ initial_capital=100_000,
+ trailing_stop_pct=0.05,
+ ),
+ )
+
+ assert len(result.trades) == 1
+ trade = result.trades[0]
+ assert trade.exit_reason == "trailing_stop"
+ assert trade.exit_price == 11.4
+
+
+def test_trailing_take_profit_requires_activation():
+ panel = _panel(
+ ["A"],
+ days=5,
+ overrides={
+ ("A", 2): {"open": 10, "high": 10.8, "low": 10.4, "close": 10.8},
+ ("A", 3): {"open": 10.8, "high": 10.8, "low": 10.4, "close": 10.4},
+ },
+ )
+ entries = _mask(panel, {("A", 0)})
+ exits = _mask(panel, set())
+
+ result = _engine().simulate_portfolio(
+ panel,
+ entries,
+ exits,
+ MatcherConfig(
+ matching="open_t+1",
+ fees_pct=0,
+ slippage_bps=0,
+ max_positions=1,
+ initial_capital=100_000,
+ trailing_take_profit_activate_pct=0.10,
+ trailing_take_profit_drawdown_pct=0.03,
+ ),
+ )
+
+ assert result.trades[0].exit_reason == "end"
+
+
+def test_trailing_take_profit_exits_after_activation():
+ panel = _panel(
+ ["A"],
+ days=5,
+ overrides={
+ ("A", 2): {"open": 10, "high": 12, "low": 11.8, "close": 12},
+ ("A", 3): {"open": 12, "high": 12, "low": 11.5, "close": 11.5},
+ },
+ )
+ entries = _mask(panel, {("A", 0)})
+ exits = _mask(panel, set())
+
+ result = _engine().simulate_portfolio(
+ panel,
+ entries,
+ exits,
+ MatcherConfig(
+ matching="open_t+1",
+ fees_pct=0,
+ slippage_bps=0,
+ max_positions=1,
+ initial_capital=100_000,
+ trailing_take_profit_activate_pct=0.10,
+ trailing_take_profit_drawdown_pct=0.03,
+ ),
+ )
+
+ assert len(result.trades) == 1
+ trade = result.trades[0]
+ assert trade.exit_reason == "trailing_take_profit"
+ assert trade.exit_price == 11.7
+
+
+def test_score_filter_uses_signal_day_score_range():
+ panel = _panel(
+ ["A", "B", "C"],
+ days=3,
+ overrides={
+ ("A", 0): {"score": 70},
+ ("B", 0): {"score": 80},
+ ("C", 0): {"score": 90},
+ ("A", 1): {"score": 100},
+ ("B", 1): {"score": 1},
+ ("C", 1): {"score": 1},
+ },
+ )
+ entries = _mask(panel, {("A", 0), ("B", 0), ("C", 0)})
+ exits = _mask(panel, set())
+
+ result = _engine().simulate_portfolio(
+ panel,
+ entries,
+ exits,
+ MatcherConfig(
+ matching="open_t+1",
+ fees_pct=0,
+ slippage_bps=0,
+ max_positions=3,
+ initial_capital=100_000,
+ score_min=71,
+ score_max=85,
+ ),
+ )
+
+ assert {t.symbol for t in result.trades} == {"B"}
+ assert result.trades[0].entry_score == 80
+ assert result.stats["execution"]["buy_score_filter"] == 2
+
+
+def test_independent_candidates_allow_overlapping_same_symbol_trades():
+ panel = _panel(
+ ["A"],
+ days=5,
+ overrides={
+ ("A", 0): {"close": 10},
+ ("A", 1): {"close": 11},
+ ("A", 2): {"close": 12},
+ ("A", 3): {"close": 13},
+ ("A", 4): {"close": 14},
+ },
+ )
+ entries = _mask(panel, {("A", 0), ("A", 1)})
+ exits = _mask(panel, set())
+
+ result = _engine().simulate_independent_candidates(
+ panel,
+ entries,
+ exits,
+ MatcherConfig(matching="close_t", fees_pct=0, slippage_bps=0, max_hold_days=2),
+ )
+
+ assert result.stats["full_kind"] == "candidate_execution"
+ assert result.stats["n_candidates"] == 2
+ assert len(result.trades) == 2
+ assert [t.entry_date for t in result.trades] == ["2024-01-01", "2024-01-02"]
+ assert [t.exit_date for t in result.trades] == ["2024-01-03", "2024-01-04"]
+ assert all(t.exit_reason == "max_hold" for t in result.trades)
+
+
+def test_independent_candidates_apply_stop_loss():
+ panel = _panel(
+ ["A"],
+ days=4,
+ overrides={
+ ("A", 0): {"close": 10, "low": 10},
+ ("A", 1): {"open": 10, "high": 10, "low": 8.9, "close": 9},
+ },
+ )
+ entries = _mask(panel, {("A", 0)})
+ exits = _mask(panel, set())
+
+ result = _engine().simulate_independent_candidates(
+ panel,
+ entries,
+ exits,
+ MatcherConfig(matching="close_t", fees_pct=0, slippage_bps=0, stop_loss_pct=0.1),
+ )
+
+ assert len(result.trades) == 1
+ assert result.trades[0].exit_reason == "stop_loss"
+ assert result.trades[0].exit_price == 9.0
diff --git a/backend/tests/backtest/test_full_simulation_tail.py b/backend/tests/backtest/test_full_simulation_tail.py
new file mode 100644
index 0000000..f5352b9
--- /dev/null
+++ b/backend/tests/backtest/test_full_simulation_tail.py
@@ -0,0 +1,59 @@
+"""全量模拟 (full mode) 尾部执行回归测试。"""
+from __future__ import annotations
+
+from datetime import date, timedelta
+
+import polars as pl
+
+from app.backtest.engine import BacktestEngine, MatcherConfig
+
+
+def _panel_with_tail(symbols: list[str], n_data_days: int) -> pl.DataFrame:
+ start = date(2024, 1, 1)
+ rows = []
+ for sym in symbols:
+ for i in range(n_data_days):
+ px = 10.0 + i
+ rows.append({
+ "symbol": sym,
+ "date": start + timedelta(days=i),
+ "open": px,
+ "high": px,
+ "low": px,
+ "close": px,
+ "volume": 100_000,
+ "signal_limit_up": False,
+ "signal_limit_down": False,
+ })
+ return pl.DataFrame(rows).sort(["symbol", "date"])
+
+
+def test_full_simulation_executes_signal_at_tail():
+ """信号集中在正式区间最后一天时, tail 数据应允许次日开盘买入并按策略退出。"""
+ n_days = 6
+ panel = _panel_with_tail(["A"], n_days + 3)
+
+ start = date(2024, 1, 1)
+ end = start + timedelta(days=n_days - 1)
+ entry_vals = []
+ for row in panel.select(["symbol", "date"]).iter_rows(named=True):
+ entry_vals.append(row["date"] == end)
+ entry_mask = pl.Series(entry_vals, dtype=pl.Boolean)
+ exit_mask = pl.Series([False] * len(panel), dtype=pl.Boolean)
+
+ result = BacktestEngine(repo=None).simulate_independent_candidates( # type: ignore[arg-type]
+ panel,
+ entry_mask,
+ exit_mask,
+ MatcherConfig(matching="open_t+1", fees_pct=0, slippage_bps=0, max_hold_days=2),
+ )
+
+ assert not result.stats.get("error"), f"unexpected error: {result.stats.get('error')}"
+ assert result.stats.get("full_kind") == "candidate_execution"
+ assert result.stats.get("n_candidates") == 1
+ assert result.stats.get("n_trades") == 1
+ assert len(result.trades) == 1
+ trade = result.trades[0]
+ assert trade.entry_signal_date == str(end)
+ assert trade.entry_date == str(end + timedelta(days=1))
+ assert trade.exit_reason == "max_hold"
diff --git a/backend/tests/backtest/test_strategy_backtest_correctness.py b/backend/tests/backtest/test_strategy_backtest_correctness.py
new file mode 100644
index 0000000..6b55ba8
--- /dev/null
+++ b/backend/tests/backtest/test_strategy_backtest_correctness.py
@@ -0,0 +1,163 @@
+from __future__ import annotations
+
+from datetime import date, timedelta
+from types import SimpleNamespace
+
+import polars as pl
+
+from app.backtest.engine import BacktestEngine, SimResult
+from app.backtest.strategy import StrategyBacktestConfig, StrategyBacktestService
+from app.strategy.engine import StrategyDef
+
+
+def _strategy(**kwargs) -> StrategyDef:
+ defaults = dict(
+ meta={"id": "test", "name": "test", "scoring": {}, "params": [], "limit": 100},
+ basic_filter={"enabled": True, "amount_min": 100.0},
+ 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=[],
+ filter_fn=lambda df, params: pl.lit(True),
+ filter_history_fn=None,
+ lookback_days=1,
+ source="custom",
+ file_path=None,
+ )
+ defaults.update(kwargs)
+ return StrategyDef(**defaults)
+
+
+class _StrategyEngineStub:
+ def __init__(self, strategy: StrategyDef) -> None:
+ self.strategy = strategy
+
+ def get(self, strategy_id: str) -> StrategyDef:
+ return self.strategy
+
+
+class _RepoStub:
+ def get_index_daily(self, *args, **kwargs) -> pl.DataFrame:
+ return pl.DataFrame()
+
+
+class _EngineStub:
+ def __init__(self, panel: pl.DataFrame) -> None:
+ self.panel = panel
+ self.repo = _RepoStub()
+ self.load_args = None
+ self.sim_panel: pl.DataFrame | None = None
+ self.sim_entries: pl.Series | None = None
+
+ def load_panel(self, symbols, start: date, end: date) -> pl.DataFrame:
+ self.load_args = (symbols, start, end)
+ return self.panel
+
+ def simulate_portfolio(self, panel, entries, exits, config, progress_cb=None, cancel_event=None) -> SimResult:
+ self.sim_panel = panel
+ self.sim_entries = entries
+ return SimResult(
+ equity_curve=[{"date": "2024-01-01", "value": config.initial_capital}],
+ drawdown_curve=[{"date": "2024-01-01", "value": 0.0}],
+ trades=[],
+ per_symbol_stats=[],
+ stats={"total_return": 0.0, "n_trades": 0},
+ )
+
+
+def test_basic_filter_only_limits_entries_not_panel_rows():
+ start = date(2024, 1, 1)
+ rows = []
+ for i, amount in enumerate([1000.0, 0.0, 1000.0]):
+ rows.append({
+ "symbol": "A",
+ "name": "A",
+ "date": start + timedelta(days=i),
+ "open": 10.0 + i,
+ "high": 10.0 + i,
+ "low": 10.0 + i,
+ "close": 10.0 + i,
+ "volume": 100_000,
+ "amount": amount,
+ "signal_limit_up": False,
+ "signal_limit_down": False,
+ })
+ panel = pl.DataFrame(rows).sort(["symbol", "date"])
+ engine = _EngineStub(panel)
+ 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",
+ ))
+
+ assert result.error is None
+ assert engine.sim_panel is not None
+ assert engine.sim_panel.height == 3
+ assert engine.sim_panel.filter(pl.col("amount") == 0.0).height == 1
+ assert engine.sim_entries is not None
+ assert engine.sim_entries.to_list() == [True, False, True]
+ assert engine.load_args is not None
+ assert engine.load_args[1] < start # warmup 只用于计算, 不参与正式交易
+
+
+def test_score_normalizes_inside_strategy_candidate_universe():
+ panel = pl.DataFrame({
+ "symbol": ["A", "B", "C"],
+ "date": [date(2024, 1, 1)] * 3,
+ "factor": [10.0, 20.0, 1000.0],
+ })
+ universe = pl.Series([True, True, False], dtype=pl.Boolean)
+ strategy = SimpleNamespace(meta={"scoring": {"factor": 1.0}, "order_by": "score", "descending": True})
+
+ scored = StrategyBacktestService._apply_score(panel, strategy, None, universe_mask=universe)
+ scores = dict(zip(scored["symbol"].to_list(), scored["score"].to_list()))
+
+ assert scores["A"] == 0.0
+ assert scores["B"] == 100.0
+ assert scores["C"] == 0.0
+
+
+def test_full_mode_executes_every_candidate_with_strategy_rules():
+ start = date(2024, 1, 1)
+ panel = pl.DataFrame([
+ {"symbol": "A", "name": "A", "date": start, "open": 10.0, "high": 10.0, "low": 10.0, "close": 10.0, "volume": 1, "amount": 1000.0, "signal_limit_up": False, "signal_limit_down": False},
+ {"symbol": "A", "name": "A", "date": start + timedelta(days=1), "open": 11.0, "high": 11.0, "low": 11.0, "close": 11.0, "volume": 1, "amount": 0.0, "signal_limit_up": False, "signal_limit_down": False},
+ {"symbol": "A", "name": "A", "date": start + timedelta(days=2), "open": 20.0, "high": 20.0, "low": 20.0, "close": 20.0, "volume": 1, "amount": 1000.0, "signal_limit_up": False, "signal_limit_down": False},
+ ]).sort(["symbol", "date"])
+
+ engine = BacktestEngine(repo=None) # type: ignore[arg-type]
+ engine.load_panel = lambda symbols, s, e: panel # type: ignore[method-assign]
+ strategy = _strategy(
+ filter_fn=lambda df, params: pl.col("date") == start,
+ max_hold_days=1,
+ )
+ service = StrategyBacktestService(engine=engine, strategy_engine=_StrategyEngineStub(strategy))
+
+ result = service.run(StrategyBacktestConfig(
+ strategy_id="test",
+ symbols=None,
+ start=start,
+ end=start,
+ mode="full",
+ matching="open_t+1",
+ fees_pct=0,
+ slippage_bps=0,
+ holding_days=1,
+ ))
+
+ assert result.error is None
+ assert result.stats["full_kind"] == "candidate_execution"
+ assert result.stats["n_candidates"] == 1
+ assert result.stats["n_trades"] == 1
+ assert result.trades[0]["entry_date"] == str(start + timedelta(days=1))
+ assert result.trades[0]["exit_reason"] == "max_hold"
+ assert result.stats["avg_return"] == round(20 / 11 - 1, 4)
diff --git a/backend/uv.lock b/backend/uv.lock
new file mode 100644
index 0000000..dd0f55b
--- /dev/null
+++ b/backend/uv.lock
@@ -0,0 +1,2638 @@
+version = 1
+revision = 3
+requires-python = ">=3.11"
+resolution-markers = [
+ "python_full_version >= '3.15' and sys_platform == 'win32'",
+ "python_full_version == '3.14.*' and sys_platform == 'win32'",
+ "python_full_version >= '3.15' and sys_platform == 'emscripten'",
+ "python_full_version == '3.14.*' and sys_platform == 'emscripten'",
+ "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+ "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+ "python_full_version < '3.14' and sys_platform == 'win32'",
+ "python_full_version < '3.14' and sys_platform == 'emscripten'",
+ "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
+]
+
+[[package]]
+name = "annotated-doc"
+version = "0.0.4"
+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://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.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://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.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+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://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.org/simple" }
+dependencies = [
+ { name = "tzlocal" },
+]
+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://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.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://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.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://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 = "certifi"
+version = "2026.5.20"
+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://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 = "charset-normalizer"
+version = "3.4.7"
+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://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.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+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://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 = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "comm"
+version = "0.2.3"
+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://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.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+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://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.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://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.org/simple" }
+dependencies = [
+ { name = "python-dateutil" },
+ { name = "pytz" },
+ { name = "regex" },
+ { name = "tzlocal" },
+]
+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://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.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://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.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://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.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://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.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://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.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://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.org/simple" }
+dependencies = [
+ { name = "annotated-doc" },
+ { name = "pydantic" },
+ { name = "starlette" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+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://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.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://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.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://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.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httptools"
+version = "0.7.1"
+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://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.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.16"
+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://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.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "pillow" },
+]
+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://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.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "ipython"
+version = "9.13.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "decorator" },
+ { name = "ipython-pygments-lexers" },
+ { name = "jedi" },
+ { name = "matplotlib-inline" },
+ { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
+ { name = "prompt-toolkit" },
+ { name = "psutil" },
+ { name = "pygments" },
+ { name = "stack-data" },
+ { name = "traitlets" },
+ { name = "typing-extensions", marker = "python_full_version < '3.12'" },
+]
+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://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.org/simple" }
+dependencies = [
+ { name = "pygments" },
+]
+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://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.org/simple" }
+dependencies = [
+ { name = "comm" },
+ { name = "ipython" },
+ { name = "jupyterlab-widgets" },
+ { name = "traitlets" },
+ { name = "widgetsnbextension" },
+]
+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://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.org/simple" }
+dependencies = [
+ { name = "parso" },
+]
+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://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.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://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.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://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.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://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.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://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.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://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.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://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.org/simple" }
+dependencies = [
+ { name = "contourpy" },
+ { name = "cycler" },
+ { name = "fonttools" },
+ { name = "kiwisolver" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "pillow" },
+ { name = "pyparsing" },
+ { name = "python-dateutil" },
+]
+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://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.org/simple" }
+dependencies = [
+ { name = "traitlets" },
+]
+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://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.org/simple" }
+dependencies = [
+ { name = "ast-serialize" },
+ { name = "librt", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "mypy-extensions" },
+ { name = "pathspec" },
+ { name = "typing-extensions" },
+]
+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://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.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://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.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://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.org/simple" }
+dependencies = [
+ { name = "llvmlite" },
+ { name = "numpy" },
+]
+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://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.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://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.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "distro" },
+ { name = "httpx" },
+ { name = "jiter" },
+ { name = "pydantic" },
+ { name = "sniffio" },
+ { name = "tqdm" },
+ { name = "typing-extensions" },
+]
+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://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.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://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.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "python-dateutil" },
+ { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
+]
+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://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.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://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.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://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.org/simple" }
+dependencies = [
+ { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
+]
+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://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.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://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 = "plotly"
+version = "6.7.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "narwhals" },
+ { name = "packaging" },
+]
+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://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.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "polars"
+version = "1.40.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "polars-runtime-32" },
+]
+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://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]]
+name = "polars-runtime-32"
+version = "1.40.1"
+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://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 = "prompt-toolkit"
+version = "3.0.52"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "wcwidth" },
+]
+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://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 = "psutil"
+version = "7.2.2"
+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://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.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://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.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://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.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://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 = "pydantic"
+version = "2.13.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/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://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.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+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://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.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "python-dotenv" },
+ { name = "typing-inspection" },
+]
+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://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.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://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 = "pyparsing"
+version = "3.3.2"
+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://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 = "pytest"
+version = "9.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/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://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.org/simple" }
+dependencies = [
+ { name = "pytest" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+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://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.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+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://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.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://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.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://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 = "pytz"
+version = "2026.2"
+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://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 = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/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 = "regex"
+version = "2026.5.9"
+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://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.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+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://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.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://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.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://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.org/simple" }
+dependencies = [
+ { name = "joblib" },
+ { name = "numpy" },
+ { name = "scipy" },
+ { name = "threadpoolctl" },
+]
+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://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.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+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://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.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://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.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://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.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "starlette" },
+]
+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://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.org/simple" }
+dependencies = [
+ { name = "asttokens" },
+ { name = "executing" },
+ { name = "pure-eval" },
+]
+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://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.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+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://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 = "tf-stocks-panel-backend"
+version = "0.1.0"
+source = { editable = "." }
+dependencies = [
+ { name = "apscheduler" },
+ { name = "duckdb" },
+ { name = "fastapi" },
+ { name = "fastexcel" },
+ { name = "httpx" },
+ { name = "openai" },
+ { name = "pandas" },
+ { name = "polars" },
+ { name = "pyarrow" },
+ { name = "pydantic" },
+ { name = "pydantic-settings" },
+ { name = "python-dotenv" },
+ { name = "python-multipart" },
+ { name = "pyyaml" },
+ { name = "sse-starlette" },
+ { name = "tickflow", extra = ["all"] },
+ { name = "uvicorn", extra = ["standard"] },
+]
+
+[package.optional-dependencies]
+backtest = [
+ { name = "vectorbt" },
+]
+dev = [
+ { name = "mypy" },
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+ { name = "ruff" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "apscheduler", specifier = ">=3.10" },
+ { name = "duckdb", specifier = ">=1.0" },
+ { name = "fastapi", specifier = ">=0.115" },
+ { name = "fastexcel", specifier = ">=0.10" },
+ { name = "httpx", specifier = ">=0.27" },
+ { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" },
+ { name = "openai", specifier = ">=1.40" },
+ { name = "pandas", specifier = ">=2.2" },
+ { name = "polars", specifier = ">=1.0" },
+ { name = "pyarrow", specifier = ">=16.0" },
+ { name = "pydantic", specifier = ">=2.7" },
+ { name = "pydantic-settings", specifier = ">=2.4" },
+ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
+ { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
+ { name = "python-dotenv", specifier = ">=1.0" },
+ { name = "python-multipart", specifier = ">=0.0.6" },
+ { name = "pyyaml", specifier = ">=6.0" },
+ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5" },
+ { name = "sse-starlette", specifier = ">=2.0" },
+ { name = "tickflow", extras = ["all"], specifier = ">=0.1.21" },
+ { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
+ { name = "vectorbt", marker = "extra == 'backtest'", specifier = ">=0.26" },
+]
+provides-extras = ["backtest", "dev"]
+
+[[package]]
+name = "threadpoolctl"
+version = "3.6.0"
+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://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.21"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "httpx" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0a/80/b808d04f52171abaa7db8f6f9a69fd3e4afe3ca569f0d99964b242d2e70f/tickflow-0.1.21.tar.gz", hash = "sha256:17fcff4fa3d38805923d92e4861c0bb592a89c6e8a08187533fad2853dc50364", size = 37106, upload-time = "2026-04-29T10:15:29.949Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/53/57b629bcee5d9870a034a321c21325535612b9d69228612302e8facdcea7/tickflow-0.1.21-py3-none-any.whl", hash = "sha256:eba424d341b6ed7ba0c2ccd13e18e6d535513349b31ed9adcc5dd02c599980a4", size = 42731, upload-time = "2026-04-29T10:15:28.547Z" },
+]
+
+[package.optional-dependencies]
+all = [
+ { name = "pandas" },
+ { name = "tqdm" },
+ { name = "websockets" },
+]
+
+[[package]]
+name = "tqdm"
+version = "4.67.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+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://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.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://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.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://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.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+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://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.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://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.org/simple" }
+dependencies = [
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
+]
+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://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.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://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.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "h11" },
+]
+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://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]
+standard = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "httptools" },
+ { name = "python-dotenv" },
+ { name = "pyyaml" },
+ { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
+ { name = "watchfiles" },
+ { name = "websockets" },
+]
+
+[[package]]
+name = "uvloop"
+version = "0.22.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/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.org/simple" }
+dependencies = [
+ { name = "dateparser" },
+ { name = "dill" },
+ { name = "imageio" },
+ { name = "ipywidgets" },
+ { name = "matplotlib" },
+ { name = "mypy-extensions" },
+ { name = "numba" },
+ { name = "numpy" },
+ { name = "pandas" },
+ { name = "plotly" },
+ { name = "pytz" },
+ { name = "requests" },
+ { name = "schedule" },
+ { name = "scikit-learn" },
+ { name = "scipy" },
+ { name = "tqdm" },
+]
+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://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.org/simple" }
+dependencies = [
+ { name = "anyio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/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.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://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.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://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.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://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" },
+]
diff --git a/data/.gitkeep b/data/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/dev.ps1 b/dev.ps1
new file mode 100644
index 0000000..1744a87
--- /dev/null
+++ b/dev.ps1
@@ -0,0 +1,242 @@
+# tf-stocks-panel - one-shot launcher for backend + frontend (Windows / PowerShell)
+#
+# Usage:
+# .\dev.ps1
+# .\dev.ps1 -BackendPort 8000 -FrontendPort 5173
+# $env:BACKEND_PORT='8000'; .\dev.ps1
+#
+# Ctrl-C closes both processes.
+#
+# If you see "running scripts is disabled":
+# Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned
+
+[CmdletBinding()]
+param(
+ [int]$BackendPort = 0,
+ [int]$FrontendPort = 0
+)
+
+$ErrorActionPreference = 'Stop'
+
+# Port precedence: CLI arg > env var > default
+if ($BackendPort -le 0) { $BackendPort = if ($env:BACKEND_PORT) { [int]$env:BACKEND_PORT } else { 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
+try {
+ [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
+ $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 }
+function Log-Err ($m) { Write-Host "[dev] $m" -ForegroundColor Red }
+
+# ===== 1. Dependency check =====
+function Require-Cmd($cmd, $hint) {
+ if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) {
+ Log-Err "$cmd not found"
+ Write-Host " install via: $hint"
+ exit 1
+ }
+}
+
+Require-Cmd 'uv' 'powershell -c "irm https://astral.sh/uv/install.ps1 | iex" OR winget install --id=astral-sh.uv'
+Require-Cmd 'pnpm' 'npm i -g pnpm OR corepack enable; corepack prepare pnpm@9 --activate'
+
+# ===== 2. Port check - kill anything listening on the target ports =====
+function Free-Port($name, $port) {
+ $conns = Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue
+ if (-not $conns) { return }
+ $pids = @($conns.OwningProcess | Where-Object { $_ -gt 0 } | Sort-Object -Unique)
+ if ($pids.Count -eq 0) { return }
+
+ # Filter to PIDs that still exist as running processes.
+ # A zombie TCP endpoint can linger after the process is already dead.
+ $alive = @($pids | Where-Object {
+ try { [System.Diagnostics.Process]::GetProcessById($_) | Out-Null; $true }
+ catch { $false }
+ })
+
+ if ($alive.Count -eq 0) {
+ # All processes are dead but kernel still holds the socket (zombie endpoint).
+ # On Windows this can linger for minutes, but uvicorn/vite can still bind
+ # via SO_REUSEADDR — no point waiting, just proceed.
+ Log-Warn "port ${port} (${name}) - zombie socket (processes gone), starting anyway"
+ return
+ }
+
+ Log-Warn "port $port ($name) is in use, killing PID: $($alive -join ', ')"
+ # Use taskkill /F /T to kill the entire process tree (parent + children),
+ # not just the parent. Stop-Process only kills one process, leaving child
+ # processes (e.g. uvicorn spawned by uv) as orphans holding the socket.
+ foreach ($p in $alive) {
+ # Suppress stderr properly for Windows PowerShell (5.x)
+ $null = & cmd /c "taskkill /F /T /PID $p 2>nul"
+ # Fallback: if taskkill failed, try Stop-Process
+ try { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue } catch {}
+ }
+
+ # Wait up to 5 seconds for the kernel to release the TCP endpoint
+ for ($i = 0; $i -lt 10; $i++) {
+ Start-Sleep -Milliseconds 500
+ $still = Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue
+ if (-not $still) {
+ Log-Ok "port $port freed"
+ return
+ }
+ }
+
+ # Port still stuck — process might be dead with zombie socket
+ $anyAlive = $still | Where-Object {
+ try { [System.Diagnostics.Process]::GetProcessById($_.OwningProcess) | Out-Null; $true }
+ catch { $false }
+ }
+ if ($anyAlive.Count -eq 0) {
+ Log-Warn "port ${port} - processes gone but socket lingers, starting anyway"
+ } else {
+ Log-Err "port ${port} still in use by live process(es). Inspect: Get-NetTCPConnection -LocalPort ${port}"
+ exit 1
+ }
+}
+
+Free-Port 'backend' $BackendPort
+Free-Port 'frontend' $FrontendPort
+
+# ===== 3. First-time dependency install =====
+if (-not (Test-Path (Join-Path $BackendDir '.venv'))) {
+ Log-Info 'first run - installing Python deps (1-2 min)...'
+ Push-Location $BackendDir
+ try { & uv sync } finally { Pop-Location }
+ if ($LASTEXITCODE -ne 0) { Log-Err 'uv sync failed'; exit 1 }
+ Log-Ok 'backend deps installed'
+}
+
+if (-not (Test-Path (Join-Path $FrontendDir 'node_modules'))) {
+ Log-Info 'first run - installing Node deps...'
+ Push-Location $FrontendDir
+ try { & pnpm install } finally { Pop-Location }
+ if ($LASTEXITCODE -ne 0) { Log-Err 'pnpm install failed'; exit 1 }
+ Log-Ok 'frontend deps installed'
+}
+
+# ===== 4. Banner (ASCII so it renders on any codepage) =====
+Write-Host ''
+Write-Host '+----------------------------------------------+' -ForegroundColor Blue
+Write-Host '| tf-stocks-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 '| |' -ForegroundColor Blue
+Write-Host '| Ctrl-C closes both |' -ForegroundColor Blue
+Write-Host '+----------------------------------------------+' -ForegroundColor Blue
+Write-Host ''
+
+# ===== 5. Launch jobs =====
+# Each job writes its $PID to a temp file so the main thread can find the
+# child powershell.exe and taskkill /T the whole process tree on exit.
+$backendPidFile = [System.IO.Path]::GetTempFileName()
+$frontendPidFile = [System.IO.Path]::GetTempFileName()
+
+$backendJob = Start-Job -Name 'backend' -ScriptBlock {
+ param($pidFile, $dir, $port)
+ $PID | Out-File -FilePath $pidFile -Encoding ascii -Force
+ $env:PYTHONUNBUFFERED = '1'
+ Set-Location $dir
+ & .\.venv\Scripts\python.exe -m uvicorn app.main:app --reload --port $port 2>&1
+} -ArgumentList $backendPidFile, $BackendDir, $BackendPort
+
+$frontendJob = Start-Job -Name 'frontend' -ScriptBlock {
+ param($pidFile, $dir, $port)
+ $PID | Out-File -FilePath $pidFile -Encoding ascii -Force
+ Set-Location $dir
+ & pnpm dev --port $port 2>&1
+} -ArgumentList $frontendPidFile, $FrontendDir, $FrontendPort
+
+# Wait up to 5 seconds for the PID files to materialise
+function Read-JobPid($file) {
+ for ($i = 0; $i -lt 50; $i++) {
+ try {
+ $c = (Get-Content $file -ErrorAction SilentlyContinue) -as [string]
+ if ($c -and $c.Trim()) { return [int]$c.Trim() }
+ } catch {}
+ Start-Sleep -Milliseconds 100
+ }
+ return $null
+}
+$backendChildPid = Read-JobPid $backendPidFile
+$frontendChildPid = Read-JobPid $frontendPidFile
+
+# ===== 6. Cleanup =====
+$script:cleaning = $false
+function Cleanup-All {
+ if ($script:cleaning) { return }
+ $script:cleaning = $true
+ Write-Host ''
+ Log-Info 'shutting down...'
+
+ foreach ($p in @($backendChildPid, $frontendChildPid)) {
+ if ($p) {
+ # /T kills the whole process tree (the job's powershell + uvicorn/vite)
+ $null = & cmd /c "taskkill /F /T /PID $p 2>nul"
+ }
+ }
+ foreach ($j in @($backendJob, $frontendJob)) {
+ if ($j) {
+ Stop-Job $j -ErrorAction SilentlyContinue
+ Remove-Job $j -Force -ErrorAction SilentlyContinue
+ }
+ }
+ foreach ($f in @($backendPidFile, $frontendPidFile)) {
+ Remove-Item $f -Force -ErrorAction SilentlyContinue
+ }
+ Log-Ok 'bye'
+}
+
+# ===== 7. Main loop - pump output, handle Ctrl-C =====
+# Treat Ctrl-C as input so try/finally is guaranteed to run.
+$prevCtrlC = [Console]::TreatControlCAsInput
+try {
+ [Console]::TreatControlCAsInput = $true
+
+ while ($true) {
+ if ([Console]::KeyAvailable) {
+ $key = [Console]::ReadKey($true)
+ if (($key.Modifiers -band [ConsoleModifiers]::Control) -and $key.Key -eq 'C') {
+ break
+ }
+ }
+
+ $bOut = Receive-Job $backendJob -ErrorAction SilentlyContinue
+ if ($bOut) {
+ foreach ($line in $bOut) {
+ Write-Host '[backend ] ' -NoNewline -ForegroundColor Blue
+ Write-Host $line
+ }
+ }
+
+ $fOut = Receive-Job $frontendJob -ErrorAction SilentlyContinue
+ if ($fOut) {
+ foreach ($line in $fOut) {
+ Write-Host '[frontend] ' -NoNewline -ForegroundColor Green
+ Write-Host $line
+ }
+ }
+
+ if ($backendJob.State -ne 'Running' -or $frontendJob.State -ne 'Running') {
+ Log-Warn 'one of the processes exited; closing the other...'
+ break
+ }
+
+ Start-Sleep -Milliseconds 150
+ }
+}
+finally {
+ [Console]::TreatControlCAsInput = $prevCtrlC
+ Cleanup-All
+}
diff --git a/dev.sh b/dev.sh
new file mode 100755
index 0000000..3df61e5
--- /dev/null
+++ b/dev.sh
@@ -0,0 +1,142 @@
+#!/usr/bin/env bash
+# tf-stocks-panel — 一键启动前后端
+#
+# 用法:
+# ./dev.sh # 默认 backend:3018 frontend:3011
+# BACKEND_PORT=8000 ./dev.sh # 改后端端口
+# FRONTEND_PORT=5173 ./dev.sh # 改前端端口
+#
+# Ctrl-C 同时关闭两端。
+
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+BACKEND_DIR="$ROOT/backend"
+FRONTEND_DIR="$ROOT/frontend"
+BACKEND_PORT="${BACKEND_PORT:-3018}"
+FRONTEND_PORT="${FRONTEND_PORT:-3011}"
+
+BLUE='\033[0;34m'
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+YELLOW='\033[0;33m'
+GRAY='\033[0;90m'
+NC='\033[0m'
+
+info() { echo -e "${GRAY}[dev]${NC} $*"; }
+ok() { echo -e "${GREEN}[dev]${NC} $*"; }
+warn() { echo -e "${YELLOW}[dev]${NC} $*"; }
+err() { echo -e "${RED}[dev]${NC} $*" >&2; }
+
+# ===== 1. 依赖检查 =====
+require_cmd() {
+ local cmd="$1" hint="$2"
+ if ! command -v "$cmd" >/dev/null 2>&1; then
+ err "$cmd 未安装"
+ echo " 安装方式:$hint"
+ exit 1
+ fi
+}
+
+require_cmd uv "curl -LsSf https://astral.sh/uv/install.sh | sh"
+require_cmd pnpm "npm i -g pnpm 或 corepack enable && corepack prepare pnpm@9 --activate"
+
+# ===== 2. 端口占用检查 —— 占用就直接 kill =====
+free_port() {
+ local name="$1" port="$2"
+ local pids
+ pids=$(lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true)
+ if [ -z "$pids" ]; then
+ return 0
+ fi
+ warn "端口 $port($name)被占用,kill 现有进程 PID: $(echo "$pids" | xargs)"
+ # 先 TERM
+ echo "$pids" | xargs kill 2>/dev/null || true
+ sleep 1
+ # 还活着就 KILL
+ pids=$(lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true)
+ if [ -n "$pids" ]; then
+ warn "TERM 没杀掉,改用 KILL -9"
+ echo "$pids" | xargs kill -9 2>/dev/null || true
+ sleep 1
+ fi
+ # 再确认一次
+ pids=$(lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true)
+ if [ -n "$pids" ]; then
+ err "端口 $port 仍被占用 — kill 失败。请手动处理:lsof -i :$port"
+ exit 1
+ fi
+ ok "端口 $port 已释放"
+}
+free_port backend "$BACKEND_PORT"
+free_port frontend "$FRONTEND_PORT"
+
+# ===== 3. 首次依赖安装 =====
+if [ ! -d "$BACKEND_DIR/.venv" ]; then
+ info "后端首次启动 — 安装 Python 依赖(约 1-2 分钟)..."
+ ( cd "$BACKEND_DIR" && uv sync )
+ ok "后端依赖装好了"
+fi
+
+if [ ! -d "$FRONTEND_DIR/node_modules" ]; then
+ info "前端首次启动 — 安装 Node 依赖..."
+ ( cd "$FRONTEND_DIR" && pnpm install )
+ ok "前端依赖装好了"
+fi
+
+# ===== 4. 启动 + 日志前缀 =====
+PIDS=()
+
+cleanup() {
+ echo
+ info "关闭服务..."
+ for pid in "${PIDS[@]:-}"; do
+ if [ -n "$pid" ]; then
+ kill "$pid" 2>/dev/null || true
+ fi
+ done
+ # 等子进程退出,避免孤儿
+ wait 2>/dev/null || true
+ ok "已退出"
+ exit 0
+}
+trap cleanup INT TERM
+
+# 用 awk 加前缀(macOS sed 没有 -u line-buffered,改用 awk + fflush 兼容)
+prefix_awk() {
+ awk -v p="$1" '{ print p $0; fflush() }'
+}
+
+echo
+echo -e "${BLUE}╭──────────────────────────────────────────────╮${NC}"
+echo -e "${BLUE}│${NC} ${GREEN}tf-stocks-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} ${BLUE}│${NC}"
+echo -e "${BLUE}│${NC} Ctrl-C 同时关闭两端 ${BLUE}│${NC}"
+echo -e "${BLUE}╰──────────────────────────────────────────────╯${NC}"
+echo
+
+(
+ cd "$BACKEND_DIR"
+ uv run uvicorn app.main:app --reload --port "$BACKEND_PORT" 2>&1 \
+ | prefix_awk "$(printf "${BLUE}[backend ]${NC} ")"
+) &
+PIDS+=("$!")
+
+(
+ cd "$FRONTEND_DIR"
+ pnpm dev --port "$FRONTEND_PORT" 2>&1 \
+ | prefix_awk "$(printf "${GREEN}[frontend]${NC} ")"
+) &
+PIDS+=("$!")
+
+# 等任一退出(bash 4.3+)或全部退出(老 bash)
+if wait -n 2>/dev/null; then
+ warn "其中一个进程退出,正在关闭另一个..."
+ cleanup
+else
+ # 老 bash 没有 wait -n,退化为 wait 全部
+ wait
+fi
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..b6c3bbf
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,16 @@
+# Phase 0 单 service:FastAPI 启动后既跑 API 又托管前端 dist。
+# 见 ADR-17 / §8.1。
+services:
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: TF-Stocks-Panel
+ ports:
+ - "${PORT:-3018}:3018"
+ env_file:
+ - .env
+ volumes:
+ - ./data:/app/data
+ - ./tiers.yaml:/app/tiers.yaml:ro
+ restart: unless-stopped
diff --git a/docs/screenshots/backtest.png b/docs/screenshots/backtest.png
new file mode 100644
index 0000000000000000000000000000000000000000..c4b55c8819881ced1d8be2c9031f965a397acda3
GIT binary patch
literal 668676
zcmZ_01ymeOw>8}O010jZf(3%R1$Pau!CeBu-QC@Ty9Wpm+zGD1-7UDg{r&uR-Fw%%
z@AJ(X7}oSmb#+(OKIiPci!cQ_iML4jNB{u5m68-y0sv%u0Dv!mK!fjyza!WLUl462
zH5|YLnE(7h0cja{06+pri3+K>W*lX@=wr>?^pCgIvbWs^l9T)W5k^!^^Ci&{6BJ5Q
z`D&PweI2%=Ay`nl?m-5j8ij^-#@cTS#CNA`i&+<^$U?7v?!
zM2WOPT2AQbrOAm3K|hNqIydkC)mnH-Ziav3m$H(60*f=cgO83UE;{Sfn
zwzEa+cCdPm!*!~(4L;2*Y13U-I*u|La`QP;j=Y0HJh7x-QAw$bx(33%0^thYqFoas^{cl
z!?CfcehL2Pd1G79rN7!ry88ME+S%DDDhkO(-&;#|kwKLBV1@Fh_9MklV`Kk3`9EbG
z9p+K3cDa_(6x%9q@kdy1Rd*=R{b}Zg3ZRZhls>6Z>%3gKF&_pXr
z6xf>%g8!}Y%szdlzCH?LvlS|^{i$jah|(}qpS^>F^ygv2EerQprdcOnYH-POem=w32)?Rko>qQ
z^FhOv*9cJ*R+7;*j$Czw0kg?GzefYDyoWb)RV&Bgc
zSRCZ+x_~$HB!}QX{jrGlv8KLN!~Hp@nVE{Jahb++rP2Ly>%8CyKCYwFfga9@nQcoi
z68O;bf(-apr+G_-*by#|ISQjhHil<3@{#`A!a9M56~bI6j!jAND25R_!D|oW+k&qW
zqwjqs>MPl`3z&QOc%#C(yR!p-hX4^yqOqAq$r_;k=2}@8YjA-(A3>nPXUq%>r}>+)
zuS8SbfCm;zJQPDJC=Y3~`Q5*)&e#?}pu=}_({Q)Dn&@6aA0i)d#Cj?x;kPVI_Bg^C
zSWI0WSq&ZrKeR+Fg}sfXI;~`T?4S7>D9tOB#3=Sg@i`$j7ogDzx}ZR$!`q-iOvyu1
zrwkAOH_&SuK07~WPqkj%r;6nfHxEfUb%YGi_ixao%W!&mdfNElVTa7p&D#A!^
zI%;WMvT`c~KUUq(PCEsWW#jvfhv7>2WGim>G4WVtL82z&Sy(qdEd$AIUR=MdYnhfu`+Zy^LooSbefh1SG#(kIn{OmqcR@J0%3|CnM7r;j
zyFcIl%NU`-Hf7>{_>;Do-MwO4L0ej{$VVTv3DYG@ts)}Xj$ShZ%h)G$mlDE{DVM+C
zMV*gs$P@04=8=x%)bdzHY|Tdl-lvy+G~xL8_>85;o8yG!WVM!lBqXG(%ggc=X7v9%
z4etqZ&tqDrNJ$#PS~fJ*$w0)!)kLBsg)33T3j8EVHSJ|_w}XcFs)U6t%5OF#hFz2A
z?`F!YIKxz?77g2UybdRPf)^Ouu;xumN7ov>C6oQKxj&U!EN_;@>qf@L_A5EB96#Zs
z>|(puQe`Gji_2*)8~&T%asJ1n6g}^D_v@i{FAq#iOiOPP>HT`EB^FlszC7aHS51(ldHcN5ljQF>?Vj1A92M(oE=`Ac2fB
z?@uXDu{m#gkP?n$4n|&wY5op(>ikMlDr6IwrL^;H)Tu6p$Nj6OtT8QVrO$--aC58`
z0b^=nVzt_u%BU>OY4gIz&h9i2OKP=L-{O8v12~?p8r_|3V58O;_20N%?v`p98W~|X
z1kcaU|M>AkGjc)Xqih-kKnz11PNvssI0@+|CWZsXc`w-tu>dxFSx3jKAY@z%@0{L<
z4hRYgyl=D9Vf)L2WrbG5JAThERvA$L0gXPWqUciwuXLhuitdzp0aalcc}SW_#oYLE
z8dgf!xxUI0O@2IbB@P?muKoa1oUK^U^hrU9Ef-Dx5EHhW8yZ0Jh6)C#Y$ka+(z#V*
z=aJAl=koX@d*cu6ns)G9gej+T@2U|^i|~ofF#e(Mt>_7DItdn#w2*Fdzs8~auGeA|
z93l~#2LU>sZ!9e=XaH9a51Y023Y`{@O6?|=ualU^i*@C$fsKvd!5dOoP=FUD4t%oP
z3~b2)@0wP%p+-fZRh?#wi_;+v21ecw>&0JjjBnm^t9<2kJyURVb9?(6
z?~|?mcoxTPgulQ4M83qFt<3+Oui%m?1;)3r7+5BfEU1@Ll}h^TX>&`;I(tk-p~54f
z@QhHT%u+b86Bnpy8ij%YlA#O4ZU{^lLq{dj%q{6%e^_3?-)2ZFB1~ANi!E07rJfkT6Cr%)|wS36%D3XSXiysvrFu}WdAV@ApR=k
z#bpD6(xj-{!eSd=5#e2!=l7y0&I8FWf8wetkmN1<@yLfpOB98p!qsN-
z7zLui-#kMJ0%*rPtiU=3riWsXgY{CSXqea7svOCtuH?p;t#EvjDv=)Aq-cg<7+{(ez<~g4Wj7
zdh6%IWv+DH+{VT)D{D(j>9^a%4Sc!{)1{btRl0m8*IO|H8_1_^w^_|g62zlw5v1Dj
zDJg3|G+0y8VTJCl=d$ZGD<&_t6Q<;{cuP<6XAX8p)90%6h@-?|fEMTDm;1f!awoZe
zK?E|zRGuJ9`XFRWq!4`KG0pJ#oY~4iEDna9lbX-k9x^KW1W4;84hnY6W+B|ja(sR70iV)rW-<1o-l^)yc@qSV9#{(oau0O(+own)2*7@oCvLQa3!c>
zB8B&%vw~ea(7;Aj=-aD%d~kyctQ~r=JHe$SQcm+l{G8N|f$t;#%y+)IfD&B9^0d6}
zL6n|Cf)EE4hB}#%rU0XsWo`O%gw3?b$jH4ck4pDP28|j~9b^%MouMT4DqWp6cWe+P
zuH5;m)}Vlo3Dl&diYxcqg9(0qP^#S4ii&ZA#$`DHYUMjaFxEHv)n91-dS33zrW}Jq
z5nc0*Bi(E?4GsYF$|^ckTaNWx(B$orou7!crZO0+gnKD?b<(24Nv3@YfIO|ktxhjS6vk~6$K=cp)`z-&$Qf5
z@Vzy{`klrJkey%CtRL&ElTX`oZeu#Deo!VCbd6HK%YUIn
z!-;+~I5ScV8PJhNm^K)ARZlgauUBhK_u@3853(GDcCt{_A#X7+wWk_94)~-eI)lRS
z3$y2hH}rX$2SqH2UFFuMM?JJ^3vsWRk_Uz85v1uh#Jrb@qMc=O6!+7
zy3w~kIbHGs&nljtylcixcw-q{qbp8}zrJckZCS6i(=$}c!M(?!yK`Odgz|LTpDY{~
zcokMfMaRN&1(yR&dAV8zh8Q})|MU~%LpaTO_N!-da&plRB3@V9p~TX$6(nS2O8KlB
z+l^vR)#9lNy^eMedM%hS{?Djw`=pb`@WTVc>Yi@psM|X`n7FuFGi~7!5sW&`B|nsC
z3JMBb8&8vH)xieK+84p?pM+*DgJ@u6{X7nUlnF34}U^u5F=?MfX3-d3AcR<
zqKb2ZSJMAvE-i2UlOr^Y!_5
zSrY~jRrI{yQHLU-rY;phXdr{%rE5yG`x?V+HYV}>aILH+pYh3U&1+>P>THyA?do``
z7>vZ8>|9)KeUa~~S}(%;DFgpc$@Y}*=DJP-e2TZb`;!~ca3!eIy!?2#WmMj*?&fx%
zBZNv_E+ph<)A3vx@<_z*c~>{5tE6@~C+}tV@EQAL!~amds_ksE7C^(reLeN*V1x*(
z827xkU2Xm$jp1X{&vAJDAc4|x-gn5)&yN$aFgGVrJZHJgqq=5yA^YQ$Zt8pV;>&*8
z=}ltQf|V7Xtlr;ZK0DQ?InB%7r!-9onHIf%2iLG%V_~!RHdycIo_;U4RRaJ%6wx<>
zlt#|f1>7YNrHcY8O?0~4MmRg@TfQ^C=;>t&A;7<{4o5pO`OJ`<7%)h;V`7TROditW
z+22aG`r6fcZ_EX>@+hn^i2a>WaW&OgSVf-A&c2*H)V?}>&6UCu5+R-Ifo==cMegEf
zPs&|>tMXS)aR*c7qUWaOjR$>i($n*phwj)~d9thuURZ#?e
zPLZH5Gf5|eR3_2VS<-0U4R$~Ysm?agY?E3+1B#R-4Ab(hrArQEgj;%Li;oA_VuIE(;rYb`RvlUAb5fOm?U_4{!
zlg>CE0kh$BJVnI)0f;M;lWFds_zSN>h-Zt7i)rc2#hvyc~wv|NQ)X
ze{grcHCv_E;ILO&mYx09=hZhaPuRM3qqQ`Lq~l}2hk-Q|>C)c~0S?0}Z%Eg7c04Xk
zC*DW-sXQqLlc222ll?F3HGKvl?CbnbbTB8C^Jp_NZMElUTK16HHK)Eq;j>K6E9Mpm$~V43WGX>-s;cu5d1)&^a0LlVN5_U
z7jbM?GvFBjr0EqL=*9N1WIapR#Ga>)Yc7{qD>hyxi*j%0tqk8ZX5@YTK*}-D`zGWb
zyphl
z5fDdc4fat%h>W(j+D^bqZ)38utF-)ar{-Y#v55;Sqy_Bf}8?7G^1SCTK#87;;I$>mlc=h*l)0
ziAls~MD_v8mnD71FQ?$}9g~~>rnrg_g$Ml5qhj5BcNCUjbh5$%kN?1jr7JY7Ml8WJ
zM4C}1BY-9qVIUkZ+Xxb&K-Ul?A)pMcsTVASv1M}2!4gymBQmD;&AFJd4HZ@S^Nquh#IJBGWo5}((g^OfT
zSxknueSLw#>c+;qofN(Iysiqy#>QX1e36ut6ckht^vwnzF)=YIDJg2@GZu9y9hK<9
z?RT3IAncZV4%6`(l}D%gKs|Sr-5GOnC2@R;HAcl-F5T*WsK`LYzS{h0RLQZ?Bb;
z*W)?j2CLVp1Un)(M`LOG=hr8fuAR@gfd0{U>Nv#-huzkc>};(rU2pFf)a
zn(fjE6g&Rt)jlhP
zl-TpE$X+Q6At5$)x$J9cvE$=*J0M7Z
zmNhZSdI`h8+^WZ)PNKz~`RXBrmgRMm^gHCnX8tZSBw&uLLbHyRp8Y-dwlGIUFj{DM
z^yiATPh4C(={D`{&d1WdnFw!*;^MVm50ZvopFREd408;A_lb*(`*{V*kTsy@*ejt5-=S{L0Jg)=X5E$>c6AC=k`S
zzPvn~D(;xE(5N+)b!!@(cH8V398GVqo#*7?d93aaZY0pipWuBwx{ShShXx!p7HUjB
zYddQ-obWgSCx8F6E2}sr)>u@3r<8Xy?2B~4=lJ$LG4aRTK9KV>XMNwS=p1F7@6ogE
zp_HTl(T5|K|Eey8oHfLr_N>?|h=PR0w7R<-_3wRAH^zE6LSIf!Ae5j%>B^^%tmbfk
zAhD_g!cPz=5?FqGQ`_6=zRjzQk1e6kKiBdt-hbd;l!D9>*Juyon!TMt#?ti=_lAeN-FQn2BvGYOth${xn?Lh?^Y2_emk?
zv*BPUSVC(wG7b58Y8pWd#({2V5)u|+NL(92P(;o>
z=F}%PWDMir5q|;tM0pg0NVG^4Xv&cwwS(y7c0oT5^p#u_=Z8uMCYWfTjRYZ3-GV=p
zHe+`K(cuFz6dCBC1%1P#!<-L>0)(tmb9fi_9uTFpIz!`E)YovK;j~sT0qJk1Z%FPf
z05svS%v1kKpWc&sQBG_IdJrd3p!@UA%s|IX<;oYoDUb9>Ec_`;*guFzfUqPhDG^&81S7
z6pN5>^YC!*lvqK83y#!yJJb%pO}898v8Epqe$@Z3=ulx3S`+o*QB0e&rOA8*AaCEk
zXEl{pRNQEFBmVS;03XNh_4ye>DuF_*Ao>RwDLnI^suocX=)wb={zF8FM+Lzjx7u;k
z?&=Q%Y5o4Y|pL6WZF@DY;|Ei}cSqzd6UIn%Tb+362D@OP)cOch7(+
zOW}8V$kx^tEd>AinmfUbg@&xz#C_>4LB}3FB4QYzV2?aP#O-|Ke%j{T>jjdkrsLjP
zXn|4*SzL~PJ9kF~?jIhI{l#sVxK9}%)B0!5*5f0*aq;ns0<#1anQim4l86lmXAr
zFl1zTzkh!a7VZ`n`Q6=3hGm&|ywvc^d_uF>!NAnzWH~k538Pr*)x8h5M>QFsIU}*;aP?+~JKXAue97B`qzT%%B4c!3QwY$h|orFgHT^**nv(+I9qGx1DvtApU&Uo&S`?(y}x_7cYS=gyL0#O0AG6gC<23n
zY1NA@=N?zWuN6m)rcIVZ#F7MM^53ALk>&
z%i(COo!Qgmur@qgJEh|0_IPj|-03UWzF2(gYdSh*HtNp9!t&4;Ro>exrdcu8-=Bhp
z$LzU0VI(1!J@zeim4=CmoQY{*O+c_Xf!{cd-LlE!*126fAzM^D-{sPDf4#>q;L&=O
zjs?GMN~``u;Buo~#inKM++lx=Pt7KO#_aV<|>re^;
zDpBQJ%NPWFa!}L#KrUeBjdDDqw78BJjAZ928WkhkAFjROjXv{qBp|<`(9(J<=F$m5k468z?O=a9V4}
zM3sMFsdm_75DDRTzSs_%RGNc&Wpz6idY_^|x1iL1n&EWA#0MOGvdTR7((sM*F`>K&wcS%l@ulr{CZvllbL&=ex
zt_1o6PfDD>pJEi)xq@gdk>I*R+d2Z3AU3Jk+4fk5xXzge&s1bz-R`G}X~=~dI}LK+iueOj
za!+$D{H%Eyb_jZb1DY{*2=YeICPt57JU-R={2Hr&@l(6se|rHg0yCM?2L#Cz(cn8F
zA8Ja29mtne0zQ}$C(
zEQyScP~d<%99!OTV$+onKN6I_y&InJHVii{5p3Bf`FZ(#bH>AsOeoPlNP@Lhy01v`
z#ize=q4qp^@te`LARi7grJaJ*b1a5EKWoj#N3sQ8<@$H1EoXn+oS)lS>vedy=~k($
z;WFxu4Es?1y?y{SktRY`+Cy>EqO0?Er^DIBx2|A9mF?YpHj>IpbX5lift95~9^mhF
zFtu`)vdzPK0B1Uf<=EP+)4VZ9$~n10!pBj~2J2H^x)kNB6R@g*`0wtdtf|>NpZ7bp
zI9qKE8yLj#s_Rfh6UVH-I|uv7X1&bI#^!mky$7;Zf5tfli>Gk0v1#}~-OkJF*|ids
zdcd?|UzIPJ2hXV88wv`_FOs=WR1vG-g2+FZP+0I?o1F{oh#Q_2~a^!d#M$ZKEN@7pZmzmOxneS7Mx$GF#NZM^cy>EO#+JL(1MOtn#QSwq$k@5VaN
zgS??Zx9jEM(wx5PUK$1c1QRy3CXF9;D8Pa3l=)Dfh-yYqpxXwY)oA$1s7`a*K0NL6
zZ)}`@PLusThI;ca85vEH@YRu;=C
zi-wF}{C{MI>#+7}=If`TQFXD{uu9597{~(bIT?xT&v(eXE#z^l6KIJe||TATK1i%Bs+Ftlq0F(jvyZb)ixW_?4I_KrD!n)y>u}^dG-IuAakv-4?|UsEKS9vxX$HT$c+FMdqhFb><4l7$0iC;s`5`+T2}&^!b1o-w#YCn(#^$Yq5OZL
z8mYuv%=e%Hk!bKrVpxi5i1UHC+A<{R+*D#zcO;3D?kIWlj>?6eVPw-JfxuSLTV83R
zFq+44W97Fz0`NnYRg%p}ip}VL(i2jTm()Kogx$h0Kd!tDq)~<{dyE}NCj}x0bqR;q
zO=OHkV~5KnaR@{UBAIDqag$(7>(c`doygCTvZV_LkaI0wG0=oaJY~F50>i>PARn+g
z)kK09t>1jq@|wqvv)kAv9Y@4hna%(;sVWU4^)aK(9>f^_hmuD=a(jo%G*0=oCI^o%
z->+(REvwqn;^Gt+yj|e<+^@{%#)8Q6EaxgoN$b^(kdcsdz3;I@_`ABg)_h(oh7DL*
zS+NUyU!ETeV-&`z7#TU!M?WT%mXws_cl?0_6kyhKmXL;z8
zi8dGa&r37Rsn%num#5$Y6BQNZ<8UOm$zVUEFZ}H=-c~ka0VQ}?Efuggl6qMw^4Zw<
zs?ko_!M+B^r~0S=`wF$g8j}c#DEzf{uRX9D234qcz`>l&-Pvz29a7fTPW(ViDJ=XO
zqgrYMRqdO5OxJM`g1XgsSmYp%V=!`eJHx#T#zV1X7?QOTTC
zVx4ma1RYf(o5yF$acB77T_3PdTR4FkPYsRd@#F29&jkzqTisO@7r6?BpP`%49lFRG~3_L%n
zXV7G8_%ZSZ!2Bqq>hmi(DT!Y1rKx!@VM_PS8LCQydELX=*+5N=l30GH4`RFLzOaEJ
z6hKZ+{_ag5^4_@Z%x5JfzKYqPuM%}!_S=HGx+kD4IssNNZEClNHUe{1Yn>`;YHFGm
zEJ=yU$<9afVptU*H!ocX_mQelo7mvr}ug7i_b1EiP`>wwxYbM
zc1>uYx7V7OOHmpVQyC!PB|`$Bq5b3I4`{XIv-vkX>D{l7h;a#Q>XLn$zM5a
zE(C-#bp_B{QJzH0S2p?Bg+Scb-`}!)wP1$K`R&VT?1{{0%ZH2Ms-)y3xwKDRnWu1h
zX9~zxhTj38{G%I`ZwiX^s6O^!nUpT~+cUzAZpBH~SbN%StXkYh=@?D~daBY+q-22v
zZ&L`NQj`On-9LK9GP8(Gs{6>X77=I@{3$>1
zROX|K2oupsBB4klnJprO-hCg4N<^a_KP~S;(hd^zqc#zl=7&=1)}=-jZkqN*iZAn{
zQY8{4yH`Tfbr!bcjio~oq-kHckmzX%8@oZl4VL&R=|@Vj(*ab~vd~H8l~nO43fMQXmDFqM?f#x+VAAb}
z;g$@+gv$XEq2daVM3cMG#EyjVh?Ak>wh5f!iD~)~#TEn;@9;up(h$8FM9JTX-b>qi
zy@?wU*+wLfJa_nj^<9!&4;dZyBb+3ak^hk;+4~eknQhh)9vB!JG<}(-1c+TtCdrT!
ztdOzVK9Q}83TRAHP*hX~g;s~BJ7%Ydy=!z(=G6Qpxz=D~V{T5>p*>aPww>$M&*k&t
zKYX**FaOIDx2F!Yw+RKn&lNa=btnNy5r6*tS@vVXpfL>t!`S%v`0(&QzsAM}M@NUp
z+U=enq6j#x6O)^-W27Y||1LMx*6=zVOoO6`E0M_KO=g~vp_rm}qph5s-9`jsd+vm}
z%kgp_EemLYf(PjIKHA=!J^bW~HbVwSx;g(t>gbK3xbAV8)78E@2
zMh)RTZYRVbAzdUk!<(2CKHi?%<8^6T+r>gR{=Pr9P{hKePkS8r@Z0BZW62Nob4OeZ
z@vAY|BP|;)HI-5^aQz58{Dp*wXZ62DLGjwb?pI+3o*st9PnMtnT!xH=4j-3VQ+fme
z7SGk?)ByOmJA)n1k1kbO4Z&tj!XhG+GOlUN^~Qr{3|1!)Khseys7~T^pTTCk;}sV@
zbsMjj`?*N*DiEuGuPpPkxPpv%pvYF+?P~E2IUZA|9DkktPD=mw6%BCpcLD+Mes;nH
zep$@$^E{>*a&<;TvVFEhC(g#hAUrrF1}#>Yn}^M(~aKIe`WD+>x}EQ&r<@%I;@A}v>ERDPKU$Ie5=ab$mrvg7GO
zAlCprhM&c7sb1jrXaNPdb>3h7@pZWHGcJu2Lo^|m?e+i^z=R(KMhd6so^KrG?>`I;
zjaYE?)*ox-9U((eVOveRKDw^awU=5eYJH-C`1kE_q7)lGFMt%!os|MbA%3xC$|InE
z+P>l$E|-~zl=yTv6&!&(4&C`DH-?g8=5cpP_5Qx;Y0U1Wuaq=}QST09pv0pIU9J~R
zA_|W#d!6BA7aLA{_-tv2$l2Jgs{8vM?(bgT2Sc2^rdJb8|L-a&kQno1H9Ui1>QoaE!#|p?!%ty-qJgpPr#<
z8d3Qusi=&v{)Xx5t^z*|dpC1(V*mWHtc)4QY5m(@!oYs
z1HfZ7RdQ=`dVZ)A35iyEApiK0;^Rl^axkiu^%{E23PRqo7-Ok-f=uSJalH4r?fy={
z|47(RQ=w56tl#Esm8GO)x3Lfr5kbb=*;<|RBPGS@LQDT|C>UtyyV@MiwTxo%ZXM*`N-=ts@E?E!RUqLA;N(NZRAnF0m{Zp7Jt9To`-`o
z`jSW2!;$^X1XG)MW&CsZ-0tUlYvQzhHdPNf^Xa%ACTIZkIE2gmG@KKVQ&gnj3NoC}
z`Tll3i(Y9jI!BV0Fhnuz{kIbU<(i8Z8Gbv~7bDNUOy7bJZliZ0%v^m}M2-p*P#jY&
zm)PZoNl%AH6ULM84ah58CxQ<|62z;v_dteL;>hG7E5Vp^X2+%4ctMCnBa>$57bTfy
z*r%r^40Ys>9O#DK6ps-`3{o;yG8`EAT=IAO8%l4`AkKVX=q|h-9Z%o+7rFbQFtGQWWqg%3MCs;VeJ}oRFt&7
z>>xUxLC0MaNGoFZm5>(zCf#t?4Z$i4fi1?N+9=TVWo%Cx~2;<9N1$j)yctlb+Bxc2uXMdAt7T_o}>o=^Rd0N2k$k9xlC<1xHIQ343Zj
z5BnjyZA9j4?U&)$5uk2WHMI|-!$sh1@Y^+xuak6KO<5$6lcUWU+e{(Q0@2a&dUvjD
zHs;AnLlccmH(T}EXChv)cov(?sCV>7mv75)oSK??Y_R&X@BRJ#;Wd1D-Lgp;9uRJ`
zXp#sT)s{*W(>nAzbA&|V6AoGXIIJ{%HbsN8^?-j2lQH=0eo7Dh#2vePdk>EYX`>%0
zdVBYs^@w$~0x7+q*8LFR-$m(qrZT=EG{^LMy%+`5yKz*&c`ushQqpW4T_en;CFnL4T=aeyzO@R#zVaf5w$NFzhs6b*;62lj!
z&(HOuUrWEPwxWWG^VsmP$8uvS0?HAXWkx*+MploEV4k0c4ZXq4JHNPKpl(V{QjI&0
zgh4=k9~$+!M6Ux%Z|2YuB(I132w&mjo16r~9&WrKfJ_laK-WLD693D#xPj0r$d^Nr
zZ2cwG?df?}UdQ`k>*uiSlScv%ld8-i$9s3jQ`rIm3OoP|d~8wT9y=F>Rq70-*De}Q
zPfs&M7+|UQB=^brh>4Vqg=TS$F{h;D>|!SZh|SO*UohVeqs#({6+5}
zsoEl0Evw}#o!&8#|8+)ga)FP9G6^N2uC7F-@|m<0%SU9s{!ybDBs8%J2l}AyxDOJ#
zUe~XQGwhb_4!2DA!zQ1B^;{^_$NR_e7xUHVVnI3uN`-K^a00G7aCH*UbCQi`<*^0WlZc9gOd=;zf-uua
zGO?KVGG&RR*aK*V-&0401JImyqZ37YksAf5n+fuJAD6JqxQSDmy#}N+QUKiY9f~85-
zv;7)MwTDLG7C??vop7trL_k3jo_7_fl^8@l!m;JNi-Yx@3fmJZ2{+WUW{7Z+4561b
z0GSt-ry^dqFn0vL8ZotzFfeo|sy|WxZmIR{SR
z>%T25)|$mgC46l7I;9q;40Lj?c}|rm{ytf0>3DfKWN=|iAN9Ctd^xtdJz1$EG6TH<
zl-Zu#ZWjg5kEk-~H`^2W{k-#o!3_;Ix?@p+cY%R-u&eeDSA*UDt*dPh5)u*)5?A0K
zm$GJK#yHs6t84k-0!vP^6M=U5K5Ej$#TCJy&f|J^_Ha$W4`z3~P@V_V%r7*pJbjT_
zghU%;M(&rUgGUQCJPoKsyu8%ZCg6fhY&f<-O;k!KHSR^?dD}lW0x#{DMM{C(F@vmV{2_82`hev3CN|op2GMk}0LEwO5y#^wx1ds`!lILd1uEI
zM(`B@oi(`pjM@e0z5ywh4lr^4Hdc;B5kV*nihG9ptc24+^==l;~;N@
zg*)e+ii%L?erbF94EjJKLqpS8_q-J8vN^1{3lo_+OolF>C{Dq|x~HcPx)YSKYHMn!
za!qRsXUBIoLPE~m)t>fTL`4%j^uWzv6C;k?K^wfvph#%6k-L;#ymiQ
z4_y3URNL=(stMbRfzk&FdN0pbI3Wk}t#v>CP9Y4Y1l^=>r2+Myi|kyN&xjJx;tg
z7Dm{G>jNf^|i`+
zkGbP&GX0>h&BOWIgB>5x(=!d`cI4_jI$#*NPQwOktz5#e84I1C>tNy*46-5|R%uyj
zi{{q{j7ggxtE&-PTx?d_Jj!EY8sy4h!0Kolv^HzzPf$=$Wb=9SVID&RgxtSgFLsbY
zd;`aYEbwWswP@+x_wFnodc?;xFs9TqE3+-we8~KcT-4j|)`nWutUJta1S
z$k)00Ew2@iY;Gsdm1cH;>9}-%EIiiNx3i{ZG6)%TU7Lb>>&>Y^s1iQr`*M}6GT*dM
zPXD`k^OX>+M0l;A%-dy3vdnq1GSj$`guZR9rlEQ}<@dQF+h)7{$DG;Ue+b13k6GoVBUs=!K=p}@f7UbnC$mS?!%nWIGEQJkqsDUjY8TE$)?
zCsOcq^|rx$V#HYv2p9Dq8jAc7!rK0Pf}jr#v{ZSd)LUt?q%U`$tQx-Ic)8ok((!nA
zwnY=N8x5_rs>^_pOZDmQO40)GL!#H>JY6nSj=+)q{CSv#b^M3M{b*d|7C@%%gocJ^
zAj-1%a}YPtp&$}~DkuM9fJJ2#E*CJ6B)R>*!V{uMZAyw(ru1n(NmMZrr-l>$?0_;R
zh6o3p2(gpn^=0qoeM{#@77|#|I(I<@E>Bw?#KA=S3SU?rB
zsMAAWJpZ#hyY;-JE&`yY?RlA!?IYlRgmnu`n#d#$VG>dkLW2hKzMXe5whMNaC91_u
zqws!fDnqtig6LbA;c7ex;VB2wa$I6AvBwAU3=J?n7w$oz5bDOk4MeC!W3kvcGKD1y
z-_(z!z2_HdNnE1A9YxOjlQT^#OG{cB^_r)ZX6MA-pFqLLN3YI8HJ!uiJ;&(x^Ib4_
zG%ujm)Yd-U`gC5xSS|i4Rm=}&55*Ey{I=3On#wv;y%intS<+@h8)gWat0zwEhNNA3MmQfYL_=c(&@%&N70cB2b^CD<2XgV7I
zUNT`$Dd*{avW#ji(yg1TTx|WGr@65lD#z>5W2vEIZY3IwXrMP4HEBj9YYmP4RMuiZuzbc2)frA$fBxqL1FAt?@_6QBmWa`~F6!*(
z($P0|Rawu^Pft+#(EVn5aSAwTfraZNwR&8@5BoSSw
z%$u3G^tMgmbzo60vm@gN&mCqg{k_H9Afi+QkrhWLY
zU#3DrxkCqFs3Qx{3wTh-pMdtwCyzq|{?1n1^4&Uauib$`^xMiqR&myYfbDp$$fL3{
z0T|tKMf#7H8Y1@QB%%q687{k$EQT(+gB&sr13o2u|2|!{_It>})s>tv)$Z?~7fW?U
z7{T&nt0sYP{~67f-E_v4=4{b5&&zADxRWWG1cUEvX~LfZDb`H6f4JR;eF!aP*QXP{F(96llXrqYL)E@r(^ox(r8Vy%a$$3xt*QuiGe@cq;H^ltGO78cQa1p&q
zUso_AdY?W79Z&za7vN^8N`;Z>f4s!o#Gr8m^DaNDlV7$oKKmDhZ`4f
zoo&%13cFdC|2-~)_LmOtm4UdJ%VaI9D9`eDbBYMS=Z1MAPWlxk{vQFR#t2p*QIRoL
zDlA=a7_n*O`=A(|J~Y%NBp0VX63K1&M`+in+8U1nR@^xr;~9t6^CiS&-x(R;b?KPrqPiO-|DoGDl?vF7x)+TcZ93G#A>(uVo`lm)6LJUf|
zjHi>9x4fXhF`s|DsD2fntX95IYsr|zx&@7fj+gY?kvZEUA0iC{1I_VGlK|h;a*aHR1(A0VOR*=x6chJyI2R-zeDC@p4%cfv}O
z=R%JU4i_Q6`3_kQ@UvueWvW>Vbc_of5G4s@p-IAy(`LpabsU-w^Y(5B(1_6MjfyYI
znjtb}vO=m0KHfVJC?N(cGE1y)7qH%NR0{fUU;)K4TcM^9rno3R6?mGbb^?Ky^Xcga
zlv-Y=MbYw^X0!1Z1Cjo)yd;d+WziC2LcYV=LiU_#{zplh2wcv-_%u87BqM)d8$3?mcM
zU5tQto8!L!rCP|9aRiQ)n|M8lKtf%)K<5eykfU7uc&GKU9M$@DY8Py*g6(IF=_{!3
zoypawCDC?lS0W~SCaL#=D+f&hzUx!kW5w7+H2suS7>^qy|#h7ehS
z#rmMi1(;q@;a2Mj;96sjErV+wRJvd~&$r$Qe6v*)x_r7v2EY7{&t2)um!3exDgKu`
zk-cz%rY0`gp6y2`YCYbYb9rpsS
zg6-Pc+C8tU9|K#=yNYBFdAW8p3&1ggh6zZTv08e$d%n#k{DBPW#PoghEvLkx>?`A5}kCr7y+r3)mDzx~_{}>GpmU1n`tB+>2@w|11(X*caSxsV&Ld;Jx
zE-*ZH^S3tw^hSfyARwr(5L#804Kg<%W&!^nQ*QxOWgESFKQz*zs5BUWNOy;HNQ1O=
zhop2jhzdw|cW%17yIZp!G9cOSHnPG3Aecx+c>vvs?)9bNFCl|pxDX9pc
z7JF6o8ymN(7B{E(;$i6M=BHE&!ulC)0}vEJl)GO=_*mQ~5WF{KF`FjzW*HQ(hx3lN
zfbJRQNbl0;x9`s6X5g@%ouV_JsWu4up^bt3rj+h%!A9V4u5!tRLE$;*siu~j8pOh1
z5fKtT4ze)NY2U7(za;j0y7}LX&|LT@6fENTG$DMs47=_XfwDAmIg?R)mPj<$qlKC%
zhQ)5rquO!E-mlm>e=i6>puUf8B|lFwm*I7
zd3X2{`*u1S&=}TB^|XaR-M#(ZMkUAhU?>?9)?>}%bTn!Tqwg^rYZR5SBsMs>JJzmM
ziW?Y&5wZnSu8*{GJwAFiF1j3&ZdRvP=d6Mvm$%3-XAAiS+NSEPM>qTDL_;5F<3;7V
znpJ+|pAg^fDO%+q`ic#S;PGFuhGMzPEDa&oh=xfF0n!iH7dc7|yLDLW+a1&Nn1~UO
z+ej!3$h2_->N07`2cOa)NYv=TCsm5-mNyPyVOuFPDQlyJ3a8GLzE<4$XuTGJC>3?W
zg_-5a(kevWwtAMTp;%6uHnn
z1$Q$}@m^a(Qys+A|a7!DQ|K&xPO@Ut{uvyS)
zu-eR&XzS_y1vOaA)$L_=&Eac4!5WoGs8T7!)m
zKwjU!on#}_zAwuQl;Py$v|Q`VrOsaQsJU&0n|Y@JQV3}HZ%DWutO26|5p$MpZ%EdB
z#+aK^=i;K*nsB*^B%qtmVmB>fC2R_Z`{G?3!MdvimJFD~b%>;Ah=cUOFj};nSpjW@x=b)yd0*qLP|IeMB*}S~kot+)-
z(~x_1(^*E@CIdsmLAn?vTRN}$L13Q0)GAxu`j
zf1nuDYKGx1N2vfJ>=5z`#1*!)s|0?*+g1d>L{>9Z`8WZaqd~EValoZSl0KZ-RRWM!
zI)BC%lvxB}2C7pF3pEOvtkB8X87Z6H{dSl@e%jk0!pt#F^GW>l^uN&0U*AT17PfbD
zb#G5PYNkTWrFJVvnh_!3dLd;tR5msJ7dI9qa5usmWOxOf6Yze*DRC!nAwy!mD%UAy
zY`N5^7!SV?O6cS(Z>KqIIOV}fPfrNELLm3lvISIJ{t^%T#`4?STM&B1W!~6jiR=v!
z^ABCBOcvAbT@P;c4!do9q6L6%@B;F8vRkX6kr99)CZd{v-a$-663QSF|SCKzL5WELs>5UDE59SXar;Crx=Bv6=fhujVt$R;2
zWXgK7lB!nvINov2Mi>FftE#>a%~%G5&Tu3oB%m6766t`m@QSkI%&Fo(iai|vGTd16#p5Dyt+nktGhY@&;=P5)1
zh%_KD!0YTZBP6Q(<;#~m7XlvK=ND!(3`GlAg;&1C
z;9}->9gO=1vJH5?OB5KA5ECOqa{yaxrYDtJ+2qtzvNaz|gNN)w=gw)ZMkqMsuJNde
z>pCB8B8pFgavSe??Fa&|XEaI83PaG}KTK#F-g6L_dERS^!Y8mrMf6Wd*nHZg@q6jI
zzb87@GcT=3+9W0wx1IRd!xE`NeFXyj`(akCYB+43SJh(xT
z^hu?AhzJzE3NKeP!yxb9XS@nkI6tMVdYzyjp=gebBGp+xKS+9yPY_V4%-odqvBgmm
z%-#18kO`1^cI$|h?(9%6-<)OBzN;8CzaI9O+%>NJ@^O2Fr-iu}i;Qc{9dJA%1+2o*
zt8_d1B(Lv$yeDv?(%&}wr-l-rLL(zB<|^t`3z59H3~j|fyf9+L9fq>4;gr82*Z+Y&dh)
z{&?L;q~Y*ieBixXgL|n0&MH0%3gNuQ4@M=YP8NmUyZAuftt8A&M~eoNM6ZGcB{UM~
zy7CqUo+sJ!9btTs*HG{X7keZ3N|`7^Y@$eA$%qveDJ<-({O;kH_OHWxa`LZN2iV%y
z+&}@|PJOEJ^^lg5B5qn-THoK-)b!Nvqsd=#yZBZ|LsyKH#l`;TPt4JZ4Z!l(BX#ld
z@eJ1S06sUJEq%SZIttj#zYI
z+BGsV0&_62)MW&XtQzxa*5msp9ar7c$g)e1xI(ebJzr(wvIIlc=(A@yu97<>SyBo@t|$s+v4zWp
zI{TNQ-oc^XX<7*qHd2^Pi16ue{qcldkNb?b8(Rd12M;X!a&RnL&L2)Fcvwt~hX<3E
zWVbxFT((BMe6(R;_VjV-!Cp|%RowSZ_`P%uoVy1vB7`A4*rywG6r0`qmV7_>v
zpzr`}ez@Q6u4HFQdz#Psd9bj4dI|
zFBsKvV1g;cLt)h1igZ(UzTq0m$w2T9!8ZX0!G7@FSU-M1f@awGks;DI!}$ur+iQMzxh3Gxf^PWzBHS+wR1(n5P;jNduj-_b(os`@1q+t)lHjUaP=Y7
zI@3c0Ra7u*X(dd4Mew4JW9y1BoX;*-xV)EQPR78&{di%K&xNOp9f}s1$fwTVl~TuZ
zVM-%&tk-arRpLc!Wv|
z&GI=SgiMcUA;Ij&V_owq`rN#}*R1AmR_u)h3i&(&6B
z7kxUJw7)SY{%snXXuw>F%sKztCxmPyye)fgf-_#IS>tg?jkcLW1R`&XASC5)jAe8A
zTc@U9y7YX*6V-S>CnpP=pQMM1XmN%Ev@`0$EWcrg1zd#lg*In2mtv&jv^
zz>S#4`l6@8#y*vw4;%t0{_<}RDK!>&W+}W&OR+Tz#!!&s(;}+H^-gMBTzKX@_fKdPBB!WQSpWH%WP!tc{7dFo
zR(6i|k2<`UFN2~nzbZh&M$*U~pD`rKLbEm4YgLEaP;nAaqW))p4%
zkaoRObd(N`x9QtT%y8409FMzT$g8E{`bdY<>mAc6R^?kLX
zbxm&K;0B;*NjsSxTMr4jj*|+)K}!>2w|<5r{L@rRTMHR$ZzfOSq&$;~UMJLOnVgd4
z0Jt$_TwQbC3Q=6Bwi^|zx}ekEQ2FDCSRlV>jd|{cOhB)drd$nd9i^&}h={}{uKm!i
z?n`m;9x$v_*P4V`WH@qlDarBKUlk#_*@l7XP;JAEiPiDZQYRSc(?lVlU|Othd7ifK
zx?LaBu)JxwHh=ZXu=%JX=sJlw+0U1V>h0p*G~4*Z#KQD+PGjSN>mn?%MxTWEAdx+W
z6^_T*8nl=mrl#LD_Bvb-cqy`k5aeJUJz7MJiWCu_KAxt
zJ|%_!?_aCmM=O23{W5aWlG1LR&PV!EU8OCLzBgnab?)bz+(L*rN-8VbRxsV%f<(h~
z6hb$*hsDTvtDMKPUPHFPeE~kVkm-c#ZFqh1_d3v>aN3rZ
zGId)h3R`sft#0pE)5O9Enu>lzn9eZ+gAgwt-_!^LITaNxEiDTxYZA?eg2Si39e3dG
z0R`w~ZOslmIurBil>|;lcrFj!u&k^GnYUx~8TgRp@;w4%Xp{r)MAyl@Z{j~lmDz!b
z6?7Wk$;brWPY7iV57Qrn`E@xlGQm+QeyEtyD7J!m*gH5NLtt?B-|HXYAR7lA+U41q
zq)fP+RDI?c>hiLurxCn1HV^cS_x8YmwZ=}E2kkQ)NiNv3^@UG~5
ze$SL49oyUNff7Fq>)f5_$;gbPiIPoVMYni|=@_BqCPH3mVS2g^BmcnTYf
zKJxG+E}7K2aVSiNc{V>jdsahCVqsi)B3|i`@iyVcT|dSddSsfv>ZMXa)|&}U8G=0#
zT3EaxTEmBDakW6Hn%*EVXu#RM>fCgLN$mFa2A%4@w$uCExNDjXZ_APR=$5;Q5`;ZN_+w%o|uO(L673Z5~p7EfmM&>nfWWhy8B-
zQ(X}}r75dwwvNAq!YdIwLV4N`1=RRzxK8q!c6jCY^Hscl1+4FN;y+ST@60ww5|>cm_|ku-UvRQEU9A0Or%8xQ#`~S~vCriE3=M}(
z@Td2mu4wXBmZ)wJr$;LjkXH3{I64*^d!Jd=D07tuW?e$U5?{X0j_`Qu-$~V{@}{kz
zLJ*F3T59CTZNz`v+N>cAL=6U@>x9=i1k|acj`vZ)<47qCz<-WrGIiKRg3Nd8Gog*P
zhoGLw6k^U=GU(sUojDu3@+1NdyXhU)&ciYG%GCwySw*um!)_fC16B9R7)~1XE|+T2
zeUVDxAISDbu_!23Tq4-P`3^P{D?%o~=X^z(oFd5`SA!1c&0efoyinFZr8537lq?XX
z=z7+o4Yeo&xgpjpLhNm*RGNlljG(UL}$Pv$K1&a7wwKRdhdgPP-}Dg6Mq4Th4Z)#fGMfryd46sUl
z`~n?!7n=;I(5*2A9;)g;gt2$WG07ED!8C}PmgfzIfaXccwAL3FcivA7FcnqrB0nBc
zGqdu#rn+a=)|feRx~p4Tfx+3??>>JX20p(%8x*!9OkTR-mx$@2VT=%`%h{{!=L)&*
zuS4|TK|7*`44^iH6I9FRh%{pi!WpkQZY3nn>m1J}V_t}(i82XbHNS0nDwdWBNAX5N
z*SWa3(5X8U;Ns$f7|kdBLCamJ=B_`Rm$upsjpiv-0-}Ri^U>>J;N+*Xx_Z8!UuIB{
zny2T{V%>-ZjQuK7{jbh>o05WpvCVPXgo-IE@WZ>EO+GWq<9%wp#p#DuuMQJ~ds(>$
zCDNL|9^uBsa5w2FuOUF>{Es<>Nkx;b9TP(^A$4^#V==%YGw3$js_ud$lqV%QS+P#w
ztsII!=Y=fiD)dg*IDCDH#6>Sls-J>uUCxn>nBQ%-)!FCPJ4GZg8(q!Abk&rjW#gH{
zLL$b;6~82AW$}QFkBlN_!;_PeCD)3mGUwx~+1UZY2u5(X+1RXXpj=NEX}?2l2g!66
zCMGSem!XrBK_D9~p`k&%PW{5~KW+EmaP{wBqwM&gpqEiu%F59#W&bXbt&cW0jZRNF
z3YH#^1yxnM`(vKi;_GbxO6%zGZ$*If^5BpJXRJSnl)@ipZPO?^XgoudHVWH3LaZ=F
zNm=;|K9^_^RHTuSo^B1dgGm<3UY0~eAYuvOp9
z+4f_4y8l3f9YYBK1CTVD!(d#
zQs(O2n@;c{!q@wYK?nkqnsV9P?>qm#=B!l|n&S@l5Ztprm`FE!KM=nNU}!Sxp!_mL
zWqB>w^`Vv5p>ypUnix6~JS!bt)Dk8(8rs1e56GWVf)JnG-}ybEDwYI%UCu6OuoE}}uFa@);*IhO&tM+?|IILay#%Be#`zac?84zS1SbzaI($k^DXuTcN9!GS>&
zOsb+GHPs8Vrj__PM9#LUXY5qZ++q}Fi-KZ-B%r#*M6;kKeSO6Dh0bLa^16c4v#{t1
zm5dAl$7U4_R+vYvTVt&fORoS@Rj;nDU|-g&)_#tqe^L{|si6n$9D1cAJl(F&&UPoc
z6n9MkHL}zYQe)FoxUkqvvQlj&tLeY|e^`J!Fh+!#lJ2doCB()y90l1}xN)-Os`{be
zs(PFosT$ud)-jZsRK$pmjfsEgAAn2KeWI|$%D&J$djO|9{iLq
z5GgkG&Pucw3e3GO=VR!#-Lz`IU#oN2;GtEaaqTE!&IuXnD5ac1J;%+H6I6vs6T_^oF75oiru*&f6PvqY
z39|N%H{B75rp)ITyZG2-Qf3|9ajCmJeAE7pF|LplsvVHA{|D%zyo;^}vAa3G5{H$6Qp
z@>4;bhIt#0O{3T_|94`dLADwxX$oP4D!Gc-IItDvsL-A#@mN?cRW#Sug6UM!Zp38J
zRJoE`PKaI`8A!a6myg4S*x9SiXQDui2Y4MbBIP~*zfuCeB(=N)N9N|%#@lz2UWf1B
zy#s$-Y@iYicuz&ORQrujz1c^egI<73+RH7Yzifu9$&%`N^7Fo#k_lH=cO|!tT0=T6A;HNnO@I4oRZ9)i
z(y7)iw~N@Y7&1O16B`6*byHZ2q;*F0`%jZ9hIfc>N1Kj9wjI%>ZPsYM*Wo~%4zhdW
z;~`fc6|-c2cDKN}^GsOx7lcj<-;U?B$b~iu^Yzmd;2?alu#_o_Dn3OGVit-U;qtgGq_sk51o31$A`#}5m|
zxc;Q1IDyfRAiV7MgefWw;#-baq|wZt4+Fok$_B(ohhCVjgGG%5YIgr$A1I7wz0<%Xm-jWtb)pa$VKo7l|6R9jA&^mqhRcG
zY=JyDg|QQ^%Okf^+Js0Tw{9D50^TKoO9e!XHC^4VIj`IxdZTrk@|LR{ytX?ik!x0Lux
zqvFx@8k6kLwa`bpwR@k&+k!TIK5lM8s6;oi!IKXQ((zh4xxWSm7<_!2*y!jgiy0r$
z>K*`o#k0JGE^hn5xg7E8w3EyM5tfsdwyOK&I5S*Yd=_4R#Agl{nihnL%WdgO8odnM#l=2z++i{E>^5izyAqGq=j
z$`h}sWFdRI0$i72ku5+SrrYGr`%i}fnD^bqYe7^n_PyIm%zlLP=@Jb@bgo`
zEL@*bTxg3@31FwB+5PNNhXqlyxpNsC8zUic3^SC;V_#id01sz@A2u3P%gMun@5^0;
z4%sx{@T0dd`b4-mJ4Y65aB^7d+_@}tgLwlezhA#LL%}*y2xkJ*inuggVy01N03@<(GDIO|w#?Q6=*$gh81?#p
zkZ39?lJdI885-Te+9xJ@mX>6h#{S>IN<6IV3$jNJ7VAF6H+u3}Pao{=AwT`=A&RUG
zgIRDWFBsqd%Z=Vk5%u*Ws;lMPcZdm|{V+N2VQdc|d(FZt41ka3$R@zI0$396>%S9R
zrQG4K&H{hvp6n*`#(>a6%xP;YDcN^>xep?AK=u}bAN+n<=1Nc>9}M~`D&6+y^HF{T
zD#6}YdEA2%7sp!Jg|8j!v)jj`u^h35Hpx0Nc;fB~)t0|K3Ij#n2r8q?g5T!+^gLf&
zJ}A`xO4RiYc|FdZobW0}>w&N}gafnggUZsK&D77l#Hpls4@>nS_l{jDIq|d95T_w`
zB&aqufb6K?pKEQbU?5ks)&D#b<>
zNFZX|NM0|UYD;CKKv+noI$h-ZWW`q2d{t`d@h^Ng**`CzKXUYWz}hXxuDbWC(~kKL(l{5AX)kGqvvdYXWD
zaNbyijDgRdEkDcbkSNcFs8nR4=
zvx)*)QMMyNi+p;q#4U&+`Ym#A+oI_(Y$JwOuNnd~~DK2%!I<#&&J4}E+%R(o&E6>0<5yPfLcay0&pgF_8^#hG`!Mb}bhp$yYQW66`p
z{`yHkhEIJ^)7DcLxN-9vb*e1`&RlQKjD`{R
zDBf5k+-$Zr#Fh%5E}sZ@8){wl;DKAs&wXF%9r)`_&*Aa|%U!30
ztZY7YY0b7ZHW|O|zZ1C2q|Gk8TbK7%55@PdoMfo_R*G;#rgN;oGO7{EpS~-pUcey&S24X6-vHXtY9st=%l3<@WQIWX
zoi2gd9J71xC~hD2ZxF?yg+c+}U^rON+18eyA$*&4c6G(}vNKMALO~aUkQW{T##K5}
zVRNa9h=}Bvsq^#m-@lI%Mx1TSYAP$$h$y~njJ1T^KRn=y0#>^=AS%j!2x~sr2O--;
zTrASXeBx=E=G?J9bfAEd2Fe?qx*gK72LlUL6)Xv>Z??8zRYW?lV}2*Vohf}nhTuVz_x=$?qVRzwEsoz43)Kqc
zE=pd`b__=LHXPqiEOk)lHs_U{jB!{5Q1yfxIXlam3JLE|xkL>&_h7|8=
zvcQW=axg|5k`B8;gvC(5DmAOKwEQMkEHsHDEUWU|bn2nqp?UL@>K_fe3hk4{#jPPa
z!&l;|oW;MDBru3LUH5-P3?;BBSVdJ~ST%n;PnglU**;3n>7G%&rM%X4J=~{_0jSQC
zikv7MD*VB0IpD$7A)ro`jG2q@866K&_@R__1Gz}E#*zwx{*lv|tw(Bhv}?e7uelo9
zc_t+%gJ%Bb-}!Zne^{)pdsxOVXNfhdj-~C=7InZj^|Op^gRt3UkLypq5bAp}HN>x-
zy?PVIycV86Nl7vF={x(i)66YUsHk$={O%H~Mg|96w-3E&O~jyJ)JPG61i0UG2rs${
z)odL0CVwOozz6^S6!>@kB>@50WkU|Z2{&s#AVeuaf?rGr!Y_y+H1DQc5gN9x%l#+u
zpN#0CQDY`4ulQdcbfzD2?f`xidPXga2u)gJJZ?9hm
z)TK#WZ>Er!-Hw}bQ$01zoHlxyy4)#2(Y?{P)7wiOwcR-6H9^Q`KDjpqqh+}U375dS
zJ;nZCVmR>XnZq`3*CIG{+)mPR-eGnjZ;jQtag^uft%m3s2or)*8hu+1M0eRw4{n2`
z8rpM#Czq$)++21W(!HZ21!b+cm>9F>fMM;d(V+BC;!|p+=ARdWogmw@=nWgNRpfkh
zbUGu?X1Y;j{{)pmu9VF}Ek28i7fVrzbSp0A`W5LBDJFuStnt+m9|YmySx*=LGe=UG
zd^+hckBaw0;Tr>-mZx*4Er_feqhzJbkd}~mBZT95YsN;K0CEzFWW`*$2Y#bs
zS&N`5kSfmXJ2EkC18t4T<_P`iZUsB!1IMGlPtb*e;SGH2Z236V)F{OpRzj=)E&r~^
zG2wgOpSop#laf*t6H7?dZT>0RR(|>UHDzWh^}V?_x?FjBTv&6ru{j2M
z2{0~eS0CWh7Ai@#{9EhTb$4CSsQGNQ?0fBJGPlM38batW-o
zY@2@>c-T!$H2*eZknGAV!2FS?((xpLpXCxY=|eS_Pc!wU|ZZaDAa;y^CEiZ>PIObN;?
z-%s!{7Z-`DzO~qIt6CmxesEHJhlO=zzfJee-Vwq3=KN);P7^N`RgV^Z8hI#X_T{~%
ztb|0==pViRMwk>~m<6(XV1bFAtCnytm?VPiEn<@7fqtoa$73XDDuImmmP4VV_3M@7
zLXK?1JaN4Um{$%Z22tvJdg=@i6bE9EddmNpp`oYOXD5nG42vDkH8EUlFljrh!oq={
zFd0Pf?(F=2`BK*UGdnAaz*At#5h;|GmB-5s%&$()*PNdBwoXoW;c@GSU7O_l
ze<+K;wvSmr2j2zTIlLDCJzMXdna{Fcn5H9ox;C(y0iN5J4US)vNfr1SJ=_a5T!9|5
z+;m411+LPV*xl;Lj*|ug^3};n?^gbTVpdG3zYdfl%_z$`q|yiG=u0V1CeRmtLZWHB?GJax41ob!JAc{n$b
z^1bUPfr8BH^4PL_wI!cga|a?d(om%qT8pDMjm(3?Vqry1HyT94uP_rcn7?=oGMb#q
z6Gdq{)^)9Jp~fDEr5+V~Ho~=bBVG9miupL48?G2}zZI_J5kvH*+x}p3RURUjS!!88
zJwdU5uGX@bN|d$asi3Qf6`DlJM|P!CJa+WBF&PGy2v!95&;;eTXsf#1sc5^2BeGn>
zK_X~~5OoDB?!@L6Tl(3V-4tn|V@ug+&G>jI6|%rwwq~p-!=S~)`@uFLS2QIYe{2EI
zyAzur>9BY%0hHEg`c{uSDg-&Pp;QW
z!Xkn+tDTM5O?Ov2OEp2vE~oB;r*FmtQH2Ei*5UBijzJ=2U41m+nNBy0p{nl^6%6aP@oBKEZa>B$*X^JXKs?N{PZ|?)ejNtt+=mrsyrc7X$
zAaY(+hm6Mw0|I(#!?$(0=H@`%K#*FrJ(5~uESn3GGRvu~6WJ`Bc1GKL5KutUkQvw)
zv%4D){9Hh6QBc*BLN|^*<9NgT->)<0Wivs+Rs4?bGIv*nNpP^vy)E}utpBz?|AV9f
z^EERsz>mUTpM%cwvj|y8ox^8^CasXBxIYP$WTIpo9Db9T&c~0qhuqXrp3Q$)jy0<>
zG%GfS5*%jx`ue_i*%A>gG&X9f>RF9tO!+?>ffsbR7~9!-)6DNF)E1HIX`FD&Wk8g8
z%UYxe3QWm@4C*P6DTfb2ok!~(5i5IYYD2kQ(%+-{t{#)+<-MAsc@b`>jr3m-#*3FI
zCdS6fs)XU(Tf-hYS(Knf2f}F_)5y*TM`(3x9)fb@5(oZp6A2tHHM$f6tNGo(+xb}u
z8!;)fGe5VvIj*IK%kc2M!-E5Ohz2{#6w_?mX|50sH7hj+Iz{|DWK*#U^AOY-_{C
zt+86jbEm|G2f@{d-fyMbBmvigtI}swX+j6@4Mu8%UTltK9F28AQO%J}+&(g%&j_e~TBoR_fIc=NQ`r$Zf|m)QVVbzkMqF@CZmr2?%r+96Q=A%KJm`$9CgiLpb)Qzi(uv0qnyKY*5My
z#y2BZUK>PC4w1iuBz^@XLKRb}R;o9Sq7t
zf5*_yRf;8{pUIe%b6m;|`ESHC;XbmaTwH`RfEh9S-!d4?YPxZ^KxIIYH0NrcLu+!t
zMh%hNcGK=)iAQ0Jq~1ZYE<|o@mM1GK>tZ8h0+LtOvb&${GZ4>oe8Fvmk>(KcwdW{e
z&mFOn@2Be+rh0KeVLK1b;5g6YdooEcH&lvWzm}r=7@EI)Ky5E=AysUM-gl@6RHTB`
zNHWjUAj^p5d{syc7+zR_57_kJ;Na)oZFCd!*$=15`6&5rnBf
zZ3}Hyd}8XQp1cFW+n1~qwKSFV832fTi?F~5aB7=aqnL*|3V+=S--gPBbJ0;vQK7oI
z*KCVvg%*D5l^8IYOA9to-5_|A@No3#geVv%OkRPainuMmAREocM+j8GI7~b|=b!c6
z7cVVnXe6tvoxmn3tU?(u&j(HO=5+Bs_%|jpsURTK#F*W7ty3XNmdUK$+ovN(F15yC
zmuinXB0+w;jpf@KE19kr#|(0`;^pUOxZRPd8~gh<1_qj1Ni5cjA|hH1M?_M)BLX%F
zoZsfbx0iHwQx}r20du5))n3w1FSZ-QD2FPEpkO@})K9>&ZF#&%o1Ue~^;NC>IQBatN5@Kda{lv^2Xv+(&;h
zCg!qZeDlU*u{LDxj~0Fr%hy&$Ku7dTEdkPG_wsgi_5BDhOSa95kjEX?Z{brM+$rNg
z-=w<0;})9)4JjI!{`XJ8U#*M(?(VV}+`0?lfTWiOi<#$^(y^DW`
zJU=^&jKWr7X9@-yisJ+638QE7G)9wT2u|{}qi@q+g%T4x@AEcfFTdx-
z$W{{CjM8_|cwX+uN@KII3WmpF5E&nI6}v#}l41u&osQtSC*wZCQEV+TaL>X_o&<>pGG
z^B)IA9}z1yvIE-MeBK{Dhl$Vsq7Efkckm|F~qR`{B4pg#t
z;3jd4tHt_vd;8%Vd$?+`a10mda?1f?0^$~b!69TV5;y4xm{0Cxz1^i)e&n}>rNNpg4D8PZ`%lYIfa
zgdf$<>YEkS0AO%3U64wt*YfKla{S>DoLh61-J6ta}
z@$6Ae(th}$PoHct%ee6`WCaf|OVOgg0|iVffy|PWo9pa;e_5zT=Z}`m?Xf73ruY53
zh`fAR{VSVfabjMmvuh4kq3C?;z{&&Ga_TUE&o=1e2?z*)!LdQJMyJMFdt_h;*&FCI
zqynC`>JtQ836YmXsx2q&`#>)F{JEs7i!Ic$@derT`hIM*gBp%MC``-YZ+0ifVq!Lz
zm*J*oh6alq=bGQgJr4wpep0N-#pgQv?W^JB1!m@~3`g_5Pk_OiPytHh;l-|jibn`w
zbAjCNgEO8Z8&tBC34c%6-Lt}I^Cu}OYs|E=f*F9;LzD)P;B(q|1vD0(o6DMq_cl7&
zz9sUi8X7t}f0En<LeH)t5$Dsg3eQvr<;JF6nyXyNll-$fP
zw9Kq5YPNTC)5Aw#o0TC#dSj#I9j%Oo8B#353D`=}lALgOYbb?Zj9c!3Y_in8EOH
zh?IzIJIvf`7LWv8W5s$uvCID8u}>30mMbG1G!7eU#1e|4yQ-DZJ?V(oE70KZwZx)8
zM@1!fz=~g@F?_$Uel<`Dhojy{L|vI6zqb-B0r@~N&*6T*M#3Wx_LqeB#%VvA14Q1l
zXT0tX0t*XKnN(m~VKN;`S}=%?CKo8ueKD6wWS_f(%LNIN3jznTsK_oG(%W82W_cV8
zQCmutooud7Tc;3m(^4(IuHWQ9=Ds{svxp1)1clc*h4O{EJ|1-SMQu@2iGD(be>`vE
zNQq}9h5GyZ7pi~2r};XZDTu&(!&5w`;6J5oplP(&ZjRM;m4*cUotNDV#19JsF_9cxIF9m!kzWohHTcA|dAI@?W7^yArv
zyH@G26^$~}tMjn!a`ZZzk+ydXjGA^?zjY9)eOo{BZTRdSZa(h+S~_4nUTPAsZAFEm
z6;)9oJoX=PNxt!+z1XyPhGvuyl(6K+!4e>{cE}@#;ezKaPwqYV;Kv9HUBvv`6S3lS
zH2+IwdZBu+y968l#^_p3PcL02YOHkI7SRVM6qV=7QrXHqNf1@B%)TtL$uohZZ*eXx
zk|e~~Al=Py#*s+1{Dvp10DSV~Pw;D^;mDWr1
zK!7fr`TMtcuIu!~7VONq2Fp$>K_CYbIsv?QQ`18-PY9r}fo>?rcNtz6?r?Xd>v|Oq
zYWbPL_)4&JT|=WlNoOa7<(7Ns2C0Uev=#+$lrg7}({;NX9U
z9QkFx5)ue;6~ZR3;^L&Gy8A&op6i{HEdB(@{gB=axi2d_;g;4bXkX|V8G^MfH9S)_
z|2hql4KmBe_8s@Qv&G(^`tAC!prBW<;;7P~Ls!ES{WYap*wxW7u)N%i_4@Teyrm{j!+m2?f)85XiJ%S}Lrql{m%MXZ$9ZEO2NM
zK#qz@g#40VYi8poaLNVaWK7Ki$HSk}^7xD|`IdkpGDc9WX`)Vaw6!fLnpBs&2b;gD
ztFJaUvtiuSu;qrprJ}2b$cMSF%o9V!K43rwWN{6ecC3h|lIm?RE-fjs2JShsiCCon
zYkvECFOVe~W@}%^yZwX^t-rH2-{oNUIKHScFM^Y`AV@(W>-ChOM*Ft|C=yjw
zWsk9Lq!g`i9uazD=kZ
zOv4>~zdv3#OG^#?ROB}&>(0oSuOZ0A#l;W#2i_$r#5GrUbAxmsk(`t)Q{8v*yu$U9
zB&SdxFCsPNL{8jn5U>}Bdrdxs`_i)8|^kD)Y=9OCy}mXS^;6sM$QBI=_?
z4+wYqkV?R5e@mPlE&0WGB;~3NgZOCS-l%tpiorhHh!vqXo@+U)Yw4#>D(tvv%P6JR=EVy+JbGFT4X
z_3Z7#b)N$iFss+y>r9_sE6B+y_rJdB!_6AFC?Z9kD!fLl^Zke|a<-yV{74sK)c;JB
zWL@LpV@W(ZC3@(DyHJ%Ayy-Z1PgH~g_%NUdzl%tY(d
z(A$wgygxJc=kCEL{ia~TA38uErd;0*tqkcNBS1-vO
zabhz_EXBhq6pFAUa?a4;AS$binj-D7*N=8Hrq~3{f+?Gc
za!FBX;$~vTjuuUQ&(~Y$>YHd~adV!cujb7owHLmHCUm~1xdQ>`SskPaZL}{@A(^Vi
zIoOZSO0dyyW|))Bg*8in=v5CoB~&R=jWoJL?ER(Ey8y=zBN}($z1nDN=W`AB=bDT$
zVpJc9e!jSz2pt$0=qr~(bm9y!R65YqkgrU!kB=b)ZOJ13gqA^#oMkUou%GBvG`3$zCP*yRv1y3;
zV7Xi9?Bt+cNrE~Zbb0cS9ijY9gj4m7fY-V1kE>TEY~md)-OA8F*uvC}>&>y1|C4BK
z%Ba}b%XQWrxVpOpPHSlBoD%LCFi_WzLGh8WGtY;$+fPX_F??Wz?cDhT`b{LgrhL{!
zk4!hQdyML#Z_nI7Rc4xfn23iA5MYHN6(#v6XO4yIXph#GK5@Sj`)+$*$&2rmg^FiBB!sCTtGf
z{wKatjt{5TdP6kPnw;*FCv#xw9HAW&8&s&!eEA`ZIUFx%pR>)o@xr$}{@f4b9UDJP
z%>8c|;cw{^f4U0@o4^lNYC@#9p1p9HF*?j-ix0#HknP)UB(W
zX>9|)OY0+xtt19rV&Ox9Xog8Hu3qg!{|p}(Is*opa$!@i&agr>Cg)Ku3`VF@3Uw{r&Sp3inHMM>mVw%#B!66Vv{*v*`#*K04Gxm4HI}r3Dcxk@dpg
zi}`)J2~giNBNU{dcqGEp=n_=q$}1?ukuK*h=RZ%Ud}Ir_7;Eie*kZohQy%8)BW@sE
zU@18dFv^w4=j=rfW$
zbeU$nIZb}_1)RD{(b7`fzm{K(gkg%|hG-y_)qbTUcR=4PG){
zIa$oxGY*DIH&I#oKUF^2Jw&i@^72~k`anNxmKK$fKu}q;T^}U0lK#6e`s2z+fndU#v`~IRbkMiV5$kCd)|CvKZUM
zuTW((fWJzmRUA-^5~<70H`T2t#exxLsD-eI*PfT6`2}QbUedyp(9z*0SYlc_5);25
z<`@}%FeTXbwzbt{_4|5fx+nY*a1{i)BnDxmlAj-aP*f?O#T+8bH+f@})ea!F?0!~muK`Npfq$ZWZ~1kZz?Y|8mDHDb%fGT1Oe
zXV0&t*|2<{PaB9@DcQwm_mP|Hv}k^)9^Ts|s3lwp3}0+)NPF*Ky<`yT$B**y-p^8j
z-MqvRZ(T~Fu4hqZH&A1du%y_b%CsDFa
zmN8h1LmB*$fjBCuI{Fgap$pa~n&wwoCvPTa4koh{+O#P0UN_Y4f2mvRpRK4?2)
zV=y4lxJx6e+Z_qT#`T$r&|m249&WT4h(D?!M8igtq`-nfexvcCuqm`e(NJPI71oiC
zx8Yr5p@g_yhj)yut*`ftnS8rBn1T1KtfHob7R+q%5}S1pkBiJ0GLABhB18A1E)YXu
zi;dU8F|!KnNbGJ)(PJ!ytCC=oYlsI_hJ9@GAYf*CQHK%T^)(|nsw_K)+7N!vvJ#c`
zQFTZbho)y<943Yq?ARf797Bl2g_~ph_J#G`y6{$Yg!W{_c);MeV)PUE=nN1DMcjf6
zh6Dry0!5(twZk@Oef+Q!6J41Ad;n)79=eYhHoSagz2-g7$FP-x4(EH?xc^d}|7WnT
zmdR_KGmzE%@^<79XvQ*GOSt~cJ|d;~@(z!^X(axGBKE}|B3$G|w%-Ka*T$5^6NmS=
zAU@xV&t3;$?^#?7hnA8nFo(IkMxAl$m9~2)GI=G6W^hnJ;v)Pr)S{U%k{BSgEC;t%
z%v%oc$U>AE*dn7I39GH&HkF6H=D@&xdYe`WhMpxJh|Y{)!OoRU%*7Ii*V_6lU4MOW
zFE+gQ2ZY7L^wU-_I$DOb;%)mAH04(mW*5;OUQ*R!Yr
z3xH)&BhfIO(I>c|@Nc!8;#h=;M&u+6`|C$;YzR8$2%($JI$qIZYxO>vqlrH@#Q&=v
zy=j^6@lup9e|Y!J;rbXG9U(ux^CP+!ac!U!`^DgM-gooL*pOy#8jNCv@SY!x@)!&r
zKIBDHD>pi>=K5UhN-NoFNT%}BRwZU>h8{G+(LB5!dqFP{8GC6YPU|6Du*efe@V(%{
zo!HS61OyUC5D!B~Vaq}h;`AsCM2r|A20aE2N(=$x6|#j!`pJsZ2jIY&GYrRZlxnx%
zL*SSgglI?zJu0A!5{ZFiS%Vg=AF_-Eh4hJIAnN(x#UJU=S&CtyW3V?LGP)RX`~U?z
z1z$nRtDJJTTU}$AzC;pGv?wJpdUp(@m^?G=r6Tpa_~_tUMSQ*Qp=eSVFtNfBNRp4_
zWXYLS0tGEP(ZowhBEpQWFy`1TRYG4UWcht@w@(=g_#KQR6v7b=
zjRr}eKCYyo;Pk3q8DLfLRJ1;@{1r#LDX~mzcY8l#j+g&;;`SM7c2
z3!d38J+3}og}CxN_$4`)cQt-9l0K4;L|3sOt%!#ec}K@UR5c0FH))8X)>sImh}2ey
zNpR5pe7-tThEQSVM!)v!)*{8uf(1jB-n&I+Y*ip!;PQZMZ}9L>HF4h`Swsr`N=X9RkMaX
zO_m@;TMnm#Wn!bEB?2FH2mUg=XA%%Q>X*T+hHnNn
zZ4J^(UTErgR5@265V(AJ;id*0hYmZ6L5#Urs6e<$`LS;Sicc*FQ7%G+lt;+hGsKGU
zqa3esh%!1-69#H|I>tkUSN#89d3Z1|7Y^`1v9R)=)RCn;3F{-?xf#-%7j}MHS`Nxg
z02Keu+?|bW62=##GA}I6Rhra=bjVrGN#f2dWpHbe10zJ`2AEJKwoHiJiwwS8&LNeq
ztDF$tz;819%3GB!eUKc;ZNk382P8R`{4pBLm~U}mD&6gVoNgH6Q7~LF%p6E8%!CEE
zw(Hrl_m5JBGCXBLt*i#B6(1Pq&0Ts+^?)~~KC%UtL@(GeiO~IWEMQ$2#471hxLxww
z0fktvh_RKI^=PdkS=Q0ySThQw>7V(JyjZXHOk3{ti6xA*VnThC5R>Z`V|aoenZpxZ
zczfzg%BA@7fg}!kl;?f?cMq~2%EQXj{FUG?x3L*eV)O?1XoQ4?FjnEnRJdP&l7yI{
zG!z@%?+uSc93FkJ0CS9yk`}Av#4ZtG(pxEv5DX}Sq9f(uj-fO$EL|*1luhl!U=Gy1
z%HcNU8xou;C>QCrk~4G&mctqFGZ80xg!@b1-;(M6Gp7$2M6|YgnwgnNxGtRC$YA)!
z=62?7X`RZr1RRjdO;vn)D54>E4hsOF6Ckp5b1hOBmm=YpSj0enJ2jx?>T83MLJM=8
zWQc-Nb;fU&SA_u$YARel`-6`6Bj(_`3hY&QYlN8J24ysoz~v*
z-De*Bq-W@~lC6>fsE?Oo51lcwH9G%TQ(*gqVv{lW^r22FFJB(7jA0qNl=v`-$Z!k#x&q
z1dA;lhzFDKw%&$J)#nIDJ`z4uNsqG9)sBKm(L?%}(6NP!g1)bnwWTDraTj7wAkc3`Y9LD
z<|n2u&eeI5ggqN)^Iwt%Jxy1*xnGN|R_#RO2wN}?a!FpBy2-W&Ls!ERRSpB7#$jBw
zvw_IzzU3kn;Dyj3Jwkr4i<5>wMoYRXkmwIVCjPicxP(wlgYYumZgyd;E4zQ
zLg(66z&K@ORLX^q426_WTD_De%Bw~iM*3k{FFb_hW=^tT;;XBa_VR0Dp3NQi#>kw`
z#$G(On@e`RNqu?84BHD#I!b^7yv7e?9`Y~4)_#>&K
zS=CR8fXbH+V3mnys)iZ0i%e5yM&7K_=qSlcrset($C#BE7QIyHDY$BjcR3^^Yrrzy
z$LiM65U&+1y0-uAN6(0@yD##}CPQL>4r7^%D_AyC)~HB1ASu5?h&YJl0bQYdwJSXl
zhM;OI1Du`9_bepab>{;=D?R7-Ai~vCkk*Fg?AC&YkyMjBqDhAy?Zi^jtVQ06(}WG3Djg;GEzz}u~zJQv~0+$eyK##UrxWT
z@kme;4|#Y1Qzs);dJk_6fUOtD7!=xXTv|lC(#%S?4X}D?}BC^pVD0TmRy;rauW~1~}Zw2Xf3-ic?W$@dgM+V8Z
zFCT4Ho215nI}QR-G;^-6D%b_YKvej*y_}rXbsDV#tKrRd36l40JCyuL!aldR=OiYM
zXSMp@4Pu+-Uw!^=*AAE3PC$3xYjsve#ar1xWEty3aTTgW3Lh4RfzRO=hR&K@^ys;1
z{x$T;q!?gKzTRr{z4~lCoZW3Rdu#DJu-eiTp2BIIc
zi2Fv77~Co(m_?!E`|7B7f3OXX{gnG`BpCNh6{@5sxqoBa5-W}?zebE39&uWxx$hkR
zLd;CODuHDCQJbhzX;E}*mZ?1Y&&b@r^^XrXo`b*}EQ0N4a?b__wqIy!npKPnr7A{r
z&iuUPA0y*-@m}|v#WMgQSOAhnq_Yuxd_Pr;1zl}9R#kLN5EMmWBDE@_UG%Z}}at1@f>x0|Jdt^G;4HRzgu~zmp@==^W
zb|ph*wc_#s6)ehrLcYtNb!Xno1V7fHR#M@0z$?I$*^y(*aioq2_xV-$_#y^vDLI?|
z_o;6%+EFFduM3$kn%CdH{`T?KCn0t8`6`1LdmkCY6`j#EIz89Pq2el~QdhVgmJ^#u
z_+`KLj>oMwQ@TW?F7|_F%kzk?^qP5m%xe+=8v9rC;l^3iWqG2ZqVD!kRFsRIUA$}z
z&u{!^wjZBAoFSj816_YYJ{lkcjEs&_3u!EbN>^#c!+8{;yR)^K==VNyslGiWy62c<
zTER~Z-RAaeFGuX=)@D59J&amqh-ZV=a6LAfAdk*fnq45q6TG!}Eg;dAic?Ag{ZduP
zQA|0k7nf(A(IoLZDhY|4P9RWnKBS?|@Gq;B9{(+7h%ko|{w^jVt@daO=#ICg)fxkzaCE4+jI@%
zJFsT4{av}F(2_&lgh6%AKKN8q<3lPls_|7^Wg^pNMc;TBTA(s~AKipehjlYhVB9>w
z3gafatY;3|ui-RNO?-p7n8N$EhhSTTkKn>u_exO&Bo~ir;Re&MlyE}%6rtA{Bzeg<
zMQ!-iXA)`1LxPCr2jXH*5w=2@D?H5m3hq17JA9)GSJ?OOkK#~nKd|umn0Y~(w=3a)
zBIs@vF<;Icez7_-7VJSxf88z_FwlT=B3!AOlYjp>Hs2)@E>zzJ5?rR-N&b3&;J59%
zP~%)M#OL`G7UGpM)5UJ{{+%NAOm-#l6y*`b6HkMK)oIN_|BG2%htW`{6%VaGEkh_;
z>4!l&0X}gO!iM>WpE3gXZ-fh^a0Q%fzIP!c{1jq#zX}mMLBi`+
zY01d1ETm`e{q2V5@m-1<{4jmEB`XD_3}fLxHfgS%=cX}dNrQoG3l_u3Dh$_M27Bz#
z=LlrvHBT>YzxP$MIlpBZrPLom+{7Mu6RUgQZM+v>)KRCQ)cVy2c!9(CD03=h%u936
zo9{tHyd~n#8v8Wd38Oj*)#%`T@B3w)cpg&|FZccJF2JAPC4e~7;>8qzIb%MBs{`d=
zkl`n=7$#e)oXO7jRUmZ^57NK
z5$~$hYS|5g
ztAEJ?HM1Gmfn`H7`)u6QpXB0lmE4VtF#mb9PlaBuTK?{BTD4$YIId2fU&F93#u5uj
zlaEvdF@B4OTuaP5x6YeER4f
zaw++m?hQ@|X($`=jtXI6IY|!>%`bUXj8}-r!hfAbfRaZyHLC@Q35~hrWooE+OQ<{!
z&hYfT00wEX((4~QG-&kd^S)9A_OzH7h|2{3hyQv8NEiGY0{szjxSZN)d?R>{j{AXZ
zs=u~6wZ7J^n>W<@hJ31GCLxlu?`qSGlAyLaC?~L!@f(nHL0;(y(RS#Dk@A~r?*
zRO<;dj~bNcBfbgKkRp;_Sl
zV|R#8g#=x(Y5*h7ivXmQSP(N-l&4ofD}T^l}pDF*5S+DjXa=|L{j&wO8>L55$0nnsxea}UT5D4Odq|KiSy6xkVsx3McPgRxLO|e
zlPj6=`(lcIkvYh=JIcxbI@Nrbv)?Gw!flz
z8OWiSiOnq}%Nmx^t@E!PAlht-&?bREcOA-q8oqz_%X%FpAT^GX2v`%HBq68=xQ72|
z89zcs5wB2e|M(ID`MM@$zVWX+hCouZwK){+SQ2st;yQ2t>jR`6WCp9uQ=BQob
z(9+tPu^t08Rhl1Pu*<3B%yhiJpB$TV2{Mg<N?KR_J`U|D~xITlU<1i8^hj|f?;hYwlCzq~Zg
zDPc1LB%nSyf{
zupsM_5fz~?s_^nxCZMU8TEF$rJ}|4*2v>ET0sJb)o%&zNV>AIJ7PrGnAE3|L0%(d{
zZ*DeB`yYKv |