feat(market-data): 迁移 Tushare 同步到 PostgreSQL
This commit is contained in:
+6
-1
@@ -3,5 +3,10 @@ ZHIXING_SERVER_PORT=8000
|
|||||||
ZHIXING_WEB_PORT=5173
|
ZHIXING_WEB_PORT=5173
|
||||||
ZHIXING_APP_ENV=development
|
ZHIXING_APP_ENV=development
|
||||||
ZHIXING_LOG_LEVEL=INFO
|
ZHIXING_LOG_LEVEL=INFO
|
||||||
|
ZHIXING_POSTGRES_DB=zhixing
|
||||||
|
ZHIXING_POSTGRES_USER=zhixing
|
||||||
|
ZHIXING_POSTGRES_PASSWORD=zhixing
|
||||||
|
ZHIXING_DATABASE_URL=postgresql://zhixing:zhixing@postgres:5432/zhixing
|
||||||
|
ZHIXING_TUSHARE_TOKEN=
|
||||||
|
ZHIXING_MARKET_DATA_CSV_ROOT=/app/data/market-data
|
||||||
API_UPSTREAM=http://server:8000
|
API_UPSTREAM=http://server:8000
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [目录与模块边界](./directory-structure.md) | 包结构、bounded context 和导入边界 |
|
| [目录与模块边界](./directory-structure.md) | 包结构、bounded context 和导入边界 |
|
||||||
| [配置与运行时](./configuration-and-runtime.md) | `Settings`、应用工厂和部署环境 |
|
| [配置与运行时](./configuration-and-runtime.md) | `Settings`、应用工厂和部署环境 |
|
||||||
|
| [市场数据同步](./market-data-sync.md) | Tushare qfq、PostgreSQL、CSV 快照和一次性 Job 契约 |
|
||||||
| [HTTP 契约](./http-api-contracts.md) | 路由组合、响应模型和同源 API 路径 |
|
| [HTTP 契约](./http-api-contracts.md) | 路由组合、响应模型和同源 API 路径 |
|
||||||
| [错误处理](./error-handling.md) | 当前 FastAPI 错误行为及跨层错误传递 |
|
| [错误处理](./error-handling.md) | 当前 FastAPI 错误行为及跨层错误传递 |
|
||||||
| [质量与测试](./quality-guidelines.md) | Ruff、Pyright、pytest 及禁止模式 |
|
| [质量与测试](./quality-guidelines.md) | Ruff、Pyright、pytest 及禁止模式 |
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# 市场数据同步代码规格
|
||||||
|
|
||||||
|
## Scenario: Tushare qfq 日线与每日指标一次性同步
|
||||||
|
|
||||||
|
### 1. Scope / Trigger
|
||||||
|
|
||||||
|
- 触发:新增 `modules/market_data` bounded context、Alembic PostgreSQL schema、Tushare/CSV/Compose Job 集成。
|
||||||
|
- 目标:外部调度器每次启动一个进程,向 PostgreSQL 写入当前沪深非 ST A 股的六年 qfq 日线和按交易日归属的 `daily_basic`,并发布可恢复 CSV。
|
||||||
|
- 边界:FastAPI 生命周期不启动定时器;本上下文不实现具体选股策略、分钟行情或退市成员资格回溯。
|
||||||
|
|
||||||
|
### 2. Signatures
|
||||||
|
|
||||||
|
- 应用用例:`SyncMarketData.execute(command: SyncMarketDataCommand | None = None) -> SyncBatchSummary`。
|
||||||
|
- CLI:`market-data-sync [--initialize | --retry-batch-id <id>] [--trade-date YYYY-MM-DD]`。
|
||||||
|
- 数据源端口:`fetch_stocks()`、`fetch_open_dates(start, end)`、`fetch_bars(ts_code, window)`、`fetch_daily_basic(trade_date)`。
|
||||||
|
- 数据库事实表:`market_stock`、`market_daily_bar(ts_code, trade_date)`、`market_daily_basic(ts_code, trade_date)`;同步审计表:`market_sync_batch`、`market_sync_item`。
|
||||||
|
|
||||||
|
### 3. Contracts
|
||||||
|
|
||||||
|
- 配置入口只允许 `bootstrap.config.Settings` 读取环境变量:
|
||||||
|
- `ZHIXING_DATABASE_URL`
|
||||||
|
- `ZHIXING_TUSHARE_TOKEN`
|
||||||
|
- `ZHIXING_MARKET_DATA_CSV_ROOT`
|
||||||
|
- `ZHIXING_MARKET_DATA_COVERAGE_THRESHOLD`,默认 `0.99`
|
||||||
|
- `ZHIXING_MARKET_DATA_MAX_RETRIES`、`ZHIXING_MARKET_DATA_RETRY_BACKOFF_SECONDS`、`ZHIXING_MARKET_DATA_REQUEST_INTERVAL_SECONDS`
|
||||||
|
- `ZHIXING_MARKET_DATA_ADVISORY_LOCK_KEY`
|
||||||
|
- 股票 qfq 快照路径为 `bars/<ts_code>.csv`;每日指标路径为 `daily-basic/<YYYY>/<YYYYMMDD>.csv`;当前股票主数据为 `stock-basic/current.csv`。
|
||||||
|
- `market_daily_bar` 的唯一键是 `(ts_code, trade_date)`,`source_adj` 必须是 `qfq`;所有价格、金额和比率使用有限 `NUMERIC`/`Decimal`。
|
||||||
|
- `SyncBatchSummary` 至少返回 `batch_id`、目标交易日、窗口、状态、目标数、有效数、覆盖率、策略资格、插入数、更新数、未变化数和失败列表。
|
||||||
|
- 退出码:`0` 表示覆盖率达标的成功批次;`2` 表示部分成功或覆盖率不足;`1` 表示失败或没有可用成功结果。
|
||||||
|
- 单股票流程必须保持“临时 CSV → 指纹比较 → PostgreSQL 事务 → `os.replace` 发布”顺序;数据库异常不得替换正式 CSV。
|
||||||
|
|
||||||
|
### 4. Validation & Error Matrix
|
||||||
|
|
||||||
|
| 条件 | 行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| 非 `SH`/`SZ`、非上市、ST/退市名称或北交所股票 | 从当前目标股票池排除 |
|
||||||
|
| 指定日期不是交易日 | 批次准备失败,不发起行情写入 |
|
||||||
|
| qfq 快照重复键、空快照、OHLC 非法 | 当前股票 item 失败,旧 CSV 和旧库行保持不变 |
|
||||||
|
| 重叠区间指纹相同 | 只批量插入旧上界之后的新交易日 |
|
||||||
|
| 重叠行字段变化、缺失或快照开始边界异常变化 | 当前股票执行六年窗口集合式 upsert/删除 |
|
||||||
|
| `daily_basic` 没有目标股票行 | 当前指标日期 item 失败,不发布空正式快照 |
|
||||||
|
| PostgreSQL/COPY/迁移异常 | 转换为安全的 repository error;成功对象不回滚,失败对象保留旧发布版本 |
|
||||||
|
| 同一环境已有同步锁 | 返回 `failed` 和退出码 `1`,不执行第二个批次 |
|
||||||
|
| `valid / target < coverage_threshold` | 不触发选股,批次显式标记 `strategy_eligible=false` |
|
||||||
|
|
||||||
|
### 5. Good/Base/Bad Cases
|
||||||
|
|
||||||
|
- Good:相同六年 qfq 返回重复执行,指纹相同,数据库无无意义 update,正式 CSV 可原子替换,覆盖率为 `1`。
|
||||||
|
- Base:新增一个开市日且历史重叠不变,只 COPY 新日期;批次成功后清理窗口起点之前的数据库行和日期文件。
|
||||||
|
- Bad:一只股票历史 qfq 行变化或数据库事务失败,只修复/失败该股票;其他股票成功结果保留,失败对象正式 CSV 不被替换。
|
||||||
|
|
||||||
|
### 6. Tests Required
|
||||||
|
|
||||||
|
- 领域单元测试:六年窗口边界、输入顺序稳定性、历史值变化、重叠缺行、窗口提前、ST/北交所过滤;断言 `SnapshotChange` 和股票代码集合。
|
||||||
|
- CSV 单元测试:固定表头、临时文件、原子发布、异常/丢弃后旧正式文件仍可读;断言正式文件内容和临时文件清理。
|
||||||
|
- 同步编排测试:`success`、覆盖率、幂等重跑、空指标失败、部分失败和失败 item 重试;断言 batch/item 状态与退出码。
|
||||||
|
- PostgreSQL 集成测试:设置 `ZHIXING_TEST_DATABASE_URL` 后运行 Alembic upgrade,断言五张业务/审计表、唯一键和迁移头;批量 upsert 测试断言 insert/update/unchanged 计数和事务回滚。
|
||||||
|
- 质量门禁:`uv lock --check`、后端 Ruff/Pyright/pytest、前端/root `./dev.sh check` 与 `./dev.sh test`、四种 Compose config。
|
||||||
|
|
||||||
|
### 7. Wrong vs Correct
|
||||||
|
|
||||||
|
#### Wrong
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 把最新总市值写入六年前每一根 K 线,产生前视偏差。
|
||||||
|
for bar in bars:
|
||||||
|
bar.total_mv = latest_total_mv
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Correct
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 行情和估值按各自交易日保存;策略只使用同一目标日的两类事实。
|
||||||
|
market_daily_bar[(ts_code, trade_date)] = bar
|
||||||
|
market_daily_basic[(ts_code, trade_date)] = daily_basic
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Warning**:CSV 发布不是数据库事务的一部分。只有数据库事务成功后才能执行原子 `os.replace`;发布失败必须记录为 item 失败并依靠幂等重试恢复。
|
||||||
+56
-1
@@ -1,18 +1,39 @@
|
|||||||
name: ${COMPOSE_PROJECT_NAME:-zhixing-system-dev}
|
name: ${COMPOSE_PROJECT_NAME:-zhixing-system-dev}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${ZHIXING_POSTGRES_DB:-zhixing}
|
||||||
|
POSTGRES_PASSWORD: ${ZHIXING_POSTGRES_PASSWORD:-zhixing}
|
||||||
|
POSTGRES_USER: ${ZHIXING_POSTGRES_USER:-zhixing}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
server:
|
server:
|
||||||
build:
|
build:
|
||||||
context: ./zhixing-server
|
context: ./zhixing-server
|
||||||
target: development
|
target: development
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
ZHIXING_APP_ENV: ${ZHIXING_APP_ENV:-development}
|
ZHIXING_APP_ENV: ${ZHIXING_APP_ENV:-development}
|
||||||
|
ZHIXING_DATABASE_URL: ${ZHIXING_DATABASE_URL:-postgresql://zhixing:zhixing@postgres:5432/zhixing}
|
||||||
ZHIXING_LOG_LEVEL: ${ZHIXING_LOG_LEVEL:-INFO}
|
ZHIXING_LOG_LEVEL: ${ZHIXING_LOG_LEVEL:-INFO}
|
||||||
|
ZHIXING_MARKET_DATA_CSV_ROOT: /app/data/market-data
|
||||||
|
ZHIXING_TUSHARE_TOKEN: ${ZHIXING_TUSHARE_TOKEN:-}
|
||||||
init: true
|
init: true
|
||||||
ports:
|
ports:
|
||||||
- "${ZHIXING_SERVER_PORT:-8000}:8000"
|
- "${ZHIXING_SERVER_PORT:-8000}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./zhixing-server:/app
|
- ./zhixing-server:/app
|
||||||
|
- market-data:/app/data/market-data
|
||||||
- server-venv:/app/.venv
|
- server-venv:/app/.venv
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
@@ -41,7 +62,41 @@ services:
|
|||||||
- ./zhixing-web:/app
|
- ./zhixing-web:/app
|
||||||
- web-node-modules:/app/node_modules
|
- web-node-modules:/app/node_modules
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
profiles: ["jobs"]
|
||||||
|
build:
|
||||||
|
context: ./zhixing-server
|
||||||
|
target: development
|
||||||
|
command: ["alembic", "upgrade", "head"]
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
ZHIXING_DATABASE_URL: ${ZHIXING_DATABASE_URL:-postgresql://zhixing:zhixing@postgres:5432/zhixing}
|
||||||
|
volumes:
|
||||||
|
- ./zhixing-server:/app
|
||||||
|
- server-venv:/app/.venv
|
||||||
|
|
||||||
|
market-sync:
|
||||||
|
profiles: ["jobs"]
|
||||||
|
build:
|
||||||
|
context: ./zhixing-server
|
||||||
|
target: development
|
||||||
|
command: ["market-data-sync"]
|
||||||
|
depends_on:
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
ZHIXING_DATABASE_URL: ${ZHIXING_DATABASE_URL:-postgresql://zhixing:zhixing@postgres:5432/zhixing}
|
||||||
|
ZHIXING_MARKET_DATA_CSV_ROOT: /app/data/market-data
|
||||||
|
ZHIXING_TUSHARE_TOKEN: ${ZHIXING_TUSHARE_TOKEN:-}
|
||||||
|
volumes:
|
||||||
|
- ./zhixing-server:/app
|
||||||
|
- market-data:/app/data/market-data
|
||||||
|
- server-venv:/app/.venv
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
market-data:
|
||||||
|
postgres-data:
|
||||||
server-venv:
|
server-venv:
|
||||||
web-node-modules:
|
web-node-modules:
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,35 @@
|
|||||||
name: ${COMPOSE_PROJECT_NAME:-zhixing-system}
|
name: ${COMPOSE_PROJECT_NAME:-zhixing-system}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${ZHIXING_POSTGRES_DB:-zhixing}
|
||||||
|
POSTGRES_PASSWORD: ${ZHIXING_POSTGRES_PASSWORD:-zhixing}
|
||||||
|
POSTGRES_USER: ${ZHIXING_POSTGRES_USER:-zhixing}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
server:
|
server:
|
||||||
build:
|
build:
|
||||||
context: ./zhixing-server
|
context: ./zhixing-server
|
||||||
target: production
|
target: production
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
ZHIXING_APP_ENV: production
|
ZHIXING_APP_ENV: production
|
||||||
|
ZHIXING_DATABASE_URL: ${ZHIXING_DATABASE_URL:-postgresql://zhixing:zhixing@postgres:5432/zhixing}
|
||||||
ZHIXING_LOG_LEVEL: ${ZHIXING_LOG_LEVEL:-INFO}
|
ZHIXING_LOG_LEVEL: ${ZHIXING_LOG_LEVEL:-INFO}
|
||||||
|
ZHIXING_MARKET_DATA_CSV_ROOT: /app/data/market-data
|
||||||
|
ZHIXING_TUSHARE_TOKEN: ${ZHIXING_TUSHARE_TOKEN:-}
|
||||||
init: true
|
init: true
|
||||||
expose:
|
expose:
|
||||||
- "8000"
|
- "8000"
|
||||||
@@ -22,6 +43,8 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
volumes:
|
||||||
|
- market-data:/app/data/market-data
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
@@ -36,3 +59,35 @@ services:
|
|||||||
init: true
|
init: true
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${ZHIXING_WEB_PORT:-8111}:80"
|
- "127.0.0.1:${ZHIXING_WEB_PORT:-8111}:80"
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
profiles: ["jobs"]
|
||||||
|
build:
|
||||||
|
context: ./zhixing-server
|
||||||
|
target: production
|
||||||
|
command: ["alembic", "upgrade", "head"]
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
ZHIXING_DATABASE_URL: ${ZHIXING_DATABASE_URL:-postgresql://zhixing:zhixing@postgres:5432/zhixing}
|
||||||
|
|
||||||
|
market-sync:
|
||||||
|
profiles: ["jobs"]
|
||||||
|
build:
|
||||||
|
context: ./zhixing-server
|
||||||
|
target: production
|
||||||
|
command: ["market-data-sync"]
|
||||||
|
depends_on:
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
ZHIXING_DATABASE_URL: ${ZHIXING_DATABASE_URL:-postgresql://zhixing:zhixing@postgres:5432/zhixing}
|
||||||
|
ZHIXING_MARKET_DATA_CSV_ROOT: /app/data/market-data
|
||||||
|
ZHIXING_TUSHARE_TOKEN: ${ZHIXING_TUSHARE_TOKEN:-}
|
||||||
|
volumes:
|
||||||
|
- market-data:/app/data/market-data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
market-data:
|
||||||
|
postgres-data:
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 市场数据同步 Job
|
||||||
|
|
||||||
|
`market-data-sync` 是外部调度器触发的一次性任务。FastAPI 进程不包含定时器;PostgreSQL 是策略查询事实源,`market-data` 卷中的 CSV 只作为 qfq 落地快照和恢复介质。
|
||||||
|
|
||||||
|
## 首次部署
|
||||||
|
|
||||||
|
先准备 `.env`,至少设置 `ZHIXING_TUSHARE_TOKEN`、PostgreSQL 凭据和 `ZHIXING_DATABASE_URL`,然后执行迁移:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml --profile jobs run --rm migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
首次初始化会回补六年交易日指标,并为当前沪深非 ST A 股获取六年 qfq 行情:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml --profile jobs run --rm market-sync --initialize
|
||||||
|
```
|
||||||
|
|
||||||
|
日常收盘后同步默认解析最近的开市日;也可以显式指定日期:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml --profile jobs run --rm market-sync --trade-date 2026-08-05
|
||||||
|
```
|
||||||
|
|
||||||
|
`success` 返回 0;覆盖率低于配置阈值或出现部分失败返回 2;没有可用成功结果或基础设施失败返回 1。CLI 输出不包含 Tushare token 或数据库密码。
|
||||||
|
|
||||||
|
## Cron 与重试
|
||||||
|
|
||||||
|
宿主机 cron 只负责启动临时容器,不写入容器内部的 crontab。下面的示例每天工作日 18:00 触发;交易日历、唯一约束和 PostgreSQL advisory lock 使周末、节假日、重复触发和重叠触发保持安全:
|
||||||
|
|
||||||
|
```cron
|
||||||
|
0 18 * * 1-5 cd /srv/zhixing-system && docker compose -f docker-compose.prod.yml --profile jobs run --rm market-sync >> /var/log/zhixing-market-sync.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
部分成功批次的输出会包含 `batch_id`。修复凭据、网络或数据库问题后,只重试该批次失败的股票和指标日期:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml --profile jobs run --rm market-sync \
|
||||||
|
--retry-batch-id <batch-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
单只股票按数据库事务和 CSV 发布作为恢复边界。数据库提交失败时正式 CSV 不会替换;CSV 发布失败时数据库写入保持幂等,重试可以再次发布。成功批次完成后,数据库事实表和正式日期快照清理六年窗口起点以前的数据;失败对象保留上一次成功版本。
|
||||||
|
|
||||||
|
## 本地检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml config
|
||||||
|
docker compose -f docker-compose.dev.yml --profile jobs config
|
||||||
|
docker compose -f docker-compose.prod.yml config
|
||||||
|
docker compose -f docker-compose.prod.yml --profile jobs config
|
||||||
|
```
|
||||||
|
|
||||||
|
真实 PostgreSQL 迁移和批量 upsert 集成测试使用 `ZHIXING_TEST_DATABASE_URL` 显式开启;普通单元测试不会访问网络、Tushare 或数据库。
|
||||||
@@ -25,6 +25,8 @@ COPY pyproject.toml uv.lock ./
|
|||||||
RUN uv sync --frozen --no-dev --no-install-project
|
RUN uv sync --frozen --no-dev --no-install-project
|
||||||
COPY README.md ./
|
COPY README.md ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
COPY alembic.ini ./
|
||||||
|
COPY migrations ./migrations
|
||||||
RUN uv sync --frozen --no-dev
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
RUN useradd --create-home --uid 10001 appuser
|
RUN useradd --create-home --uid 10001 appuser
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = migrations
|
||||||
|
prepend_sys_path = .
|
||||||
|
sqlalchemy.url = postgresql://unused
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Alembic migration package for Zhixing Server."""
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Alembic environment using the server's single Settings entry point."""
|
||||||
|
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
from zhixing_server.bootstrap.config import get_settings
|
||||||
|
from zhixing_server.modules.market_data.infrastructure.schema import metadata
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
settings = get_settings()
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.database_url.replace("%", "%%"))
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations without opening a database connection."""
|
||||||
|
|
||||||
|
context.configure(
|
||||||
|
url=settings.database_url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
compare_type=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations against the configured PostgreSQL connection."""
|
||||||
|
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""Create the market-data bounded-context schema."""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0001_market_data"
|
||||||
|
down_revision: str | None = None
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create market master, facts, and auditable synchronization tables."""
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"market_stock",
|
||||||
|
sa.Column("ts_code", sa.String(12), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(128), nullable=False),
|
||||||
|
sa.Column("market", sa.String(32), nullable=False),
|
||||||
|
sa.Column("exchange", sa.String(16), nullable=False),
|
||||||
|
sa.Column("list_status", sa.String(2), nullable=False),
|
||||||
|
sa.Column("list_date", sa.Date()),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"market_daily_bar",
|
||||||
|
sa.Column("ts_code", sa.String(12), nullable=False),
|
||||||
|
sa.Column("trade_date", sa.Date(), nullable=False),
|
||||||
|
*[
|
||||||
|
sa.Column(field, sa.Numeric(20, 6))
|
||||||
|
for field in ("open", "high", "low", "close", "pre_close", "change", "pct_chg")
|
||||||
|
],
|
||||||
|
sa.Column("vol", sa.Numeric(24, 6)),
|
||||||
|
sa.Column("amount", sa.Numeric(24, 6)),
|
||||||
|
sa.Column("source_adj", sa.String(8), nullable=False, server_default="qfq"),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("ts_code", "trade_date"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_market_daily_bar_trade_date", "market_daily_bar", ["trade_date"])
|
||||||
|
op.create_table(
|
||||||
|
"market_daily_basic",
|
||||||
|
sa.Column("ts_code", sa.String(12), nullable=False),
|
||||||
|
sa.Column("trade_date", sa.Date(), nullable=False),
|
||||||
|
*[
|
||||||
|
sa.Column(field, sa.Numeric(24, 6))
|
||||||
|
for field in (
|
||||||
|
"close",
|
||||||
|
"turnover_rate",
|
||||||
|
"turnover_rate_f",
|
||||||
|
"volume_ratio",
|
||||||
|
"pe",
|
||||||
|
"pe_ttm",
|
||||||
|
"pb",
|
||||||
|
"ps",
|
||||||
|
"ps_ttm",
|
||||||
|
"dv_ratio",
|
||||||
|
"dv_ttm",
|
||||||
|
"total_share",
|
||||||
|
"float_share",
|
||||||
|
"free_share",
|
||||||
|
"total_mv",
|
||||||
|
"circ_mv",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("ts_code", "trade_date"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_market_daily_basic_trade_date", "market_daily_basic", ["trade_date"])
|
||||||
|
op.create_table(
|
||||||
|
"market_sync_batch",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("target_trade_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("window_start", sa.Date(), nullable=False),
|
||||||
|
sa.Column("mode", sa.String(16), nullable=False),
|
||||||
|
sa.Column("status", sa.String(24), nullable=False),
|
||||||
|
sa.Column("target_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("valid_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("coverage", sa.Numeric(8, 6), nullable=False, server_default="0"),
|
||||||
|
sa.Column("strategy_eligible", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
sa.Column("parent_batch_id", sa.String(36)),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.Column("finished_at", sa.DateTime(timezone=True)),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"market_sync_item",
|
||||||
|
sa.Column("batch_id", sa.String(36), nullable=False),
|
||||||
|
sa.Column("item_kind", sa.String(24), nullable=False),
|
||||||
|
sa.Column("item_key", sa.String(64), nullable=False),
|
||||||
|
sa.Column("status", sa.String(24), nullable=False),
|
||||||
|
sa.Column("inserted_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("updated_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("unchanged_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("fingerprint", sa.String(64)),
|
||||||
|
sa.Column("error_type", sa.String(64)),
|
||||||
|
sa.Column("error_message", sa.Text()),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("batch_id", "item_kind", "item_key"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_market_sync_item_status", "market_sync_item", ["batch_id", "status"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop market-data tables in dependency-safe order."""
|
||||||
|
|
||||||
|
op.drop_index("ix_market_sync_item_status", table_name="market_sync_item")
|
||||||
|
op.drop_table("market_sync_item")
|
||||||
|
op.drop_table("market_sync_batch")
|
||||||
|
op.drop_index("ix_market_daily_basic_trade_date", table_name="market_daily_basic")
|
||||||
|
op.drop_table("market_daily_basic")
|
||||||
|
op.drop_index("ix_market_daily_bar_trade_date", table_name="market_daily_bar")
|
||||||
|
op.drop_table("market_daily_bar")
|
||||||
|
op.drop_table("market_stock")
|
||||||
@@ -5,8 +5,12 @@ description = "FastAPI backend and quantitative processing foundation for Zhixin
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12,<3.13"
|
requires-python = ">=3.12,<3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"alembic>=1.18.0",
|
||||||
"fastapi>=0.141.1",
|
"fastapi>=0.141.1",
|
||||||
|
"psycopg[binary]>=3.3.2",
|
||||||
"pydantic-settings>=2.14.2",
|
"pydantic-settings>=2.14.2",
|
||||||
|
"sqlalchemy>=2.0.46",
|
||||||
|
"tushare>=1.4.24",
|
||||||
"uvicorn[standard]>=0.52.1",
|
"uvicorn[standard]>=0.52.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -26,13 +30,18 @@ build-backend = "hatchling.build"
|
|||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/zhixing_server"]
|
packages = ["src/zhixing_server"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
market-data-sync = "zhixing_server.modules.market_data.presentation.cli:main"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
addopts = "-ra --strict-config --strict-markers"
|
addopts = "-ra --strict-config --strict-markers"
|
||||||
pythonpath = ["src"]
|
pythonpath = ["src"]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
markers = ["integration: requires a PostgreSQL test database"]
|
||||||
|
|
||||||
[tool.pyright]
|
[tool.pyright]
|
||||||
include = ["src", "tests"]
|
include = ["src", "tests"]
|
||||||
|
extraPaths = ["src"]
|
||||||
pythonVersion = "3.12"
|
pythonVersion = "3.12"
|
||||||
typeCheckingMode = "strict"
|
typeCheckingMode = "strict"
|
||||||
venvPath = "."
|
venvPath = "."
|
||||||
@@ -47,4 +56,3 @@ select = ["B", "E", "F", "I", "SIM", "UP"]
|
|||||||
|
|
||||||
[tool.ruff.format]
|
[tool.ruff.format]
|
||||||
quote-style = "double"
|
quote-style = "double"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Environment-backed application configuration."""
|
"""Environment-backed application configuration."""
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
@@ -12,6 +14,15 @@ class Settings(BaseSettings):
|
|||||||
app_name: str = "Zhixing Server"
|
app_name: str = "Zhixing Server"
|
||||||
app_env: Literal["development", "test", "production"] = "development"
|
app_env: Literal["development", "test", "production"] = "development"
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
|
database_url: str = "postgresql://zhixing:zhixing@localhost:5432/zhixing"
|
||||||
|
tushare_token: str = ""
|
||||||
|
market_data_csv_root: Path = Path("./data/market-data")
|
||||||
|
market_data_coverage_threshold: Decimal = Decimal("0.99")
|
||||||
|
market_data_max_workers: int = 4
|
||||||
|
market_data_request_interval_seconds: float = 0.2
|
||||||
|
market_data_max_retries: int = 3
|
||||||
|
market_data_retry_backoff_seconds: float = 1.0
|
||||||
|
market_data_advisory_lock_key: int = 7_380_521
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Market data bounded context.
|
||||||
|
|
||||||
|
The context owns the current A-share universe, daily qfq bars, daily basic
|
||||||
|
metrics, and the externally-triggered synchronization use case.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Market data synchronization use cases."""
|
||||||
|
|
||||||
|
from .sync import SyncBatchSummary, SyncMarketData, SyncMarketDataCommand
|
||||||
|
|
||||||
|
__all__ = ["SyncBatchSummary", "SyncMarketData", "SyncMarketDataCommand"]
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
"""One-shot market data synchronization orchestration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from ..domain.fingerprint import SnapshotChange, compare_snapshots
|
||||||
|
from ..domain.models import Bar, Stock, SyncWindow
|
||||||
|
from ..domain.ports import MarketDataRepository, MarketDataSource, SnapshotStore, WriteResult
|
||||||
|
from ..domain.rules import filter_current_hs_a_stocks
|
||||||
|
|
||||||
|
SyncMode = Literal["daily", "initialize", "retry"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SyncMarketDataCommand:
|
||||||
|
"""Input for a daily, initial, or failed-item retry synchronization."""
|
||||||
|
|
||||||
|
mode: SyncMode = "daily"
|
||||||
|
target_trade_date: date | None = None
|
||||||
|
parent_batch_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SyncFailure:
|
||||||
|
"""A redacted, actionable item failure."""
|
||||||
|
|
||||||
|
item_kind: str
|
||||||
|
item_key: str
|
||||||
|
error_type: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SyncBatchSummary:
|
||||||
|
"""Stable output contract for CLI, cron, and later strategy callers."""
|
||||||
|
|
||||||
|
batch_id: str | None
|
||||||
|
target_trade_date: date | None
|
||||||
|
window: SyncWindow | None
|
||||||
|
status: str
|
||||||
|
target_count: int
|
||||||
|
valid_count: int
|
||||||
|
coverage: Decimal
|
||||||
|
strategy_eligible: bool
|
||||||
|
inserted_count: int = 0
|
||||||
|
updated_count: int = 0
|
||||||
|
unchanged_count: int = 0
|
||||||
|
failures: tuple[SyncFailure, ...] = field(default_factory=tuple)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def exit_code(self) -> int:
|
||||||
|
"""Return a cron-friendly code: success, incomplete, or infrastructure failure."""
|
||||||
|
|
||||||
|
if self.status == "failed":
|
||||||
|
return 1
|
||||||
|
return 0 if self.strategy_eligible else 2
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
"""Serialize the summary without credentials or raw vendor responses."""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"batch_id": self.batch_id,
|
||||||
|
"target_trade_date": self.target_trade_date.isoformat()
|
||||||
|
if self.target_trade_date
|
||||||
|
else None,
|
||||||
|
"window_start": self.window.start.isoformat() if self.window else None,
|
||||||
|
"window_end": self.window.end.isoformat() if self.window else None,
|
||||||
|
"status": self.status,
|
||||||
|
"target_count": self.target_count,
|
||||||
|
"valid_count": self.valid_count,
|
||||||
|
"coverage": str(self.coverage),
|
||||||
|
"strategy_eligible": self.strategy_eligible,
|
||||||
|
"inserted_count": self.inserted_count,
|
||||||
|
"updated_count": self.updated_count,
|
||||||
|
"unchanged_count": self.unchanged_count,
|
||||||
|
"failures": [
|
||||||
|
{
|
||||||
|
"item_kind": failure.item_kind,
|
||||||
|
"item_key": failure.item_key,
|
||||||
|
"error_type": failure.error_type,
|
||||||
|
"message": failure.message,
|
||||||
|
}
|
||||||
|
for failure in self.failures
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SyncMarketData:
|
||||||
|
"""Coordinate source, snapshot, and repository ports at item boundaries."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
source: MarketDataSource,
|
||||||
|
snapshots: SnapshotStore,
|
||||||
|
repository: MarketDataRepository,
|
||||||
|
*,
|
||||||
|
coverage_threshold: Decimal = Decimal("0.99"),
|
||||||
|
lock_key: int = 7_380_521,
|
||||||
|
today: date | None = None,
|
||||||
|
) -> None:
|
||||||
|
if not Decimal("0") <= coverage_threshold <= Decimal("1"):
|
||||||
|
raise ValueError("coverage_threshold must be between 0 and 1")
|
||||||
|
self.source = source
|
||||||
|
self.snapshots = snapshots
|
||||||
|
self.repository = repository
|
||||||
|
self.coverage_threshold = coverage_threshold
|
||||||
|
self.lock_key = lock_key
|
||||||
|
self.today = today or date.today()
|
||||||
|
|
||||||
|
def execute(self, command: SyncMarketDataCommand | None = None) -> SyncBatchSummary:
|
||||||
|
"""Run one synchronization and retain successful items on partial failure."""
|
||||||
|
|
||||||
|
command = command or SyncMarketDataCommand()
|
||||||
|
with self.repository.advisory_lock(self.lock_key) as acquired:
|
||||||
|
if not acquired:
|
||||||
|
return SyncBatchSummary(
|
||||||
|
batch_id=None,
|
||||||
|
target_trade_date=None,
|
||||||
|
window=None,
|
||||||
|
status="failed",
|
||||||
|
target_count=0,
|
||||||
|
valid_count=0,
|
||||||
|
coverage=Decimal("0"),
|
||||||
|
strategy_eligible=False,
|
||||||
|
failures=(
|
||||||
|
SyncFailure("batch", "lock", "sync_locked", "another sync is running"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return self._execute_locked(command)
|
||||||
|
|
||||||
|
def _execute_locked(self, command: SyncMarketDataCommand) -> SyncBatchSummary:
|
||||||
|
target_trade_date = self._resolve_target(command.target_trade_date)
|
||||||
|
window = SyncWindow.from_target(target_trade_date)
|
||||||
|
all_stocks = filter_current_hs_a_stocks(self.source.fetch_stocks())
|
||||||
|
if not all_stocks:
|
||||||
|
return SyncBatchSummary(
|
||||||
|
None,
|
||||||
|
target_trade_date,
|
||||||
|
window,
|
||||||
|
"failed",
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Decimal("0"),
|
||||||
|
False,
|
||||||
|
failures=(
|
||||||
|
SyncFailure("stock", "universe", "empty_universe", "no eligible stocks"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
batch_id = self.repository.create_batch(
|
||||||
|
target_trade_date,
|
||||||
|
window,
|
||||||
|
command.mode,
|
||||||
|
command.parent_batch_id,
|
||||||
|
len(all_stocks),
|
||||||
|
)
|
||||||
|
failures: list[SyncFailure] = []
|
||||||
|
totals = [0, 0, 0]
|
||||||
|
stock_codes = {stock.ts_code for stock in all_stocks}
|
||||||
|
retry_items: set[tuple[str, str]] = (
|
||||||
|
self._retry_items(command.parent_batch_id) if command.mode == "retry" else set()
|
||||||
|
)
|
||||||
|
|
||||||
|
self._process_stock_master(batch_id, all_stocks, failures, totals)
|
||||||
|
dates = self._dates_to_process(window, target_trade_date, command.mode, retry_items)
|
||||||
|
for trade_date in dates:
|
||||||
|
if (
|
||||||
|
command.mode == "retry"
|
||||||
|
and ("daily_basic", trade_date.isoformat()) not in retry_items
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
self._process_daily_basic(batch_id, trade_date, stock_codes, window, failures, totals)
|
||||||
|
for stock in all_stocks:
|
||||||
|
if command.mode == "retry" and ("bar", stock.ts_code) not in retry_items:
|
||||||
|
continue
|
||||||
|
self._process_bar(batch_id, stock, window, failures, totals)
|
||||||
|
|
||||||
|
if not failures:
|
||||||
|
try:
|
||||||
|
self.repository.purge_before(window)
|
||||||
|
self.snapshots.clean_daily_basic_before(window.start)
|
||||||
|
except (OSError, RuntimeError, TypeError, ValueError) as exc:
|
||||||
|
failure = self._failure("batch", "retention", exc)
|
||||||
|
failures.append(failure)
|
||||||
|
self.repository.record_item(
|
||||||
|
batch_id,
|
||||||
|
"batch",
|
||||||
|
"retention",
|
||||||
|
"failed",
|
||||||
|
WriteResult(),
|
||||||
|
error_type=failure.error_type,
|
||||||
|
error_message=failure.message,
|
||||||
|
)
|
||||||
|
valid_count = sum(
|
||||||
|
1
|
||||||
|
for stock in all_stocks
|
||||||
|
if self.repository.has_bar(stock.ts_code, target_trade_date)
|
||||||
|
and self.repository.has_daily_basic(stock.ts_code, target_trade_date)
|
||||||
|
)
|
||||||
|
coverage = Decimal(valid_count) / Decimal(len(all_stocks))
|
||||||
|
status = "success" if not failures else "partial_success" if valid_count else "failed"
|
||||||
|
eligible = coverage >= self.coverage_threshold
|
||||||
|
self.repository.record_batch(batch_id, status, valid_count, coverage, eligible)
|
||||||
|
return SyncBatchSummary(
|
||||||
|
batch_id,
|
||||||
|
target_trade_date,
|
||||||
|
window,
|
||||||
|
status,
|
||||||
|
len(all_stocks),
|
||||||
|
valid_count,
|
||||||
|
coverage,
|
||||||
|
eligible,
|
||||||
|
totals[0],
|
||||||
|
totals[1],
|
||||||
|
totals[2],
|
||||||
|
tuple(failures),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _resolve_target(self, requested: date | None) -> date:
|
||||||
|
if requested is not None:
|
||||||
|
open_dates = self.source.fetch_open_dates(requested, requested)
|
||||||
|
if requested not in open_dates:
|
||||||
|
raise ValueError(f"target date is not an open trading day: {requested}")
|
||||||
|
return requested
|
||||||
|
start = self.today - timedelta(days=14)
|
||||||
|
open_dates = self.source.fetch_open_dates(start, self.today)
|
||||||
|
if not open_dates:
|
||||||
|
raise ValueError("no open trading day found before today")
|
||||||
|
return max(open_dates)
|
||||||
|
|
||||||
|
def _dates_to_process(
|
||||||
|
self,
|
||||||
|
window: SyncWindow,
|
||||||
|
target: date,
|
||||||
|
mode: SyncMode,
|
||||||
|
retry_items: set[tuple[str, str]],
|
||||||
|
) -> tuple[date, ...]:
|
||||||
|
if mode == "initialize":
|
||||||
|
dates = self.source.fetch_open_dates(window.start, target)
|
||||||
|
return tuple(sorted(set(dates)))
|
||||||
|
if mode == "retry":
|
||||||
|
return tuple(
|
||||||
|
sorted(
|
||||||
|
date.fromisoformat(key) for kind, key in retry_items if kind == "daily_basic"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return (target,)
|
||||||
|
|
||||||
|
def _retry_items(self, parent_batch_id: str | None) -> set[tuple[str, str]]:
|
||||||
|
if not parent_batch_id:
|
||||||
|
raise ValueError("retry mode requires parent_batch_id")
|
||||||
|
return set(self.repository.failed_items(parent_batch_id))
|
||||||
|
|
||||||
|
def _process_stock_master(
|
||||||
|
self,
|
||||||
|
batch_id: str,
|
||||||
|
stocks: Sequence[Stock],
|
||||||
|
failures: list[SyncFailure],
|
||||||
|
totals: list[int],
|
||||||
|
) -> None:
|
||||||
|
staged = None
|
||||||
|
try:
|
||||||
|
staged = self.snapshots.stage_stocks(stocks)
|
||||||
|
result = self.repository.upsert_stocks(stocks)
|
||||||
|
self.snapshots.publish(staged)
|
||||||
|
totals[0] += result.inserted
|
||||||
|
totals[1] += result.updated
|
||||||
|
totals[2] += result.unchanged
|
||||||
|
self.repository.record_item(
|
||||||
|
batch_id,
|
||||||
|
"stock",
|
||||||
|
"current",
|
||||||
|
"success",
|
||||||
|
result,
|
||||||
|
staged.fingerprint,
|
||||||
|
)
|
||||||
|
except (OSError, RuntimeError, TypeError, ValueError) as exc:
|
||||||
|
if staged is not None:
|
||||||
|
self.snapshots.discard(staged)
|
||||||
|
failure = self._failure("stock", "current", exc)
|
||||||
|
failures.append(failure)
|
||||||
|
self.repository.record_item(
|
||||||
|
batch_id,
|
||||||
|
"stock",
|
||||||
|
"current",
|
||||||
|
"failed",
|
||||||
|
WriteResult(),
|
||||||
|
error_type=failure.error_type,
|
||||||
|
error_message=failure.message,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _process_daily_basic(
|
||||||
|
self,
|
||||||
|
batch_id: str,
|
||||||
|
trade_date: date,
|
||||||
|
stock_codes: set[str],
|
||||||
|
window: SyncWindow,
|
||||||
|
failures: list[SyncFailure],
|
||||||
|
totals: list[int],
|
||||||
|
) -> None:
|
||||||
|
staged = None
|
||||||
|
key = trade_date.isoformat()
|
||||||
|
try:
|
||||||
|
rows = tuple(
|
||||||
|
row
|
||||||
|
for row in self.source.fetch_daily_basic(trade_date)
|
||||||
|
if row.ts_code in stock_codes
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
raise ValueError(f"daily-basic returned no target rows for {trade_date}")
|
||||||
|
staged = self.snapshots.stage_daily_basic(trade_date, rows)
|
||||||
|
result = self.repository.upsert_daily_basic(rows, window)
|
||||||
|
self.snapshots.publish(staged)
|
||||||
|
self._add_counts(totals, result)
|
||||||
|
self.repository.record_item(
|
||||||
|
batch_id,
|
||||||
|
"daily_basic",
|
||||||
|
key,
|
||||||
|
"success",
|
||||||
|
result,
|
||||||
|
staged.fingerprint,
|
||||||
|
)
|
||||||
|
except (OSError, RuntimeError, TypeError, ValueError) as exc:
|
||||||
|
if staged is not None:
|
||||||
|
self.snapshots.discard(staged)
|
||||||
|
failure = self._failure("daily_basic", key, exc)
|
||||||
|
failures.append(failure)
|
||||||
|
self.repository.record_item(
|
||||||
|
batch_id,
|
||||||
|
"daily_basic",
|
||||||
|
key,
|
||||||
|
"failed",
|
||||||
|
WriteResult(),
|
||||||
|
error_type=failure.error_type,
|
||||||
|
error_message=failure.message,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _process_bar(
|
||||||
|
self,
|
||||||
|
batch_id: str,
|
||||||
|
stock: Stock,
|
||||||
|
window: SyncWindow,
|
||||||
|
failures: list[SyncFailure],
|
||||||
|
totals: list[int],
|
||||||
|
) -> None:
|
||||||
|
staged = None
|
||||||
|
try:
|
||||||
|
rows = tuple(self.source.fetch_bars(stock.ts_code, window))
|
||||||
|
old_rows = self.snapshots.read_bars(stock.ts_code)
|
||||||
|
comparison = compare_snapshots(old_rows, rows)
|
||||||
|
staged = self.snapshots.stage_bars(stock.ts_code, rows)
|
||||||
|
if comparison.change is SnapshotChange.UNCHANGED:
|
||||||
|
result = WriteResult(unchanged=len(rows))
|
||||||
|
else:
|
||||||
|
to_write: Iterable[Bar] = rows
|
||||||
|
if comparison.change is SnapshotChange.NEW_DATES:
|
||||||
|
old_end = max((row.trade_date for row in old_rows or ()), default=window.start)
|
||||||
|
to_write = tuple(row for row in rows if row.trade_date > old_end)
|
||||||
|
result = self.repository.upsert_bars(
|
||||||
|
to_write,
|
||||||
|
window,
|
||||||
|
full_snapshot=comparison.change
|
||||||
|
in {SnapshotChange.INITIAL, SnapshotChange.CHANGED},
|
||||||
|
)
|
||||||
|
self.snapshots.publish(staged)
|
||||||
|
self._add_counts(totals, result)
|
||||||
|
self.repository.record_item(
|
||||||
|
batch_id,
|
||||||
|
"bar",
|
||||||
|
stock.ts_code,
|
||||||
|
"success",
|
||||||
|
result,
|
||||||
|
staged.fingerprint,
|
||||||
|
)
|
||||||
|
except (OSError, RuntimeError, TypeError, ValueError) as exc:
|
||||||
|
if staged is not None:
|
||||||
|
self.snapshots.discard(staged)
|
||||||
|
failure = self._failure("bar", stock.ts_code, exc)
|
||||||
|
failures.append(failure)
|
||||||
|
self.repository.record_item(
|
||||||
|
batch_id,
|
||||||
|
"bar",
|
||||||
|
stock.ts_code,
|
||||||
|
"failed",
|
||||||
|
WriteResult(),
|
||||||
|
error_type=failure.error_type,
|
||||||
|
error_message=failure.message,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _add_counts(totals: list[int], result: WriteResult) -> None:
|
||||||
|
totals[0] += result.inserted
|
||||||
|
totals[1] += result.updated
|
||||||
|
totals[2] += result.unchanged
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _failure(item_kind: str, item_key: str, error: BaseException) -> SyncFailure:
|
||||||
|
message = " ".join(str(error).split())[:500] or "synchronization item failed"
|
||||||
|
return SyncFailure(item_kind, item_key, type(error).__name__, message)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Pure market data domain types and ports."""
|
||||||
|
|
||||||
|
from .fingerprint import SnapshotChange, SnapshotComparison, compare_snapshots, snapshot_fingerprint
|
||||||
|
from .models import Bar, DailyBasic, Stock, SyncWindow
|
||||||
|
from .rules import filter_current_hs_a_stocks, is_current_hs_a_stock
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Bar",
|
||||||
|
"DailyBasic",
|
||||||
|
"SnapshotChange",
|
||||||
|
"SnapshotComparison",
|
||||||
|
"Stock",
|
||||||
|
"SyncWindow",
|
||||||
|
"compare_snapshots",
|
||||||
|
"filter_current_hs_a_stocks",
|
||||||
|
"is_current_hs_a_stock",
|
||||||
|
"snapshot_fingerprint",
|
||||||
|
]
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Stable qfq snapshot fingerprinting and change classification."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from enum import StrEnum
|
||||||
|
from hashlib import sha256
|
||||||
|
|
||||||
|
from .models import Bar, decimal_text
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotChange(StrEnum):
|
||||||
|
"""Action required after comparing a previous and current bar snapshot."""
|
||||||
|
|
||||||
|
INITIAL = "initial"
|
||||||
|
UNCHANGED = "unchanged"
|
||||||
|
NEW_DATES = "new_dates"
|
||||||
|
CHANGED = "changed"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SnapshotComparison:
|
||||||
|
"""Result of comparing normalized snapshots for one stock."""
|
||||||
|
|
||||||
|
change: SnapshotChange
|
||||||
|
old_fingerprint: str | None
|
||||||
|
new_fingerprint: str
|
||||||
|
new_dates: tuple[date, ...]
|
||||||
|
|
||||||
|
|
||||||
|
FINGERPRINT_FIELDS = (
|
||||||
|
"trade_date",
|
||||||
|
"open",
|
||||||
|
"high",
|
||||||
|
"low",
|
||||||
|
"close",
|
||||||
|
"pre_close",
|
||||||
|
"change",
|
||||||
|
"pct_chg",
|
||||||
|
"vol",
|
||||||
|
"amount",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate(rows: Iterable[Bar]) -> tuple[Bar, ...]:
|
||||||
|
"""Sort rows and reject duplicate identity keys before hashing or writing."""
|
||||||
|
|
||||||
|
sorted_rows = tuple(sorted(rows, key=lambda row: (row.ts_code, row.trade_date)))
|
||||||
|
seen: set[tuple[str, date]] = set()
|
||||||
|
for row in sorted_rows:
|
||||||
|
key = (row.ts_code, row.trade_date)
|
||||||
|
if not row.ts_code or key in seen:
|
||||||
|
raise ValueError(f"duplicate or empty bar key: {key!r}")
|
||||||
|
seen.add(key)
|
||||||
|
for value in (row.open, row.high, row.low, row.close):
|
||||||
|
if value is not None and value < 0:
|
||||||
|
raise ValueError(f"OHLC values must be non-negative: {key!r}")
|
||||||
|
if row.high is not None and row.low is not None and row.high < row.low:
|
||||||
|
raise ValueError(f"high must not be lower than low: {key!r}")
|
||||||
|
return sorted_rows
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical(row: Bar) -> str:
|
||||||
|
values = (
|
||||||
|
row.trade_date.isoformat(),
|
||||||
|
decimal_text(row.open),
|
||||||
|
decimal_text(row.high),
|
||||||
|
decimal_text(row.low),
|
||||||
|
decimal_text(row.close),
|
||||||
|
decimal_text(row.pre_close),
|
||||||
|
decimal_text(row.change),
|
||||||
|
decimal_text(row.pct_chg),
|
||||||
|
decimal_text(row.vol),
|
||||||
|
decimal_text(row.amount),
|
||||||
|
)
|
||||||
|
return ",".join(values)
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_fingerprint(rows: Iterable[Bar]) -> str:
|
||||||
|
"""Hash sorted, normalized fixed bar columns, excluding runtime metadata."""
|
||||||
|
|
||||||
|
normalized = _validate(rows)
|
||||||
|
payload = "\n".join(_canonical(row) for row in normalized).encode("utf-8")
|
||||||
|
return sha256(payload).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _overlap_fingerprint(rows: dict[date, Bar], dates: set[date]) -> str:
|
||||||
|
payload = "\n".join(_canonical(rows[day]) for day in sorted(dates)).encode("utf-8")
|
||||||
|
return sha256(payload).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def compare_snapshots(
|
||||||
|
old_rows: Iterable[Bar] | None,
|
||||||
|
new_rows: Iterable[Bar],
|
||||||
|
) -> SnapshotComparison:
|
||||||
|
"""Compare a complete old/new six-year snapshot.
|
||||||
|
|
||||||
|
A shifted rolling start is expected and remains unchanged. Missing rows
|
||||||
|
or changed values inside the common historical range are repairs, while
|
||||||
|
rows strictly after the old upper bound are ordinary new dates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
current = _validate(new_rows)
|
||||||
|
if not current:
|
||||||
|
raise ValueError("a bar snapshot must contain at least one row")
|
||||||
|
new_by_date = {row.trade_date: row for row in current}
|
||||||
|
new_dates = set(new_by_date)
|
||||||
|
new_fingerprint = snapshot_fingerprint(current)
|
||||||
|
if old_rows is None:
|
||||||
|
return SnapshotComparison(
|
||||||
|
SnapshotChange.INITIAL,
|
||||||
|
None,
|
||||||
|
new_fingerprint,
|
||||||
|
tuple(sorted(new_dates)),
|
||||||
|
)
|
||||||
|
|
||||||
|
previous = _validate(old_rows)
|
||||||
|
if not previous:
|
||||||
|
return SnapshotComparison(
|
||||||
|
SnapshotChange.INITIAL,
|
||||||
|
None,
|
||||||
|
new_fingerprint,
|
||||||
|
tuple(sorted(new_dates)),
|
||||||
|
)
|
||||||
|
old_by_date = {row.trade_date: row for row in previous}
|
||||||
|
old_dates = set(old_by_date)
|
||||||
|
overlap_start = max(min(old_dates), min(new_dates))
|
||||||
|
overlap_end = min(max(old_dates), max(new_dates))
|
||||||
|
overlap = {day for day in new_dates if overlap_start <= day <= overlap_end}
|
||||||
|
old_overlap = {day for day in old_dates if overlap_start <= day <= overlap_end}
|
||||||
|
old_fp = _overlap_fingerprint(old_by_date, old_overlap)
|
||||||
|
new_fp = _overlap_fingerprint(new_by_date, overlap)
|
||||||
|
|
||||||
|
starts_earlier = min(new_dates) < min(old_dates)
|
||||||
|
changed = (
|
||||||
|
old_overlap != overlap
|
||||||
|
or old_fp != new_fp
|
||||||
|
or max(new_dates) < max(old_dates)
|
||||||
|
or starts_earlier
|
||||||
|
)
|
||||||
|
if changed:
|
||||||
|
kind = SnapshotChange.CHANGED
|
||||||
|
elif max(new_dates) > max(old_dates):
|
||||||
|
kind = SnapshotChange.NEW_DATES
|
||||||
|
else:
|
||||||
|
kind = SnapshotChange.UNCHANGED
|
||||||
|
additions = tuple(sorted(day for day in new_dates if day > max(old_dates)))
|
||||||
|
return SnapshotComparison(kind, old_fp, new_fp, additions)
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""Market data entities and stable scalar normalization helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date(value: object) -> date:
|
||||||
|
"""Parse a Tushare date value without accepting locale-dependent formats."""
|
||||||
|
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value
|
||||||
|
text = str(value).strip()
|
||||||
|
if len(text) == 8 and text.isdigit():
|
||||||
|
return date.fromisoformat(f"{text[:4]}-{text[4:6]}-{text[6:]}")
|
||||||
|
return date.fromisoformat(text[:10])
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_decimal(value: object | None) -> Decimal | None:
|
||||||
|
"""Convert vendor numbers to finite, text-stable decimals.
|
||||||
|
|
||||||
|
Tushare may return a float, a decimal, a string, or ``None`` depending on
|
||||||
|
the transport. Decimal constructed from the textual representation keeps
|
||||||
|
those variants from changing snapshot fingerprints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if value is None or str(value).strip() == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
result = Decimal(str(value).strip())
|
||||||
|
except (InvalidOperation, ValueError) as exc:
|
||||||
|
raise ValueError(f"invalid numeric value: {value!r}") from exc
|
||||||
|
if not result.is_finite():
|
||||||
|
raise ValueError(f"numeric value must be finite: {value!r}")
|
||||||
|
return Decimal(0) if result == 0 else result.normalize()
|
||||||
|
|
||||||
|
|
||||||
|
def decimal_text(value: Decimal | None) -> str:
|
||||||
|
"""Serialize a decimal in a locale-independent, non-exponent form."""
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
text = format(value, "f")
|
||||||
|
if "." in text:
|
||||||
|
text = text.rstrip("0").rstrip(".")
|
||||||
|
return text or "0"
|
||||||
|
|
||||||
|
|
||||||
|
def _text(row: Mapping[str, object], key: str, default: str = "") -> str:
|
||||||
|
value = row.get(key, default)
|
||||||
|
return default if value is None else str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Stock:
|
||||||
|
"""Current stock master data used to construct the target universe."""
|
||||||
|
|
||||||
|
ts_code: str
|
||||||
|
name: str
|
||||||
|
market: str = ""
|
||||||
|
exchange: str = ""
|
||||||
|
list_status: str = "L"
|
||||||
|
list_date: date | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mapping(cls, row: Mapping[str, object]) -> Stock:
|
||||||
|
"""Build a stock from a ``stock_basic`` row."""
|
||||||
|
|
||||||
|
raw_date = row.get("list_date")
|
||||||
|
return cls(
|
||||||
|
ts_code=_text(row, "ts_code"),
|
||||||
|
name=_text(row, "name"),
|
||||||
|
market=_text(row, "market"),
|
||||||
|
exchange=_text(row, "exchange"),
|
||||||
|
list_status=_text(row, "list_status", "L").upper(),
|
||||||
|
list_date=parse_date(raw_date) if raw_date else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Bar:
|
||||||
|
"""One qfq daily OHLCV record."""
|
||||||
|
|
||||||
|
ts_code: str
|
||||||
|
trade_date: date
|
||||||
|
open: Decimal | None = None
|
||||||
|
high: Decimal | None = None
|
||||||
|
low: Decimal | None = None
|
||||||
|
close: Decimal | None = None
|
||||||
|
pre_close: Decimal | None = None
|
||||||
|
change: Decimal | None = None
|
||||||
|
pct_chg: Decimal | None = None
|
||||||
|
vol: Decimal | None = None
|
||||||
|
amount: Decimal | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mapping(cls, row: Mapping[str, object]) -> Bar:
|
||||||
|
"""Build a normalized bar from a Tushare ``pro_bar`` row."""
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
ts_code=_text(row, "ts_code"),
|
||||||
|
trade_date=parse_date(row["trade_date"]),
|
||||||
|
open=normalize_decimal(row.get("open")),
|
||||||
|
high=normalize_decimal(row.get("high")),
|
||||||
|
low=normalize_decimal(row.get("low")),
|
||||||
|
close=normalize_decimal(row.get("close")),
|
||||||
|
pre_close=normalize_decimal(row.get("pre_close")),
|
||||||
|
change=normalize_decimal(row.get("change")),
|
||||||
|
pct_chg=normalize_decimal(row.get("pct_chg")),
|
||||||
|
vol=normalize_decimal(row.get("vol")),
|
||||||
|
amount=normalize_decimal(row.get("amount")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DailyBasic:
|
||||||
|
"""One trading-day valuation and liquidity snapshot."""
|
||||||
|
|
||||||
|
ts_code: str
|
||||||
|
trade_date: date
|
||||||
|
close: Decimal | None = None
|
||||||
|
turnover_rate: Decimal | None = None
|
||||||
|
turnover_rate_f: Decimal | None = None
|
||||||
|
volume_ratio: Decimal | None = None
|
||||||
|
pe: Decimal | None = None
|
||||||
|
pe_ttm: Decimal | None = None
|
||||||
|
pb: Decimal | None = None
|
||||||
|
ps: Decimal | None = None
|
||||||
|
ps_ttm: Decimal | None = None
|
||||||
|
dv_ratio: Decimal | None = None
|
||||||
|
dv_ttm: Decimal | None = None
|
||||||
|
total_share: Decimal | None = None
|
||||||
|
float_share: Decimal | None = None
|
||||||
|
free_share: Decimal | None = None
|
||||||
|
total_mv: Decimal | None = None
|
||||||
|
circ_mv: Decimal | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mapping(cls, row: Mapping[str, object]) -> DailyBasic:
|
||||||
|
"""Build a normalized row from a Tushare ``daily_basic`` response."""
|
||||||
|
|
||||||
|
decimal_fields = (
|
||||||
|
"close",
|
||||||
|
"turnover_rate",
|
||||||
|
"turnover_rate_f",
|
||||||
|
"volume_ratio",
|
||||||
|
"pe",
|
||||||
|
"pe_ttm",
|
||||||
|
"pb",
|
||||||
|
"ps",
|
||||||
|
"ps_ttm",
|
||||||
|
"dv_ratio",
|
||||||
|
"dv_ttm",
|
||||||
|
"total_share",
|
||||||
|
"float_share",
|
||||||
|
"free_share",
|
||||||
|
"total_mv",
|
||||||
|
"circ_mv",
|
||||||
|
)
|
||||||
|
values = {field: normalize_decimal(row.get(field)) for field in decimal_fields}
|
||||||
|
return cls(
|
||||||
|
ts_code=_text(row, "ts_code"),
|
||||||
|
trade_date=parse_date(row["trade_date"]),
|
||||||
|
**values,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SyncWindow:
|
||||||
|
"""Inclusive rolling window used by both the database and CSV retention."""
|
||||||
|
|
||||||
|
start: date
|
||||||
|
end: date
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_target(cls, target_trade_date: date, years: int = 6) -> SyncWindow:
|
||||||
|
"""Return a six-calendar-year window including the target date."""
|
||||||
|
|
||||||
|
if years < 1:
|
||||||
|
raise ValueError("years must be positive")
|
||||||
|
try:
|
||||||
|
start = target_trade_date.replace(year=target_trade_date.year - years)
|
||||||
|
except ValueError:
|
||||||
|
# 29 February has no counterpart in a non-leap year. The last
|
||||||
|
# day of February is the deterministic calendar boundary.
|
||||||
|
start = target_trade_date.replace(
|
||||||
|
year=target_trade_date.year - years,
|
||||||
|
month=2,
|
||||||
|
day=28,
|
||||||
|
)
|
||||||
|
return cls(start=start, end=target_trade_date)
|
||||||
|
|
||||||
|
def contains(self, value: date) -> bool:
|
||||||
|
"""Return whether a date is within the inclusive window."""
|
||||||
|
|
||||||
|
return self.start <= value <= self.end
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Ports used by the market data application layer."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable, Sequence
|
||||||
|
from contextlib import AbstractContextManager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from .models import Bar, DailyBasic, Stock, SyncWindow
|
||||||
|
|
||||||
|
|
||||||
|
class MarketDataRepositoryError(RuntimeError):
|
||||||
|
"""A storage adapter failed without exposing vendor or credential details."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class WriteResult:
|
||||||
|
"""Counts returned by one idempotent repository write."""
|
||||||
|
|
||||||
|
inserted: int = 0
|
||||||
|
updated: int = 0
|
||||||
|
unchanged: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class StagedSnapshot:
|
||||||
|
"""A temporary file that may be published after its database transaction."""
|
||||||
|
|
||||||
|
temporary_path: Path
|
||||||
|
final_path: Path
|
||||||
|
fingerprint: str
|
||||||
|
|
||||||
|
|
||||||
|
class MarketDataSource(Protocol):
|
||||||
|
"""External source port for stock, calendar, bar, and metric data."""
|
||||||
|
|
||||||
|
def fetch_stocks(self) -> Sequence[Stock]: ...
|
||||||
|
|
||||||
|
def fetch_open_dates(self, start: date, end: date) -> Sequence[date]: ...
|
||||||
|
|
||||||
|
def fetch_bars(self, ts_code: str, window: SyncWindow) -> Sequence[Bar]: ...
|
||||||
|
|
||||||
|
def fetch_daily_basic(self, trade_date: date) -> Sequence[DailyBasic]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotStore(Protocol):
|
||||||
|
"""Snapshot and atomic-file publication port."""
|
||||||
|
|
||||||
|
def read_bars(self, ts_code: str) -> tuple[Bar, ...] | None: ...
|
||||||
|
|
||||||
|
def stage_bars(self, ts_code: str, rows: Iterable[Bar]) -> StagedSnapshot: ...
|
||||||
|
|
||||||
|
def stage_daily_basic(self, trade_date: date, rows: Iterable[DailyBasic]) -> StagedSnapshot: ...
|
||||||
|
|
||||||
|
def stage_stocks(self, rows: Iterable[Stock]) -> StagedSnapshot: ...
|
||||||
|
|
||||||
|
def publish(self, staged: StagedSnapshot) -> None: ...
|
||||||
|
|
||||||
|
def discard(self, staged: StagedSnapshot) -> None: ...
|
||||||
|
|
||||||
|
def clean_daily_basic_before(self, boundary: date) -> int: ...
|
||||||
|
|
||||||
|
|
||||||
|
class MarketDataRepository(Protocol):
|
||||||
|
"""PostgreSQL repository port.
|
||||||
|
|
||||||
|
``full_snapshot`` controls whether rows missing from the current six-year
|
||||||
|
window are deleted for the affected stock. Incremental writes never
|
||||||
|
remove rows before the database transaction has succeeded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def upsert_stocks(self, rows: Iterable[Stock]) -> WriteResult: ...
|
||||||
|
|
||||||
|
def upsert_bars(
|
||||||
|
self,
|
||||||
|
rows: Iterable[Bar],
|
||||||
|
window: SyncWindow,
|
||||||
|
*,
|
||||||
|
full_snapshot: bool,
|
||||||
|
) -> WriteResult: ...
|
||||||
|
|
||||||
|
def upsert_daily_basic(self, rows: Iterable[DailyBasic], window: SyncWindow) -> WriteResult: ...
|
||||||
|
|
||||||
|
def purge_before(self, window: SyncWindow) -> None: ...
|
||||||
|
|
||||||
|
def has_bar(self, ts_code: str, trade_date: date) -> bool: ...
|
||||||
|
|
||||||
|
def has_daily_basic(self, ts_code: str, trade_date: date) -> bool: ...
|
||||||
|
|
||||||
|
def create_batch(
|
||||||
|
self,
|
||||||
|
target_trade_date: date,
|
||||||
|
window: SyncWindow,
|
||||||
|
mode: str,
|
||||||
|
parent_batch_id: str | None,
|
||||||
|
target_count: int,
|
||||||
|
) -> str: ...
|
||||||
|
|
||||||
|
def record_batch(
|
||||||
|
self,
|
||||||
|
batch_id: str,
|
||||||
|
status: str,
|
||||||
|
valid_count: int,
|
||||||
|
coverage: Decimal,
|
||||||
|
strategy_eligible: bool,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
def record_item(
|
||||||
|
self,
|
||||||
|
batch_id: str,
|
||||||
|
item_kind: str,
|
||||||
|
item_key: str,
|
||||||
|
status: str,
|
||||||
|
result: WriteResult,
|
||||||
|
fingerprint: str | None = None,
|
||||||
|
error_type: str | None = None,
|
||||||
|
error_message: str | None = None,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
def failed_items(self, parent_batch_id: str) -> tuple[tuple[str, str], ...]: ...
|
||||||
|
|
||||||
|
def advisory_lock(self, key: int) -> AbstractContextManager[bool]: ...
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Target universe rules for the current listed Shanghai/Shenzhen A shares."""
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
from .models import Stock
|
||||||
|
|
||||||
|
|
||||||
|
def is_current_hs_a_stock(stock: Stock) -> bool:
|
||||||
|
"""Return whether a stock is a current non-ST Shanghai/Shenzhen A share."""
|
||||||
|
|
||||||
|
code, separator, suffix = stock.ts_code.upper().partition(".")
|
||||||
|
if not separator or suffix not in {"SH", "SZ"} or not code.isdigit():
|
||||||
|
return False
|
||||||
|
if stock.list_status.upper() != "L":
|
||||||
|
return False
|
||||||
|
if stock.exchange and stock.exchange.upper() not in {"SSE", "SZSE", "SH", "SZ"}:
|
||||||
|
return False
|
||||||
|
market = stock.market.upper().replace(" ", "")
|
||||||
|
if "北交所" in market or market in {"B", "B股", "BEIJING"}:
|
||||||
|
return False
|
||||||
|
return "ST" not in stock.name.upper().replace(" ", "") and "退" not in stock.name
|
||||||
|
|
||||||
|
|
||||||
|
def filter_current_hs_a_stocks(stocks: Iterable[Stock]) -> tuple[Stock, ...]:
|
||||||
|
"""Filter and deterministically sort the current target universe."""
|
||||||
|
|
||||||
|
return tuple(
|
||||||
|
sorted(
|
||||||
|
(stock for stock in stocks if is_current_hs_a_stock(stock)),
|
||||||
|
key=lambda stock: stock.ts_code,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Compatibility import for the market data rolling window."""
|
||||||
|
|
||||||
|
from .models import SyncWindow
|
||||||
|
|
||||||
|
__all__ = ["SyncWindow"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Market data infrastructure adapters."""
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
"""Fixed-layout CSV snapshots with atomic publication semantics."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from contextlib import suppress
|
||||||
|
from datetime import date
|
||||||
|
from hashlib import sha256
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from ..domain.fingerprint import snapshot_fingerprint
|
||||||
|
from ..domain.models import Bar, DailyBasic, Stock, decimal_text
|
||||||
|
from ..domain.ports import StagedSnapshot
|
||||||
|
|
||||||
|
_SAFE_CODE = re.compile(r"^[0-9]{6}\.(SH|SZ)$")
|
||||||
|
BAR_COLUMNS = (
|
||||||
|
"ts_code",
|
||||||
|
"trade_date",
|
||||||
|
"open",
|
||||||
|
"high",
|
||||||
|
"low",
|
||||||
|
"close",
|
||||||
|
"pre_close",
|
||||||
|
"change",
|
||||||
|
"pct_chg",
|
||||||
|
"vol",
|
||||||
|
"amount",
|
||||||
|
)
|
||||||
|
DAILY_BASIC_COLUMNS = (
|
||||||
|
"ts_code",
|
||||||
|
"trade_date",
|
||||||
|
"close",
|
||||||
|
"turnover_rate",
|
||||||
|
"turnover_rate_f",
|
||||||
|
"volume_ratio",
|
||||||
|
"pe",
|
||||||
|
"pe_ttm",
|
||||||
|
"pb",
|
||||||
|
"ps",
|
||||||
|
"ps_ttm",
|
||||||
|
"dv_ratio",
|
||||||
|
"dv_ttm",
|
||||||
|
"total_share",
|
||||||
|
"float_share",
|
||||||
|
"free_share",
|
||||||
|
"total_mv",
|
||||||
|
"circ_mv",
|
||||||
|
)
|
||||||
|
STOCK_COLUMNS = ("ts_code", "name", "market", "exchange", "list_status", "list_date")
|
||||||
|
_DAILY_DECIMAL_FIELDS = DAILY_BASIC_COLUMNS[2:]
|
||||||
|
|
||||||
|
|
||||||
|
def _code_path(ts_code: str) -> str:
|
||||||
|
if not _SAFE_CODE.fullmatch(ts_code):
|
||||||
|
raise ValueError(f"unsupported stock code: {ts_code!r}")
|
||||||
|
return ts_code
|
||||||
|
|
||||||
|
|
||||||
|
def _bar_row(row: Bar) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"ts_code": row.ts_code,
|
||||||
|
"trade_date": row.trade_date.isoformat(),
|
||||||
|
"open": decimal_text(row.open),
|
||||||
|
"high": decimal_text(row.high),
|
||||||
|
"low": decimal_text(row.low),
|
||||||
|
"close": decimal_text(row.close),
|
||||||
|
"pre_close": decimal_text(row.pre_close),
|
||||||
|
"change": decimal_text(row.change),
|
||||||
|
"pct_chg": decimal_text(row.pct_chg),
|
||||||
|
"vol": decimal_text(row.vol),
|
||||||
|
"amount": decimal_text(row.amount),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _daily_basic_row(row: DailyBasic) -> dict[str, str]:
|
||||||
|
values: dict[str, str] = {
|
||||||
|
"ts_code": row.ts_code,
|
||||||
|
"trade_date": row.trade_date.isoformat(),
|
||||||
|
}
|
||||||
|
for field in _DAILY_DECIMAL_FIELDS:
|
||||||
|
values[field] = decimal_text(getattr(row, field))
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _stock_row(row: Stock) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"ts_code": row.ts_code,
|
||||||
|
"name": row.name,
|
||||||
|
"market": row.market,
|
||||||
|
"exchange": row.exchange,
|
||||||
|
"list_status": row.list_status,
|
||||||
|
"list_date": row.list_date.isoformat() if row.list_date else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CsvSnapshotStore:
|
||||||
|
"""Store and publish the three market-data CSV layouts.
|
||||||
|
|
||||||
|
Temporary files are created beside their final file. Therefore ``os.replace``
|
||||||
|
is a same-filesystem operation and a database failure can leave the previous
|
||||||
|
published snapshot untouched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, root: Path) -> None:
|
||||||
|
self.root = root
|
||||||
|
|
||||||
|
def bars_path(self, ts_code: str) -> Path:
|
||||||
|
"""Return the formal per-stock qfq path."""
|
||||||
|
|
||||||
|
return self.root / "bars" / f"{_code_path(ts_code)}.csv"
|
||||||
|
|
||||||
|
def daily_basic_path(self, trade_date: date) -> Path:
|
||||||
|
"""Return the formal per-trading-day metrics path."""
|
||||||
|
|
||||||
|
return self.root / "daily-basic" / f"{trade_date:%Y}" / f"{trade_date:%Y%m%d}.csv"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stock_basic_path(self) -> Path:
|
||||||
|
"""Return the formal current stock-master path."""
|
||||||
|
|
||||||
|
return self.root / "stock-basic" / "current.csv"
|
||||||
|
|
||||||
|
def read_bars(self, ts_code: str) -> tuple[Bar, ...] | None:
|
||||||
|
"""Read the current formal snapshot, returning ``None`` if absent."""
|
||||||
|
|
||||||
|
path = self.bars_path(ts_code)
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
with path.open(newline="", encoding="utf-8") as handle:
|
||||||
|
reader = csv.DictReader(handle)
|
||||||
|
if tuple(reader.fieldnames or ()) != BAR_COLUMNS:
|
||||||
|
raise ValueError(f"unexpected bar CSV header: {path}")
|
||||||
|
rows = tuple(Bar.from_mapping(row) for row in reader)
|
||||||
|
return tuple(sorted(rows, key=lambda row: row.trade_date))
|
||||||
|
|
||||||
|
def stage_bars(self, ts_code: str, rows: Iterable[Bar]) -> StagedSnapshot:
|
||||||
|
"""Validate and write a temporary bar snapshot."""
|
||||||
|
|
||||||
|
normalized = tuple(sorted(rows, key=lambda row: row.trade_date))
|
||||||
|
if not normalized or any(row.ts_code != ts_code for row in normalized):
|
||||||
|
raise ValueError("bar snapshot must be non-empty and belong to one stock")
|
||||||
|
final_path = self.bars_path(ts_code)
|
||||||
|
temp_path = self._write_csv(final_path, BAR_COLUMNS, (_bar_row(row) for row in normalized))
|
||||||
|
return StagedSnapshot(temp_path, final_path, snapshot_fingerprint(normalized))
|
||||||
|
|
||||||
|
def stage_daily_basic(self, trade_date: date, rows: Iterable[DailyBasic]) -> StagedSnapshot:
|
||||||
|
"""Validate and stage one daily-basic date snapshot."""
|
||||||
|
|
||||||
|
normalized = tuple(sorted(rows, key=lambda row: row.ts_code))
|
||||||
|
if any(row.trade_date != trade_date for row in normalized):
|
||||||
|
raise ValueError("daily-basic snapshot contains an unexpected date")
|
||||||
|
keys = [row.ts_code for row in normalized]
|
||||||
|
if len(keys) != len(set(keys)):
|
||||||
|
raise ValueError("daily-basic snapshot contains duplicate stock codes")
|
||||||
|
final_path = self.daily_basic_path(trade_date)
|
||||||
|
payload = "\n".join(
|
||||||
|
",".join(_daily_basic_row(row)[column] for column in DAILY_BASIC_COLUMNS)
|
||||||
|
for row in normalized
|
||||||
|
).encode("utf-8")
|
||||||
|
fingerprint = sha256(payload).hexdigest()
|
||||||
|
temp_path = self._write_csv(
|
||||||
|
final_path,
|
||||||
|
DAILY_BASIC_COLUMNS,
|
||||||
|
(_daily_basic_row(row) for row in normalized),
|
||||||
|
)
|
||||||
|
return StagedSnapshot(temp_path, final_path, fingerprint)
|
||||||
|
|
||||||
|
def stage_stocks(self, rows: Iterable[Stock]) -> StagedSnapshot:
|
||||||
|
"""Validate and stage the current stock-master snapshot."""
|
||||||
|
|
||||||
|
normalized = tuple(sorted(rows, key=lambda row: row.ts_code))
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("stock-master snapshot must not be empty")
|
||||||
|
keys = [row.ts_code for row in normalized]
|
||||||
|
if len(keys) != len(set(keys)):
|
||||||
|
raise ValueError("stock-master snapshot contains duplicate stock codes")
|
||||||
|
payload = "\n".join(
|
||||||
|
",".join(_stock_row(row)[field] for field in STOCK_COLUMNS) for row in normalized
|
||||||
|
)
|
||||||
|
final_path = self.stock_basic_path
|
||||||
|
temp_path = self._write_csv(
|
||||||
|
final_path,
|
||||||
|
STOCK_COLUMNS,
|
||||||
|
(_stock_row(row) for row in normalized),
|
||||||
|
)
|
||||||
|
return StagedSnapshot(temp_path, final_path, sha256(payload.encode("utf-8")).hexdigest())
|
||||||
|
|
||||||
|
def publish(self, staged: StagedSnapshot) -> None:
|
||||||
|
"""Atomically promote a staged snapshot and sync the parent directory."""
|
||||||
|
|
||||||
|
if not staged.temporary_path.exists():
|
||||||
|
raise FileNotFoundError(staged.temporary_path)
|
||||||
|
os.replace(staged.temporary_path, staged.final_path)
|
||||||
|
try:
|
||||||
|
directory_fd = os.open(staged.final_path.parent, os.O_RDONLY)
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
os.fsync(directory_fd)
|
||||||
|
finally:
|
||||||
|
os.close(directory_fd)
|
||||||
|
|
||||||
|
def discard(self, staged: StagedSnapshot) -> None:
|
||||||
|
"""Remove a staged file without touching its formal counterpart."""
|
||||||
|
|
||||||
|
with suppress(FileNotFoundError):
|
||||||
|
staged.temporary_path.unlink()
|
||||||
|
|
||||||
|
def clean_daily_basic_before(self, boundary: date) -> int:
|
||||||
|
"""Remove only formal daily-basic files older than the retention boundary."""
|
||||||
|
|
||||||
|
removed = 0
|
||||||
|
root = self.root / "daily-basic"
|
||||||
|
if not root.exists():
|
||||||
|
return removed
|
||||||
|
for path in root.glob("*/????????.csv"):
|
||||||
|
try:
|
||||||
|
file_date = date.fromisoformat(
|
||||||
|
path.stem[:4] + "-" + path.stem[4:6] + "-" + path.stem[6:]
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if file_date < boundary:
|
||||||
|
path.unlink()
|
||||||
|
removed += 1
|
||||||
|
return removed
|
||||||
|
|
||||||
|
def _write_csv(
|
||||||
|
self,
|
||||||
|
final_path: Path,
|
||||||
|
columns: tuple[str, ...],
|
||||||
|
rows: Iterable[dict[str, str]],
|
||||||
|
) -> Path:
|
||||||
|
final_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
descriptor, raw_path = tempfile.mkstemp(
|
||||||
|
prefix=f".{final_path.name}.{uuid4().hex}.",
|
||||||
|
suffix=".tmp",
|
||||||
|
dir=final_path.parent,
|
||||||
|
)
|
||||||
|
temp_path = Path(raw_path)
|
||||||
|
try:
|
||||||
|
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as handle:
|
||||||
|
writer = csv.DictWriter(handle, fieldnames=columns, lineterminator="\n")
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
except BaseException:
|
||||||
|
temp_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
return temp_path
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
"""Psycopg 3 PostgreSQL adapter using COPY staging and set-based upserts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator, Iterable
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
from ..domain.models import Bar, DailyBasic, Stock, SyncWindow
|
||||||
|
from ..domain.ports import MarketDataRepositoryError, WriteResult
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresMarketDataRepository:
|
||||||
|
"""Persist market data without an ORM identity map.
|
||||||
|
|
||||||
|
Each public write opens a short transaction. The caller publishes the
|
||||||
|
matching CSV only after this method returns successfully.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, database_url: str) -> None:
|
||||||
|
self.database_url = database_url
|
||||||
|
|
||||||
|
def upsert_stocks(self, rows: Iterable[Stock]) -> WriteResult:
|
||||||
|
records = tuple(rows)
|
||||||
|
if not records:
|
||||||
|
return WriteResult()
|
||||||
|
with self._connection() as connection, connection.transaction():
|
||||||
|
cursor = connection.cursor()
|
||||||
|
codes = [row.ts_code for row in records]
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE market_stock
|
||||||
|
SET is_active = false, updated_at = now()
|
||||||
|
WHERE is_active AND NOT (ts_code = ANY(%s))
|
||||||
|
""",
|
||||||
|
(codes,),
|
||||||
|
)
|
||||||
|
inserted = 0
|
||||||
|
updated = 0
|
||||||
|
unchanged = 0
|
||||||
|
for row in records:
|
||||||
|
result = cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO market_stock
|
||||||
|
(
|
||||||
|
ts_code, name, market, exchange, list_status, list_date,
|
||||||
|
is_active, updated_at
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, true, now())
|
||||||
|
ON CONFLICT (ts_code) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
market = EXCLUDED.market,
|
||||||
|
exchange = EXCLUDED.exchange,
|
||||||
|
list_status = EXCLUDED.list_status,
|
||||||
|
list_date = EXCLUDED.list_date,
|
||||||
|
is_active = true,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE (market_stock.name, market_stock.market, market_stock.exchange,
|
||||||
|
market_stock.list_status, market_stock.list_date, market_stock.is_active)
|
||||||
|
IS DISTINCT FROM (EXCLUDED.name, EXCLUDED.market, EXCLUDED.exchange,
|
||||||
|
EXCLUDED.list_status, EXCLUDED.list_date, true)
|
||||||
|
RETURNING (xmax = 0) AS inserted
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
row.ts_code,
|
||||||
|
row.name,
|
||||||
|
row.market,
|
||||||
|
row.exchange,
|
||||||
|
row.list_status,
|
||||||
|
row.list_date,
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
if result is None:
|
||||||
|
unchanged += 1
|
||||||
|
elif bool(result[0]):
|
||||||
|
inserted += 1
|
||||||
|
else:
|
||||||
|
updated += 1
|
||||||
|
return WriteResult(inserted=inserted, updated=updated, unchanged=unchanged)
|
||||||
|
|
||||||
|
def upsert_bars(
|
||||||
|
self,
|
||||||
|
rows: Iterable[Bar],
|
||||||
|
window: SyncWindow,
|
||||||
|
*,
|
||||||
|
full_snapshot: bool,
|
||||||
|
) -> WriteResult:
|
||||||
|
records = tuple(rows)
|
||||||
|
if records and len({row.ts_code for row in records}) != 1:
|
||||||
|
raise ValueError("a bar write must contain one stock")
|
||||||
|
if not records:
|
||||||
|
return WriteResult()
|
||||||
|
code = records[0].ts_code
|
||||||
|
with self._connection() as connection, connection.transaction():
|
||||||
|
cursor = connection.cursor()
|
||||||
|
self._create_bar_stage(cursor)
|
||||||
|
self._copy_bars(cursor, records)
|
||||||
|
before = self._count_stage_matches(cursor, "market_daily_bar")
|
||||||
|
inserted, updated = self._upsert_bars_from_stage(cursor)
|
||||||
|
if full_snapshot:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM market_daily_bar AS target
|
||||||
|
WHERE target.ts_code = %s
|
||||||
|
AND target.trade_date BETWEEN %s AND %s
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM market_daily_bar_stage AS stage
|
||||||
|
WHERE stage.ts_code = target.ts_code
|
||||||
|
AND stage.trade_date = target.trade_date
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(code, window.start, window.end),
|
||||||
|
)
|
||||||
|
return WriteResult(inserted=inserted, updated=updated, unchanged=max(0, before - updated))
|
||||||
|
|
||||||
|
def upsert_daily_basic(self, rows: Iterable[DailyBasic], window: SyncWindow) -> WriteResult:
|
||||||
|
records = tuple(rows)
|
||||||
|
if not records:
|
||||||
|
return WriteResult()
|
||||||
|
if len({row.trade_date for row in records}) != 1:
|
||||||
|
raise ValueError("a daily-basic write must contain one trade date")
|
||||||
|
with self._connection() as connection, connection.transaction():
|
||||||
|
cursor = connection.cursor()
|
||||||
|
self._create_basic_stage(cursor)
|
||||||
|
self._copy_daily_basic(cursor, records)
|
||||||
|
before = self._count_stage_matches(cursor, "market_daily_basic")
|
||||||
|
inserted, updated = self._upsert_daily_basic_from_stage(cursor)
|
||||||
|
return WriteResult(inserted=inserted, updated=updated, unchanged=max(0, before - updated))
|
||||||
|
|
||||||
|
def purge_before(self, window: SyncWindow) -> None:
|
||||||
|
"""Apply rolling retention only after the caller has finished a batch."""
|
||||||
|
|
||||||
|
with self._connection() as connection, connection.transaction():
|
||||||
|
cursor = connection.cursor()
|
||||||
|
cursor.execute("DELETE FROM market_daily_bar WHERE trade_date < %s", (window.start,))
|
||||||
|
cursor.execute("DELETE FROM market_daily_basic WHERE trade_date < %s", (window.start,))
|
||||||
|
|
||||||
|
def has_bar(self, ts_code: str, trade_date: date) -> bool:
|
||||||
|
return self._exists("market_daily_bar", ts_code, trade_date)
|
||||||
|
|
||||||
|
def has_daily_basic(self, ts_code: str, trade_date: date) -> bool:
|
||||||
|
return self._exists("market_daily_basic", ts_code, trade_date)
|
||||||
|
|
||||||
|
def create_batch(
|
||||||
|
self,
|
||||||
|
target_trade_date: date,
|
||||||
|
window: SyncWindow,
|
||||||
|
mode: str,
|
||||||
|
parent_batch_id: str | None,
|
||||||
|
target_count: int,
|
||||||
|
) -> str:
|
||||||
|
batch_id = str(uuid4())
|
||||||
|
with self._connection() as connection, connection.transaction():
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO market_sync_batch
|
||||||
|
(id, target_trade_date, window_start, mode, status, target_count)
|
||||||
|
VALUES (%s, %s, %s, %s, 'running', %s)
|
||||||
|
""",
|
||||||
|
(batch_id, target_trade_date, window.start, mode, target_count),
|
||||||
|
)
|
||||||
|
if parent_batch_id:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE market_sync_batch SET parent_batch_id = %s WHERE id = %s",
|
||||||
|
(parent_batch_id, batch_id),
|
||||||
|
)
|
||||||
|
return batch_id
|
||||||
|
|
||||||
|
def record_batch(
|
||||||
|
self,
|
||||||
|
batch_id: str,
|
||||||
|
status: str,
|
||||||
|
valid_count: int,
|
||||||
|
coverage: Decimal,
|
||||||
|
strategy_eligible: bool,
|
||||||
|
) -> None:
|
||||||
|
with self._connection() as connection, connection.transaction():
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE market_sync_batch
|
||||||
|
SET status = %s, valid_count = %s, coverage = %s,
|
||||||
|
strategy_eligible = %s, finished_at = now()
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(status, valid_count, coverage, strategy_eligible, batch_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_item(
|
||||||
|
self,
|
||||||
|
batch_id: str,
|
||||||
|
item_kind: str,
|
||||||
|
item_key: str,
|
||||||
|
status: str,
|
||||||
|
result: WriteResult,
|
||||||
|
fingerprint: str | None = None,
|
||||||
|
error_type: str | None = None,
|
||||||
|
error_message: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
with self._connection() as connection, connection.transaction():
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO market_sync_item
|
||||||
|
(batch_id, item_kind, item_key, status, inserted_count,
|
||||||
|
updated_count, unchanged_count, fingerprint, error_type, error_message)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (batch_id, item_kind, item_key) DO UPDATE SET
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
inserted_count = EXCLUDED.inserted_count,
|
||||||
|
updated_count = EXCLUDED.updated_count,
|
||||||
|
unchanged_count = EXCLUDED.unchanged_count,
|
||||||
|
fingerprint = EXCLUDED.fingerprint,
|
||||||
|
error_type = EXCLUDED.error_type,
|
||||||
|
error_message = EXCLUDED.error_message
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
batch_id,
|
||||||
|
item_kind,
|
||||||
|
item_key,
|
||||||
|
status,
|
||||||
|
result.inserted,
|
||||||
|
result.updated,
|
||||||
|
result.unchanged,
|
||||||
|
fingerprint,
|
||||||
|
error_type,
|
||||||
|
self._safe_error(error_message),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def failed_items(self, parent_batch_id: str) -> tuple[tuple[str, str], ...]:
|
||||||
|
with self._connection() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT item_kind, item_key
|
||||||
|
FROM market_sync_item
|
||||||
|
WHERE batch_id = %s AND status = 'failed'
|
||||||
|
ORDER BY item_kind, item_key
|
||||||
|
""",
|
||||||
|
(parent_batch_id,),
|
||||||
|
).fetchall()
|
||||||
|
return tuple((str(row[0]), str(row[1])) for row in rows)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def advisory_lock(self, key: int) -> Generator[bool, None, None]:
|
||||||
|
"""Hold a PostgreSQL advisory lock for the lifetime of one sync run."""
|
||||||
|
|
||||||
|
with self._connection() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT pg_try_advisory_lock(%s)",
|
||||||
|
(key,),
|
||||||
|
).fetchone()
|
||||||
|
acquired = bool(row[0]) if row is not None else False
|
||||||
|
if not acquired:
|
||||||
|
yield False
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
yield True
|
||||||
|
finally:
|
||||||
|
connection.execute("SELECT pg_advisory_unlock(%s)", (key,))
|
||||||
|
|
||||||
|
def _exists(self, table: str, ts_code: str, trade_date: date) -> bool:
|
||||||
|
if table == "market_daily_bar":
|
||||||
|
query = "SELECT 1 FROM market_daily_bar WHERE ts_code = %s AND trade_date = %s LIMIT 1"
|
||||||
|
elif table == "market_daily_basic":
|
||||||
|
query = (
|
||||||
|
"SELECT 1 FROM market_daily_basic WHERE ts_code = %s AND trade_date = %s LIMIT 1"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"unsupported existence table: {table}")
|
||||||
|
with self._connection() as connection:
|
||||||
|
result = connection.execute(
|
||||||
|
query,
|
||||||
|
(ts_code, trade_date),
|
||||||
|
).fetchone()
|
||||||
|
return result is not None
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _connection(self) -> Generator[Any, None, None]:
|
||||||
|
"""Translate driver failures into a safe application-level error."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
with psycopg.connect(self.database_url) as connection:
|
||||||
|
yield connection
|
||||||
|
except psycopg.Error as exc:
|
||||||
|
raise MarketDataRepositoryError("market data database operation failed") from exc
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _create_bar_stage(cursor: Any) -> None:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TEMP TABLE market_daily_bar_stage (
|
||||||
|
ts_code varchar(12) NOT NULL,
|
||||||
|
trade_date date NOT NULL,
|
||||||
|
open numeric(20,6), high numeric(20,6), low numeric(20,6), close numeric(20,6),
|
||||||
|
pre_close numeric(20,6), change numeric(20,6), pct_chg numeric(20,6),
|
||||||
|
vol numeric(24,6), amount numeric(24,6)
|
||||||
|
) ON COMMIT DROP
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _create_basic_stage(cursor: Any) -> None:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TEMP TABLE market_daily_basic_stage (
|
||||||
|
ts_code varchar(12) NOT NULL,
|
||||||
|
trade_date date NOT NULL,
|
||||||
|
close numeric(24,6), turnover_rate numeric(24,6),
|
||||||
|
turnover_rate_f numeric(24,6), volume_ratio numeric(24,6),
|
||||||
|
pe numeric(24,6), pe_ttm numeric(24,6), pb numeric(24,6),
|
||||||
|
ps numeric(24,6), ps_ttm numeric(24,6), dv_ratio numeric(24,6),
|
||||||
|
dv_ttm numeric(24,6), total_share numeric(24,6),
|
||||||
|
float_share numeric(24,6), free_share numeric(24,6),
|
||||||
|
total_mv numeric(24,6), circ_mv numeric(24,6)
|
||||||
|
) ON COMMIT DROP
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _copy_bars(cursor: Any, rows: Iterable[Bar]) -> None:
|
||||||
|
copy_sql = (
|
||||||
|
"COPY market_daily_bar_stage "
|
||||||
|
"(ts_code, trade_date, open, high, low, close, pre_close, "
|
||||||
|
"change, pct_chg, vol, amount) FROM STDIN"
|
||||||
|
)
|
||||||
|
with cursor.copy(copy_sql) as copy:
|
||||||
|
for row in rows:
|
||||||
|
copy.write_row(
|
||||||
|
(
|
||||||
|
row.ts_code,
|
||||||
|
row.trade_date,
|
||||||
|
row.open,
|
||||||
|
row.high,
|
||||||
|
row.low,
|
||||||
|
row.close,
|
||||||
|
row.pre_close,
|
||||||
|
row.change,
|
||||||
|
row.pct_chg,
|
||||||
|
row.vol,
|
||||||
|
row.amount,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _copy_daily_basic(cursor: Any, rows: Iterable[DailyBasic]) -> None:
|
||||||
|
copy_sql = (
|
||||||
|
"COPY market_daily_basic_stage "
|
||||||
|
"(ts_code, trade_date, close, turnover_rate, turnover_rate_f, "
|
||||||
|
"volume_ratio, pe, pe_ttm, pb, ps, ps_ttm, dv_ratio, dv_ttm, "
|
||||||
|
"total_share, float_share, free_share, total_mv, circ_mv) FROM STDIN"
|
||||||
|
)
|
||||||
|
with cursor.copy(copy_sql) as copy:
|
||||||
|
for row in rows:
|
||||||
|
copy.write_row(
|
||||||
|
(
|
||||||
|
row.ts_code,
|
||||||
|
row.trade_date,
|
||||||
|
row.close,
|
||||||
|
row.turnover_rate,
|
||||||
|
row.turnover_rate_f,
|
||||||
|
row.volume_ratio,
|
||||||
|
row.pe,
|
||||||
|
row.pe_ttm,
|
||||||
|
row.pb,
|
||||||
|
row.ps,
|
||||||
|
row.ps_ttm,
|
||||||
|
row.dv_ratio,
|
||||||
|
row.dv_ttm,
|
||||||
|
row.total_share,
|
||||||
|
row.float_share,
|
||||||
|
row.free_share,
|
||||||
|
row.total_mv,
|
||||||
|
row.circ_mv,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _upsert_bars_from_stage(cursor: Any) -> tuple[int, int]:
|
||||||
|
rows = cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO market_daily_bar
|
||||||
|
(
|
||||||
|
ts_code, trade_date, open, high, low, close, pre_close,
|
||||||
|
change, pct_chg, vol, amount, source_adj, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
ts_code, trade_date, open, high, low, close, pre_close,
|
||||||
|
change, pct_chg, vol, amount, 'qfq', now()
|
||||||
|
FROM market_daily_bar_stage
|
||||||
|
ON CONFLICT (ts_code, trade_date) DO UPDATE SET
|
||||||
|
open = EXCLUDED.open, high = EXCLUDED.high, low = EXCLUDED.low,
|
||||||
|
close = EXCLUDED.close, pre_close = EXCLUDED.pre_close,
|
||||||
|
change = EXCLUDED.change, pct_chg = EXCLUDED.pct_chg,
|
||||||
|
vol = EXCLUDED.vol, amount = EXCLUDED.amount, source_adj = 'qfq', updated_at = now()
|
||||||
|
WHERE (
|
||||||
|
market_daily_bar.open, market_daily_bar.high, market_daily_bar.low,
|
||||||
|
market_daily_bar.close, market_daily_bar.pre_close,
|
||||||
|
market_daily_bar.change, market_daily_bar.pct_chg,
|
||||||
|
market_daily_bar.vol, market_daily_bar.amount
|
||||||
|
) IS DISTINCT FROM (
|
||||||
|
EXCLUDED.open, EXCLUDED.high, EXCLUDED.low, EXCLUDED.close,
|
||||||
|
EXCLUDED.pre_close, EXCLUDED.change, EXCLUDED.pct_chg,
|
||||||
|
EXCLUDED.vol, EXCLUDED.amount
|
||||||
|
)
|
||||||
|
RETURNING (xmax = 0) AS inserted
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
inserted = sum(1 for row in rows if bool(row[0]))
|
||||||
|
return inserted, len(rows) - inserted
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _upsert_daily_basic_from_stage(cursor: Any) -> tuple[int, int]:
|
||||||
|
fields = (
|
||||||
|
"close",
|
||||||
|
"turnover_rate",
|
||||||
|
"turnover_rate_f",
|
||||||
|
"volume_ratio",
|
||||||
|
"pe",
|
||||||
|
"pe_ttm",
|
||||||
|
"pb",
|
||||||
|
"ps",
|
||||||
|
"ps_ttm",
|
||||||
|
"dv_ratio",
|
||||||
|
"dv_ttm",
|
||||||
|
"total_share",
|
||||||
|
"float_share",
|
||||||
|
"free_share",
|
||||||
|
"total_mv",
|
||||||
|
"circ_mv",
|
||||||
|
)
|
||||||
|
assignments = ", ".join(f"{field} = EXCLUDED.{field}" for field in fields)
|
||||||
|
comparison = ", ".join(f"market_daily_basic.{field}" for field in fields)
|
||||||
|
excluded = ", ".join(f"EXCLUDED.{field}" for field in fields)
|
||||||
|
query = f"""
|
||||||
|
INSERT INTO market_daily_basic (ts_code, trade_date, {", ".join(fields)}, updated_at)
|
||||||
|
SELECT ts_code, trade_date, {", ".join(fields)}, now() FROM market_daily_basic_stage
|
||||||
|
ON CONFLICT (ts_code, trade_date) DO UPDATE SET {assignments}, updated_at = now()
|
||||||
|
WHERE ({comparison}) IS DISTINCT FROM ({excluded})
|
||||||
|
RETURNING (xmax = 0) AS inserted
|
||||||
|
"""
|
||||||
|
rows = cursor.execute(query).fetchall()
|
||||||
|
inserted = sum(1 for row in rows if bool(row[0]))
|
||||||
|
return inserted, len(rows) - inserted
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _count_stage_matches(cursor: Any, table: str) -> int:
|
||||||
|
stage = (
|
||||||
|
"market_daily_bar_stage" if table == "market_daily_bar" else "market_daily_basic_stage"
|
||||||
|
)
|
||||||
|
return int(
|
||||||
|
cursor.execute(
|
||||||
|
f"SELECT count(*) FROM {stage} AS stage JOIN {table} AS target "
|
||||||
|
"USING (ts_code, trade_date)"
|
||||||
|
).fetchone()[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_error(message: str | None) -> str | None:
|
||||||
|
if message is None:
|
||||||
|
return None
|
||||||
|
return " ".join(message.split())[:500]
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""SQLAlchemy metadata for the market-data migration contract."""
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
Integer,
|
||||||
|
MetaData,
|
||||||
|
Numeric,
|
||||||
|
PrimaryKeyConstraint,
|
||||||
|
String,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = MetaData()
|
||||||
|
|
||||||
|
market_stock = Table(
|
||||||
|
"market_stock",
|
||||||
|
metadata,
|
||||||
|
Column("ts_code", String(12), primary_key=True),
|
||||||
|
Column("name", String(128), nullable=False),
|
||||||
|
Column("market", String(32), nullable=False),
|
||||||
|
Column("exchange", String(16), nullable=False),
|
||||||
|
Column("list_status", String(2), nullable=False),
|
||||||
|
Column("list_date", Date),
|
||||||
|
Column("is_active", Boolean, nullable=False, server_default="true"),
|
||||||
|
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
market_daily_bar = Table(
|
||||||
|
"market_daily_bar",
|
||||||
|
metadata,
|
||||||
|
Column("ts_code", String(12), nullable=False),
|
||||||
|
Column("trade_date", Date, nullable=False),
|
||||||
|
Column("open", Numeric(20, 6)),
|
||||||
|
Column("high", Numeric(20, 6)),
|
||||||
|
Column("low", Numeric(20, 6)),
|
||||||
|
Column("close", Numeric(20, 6)),
|
||||||
|
Column("pre_close", Numeric(20, 6)),
|
||||||
|
Column("change", Numeric(20, 6)),
|
||||||
|
Column("pct_chg", Numeric(20, 6)),
|
||||||
|
Column("vol", Numeric(24, 6)),
|
||||||
|
Column("amount", Numeric(24, 6)),
|
||||||
|
Column("source_adj", String(8), nullable=False, server_default="qfq"),
|
||||||
|
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||||
|
PrimaryKeyConstraint("ts_code", "trade_date"),
|
||||||
|
)
|
||||||
|
|
||||||
|
market_daily_basic = Table(
|
||||||
|
"market_daily_basic",
|
||||||
|
metadata,
|
||||||
|
Column("ts_code", String(12), nullable=False),
|
||||||
|
Column("trade_date", Date, nullable=False),
|
||||||
|
*(
|
||||||
|
[
|
||||||
|
Column(field, Numeric(24, 6))
|
||||||
|
for field in (
|
||||||
|
"close",
|
||||||
|
"turnover_rate",
|
||||||
|
"turnover_rate_f",
|
||||||
|
"volume_ratio",
|
||||||
|
"pe",
|
||||||
|
"pe_ttm",
|
||||||
|
"pb",
|
||||||
|
"ps",
|
||||||
|
"ps_ttm",
|
||||||
|
"dv_ratio",
|
||||||
|
"dv_ttm",
|
||||||
|
"total_share",
|
||||||
|
"float_share",
|
||||||
|
"free_share",
|
||||||
|
"total_mv",
|
||||||
|
"circ_mv",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||||
|
PrimaryKeyConstraint("ts_code", "trade_date"),
|
||||||
|
)
|
||||||
|
|
||||||
|
market_sync_batch = Table(
|
||||||
|
"market_sync_batch",
|
||||||
|
metadata,
|
||||||
|
Column("id", String(36), primary_key=True),
|
||||||
|
Column("target_trade_date", Date, nullable=False),
|
||||||
|
Column("window_start", Date, nullable=False),
|
||||||
|
Column("mode", String(16), nullable=False),
|
||||||
|
Column("status", String(24), nullable=False),
|
||||||
|
Column("target_count", Integer, nullable=False),
|
||||||
|
Column("valid_count", Integer, nullable=False, server_default="0"),
|
||||||
|
Column("coverage", Numeric(8, 6), nullable=False, server_default="0"),
|
||||||
|
Column("strategy_eligible", Boolean, nullable=False, server_default="false"),
|
||||||
|
Column("parent_batch_id", String(36)),
|
||||||
|
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||||
|
Column("finished_at", DateTime(timezone=True)),
|
||||||
|
)
|
||||||
|
|
||||||
|
market_sync_item = Table(
|
||||||
|
"market_sync_item",
|
||||||
|
metadata,
|
||||||
|
Column("batch_id", String(36), nullable=False),
|
||||||
|
Column("item_kind", String(24), nullable=False),
|
||||||
|
Column("item_key", String(64), nullable=False),
|
||||||
|
Column("status", String(24), nullable=False),
|
||||||
|
Column("inserted_count", Integer, nullable=False, server_default="0"),
|
||||||
|
Column("updated_count", Integer, nullable=False, server_default="0"),
|
||||||
|
Column("unchanged_count", Integer, nullable=False, server_default="0"),
|
||||||
|
Column("fingerprint", String(64)),
|
||||||
|
Column("error_type", String(64)),
|
||||||
|
Column("error_message", Text),
|
||||||
|
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||||
|
PrimaryKeyConstraint("batch_id", "item_kind", "item_key"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""Tushare source adapter and current-universe filtering."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable, Iterable, Mapping
|
||||||
|
from datetime import date
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from ..domain.models import Bar, DailyBasic, Stock, SyncWindow, parse_date
|
||||||
|
from ..domain.rules import filter_current_hs_a_stocks
|
||||||
|
|
||||||
|
|
||||||
|
class TushareSourceError(RuntimeError):
|
||||||
|
"""A vendor request failed after the configured retry budget."""
|
||||||
|
|
||||||
|
|
||||||
|
class TushareAdapter:
|
||||||
|
"""Translate Tushare SDK responses into domain records.
|
||||||
|
|
||||||
|
The SDK is kept behind this adapter so ordinary domain/application tests
|
||||||
|
can inject a tiny fake client and never need a network token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client: object,
|
||||||
|
*,
|
||||||
|
pro_bar: Callable[..., object] | None = None,
|
||||||
|
max_retries: int = 3,
|
||||||
|
backoff_seconds: float = 1.0,
|
||||||
|
request_interval_seconds: float = 0.2,
|
||||||
|
random_fn: Callable[[], float] = random.random,
|
||||||
|
sleep_fn: Callable[[float], None] = time.sleep,
|
||||||
|
) -> None:
|
||||||
|
self.client = client
|
||||||
|
self.pro_bar = pro_bar
|
||||||
|
self.max_retries = max(0, max_retries)
|
||||||
|
self.backoff_seconds = max(0.0, backoff_seconds)
|
||||||
|
self.request_interval_seconds = max(0.0, request_interval_seconds)
|
||||||
|
self.random_fn = random_fn
|
||||||
|
self.sleep_fn = sleep_fn
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_token(
|
||||||
|
cls,
|
||||||
|
token: str,
|
||||||
|
*,
|
||||||
|
max_retries: int = 3,
|
||||||
|
backoff_seconds: float = 1.0,
|
||||||
|
request_interval_seconds: float = 0.2,
|
||||||
|
) -> TushareAdapter:
|
||||||
|
"""Create a production adapter from a token without exposing it."""
|
||||||
|
|
||||||
|
if not token.strip():
|
||||||
|
raise ValueError("ZHIXING_TUSHARE_TOKEN is required for market sync")
|
||||||
|
import tushare as ts # pyright: ignore[reportMissingTypeStubs]
|
||||||
|
|
||||||
|
pro_bar_function = cast(
|
||||||
|
Callable[..., object],
|
||||||
|
ts.pro_bar, # pyright: ignore[reportUnknownMemberType]
|
||||||
|
)
|
||||||
|
return cls(
|
||||||
|
cast(object, ts.pro_api(token)),
|
||||||
|
pro_bar=pro_bar_function,
|
||||||
|
max_retries=max_retries,
|
||||||
|
backoff_seconds=backoff_seconds,
|
||||||
|
request_interval_seconds=request_interval_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fetch_stocks(self) -> tuple[Stock, ...]:
|
||||||
|
"""Fetch current listings and apply the target-universe rules."""
|
||||||
|
|
||||||
|
rows = self._records(
|
||||||
|
"stock_basic",
|
||||||
|
exchange="",
|
||||||
|
list_status="L",
|
||||||
|
fields="ts_code,name,market,exchange,list_status,list_date",
|
||||||
|
)
|
||||||
|
return filter_current_hs_a_stocks(Stock.from_mapping(row) for row in rows)
|
||||||
|
|
||||||
|
def fetch_open_dates(self, start: date, end: date) -> tuple[date, ...]:
|
||||||
|
"""Fetch open trading dates in an inclusive range."""
|
||||||
|
|
||||||
|
rows = self._records(
|
||||||
|
"trade_cal",
|
||||||
|
exchange="",
|
||||||
|
start_date=start.strftime("%Y%m%d"),
|
||||||
|
end_date=end.strftime("%Y%m%d"),
|
||||||
|
is_open=1,
|
||||||
|
)
|
||||||
|
dates: list[date] = []
|
||||||
|
for row in rows:
|
||||||
|
is_open = row.get("is_open")
|
||||||
|
if str(is_open).strip() not in {"1", "True", "true"}:
|
||||||
|
continue
|
||||||
|
dates.append(parse_date(row.get("cal_date")))
|
||||||
|
return tuple(sorted(set(dates)))
|
||||||
|
|
||||||
|
def fetch_bars(self, ts_code: str, window: SyncWindow) -> tuple[Bar, ...]:
|
||||||
|
"""Fetch a complete six-year qfq snapshot for one stock."""
|
||||||
|
|
||||||
|
rows = self._records(
|
||||||
|
"pro_bar",
|
||||||
|
ts_code=ts_code,
|
||||||
|
adj="qfq",
|
||||||
|
start_date=window.start.strftime("%Y%m%d"),
|
||||||
|
end_date=window.end.strftime("%Y%m%d"),
|
||||||
|
freq="D",
|
||||||
|
)
|
||||||
|
bars = tuple(Bar.from_mapping(row) for row in rows)
|
||||||
|
if any(row.ts_code != ts_code for row in bars):
|
||||||
|
raise ValueError(f"Tushare returned a different stock for {ts_code}")
|
||||||
|
return tuple(
|
||||||
|
sorted(
|
||||||
|
(row for row in bars if window.contains(row.trade_date)),
|
||||||
|
key=lambda row: row.trade_date,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def fetch_daily_basic(self, trade_date: date) -> tuple[DailyBasic, ...]:
|
||||||
|
"""Fetch one full-market daily-basic snapshot by trading date."""
|
||||||
|
|
||||||
|
rows = self._records("daily_basic", trade_date=trade_date.strftime("%Y%m%d"))
|
||||||
|
metrics = tuple(DailyBasic.from_mapping(row) for row in rows)
|
||||||
|
if any(row.trade_date != trade_date for row in metrics):
|
||||||
|
raise ValueError(f"Tushare returned a different metric date for {trade_date}")
|
||||||
|
return tuple(sorted(metrics, key=lambda row: row.ts_code))
|
||||||
|
|
||||||
|
def _records(self, method_name: str, **kwargs: object) -> tuple[Mapping[str, object], ...]:
|
||||||
|
"""Call an SDK method with bounded retry and normalize its tabular output."""
|
||||||
|
|
||||||
|
def request() -> object:
|
||||||
|
if method_name == "pro_bar":
|
||||||
|
if self.pro_bar is not None:
|
||||||
|
return self.pro_bar(**kwargs)
|
||||||
|
method = getattr(self.client, method_name, None)
|
||||||
|
else:
|
||||||
|
method = getattr(self.client, method_name, None)
|
||||||
|
if not callable(method):
|
||||||
|
raise TypeError(f"Tushare client has no callable {method_name}")
|
||||||
|
return method(**kwargs)
|
||||||
|
|
||||||
|
last_error: BaseException | None = None
|
||||||
|
for attempt in range(self.max_retries + 1):
|
||||||
|
try:
|
||||||
|
result = request()
|
||||||
|
self.sleep_fn(self.request_interval_seconds)
|
||||||
|
return self._as_records(result)
|
||||||
|
except (OSError, RuntimeError, TimeoutError) as exc:
|
||||||
|
last_error = exc
|
||||||
|
if attempt == self.max_retries:
|
||||||
|
break
|
||||||
|
delay = self.backoff_seconds * (2**attempt) * (0.5 + self.random_fn())
|
||||||
|
self.sleep_fn(delay)
|
||||||
|
raise TushareSourceError(f"Tushare request failed: {method_name}") from last_error
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_records(result: object) -> tuple[Mapping[str, object], ...]:
|
||||||
|
if result is None:
|
||||||
|
return ()
|
||||||
|
to_dict = getattr(result, "to_dict", None)
|
||||||
|
if callable(to_dict):
|
||||||
|
result = to_dict("records")
|
||||||
|
if isinstance(result, Mapping):
|
||||||
|
return (cast(Mapping[str, object], result),)
|
||||||
|
if isinstance(result, Iterable) and not isinstance(result, (str, bytes)):
|
||||||
|
records: list[Mapping[str, object]] = []
|
||||||
|
for row in cast(Iterable[object], result):
|
||||||
|
if not isinstance(row, Mapping):
|
||||||
|
raise TypeError("Tushare rows must be mappings")
|
||||||
|
records.append(cast(Mapping[str, object], row))
|
||||||
|
return tuple(records)
|
||||||
|
raise TypeError("unsupported Tushare tabular response")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""CLI delivery adapter for the market-data use case."""
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""One-shot ``market-data-sync`` command used by Compose and cron."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from ....bootstrap.config import get_settings
|
||||||
|
from ..application.sync import SyncMarketData, SyncMarketDataCommand
|
||||||
|
from ..infrastructure.csv_snapshot import CsvSnapshotStore
|
||||||
|
from ..infrastructure.postgres import PostgresMarketDataRepository
|
||||||
|
from ..infrastructure.tushare import TushareAdapter
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
"""Build the explicit, repeatable synchronization CLI."""
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Synchronize Tushare qfq market data into PostgreSQL"
|
||||||
|
)
|
||||||
|
mode = parser.add_mutually_exclusive_group()
|
||||||
|
mode.add_argument(
|
||||||
|
"--initialize",
|
||||||
|
action="store_true",
|
||||||
|
help="backfill daily-basic for the six-year window",
|
||||||
|
)
|
||||||
|
mode.add_argument("--retry-batch-id", help="retry only failed items from a prior batch")
|
||||||
|
parser.add_argument(
|
||||||
|
"--trade-date",
|
||||||
|
type=_parse_date,
|
||||||
|
help="target open date in YYYY-MM-DD format",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
"""Execute one synchronization and print its redacted JSON summary."""
|
||||||
|
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
settings = get_settings()
|
||||||
|
if args.retry_batch_id:
|
||||||
|
command = SyncMarketDataCommand(
|
||||||
|
mode="retry",
|
||||||
|
target_trade_date=args.trade_date,
|
||||||
|
parent_batch_id=args.retry_batch_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
command = SyncMarketDataCommand(
|
||||||
|
mode="initialize" if args.initialize else "daily",
|
||||||
|
target_trade_date=args.trade_date,
|
||||||
|
)
|
||||||
|
source = TushareAdapter.from_token(
|
||||||
|
settings.tushare_token,
|
||||||
|
max_retries=settings.market_data_max_retries,
|
||||||
|
backoff_seconds=settings.market_data_retry_backoff_seconds,
|
||||||
|
request_interval_seconds=settings.market_data_request_interval_seconds,
|
||||||
|
)
|
||||||
|
use_case = SyncMarketData(
|
||||||
|
source,
|
||||||
|
CsvSnapshotStore(settings.market_data_csv_root),
|
||||||
|
PostgresMarketDataRepository(settings.database_url),
|
||||||
|
coverage_threshold=settings.market_data_coverage_threshold,
|
||||||
|
lock_key=settings.market_data_advisory_lock_key,
|
||||||
|
)
|
||||||
|
summary = use_case.execute(command)
|
||||||
|
print(json.dumps(summary.as_dict(), ensure_ascii=False, sort_keys=True))
|
||||||
|
return summary.exit_code
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(value: str) -> date:
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise argparse.ArgumentTypeError("trade date must use YYYY-MM-DD") from exc
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from sqlalchemy import Engine, create_engine, inspect
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_postgres_migration_creates_market_data_contract() -> None:
|
||||||
|
database_url = os.getenv("ZHIXING_TEST_DATABASE_URL")
|
||||||
|
if not database_url:
|
||||||
|
pytest.skip("set ZHIXING_TEST_DATABASE_URL to run PostgreSQL integration tests")
|
||||||
|
|
||||||
|
server_root = Path(__file__).parents[2]
|
||||||
|
config = Config(str(server_root / "alembic.ini"))
|
||||||
|
config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%"))
|
||||||
|
engine: Engine = create_engine(database_url)
|
||||||
|
command.upgrade(config, "head")
|
||||||
|
try:
|
||||||
|
tables = set(inspect(engine).get_table_names())
|
||||||
|
assert {
|
||||||
|
"market_stock",
|
||||||
|
"market_daily_bar",
|
||||||
|
"market_daily_basic",
|
||||||
|
"market_sync_batch",
|
||||||
|
"market_sync_item",
|
||||||
|
} <= tables
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from zhixing_server.modules.market_data.domain.models import Bar, DailyBasic
|
||||||
|
from zhixing_server.modules.market_data.infrastructure.csv_snapshot import CsvSnapshotStore
|
||||||
|
|
||||||
|
|
||||||
|
def make_bar(trade_date: date, close: str = "10") -> Bar:
|
||||||
|
return Bar(
|
||||||
|
ts_code="000001.SZ",
|
||||||
|
trade_date=trade_date,
|
||||||
|
close=Decimal(close),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_basic(trade_date: date) -> DailyBasic:
|
||||||
|
return DailyBasic(
|
||||||
|
ts_code="000001.SZ",
|
||||||
|
trade_date=trade_date,
|
||||||
|
close=Decimal("10"),
|
||||||
|
total_mv=Decimal("100000"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bar_snapshot_is_atomic_and_failed_publish_preserves_old_file(tmp_path: Path) -> None:
|
||||||
|
store = CsvSnapshotStore(tmp_path)
|
||||||
|
original = (make_bar(date(2024, 1, 2)),)
|
||||||
|
staged = store.stage_bars("000001.SZ", original)
|
||||||
|
store.publish(staged)
|
||||||
|
|
||||||
|
replacement = store.stage_bars("000001.SZ", (make_bar(date(2024, 1, 2), "11"),))
|
||||||
|
store.discard(replacement)
|
||||||
|
|
||||||
|
assert store.read_bars("000001.SZ") == original
|
||||||
|
assert not replacement.temporary_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_basic_snapshot_rejects_duplicate_codes(tmp_path: Path) -> None:
|
||||||
|
store = CsvSnapshotStore(tmp_path)
|
||||||
|
target = date(2024, 1, 2)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="duplicate"):
|
||||||
|
store.stage_daily_basic(target, (make_basic(target), make_basic(target)))
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from zhixing_server.modules.market_data.domain.fingerprint import (
|
||||||
|
SnapshotChange,
|
||||||
|
compare_snapshots,
|
||||||
|
snapshot_fingerprint,
|
||||||
|
)
|
||||||
|
from zhixing_server.modules.market_data.domain.models import Bar, Stock, SyncWindow
|
||||||
|
from zhixing_server.modules.market_data.domain.rules import filter_current_hs_a_stocks
|
||||||
|
|
||||||
|
|
||||||
|
def make_bar(trade_date: date, close: str = "10") -> Bar:
|
||||||
|
return Bar(
|
||||||
|
ts_code="000001.SZ",
|
||||||
|
trade_date=trade_date,
|
||||||
|
open=Decimal("9"),
|
||||||
|
high=Decimal(close),
|
||||||
|
low=Decimal("8"),
|
||||||
|
close=Decimal(close),
|
||||||
|
pre_close=Decimal("9"),
|
||||||
|
change=Decimal("1"),
|
||||||
|
pct_chg=Decimal("11.11"),
|
||||||
|
vol=Decimal("100"),
|
||||||
|
amount=Decimal("1000"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_window_uses_inclusive_calendar_boundary() -> None:
|
||||||
|
window = SyncWindow.from_target(date(2024, 2, 29))
|
||||||
|
|
||||||
|
assert window.start == date(2018, 2, 28)
|
||||||
|
assert window.end == date(2024, 2, 29)
|
||||||
|
assert window.contains(date(2018, 2, 28))
|
||||||
|
|
||||||
|
|
||||||
|
def test_universe_keeps_current_non_st_hs_a_stocks() -> None:
|
||||||
|
stocks = (
|
||||||
|
Stock("000001.SZ", "平安银行", exchange="SZSE", list_status="L"),
|
||||||
|
Stock("600000.SH", "浦发银行", exchange="SSE", list_status="L"),
|
||||||
|
Stock("300001.SZ", "特锐德", exchange="SZSE", list_status="L"),
|
||||||
|
Stock("600001.SH", "*ST风险", exchange="SSE", list_status="L"),
|
||||||
|
Stock("830001.BJ", "北交所", exchange="BSE", list_status="L"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [stock.ts_code for stock in filter_current_hs_a_stocks(stocks)] == [
|
||||||
|
"000001.SZ",
|
||||||
|
"300001.SZ",
|
||||||
|
"600000.SH",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fingerprint_is_order_independent_and_excludes_runtime_metadata() -> None:
|
||||||
|
rows = (make_bar(date(2024, 1, 2)), make_bar(date(2024, 1, 3)))
|
||||||
|
|
||||||
|
assert snapshot_fingerprint(rows) == snapshot_fingerprint(tuple(reversed(rows)))
|
||||||
|
assert compare_snapshots(rows, rows).change is SnapshotChange.UNCHANGED
|
||||||
|
assert (
|
||||||
|
compare_snapshots(rows, (*rows, make_bar(date(2024, 1, 4)))).change
|
||||||
|
is SnapshotChange.NEW_DATES
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fingerprint_detects_repairs_missing_rows_and_earlier_rows() -> None:
|
||||||
|
old = (make_bar(date(2024, 1, 2)), make_bar(date(2024, 1, 3)))
|
||||||
|
|
||||||
|
changed = (make_bar(date(2024, 1, 2), close="11"), make_bar(date(2024, 1, 3)))
|
||||||
|
missing = (make_bar(date(2024, 1, 2)),)
|
||||||
|
earlier = (make_bar(date(2024, 1, 1)), *old)
|
||||||
|
|
||||||
|
assert compare_snapshots(old, changed).change is SnapshotChange.CHANGED
|
||||||
|
assert compare_snapshots(old, missing).change is SnapshotChange.CHANGED
|
||||||
|
assert compare_snapshots(old, earlier).change is SnapshotChange.CHANGED
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
from collections.abc import Generator, Iterable, Sequence
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from zhixing_server.modules.market_data.application.sync import (
|
||||||
|
SyncMarketData,
|
||||||
|
SyncMarketDataCommand,
|
||||||
|
)
|
||||||
|
from zhixing_server.modules.market_data.domain.models import Bar, DailyBasic, Stock, SyncWindow
|
||||||
|
from zhixing_server.modules.market_data.domain.ports import WriteResult
|
||||||
|
from zhixing_server.modules.market_data.infrastructure.csv_snapshot import CsvSnapshotStore
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSource:
|
||||||
|
def __init__(self, target: date) -> None:
|
||||||
|
self.target = target
|
||||||
|
self.stock = Stock("000001.SZ", "平安银行", exchange="SZSE", list_status="L")
|
||||||
|
|
||||||
|
def fetch_stocks(self) -> Sequence[Stock]:
|
||||||
|
return (self.stock,)
|
||||||
|
|
||||||
|
def fetch_open_dates(self, start: date, end: date) -> Sequence[date]:
|
||||||
|
return (self.target,) if start <= self.target <= end else ()
|
||||||
|
|
||||||
|
def fetch_bars(self, ts_code: str, window: SyncWindow) -> Sequence[Bar]:
|
||||||
|
return (Bar(ts_code, self.target, close=Decimal("10")),)
|
||||||
|
|
||||||
|
def fetch_daily_basic(self, trade_date: date) -> Sequence[DailyBasic]:
|
||||||
|
return (DailyBasic("000001.SZ", trade_date, close=Decimal("10")),)
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryRepository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.bars: dict[tuple[str, date], Bar] = {}
|
||||||
|
self.daily_basic: dict[tuple[str, date], DailyBasic] = {}
|
||||||
|
self.batch_status: str | None = None
|
||||||
|
self.batch_counter = 0
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def advisory_lock(self, key: int) -> Generator[bool, None, None]:
|
||||||
|
yield True
|
||||||
|
|
||||||
|
def upsert_stocks(self, rows: Iterable[Stock]) -> WriteResult:
|
||||||
|
records = tuple(rows)
|
||||||
|
return WriteResult(inserted=len(records))
|
||||||
|
|
||||||
|
def upsert_bars(
|
||||||
|
self,
|
||||||
|
rows: Iterable[Bar],
|
||||||
|
window: SyncWindow,
|
||||||
|
*,
|
||||||
|
full_snapshot: bool,
|
||||||
|
) -> WriteResult:
|
||||||
|
records = tuple(rows)
|
||||||
|
inserted = 0
|
||||||
|
updated = 0
|
||||||
|
unchanged = 0
|
||||||
|
code = records[0].ts_code
|
||||||
|
if full_snapshot:
|
||||||
|
for key in tuple(self.bars):
|
||||||
|
if (
|
||||||
|
key[0] == code
|
||||||
|
and window.contains(key[1])
|
||||||
|
and key[1] not in {row.trade_date for row in records}
|
||||||
|
):
|
||||||
|
del self.bars[key]
|
||||||
|
for row in records:
|
||||||
|
key = (row.ts_code, row.trade_date)
|
||||||
|
previous = self.bars.get(key)
|
||||||
|
if previous is None:
|
||||||
|
inserted += 1
|
||||||
|
elif previous == row:
|
||||||
|
unchanged += 1
|
||||||
|
else:
|
||||||
|
updated += 1
|
||||||
|
self.bars[key] = row
|
||||||
|
return WriteResult(inserted=inserted, updated=updated, unchanged=unchanged)
|
||||||
|
|
||||||
|
def upsert_daily_basic(self, rows: Iterable[DailyBasic], window: SyncWindow) -> WriteResult:
|
||||||
|
inserted = 0
|
||||||
|
updated = 0
|
||||||
|
unchanged = 0
|
||||||
|
for row in rows:
|
||||||
|
key = (row.ts_code, row.trade_date)
|
||||||
|
previous = self.daily_basic.get(key)
|
||||||
|
if previous is None:
|
||||||
|
inserted += 1
|
||||||
|
elif previous == row:
|
||||||
|
unchanged += 1
|
||||||
|
else:
|
||||||
|
updated += 1
|
||||||
|
self.daily_basic[key] = row
|
||||||
|
return WriteResult(inserted=inserted, updated=updated, unchanged=unchanged)
|
||||||
|
|
||||||
|
def purge_before(self, window: SyncWindow) -> None:
|
||||||
|
self.bars = {key: row for key, row in self.bars.items() if key[1] >= window.start}
|
||||||
|
self.daily_basic = {
|
||||||
|
key: row for key, row in self.daily_basic.items() if key[1] >= window.start
|
||||||
|
}
|
||||||
|
|
||||||
|
def has_bar(self, ts_code: str, trade_date: date) -> bool:
|
||||||
|
return (ts_code, trade_date) in self.bars
|
||||||
|
|
||||||
|
def has_daily_basic(self, ts_code: str, trade_date: date) -> bool:
|
||||||
|
return (ts_code, trade_date) in self.daily_basic
|
||||||
|
|
||||||
|
def create_batch(
|
||||||
|
self,
|
||||||
|
target_trade_date: date,
|
||||||
|
window: SyncWindow,
|
||||||
|
mode: str,
|
||||||
|
parent_batch_id: str | None,
|
||||||
|
target_count: int,
|
||||||
|
) -> str:
|
||||||
|
self.batch_counter += 1
|
||||||
|
return f"batch-{self.batch_counter}"
|
||||||
|
|
||||||
|
def record_batch(
|
||||||
|
self,
|
||||||
|
batch_id: str,
|
||||||
|
status: str,
|
||||||
|
valid_count: int,
|
||||||
|
coverage: Decimal,
|
||||||
|
strategy_eligible: bool,
|
||||||
|
) -> None:
|
||||||
|
self.batch_status = status
|
||||||
|
|
||||||
|
def record_item(
|
||||||
|
self,
|
||||||
|
batch_id: str,
|
||||||
|
item_kind: str,
|
||||||
|
item_key: str,
|
||||||
|
status: str,
|
||||||
|
result: WriteResult,
|
||||||
|
fingerprint: str | None = None,
|
||||||
|
error_type: str | None = None,
|
||||||
|
error_message: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def failed_items(self, parent_batch_id: str) -> tuple[tuple[str, str], ...]:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_is_idempotent_and_reports_coverage(tmp_path: Path) -> None:
|
||||||
|
target = date(2024, 1, 2)
|
||||||
|
repository = InMemoryRepository()
|
||||||
|
use_case = SyncMarketData(
|
||||||
|
FakeSource(target),
|
||||||
|
CsvSnapshotStore(tmp_path),
|
||||||
|
repository,
|
||||||
|
today=target,
|
||||||
|
)
|
||||||
|
|
||||||
|
first = use_case.execute(SyncMarketDataCommand(target_trade_date=target))
|
||||||
|
second = use_case.execute(SyncMarketDataCommand(target_trade_date=target))
|
||||||
|
|
||||||
|
assert first.status == "success"
|
||||||
|
assert first.coverage == Decimal("1")
|
||||||
|
assert first.strategy_eligible
|
||||||
|
assert second.status == "success"
|
||||||
|
assert second.inserted_count == 1
|
||||||
|
assert second.unchanged_count >= 2
|
||||||
Generated
+377
@@ -1,6 +1,25 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 2
|
revision = 2
|
||||||
requires-python = "==3.12.*"
|
requires-python = "==3.12.*"
|
||||||
|
resolution-markers = [
|
||||||
|
"sys_platform == 'win32'",
|
||||||
|
"sys_platform == 'emscripten'",
|
||||||
|
"sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alembic"
|
||||||
|
version = "1.19.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mako" },
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fb/01/a48dab7827ac4421272399f7ed9a2ec17edd12c8bcde4417bd7b6821b71a/alembic-1.19.0.tar.gz", hash = "sha256:6487c612fc719dcfa22b17d2dd5b2b458929641e6aa2f0b65b135727f5e6d501", size = 2069906, upload-time = "2026-08-04T18:57:04.599Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/79/59ab6f1fe72eee229de91aa393225389a85df12a0fbbbfa264efcf6d7872/alembic-1.19.0-py3-none-any.whl", hash = "sha256:cf839d3849116aab3cc047e09c6968b9bd6b2fde61b6bb7c1e97352fe5503580", size = 265738, upload-time = "2026-08-04T18:57:06.219Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "annotated-doc"
|
name = "annotated-doc"
|
||||||
@@ -33,6 +52,62 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "beautifulsoup4"
|
||||||
|
version = "4.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "soupsieve" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bs4"
|
||||||
|
version = "0.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "beautifulsoup4" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/aa/4acaf814ff901145da37332e05bb510452ebed97bc9602695059dd46ef39/bs4-0.0.2.tar.gz", hash = "sha256:a48685c58f50fe127722417bae83fe6badf500d54b55f7e39ffe43b798653925", size = 698, upload-time = "2024-01-17T18:15:47.371Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/bb/bf7aab772a159614954d84aa832c129624ba6c32faa559dfb200a534e50b/bs4-0.0.2-py2.py3-none-any.whl", hash = "sha256:abf8742c0805ef7f662dce4b51cca104cffe52b835238afc169142ab9b3fbccc", size = 1189, upload-time = "2024-01-17T18:15:48.613Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.7.22"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
version = "3.4.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.4.2"
|
version = "8.4.2"
|
||||||
@@ -94,6 +169,24 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "greenlet"
|
||||||
|
version = "3.5.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/04/81bd731d6d1e3a469d9a4c36f5eb069bcf0cbb2d5d342c9fec22245b91fc/greenlet-3.5.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4", size = 295909, upload-time = "2026-07-22T11:38:09.261Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/dd/f5f22903a6ae70f5ea328ed0beaec92ad903f0e3b7d2845133b354abc4b8/greenlet-3.5.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17", size = 612011, upload-time = "2026-07-22T12:26:40.69Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/10/92a4a88d12b915d74ea5b6d288e4afefda4771647caa34442c156f7a454f/greenlet-3.5.4-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a", size = 624299, upload-time = "2026-07-22T12:29:02.089Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/f9/03e26be3487c5238e81f2b84714959a86ea8515a869828cf41f4fc54b34e/greenlet-3.5.4-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b7c895310363f310361e0fe2072af85269d2a2a285cd04c0c59e79a5e3670dcf", size = 629603, upload-time = "2026-07-22T12:43:43.456Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/6d/0b14bb9db2989f32cd9fe7f76afedea01ee8bee3f87c07e69f24adfe7e63/greenlet-3.5.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f", size = 621541, upload-time = "2026-07-22T11:51:09.464Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/6b/7c55ca72ef80d57c16c4a55210f82582622462dc4485799a30f4ec6f3372/greenlet-3.5.4-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:13b980043cb1b3134e81ea469da1250ddcc6bfe6d245bbaa59168d9cdc8f228f", size = 432554, upload-time = "2026-07-22T12:39:51.379Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/3d/25e9a2d9eb6b2e8b7ca4e80a3a26cb887cce6c8e0a87c921164f11bc5574/greenlet-3.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d", size = 1581444, upload-time = "2026-07-22T12:25:03.818Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/96/4c9bf2e2c408dcc0556edce69efa9f802e82223573c53240136a086821f1/greenlet-3.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9", size = 1645842, upload-time = "2026-07-22T11:51:12.295Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/41/303ecb26a3a56122c0f4d4073ee078881847bd6b6f463ae0ec57ec20223b/greenlet-3.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3", size = 247169, upload-time = "2026-07-22T11:38:19.893Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/e3/ef56864b4c35fcb3eb3b41b869f6cc46f4cd3f5e2c68e74acde8ac433951/greenlet-3.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:77d6ce04fed0d9aeed42e0f37923cc43eba9b027bdd9c34546bb4ccd143d0fe0", size = 245565, upload-time = "2026-07-22T11:38:27.061Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h11"
|
name = "h11"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
@@ -165,6 +258,63 @@ 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" },
|
{ 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 = "lxml"
|
||||||
|
version = "6.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mako"
|
||||||
|
version = "1.4.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165, upload-time = "2026-08-05T06:10:56.611Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010, upload-time = "2026-08-05T06:10:58.248Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markupsafe"
|
||||||
|
version = "3.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nodeenv"
|
name = "nodeenv"
|
||||||
version = "1.10.0"
|
version = "1.10.0"
|
||||||
@@ -174,6 +324,25 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
|
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "numpy"
|
||||||
|
version = "2.5.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "26.2"
|
version = "26.2"
|
||||||
@@ -183,6 +352,28 @@ 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" },
|
{ 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.5"
|
||||||
|
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/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/54/1dc810ea558d1320b597aa140a514f2fdf1d2ea09c38cf556f13ea712ec9/pandas-3.0.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fa290c16964d4963fbfbc358928239cf3bd755b20e988ce944877def2f44471d", size = 10411717, upload-time = "2026-07-22T22:18:08.307Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/56/fbe81c09195924d8b7b8d4461a20458fe80a6a5ed6b24f0314da684277e1/pandas-3.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2e26bb46934b8a2ca0c3de1d3d606fc5f6746584791b2db264d58cf370e08dc", size = 9957095, upload-time = "2026-07-22T22:18:10.6Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/51/fac252f4a913ed5eabf3c11b880a9e8d5a6c10f0b2129d0462212d238b4d/pandas-3.0.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73fa87b08a7ef706f8aafda39ddaccf2a99047bea62d8c88a0361bcafb2237bc", size = 10485458, upload-time = "2026-07-22T22:18:12.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/98/e976540c1addf70442be7842a18cf70884a964abbf69442504f4d2939989/pandas-3.0.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d373ce03ffd84010ed9839fa73672a9c8256990532e158440c0085db7d914b34", size = 10998091, upload-time = "2026-07-22T22:18:15.209Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/8c/1f29b5be8d3fc47dd7567eb167fabba2085879b31e0287ce7cba6d3d2ff4/pandas-3.0.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a29c53d85ea98c5e792c59ef82ee9fbe6ca902c0d0adb6b23f45ef894cd7bf6", size = 11499501, upload-time = "2026-07-22T22:18:17.689Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/e2/bd9c98ad2df7b38bde002adde4cdf353519da51881634323b126c55997f9/pandas-3.0.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a5ad3b02ed6bc7d7ae9b70804b2c6aa31827489d150f8e623ce82491b82085d7", size = 12060559, upload-time = "2026-07-22T22:18:20.147Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/9a/ffbd852d58bd74a617fe2f8ee6a58a96982271ce41cf981eab22190b4a4b/pandas-3.0.5-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:b2acb4650527eec6822c3dadb2b771277b65e7dae7a267d4bccf65fd1bb3fbce", size = 7197652, upload-time = "2026-07-22T22:18:22.502Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/b5/d2d3e9ae73362ba4229651b0ee1455cf78073a1ce585f6ff693782ce263e/pandas-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:80a611068e8a3ac23f7398c6c14eb46dc974e5cc9997f653e2dcfd1da74edd41", size = 9831691, upload-time = "2026-07-22T22:18:24.534Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/51/dea1e89d6a6796b9c43f85a09b484ee03edb8a4c4842e73e200a8c11301c/pandas-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49", size = 9105796, upload-time = "2026-07-22T22:18:27.064Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
@@ -192,6 +383,42 @@ 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" },
|
{ 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 = "psycopg"
|
||||||
|
version = "3.3.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
binary = [
|
||||||
|
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psycopg-binary"
|
||||||
|
version = "3.3.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.13.4"
|
version = "2.13.4"
|
||||||
@@ -303,6 +530,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -330,6 +569,21 @@ wheels = [
|
|||||||
{ 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/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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.16.1"
|
version = "0.16.1"
|
||||||
@@ -355,6 +609,64 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simplejson"
|
||||||
|
version = "4.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0e/2a/54837395a3487c725669428d513293612a48d82b95a0642c936932e5d898/simplejson-4.1.1.tar.gz", hash = "sha256:c08eb9f7a90f77ae470e19a07472e9a79ebc0d1c2315d86a72767665bd5ba79f", size = 118860, upload-time = "2026-04-24T19:24:59.819Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/25/e90998fe8e480eb43b966c09e835379887d427567ebd496563d3b1e16b19/simplejson-4.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:19040a17154dc03d289bab68d73ce0a6a0be01de30c584bbdd93490bead14b22", size = 112414, upload-time = "2026-04-24T19:23:06.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/a0/abd4785f36c3400f1fbb21f517be39295a750a714f04b7ee175adf6ef580/simplejson-4.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a94ebaecdbaa80d9551a3ec6bf0c9302fc8b53ab6c1b2bfd498a1df4cb28158d", size = 91120, upload-time = "2026-04-24T19:23:07.877Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/78/fc060d2e3b13c6ec59288574b8efac64075e316b2afba4396a56b2422f78/simplejson-4.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:67341c95c0a168ab4a6d1e807e50463f1c8da932c3286d81e201266c427061fa", size = 91055, upload-time = "2026-04-24T19:23:09.264Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/b6/156a8de1e1b47694f0e7de6675866936608d45dc68388fd017d36f8693be/simplejson-4.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45ec18e337fec538b7e902d489505c450b2454653d1290f3f50385e6fd8aa607", size = 190297, upload-time = "2026-04-24T19:23:11.226Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/1c/e4d0eab695be3eb21d0f46bce820752031f03e7113f9c80a9b3c73ee7157/simplejson-4.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:820c69a4710400e9b248d5670647d60be58824369282d3925e516b3ff1a7cd82", size = 187002, upload-time = "2026-04-24T19:23:12.982Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/0e/7f5a59d29426b062d5928fb88b403c3f797129d53be7102f955dbe51aa44/simplejson-4.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e708d373a10e4378ef2d59f8361850c7150fd907ed49efe49bc5492160476d1", size = 195146, upload-time = "2026-04-24T19:23:14.517Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/18/9943db224dd4d5fa3c090c3e56a94c37b254338c83995ec5680285111c40/simplejson-4.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:980fc33353f81fd12d8c49d44f8c2760d1dc8192285e627c5180d141035b228a", size = 183931, upload-time = "2026-04-24T19:23:16.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/08/9a690da9a766161c06c627d805362cf159f1abe480969372b2897649b955/simplejson-4.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:de2ed102fff88dacf543699f53ee3a533cc11539a39baa176b7e09dd783069d6", size = 192228, upload-time = "2026-04-24T19:23:18.33Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/88/bd8aad36b451ffb0e0a3f721d695a88befa6d1ac7d1e02ae788ca7ff4029/simplejson-4.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2785ff8edc0e28bf773a32543a6bbed46351453c997b3f6709c744e3c2f7eabb", size = 187808, upload-time = "2026-04-24T19:23:21.165Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/ee/14f91db0d1f481533b651dafbf8cd0da088d9817f7af30c68f7f19f9c847/simplejson-4.1.1-cp312-cp312-win32.whl", hash = "sha256:2e0d5ead6d14610467ec356ec1f6b5d8a56aa216abaad8d41c8b873b16cf313f", size = 88512, upload-time = "2026-04-24T19:23:22.764Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/c4/90de06b2d8737c68c05ff9274113f854dbf6a5f28b7a955212111672cb57/simplejson-4.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:63a5451f557d6be48a231bae932458655c620902b868170b2f1c8afed496f6b4", size = 90748, upload-time = "2026-04-24T19:23:24.494Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/6a/8b74c52ffd33dbbde00fe7251fee6a0acdc8cea33f7a43805aed258fb79b/simplejson-4.1.1-py3-none-any.whl", hash = "sha256:2ce92b3748f02423e26d2bfb636fb9d7a8f67c8f5854dcae69d350d123b2eee2", size = 69195, upload-time = "2026-04-24T19:24:57.962Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "soupsieve"
|
||||||
|
version = "2.9.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d9/38/e12680bbe6b4f8f3d17adcaf38d26850aa756c85cf4a80e79fc12a018fe8/soupsieve-2.9.1.tar.gz", hash = "sha256:c33e6605bbc71dd628b00c632d58ae607c22bade247e52553928f83bbb75b4ba", size = 122261, upload-time = "2026-07-21T16:57:17.452Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/2c/437fe806897c2d6cfdc3ee43a18da8bf8e568530a4ae9bac781541ca9896/soupsieve-2.9.1-py3-none-any.whl", hash = "sha256:4f4477399246b7a0c720a88ca2454b11cd6bb9ae4c9d170140786e916776c14c", size = 37404, upload-time = "2026-07-21T16:57:16.421Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlalchemy"
|
||||||
|
version = "2.0.51"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "starlette"
|
name = "starlette"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
@@ -368,6 +680,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tqdm"
|
||||||
|
version = "4.70.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "truststore"
|
name = "truststore"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -377,6 +701,24 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tushare"
|
||||||
|
version = "1.4.29"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "bs4" },
|
||||||
|
{ name = "lxml" },
|
||||||
|
{ name = "pandas" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "simplejson" },
|
||||||
|
{ name = "tqdm" },
|
||||||
|
{ name = "websocket-client" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/91/a4/5ce99585209410463e4b203d13c73d356c6ac92daea48af182932af7cb61/tushare-1.4.29.tar.gz", hash = "sha256:f578fb778868c0b744ac9173a837b695fd82e8d1b1e0d39c1f882b0e4fef7ecb", size = 128623, upload-time = "2026-03-25T06:33:44.722Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/3e/d426a56e5feac9b0aaada1c6b0745ed03422d4a713295e0bbb44c8ea86fe/tushare-1.4.29-py3-none-any.whl", hash = "sha256:82554af953ea5ac3d8771d42330493181031c7e68dccce03a491c7356e9ba4b2", size = 142920, upload-time = "2026-03-25T06:33:43.111Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.16.0"
|
version = "4.16.0"
|
||||||
@@ -398,6 +740,24 @@ 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" },
|
{ 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.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
version = "0.52.1"
|
version = "0.52.1"
|
||||||
@@ -460,6 +820,15 @@ wheels = [
|
|||||||
{ 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/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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "websocket-client"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "websockets"
|
name = "websockets"
|
||||||
version = "17.0.1"
|
version = "17.0.1"
|
||||||
@@ -494,8 +863,12 @@ name = "zhixing-server"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "alembic" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
|
{ name = "psycopg", extra = ["binary"] },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
|
{ name = "tushare" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -510,8 +883,12 @@ dev = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "alembic", specifier = ">=1.18.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.141.1" },
|
{ name = "fastapi", specifier = ">=0.141.1" },
|
||||||
|
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.2" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.14.2" },
|
{ name = "pydantic-settings", specifier = ">=2.14.2" },
|
||||||
|
{ name = "sqlalchemy", specifier = ">=2.0.46" },
|
||||||
|
{ name = "tushare", specifier = ">=1.4.24" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.52.1" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.52.1" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user