Consolidate docs and harden trading state pipeline

This commit is contained in:
2026-05-03 22:51:41 -04:00
parent 39b9d046f0
commit 57c37d5b54
58 changed files with 2172 additions and 4432 deletions
+4
View File
@@ -20,5 +20,9 @@ venv/
.claude/
memory/
# Local tool state and transient logs
.hermes/
geckodriver.log
# Large artifacts and local docs copies
*.pdf
+7 -1
View File
@@ -143,7 +143,7 @@ test "$(docker inspect -f '{{.State.Health.Status}}' ai-ict)" = "healthy"
## Runtime utility scripts
- `python3 scripts/run_dev.py` - run the scaffold entrypoint
- `python3 scripts/run_dev.py` - start the local development API server and minimal dashboard
- `python3 scripts/show_models.py` - list exported ORM model names
- `python3 scripts/check_db.py` - run a simple database connectivity check
- `python3 scripts/run_migrations.py` - apply SQL files in `migrations/` order
@@ -200,6 +200,12 @@ Optional backtest cost environment variables used by `run_backtest.py` and `run_
- `BACKTEST_SLIPPAGE_RATE`
- `BACKTEST_FIXED_COST_R`
Optional replay-backed backtest mode variables used by `run_backtest.py` and `run_pipeline.py`:
- `BACKTEST_MODE` - supported values: `entry_then_outcome` or `replay_no_lookahead`
- `BACKTEST_REPLAY_LEFT_BARS`
- `BACKTEST_REPLAY_RIGHT_BARS`
Optional backtest automation gate used by `run_backtest.py` and `run_pipeline.py`:
- `BACKTEST_OUTPUT_JSON` - set to `1`, `true`, `yes`, or `on` for machine-readable `run_backtest.py` output
+2
View File
@@ -29,6 +29,8 @@ python3 scripts/run_dev.py
python3 scripts/run_pipeline.py
```
`scripts/run_dev.py` is the persistent local dev API/dashboard entrypoint; `scripts/run_pipeline.py` runs the closed loop once and exits.
## Test And Validation
```bash
+33 -14
View File
@@ -2,7 +2,7 @@
## Current Goal
Stabilize the working state of the repository and make the next development step explicit before continuing feature work.
Continue downstream consumer hardening after report/API structure-state propagation, with the next focus on dashboard visibility and invalidation-code alignment.
## Current Branch
@@ -10,33 +10,52 @@ Stabilize the working state of the repository and make the next development step
## Last Updated
2026-05-02 America/New_York
2026-05-03 UTC
## Completed In Last Session
- Added project-local Codex workflow files for multi-project continuation.
- Recorded the current repository state and standard continuation flow.
- Propagated normalized signal structure-state fields through the public `/signals` API surface, keeping raw `meta` while exposing stable top-level fields such as `structure_state`, `mss_status_reason`, and `fvg_status_reason`.
- Enriched `IntradayReportService` so reports now surface MSS/FVG `state` and `status_reason` instead of relying only on `accepted` and coarse FVG status checks.
- Updated intraday invalidation logic to emit richer downstream reasons like `mss_invalidated`, `mss_rejected`, `fvg_mitigated`, and `fvg_invalidated`, while carrying FVG data-quality warnings through the report output.
- Enriched `PostTradeReviewService` with a `signal_context` section that exposes `structure_state`, MSS/FVG reasons, and FVG data-quality warnings for downstream review consumers.
- Added focused report/API unit coverage for signal-row normalization and richer report-state propagation.
- Revalidated the repository with `.venv/bin/python3 -m unittest discover -s tests/unit -p "test_*.py"`, `.venv/bin/python3 scripts/run_offline_checks.py`, and `.venv/bin/python3 scripts/check_local_demo_readiness.py`.
## Current Working State
- `git status --short` showed: modified `.gitignore`
- `git status --short` showed: untracked `.hermes/`
- `git status --short` showed: untracked `geckodriver.log`
- `git status --short` showed: untracked `uv.lock`
- `git status --short --branch` shows `## main...origin/main`; the worktree is still dirty from earlier doc/backtest/liquidity/dev-entrypoint sessions, and `uv.lock` remains untracked.
- MSS candidates now carry `meta.state` and `meta.status_reason`; accepted structures use `retest_held`, rejected structures use `retest_not_observed`, and invalidated structures use `old_control_reclaimed`.
- MSS persistence now records the latest structure state even when `accepted = false`, so downstream consumers no longer read stale accepted-only MSS rows.
- MSS detection still collapses duplicate same-direction displacement context at the same `end_ts` to one deterministic candidate, preferring the highest event id.
- Signal-side MSS/FVG fetches now include the latest downstream state regardless of actionability, with deterministic same-timestamp tie-breaks.
- `validate_signal_context` now returns explicit downstream structure outcomes plus richer MSS/FVG state reasons instead of a single coarse non-actionable result.
- Signal metadata now exposes `structure_state`, `mss_state`, `mss_status_reason`, `fvg_status_reason`, `fvg_touch_count`, and FVG `data_quality_warnings`.
- `/signals` API rows now normalize key signal context fields to top-level output keys, so API consumers no longer need to inspect nested `meta` for core structure-state reasons.
- Intraday reports now expose structure `state` / `status_reason`, FVG `status_reason`, and signal-side richer structure context directly in the report payload.
- Post-trade review reports now expose a `signal_context` block with richer structure-state and data-quality fields.
- FVG state metadata in the dirty worktree still includes `last_touch_ts`, `touch_count`, `status_reason`, and gap warnings.
- Replay-backed no-lookahead backtest mode remains present and validation is green.
- `scripts/run_dev.py` still starts the persistent local API/dashboard dev server using `AI_ICT_API_HOST` / `AI_ICT_API_PORT`.
- Earlier doc cleanup remains in the worktree: `docs/` root contains current requirement/engineering docs, historical material is under `docs/archive/`, and generic workflow helpers are under `docs/workflow/`.
- `.hermes/` and `geckodriver.log` are ignored local artifacts.
## Exact Next Step
Review the dirty worktree, decide what belongs in version control, then update `TASKS.md` with the single next implementation task.
Surface the normalized signal structure-state fields in dashboard table/filter UX and decide whether legacy `trade_signal.invalidations` strings should be normalized to the same machine-readable structure-state reason codes now used in signal/report/API metadata.
## Validation Commands
```bash
.venv/bin/python -m unittest discover -s tests/unit -p "test_*.py"
.venv/bin/python scripts/run_offline_checks.py
.venv/bin/python scripts/check_local_demo_readiness.py
.venv/bin/python3 -m unittest discover -s tests/unit -p "test_*.py"
.venv/bin/python3 scripts/run_offline_checks.py
.venv/bin/python3 scripts/check_local_demo_readiness.py
```
## Risks Or Open Questions
- The current product priority is not yet recorded in this repo-local workflow.
- The dirty worktree may contain intentional local artifacts, but that is not documented yet.
- `uv.lock` is intentionally left visible for version control, but it is still untracked until included in a future commit.
- Liquidity merge-dedupe prevents new duplicate inserts at the same collapsed level, but it does not retro-clean older duplicate rows already stored in a database.
- The dashboard still shows the new structure-state fields mainly through raw JSON detail rather than first-class table columns or filters.
- `trade_signal.invalidations` still uses legacy human-readable strings like `mss invalidated` / `fvg fully mitigated`, while newer signal/report/API fields now use machine-readable state codes; that contract mismatch is still unresolved.
- Signal-side FVG selection still prefers displacement context first; if newer non-contextual FVGs should override that preference, the selection rule may need further refinement against expected trading behavior.
- Training remains a maintenance-first module for now; upstream trading semantics should stabilize before adding larger Training features.
+10 -4
View File
@@ -2,12 +2,11 @@
## In Progress
- [ ] Reconcile the current dirty worktree and confirm the next product task before new feature work starts.
- [ ] Surface normalized signal structure-state fields in the dashboard UX and align legacy `trade_signal.invalidations` strings with the newer machine-readable structure-state reason contract.
## Next Up
- [ ] Decide whether `.gitignore`, `.hermes/`, `geckodriver.log`, and `uv.lock` should stay, be ignored, or be committed.
- [ ] After repo state is clarified, promote one concrete feature or bugfix into `In Progress`.
- [ ] Keep the Training M40 public contract green while upstream trading semantics are refined.
## Backlog
@@ -15,8 +14,15 @@
## Blocked
- [ ] Waiting for product priority clarification if no single next feature is agreed.
- [ ] No active blockers.
## Done
- [x] 2026-05-03 Propagated richer `structure_state` / MSS / FVG reason fields into report and API consumers, normalized `/signals` output, enriched report payloads, and revalidated with unit tests, offline checks, and local demo readiness.
- [x] 2026-05-03 Converged MSS `accepted`/`rejected`/`invalidated` semantics into explicit state metadata, persisted the latest MSS state for downstream consumers, exposed richer MSS/FVG reasons in signal metadata, and revalidated with unit tests, offline checks, and local demo readiness.
- [x] 2026-05-03 Fixed the `run_dev.py` exit bug by turning it into the persistent local API/dashboard dev entrypoint, updated docs, and revalidated with unit tests, offline checks, and local demo readiness.
- [x] 2026-05-03 Implemented liquidity equal highs/lows merge-dedupe idempotency in the persistence path, stabilized exact dedupe keys, and normalized duplicate downstream displacement context selection in MSS/FVG with focused tests.
- [x] 2026-05-03 Hardened the replay-backed no-lookahead backtest mode, aligned replay swing-count expectations with existing outside-bar swing semantics, and revalidated with unit discovery, offline checks, and local demo readiness.
- [x] 2026-05-03 Cleaned the docs root, deleted duplicate docs, archived obsolete/history docs, moved workflow helpers into `docs/workflow`, and wrote `docs/AI_ICT_当前开发方案_v2.md`.
- [x] 2026-05-03 Ignored local `.hermes/` state and `geckodriver.log`; kept `uv.lock` visible for version control.
- [x] 2026-05-02 Added Codex multi-project workflow files for independent continuation.
-872
View File
@@ -1,872 +0,0 @@
# AI ICT Codebase Skeleton Spec v1
## 1. 文档目标
本文档用于把 AI ICT 项目第一阶段的总体设计进一步落到“代码骨架”粒度,明确:
1. 目录结构
2. 模块职责
3. 配置文件分工
4. 服务边界
5. 运行入口
6. 第一轮实现顺序
目标不是一次把全部业务写完,而是先把后续开发要长期承载的工程骨架固定下来,确保数据库、规则引擎、信号、执行、回测和 AI 报告都能在同一套结构下持续推进。
---
## 2. 设计原则
代码骨架应遵循以下原则:
1. 先支撑基础数据与基础规则,再支撑信号与执行
2. 规则判定与 AI 解释分层,不混在一个模块里
3. 数据入库、规则计算、信号生成、执行管理职责分离
4. 所有核心输出都应能回写 PostgreSQL
5. 第一阶段优先单体代码仓库,模块清晰即可,不急于微服务拆分
因此,第一版更适合采用:
- 单仓库
- 单进程多入口
- 共享 `config` / `db` / `models` / `services` / `rules`
- 后续再按实际负载拆服务
---
## 3. 推荐目录结构
建议第一版代码仓库采用以下结构:
```text
ai-ict/
src/
app/
config/
db/
migrations/
seeds/
repositories/
domain/
market/
session/
structure/
signal/
execution/
backtest/
report/
services/
market-data/
rule-engine/
signal/
execution/
report/
backtest/
rules/
swings/
ranges/
liquidity/
sweep/
displacement/
structure/
fvg/
bias/
jobs/
api/
runtime/
utils/
types/
scripts/
tests/
unit/
integration/
fixtures/
docs/
.env.example
package.json
tsconfig.json
README.md
```
说明:
- `src/domain/` 放领域对象与核心数据结构,不放外部 API 细节
- `src/services/` 放跨模块编排逻辑
- `src/rules/` 放纯规则判定逻辑
- `src/db/` 放 migration、seed、repository 和数据库接入
- `src/runtime/` 放各类启动入口
- `src/jobs/` 放定时任务、重算任务、数据同步任务
- `src/api/` 放 HTTP 接口或内部管理接口
---
## 4. 目录职责拆分
## 4.1 `src/app/`
用于放应用装配层。
建议内容:
- 容器初始化
- 配置加载
- 日志初始化
- 数据库初始化
- 服务注册
- runtime 共享启动辅助
这个目录不负责业务规则本身,只负责把系统拼起来。
---
## 4.2 `src/config/`
用于集中管理配置。
建议拆分:
```text
src/config/
env.ts
database.ts
market.ts
sessions.ts
timeframes.ts
risk.ts
models.ts
```
职责建议:
- `env.ts`
- 读取环境变量
- 做最小边界校验
- `database.ts`
- PostgreSQL 连接参数
- pool 配置
- `market.ts`
- 交易所、品种、symbol 映射
- `sessions.ts`
- ASIA / LONDON / NY_AM / NY_LUNCH / NY_PM 定义
- `timeframes.ts`
- bias/setup/trigger 周期配置
- `risk.ts`
- 单笔风险、日亏损、新闻窗口、会话限制
- `models.ts`
- 各策略模型编码,例如 `SWEEP_MSS_FVG`
要求:
- 配置模块只负责提供配置值
- 不在配置模块里写业务查询或数据库逻辑
---
## 4.3 `src/db/`
用于统一管理数据库接入。
建议结构:
```text
src/db/
client.ts
migrations/
seeds/
repositories/
venues-repository.ts
instruments-repository.ts
candles-repository.ts
market-sessions-repository.ts
swings-repository.ts
dealing-ranges-repository.ts
liquidity-pools-repository.ts
sweep-events-repository.ts
displacement-events-repository.ts
structure-events-repository.ts
fvg-zones-repository.ts
bias-snapshots-repository.ts
trade-signals-repository.ts
trade-executions-repository.ts
backtest-runs-repository.ts
backtest-results-repository.ts
```
职责:
- `client.ts` 负责 PostgreSQL 连接与事务入口
- `migrations/` 放正式 migration 文件
- `seeds/` 放初始种子数据
- `repositories/` 只负责表读写,不负责业务判断
建议约束:
- repository 不直接做规则识别
- 复杂业务编排交给 `services/`
- 规则计算交给 `rules/`
---
## 4.4 `src/domain/`
用于放领域模型与跨模块共享结构。
建议子目录:
```text
src/domain/
market/
session/
structure/
signal/
execution/
backtest/
report/
```
可承载内容:
- Candle
- SessionWindow
- Swing
- DealingRange
- LiquidityPool
- SweepEvent
- DisplacementEvent
- StructureEvent
- FvgZone
- BiasSnapshot
- TradeSignal
- TradeExecution
- BacktestRun
- BacktestResult
要求:
- 领域层表达“数据是什么”
- 不表达“从哪个 API 拉来的”
- 不耦合具体数据库客户端
---
## 4.5 `src/rules/`
这是第一阶段最关键的纯规则层。
建议结构:
```text
src/rules/
swings/
detect-swings.ts
ranges/
detect-dealing-range.ts
classify-price-location.ts
liquidity/
detect-liquidity-pools.ts
sweep/
detect-sweeps.ts
displacement/
detect-displacement.ts
structure/
detect-msb.ts
detect-mss.ts
fvg/
detect-fvg.ts
update-fvg-status.ts
bias/
build-bias-snapshot.ts
```
职责:
- 输入标准化市场数据或上游结构对象
- 输出结构化判定结果
- 尽量保持纯函数、可回测、可重复执行
设计要求:
- `rules/` 不直接调用交易所 API
- `rules/` 不直接控制下单
- `rules/` 可以被实时运行和回测共用
这层是后续“规则口径与回测口径一致”的核心。
---
## 4.6 `src/services/`
服务层负责把 repository、rules、外部数据源串起来。
建议结构:
```text
src/services/
market-data/
ingest-okx-candles.ts
sync-instruments.ts
generate-market-sessions.ts
rule-engine/
rebuild-market-structure.ts
run-swing-pipeline.ts
run-liquidity-pipeline.ts
run-structure-pipeline.ts
signal/
build-bias.ts
generate-trade-signals.ts
update-signal-state.ts
execution/
run-risk-check.ts
create-paper-order.ts
create-semi-auto-order.ts
manage-open-position.ts
report/
build-pre-market-report.ts
build-intraday-report.ts
build-post-trade-review.ts
backtest/
run-backtest.ts
summarize-backtest.ts
```
服务层职责是:
- 组织调用顺序
- 控制事务边界
- 调用 repository 落库
- 调用 rules 计算
- 处理任务入口与失败恢复
要求:
- service 可以依赖多个 repository 和 rule 模块
- service 不应把 SQL 写得到处都是
- service 不应承载底层公共工具函数
---
## 4.7 `src/jobs/`
用于放异步任务与定时任务入口。
建议首批任务:
- `sync-okx-candles-job.ts`
- `generate-sessions-job.ts`
- `rebuild-swings-job.ts`
- `rebuild-liquidity-job.ts`
- `generate-signals-job.ts`
- `run-backtest-job.ts`
职责:
- 作为 cron 或手动触发入口
- 调用 `services/` 完成任务
- 记录任务执行日志
---
## 4.8 `src/api/`
第一阶段即使不做完整前端,也建议预留轻量 API 层。
建议提供:
- health check
- recent candles query
- current sessions query
- current structure query
- candidate signals query
- backtest summary query
作用:
- 供后续 Web 控制台使用
- 供人工验证与调试使用
- 避免所有数据检查都依赖直接查库
---
## 4.9 `src/runtime/`
用于放程序入口。
建议结构:
```text
src/runtime/
worker.ts
api-server.ts
market-data-runner.ts
rule-engine-runner.ts
signal-runner.ts
backtest-runner.ts
```
每个入口只负责:
- 启动应用上下文
- 选择要跑的 service 或 job
- 处理进程级日志与退出码
不建议把核心业务直接堆在入口文件里。
---
## 4.10 `src/utils/`
只放通用工具。
例如:
- 时间处理
- 数值精度处理
- 时区转换
- 分页工具
- 幂等键生成
要求:
- 不把业务规则偷偷塞进 `utils`
- 与 ICT 规则直接相关的逻辑应留在 `rules/`
---
## 4.11 `src/types/`
用于放跨层共享类型。
例如:
- DTO
- job payload
- service input/output
- API response shape
如果某类型已经明显属于领域对象,应优先放回 `domain/`,不要全堆在 `types/`
---
## 5. 服务边界
结合总体设计,第一阶段建议保留 5 条主要服务边界。
## 5.1 market-data-service
职责:
- 拉取 OKX / LMAX 行情
- 规范化 candles
- 维护 instruments 元数据
- 生成 market sessions
- 持续写入 PostgreSQL
核心输入:
- 交易所 API
- 品种配置
- session 配置
核心输出:
- `venues`
- `instruments`
- `candles`
- `market_sessions`
---
## 5.2 rule-engine-service
职责:
- 基于 candles 和 sessions 识别结构
- 生成 swings、ranges、liquidity、sweep、displacement、MSS、FVG
- 维护结构对象的更新与重算
核心输入:
- `candles`
- `market_sessions`
- 参数配置
核心输出:
- `swings`
- `dealing_ranges`
- `liquidity_pools`
- `sweep_events`
- `displacement_events`
- `structure_events`
- `fvg_zones`
---
## 5.3 signal-service
职责:
- 汇总 higher timeframe context
- 生成 bias snapshot
- 把结构链路组合成 trade signal
- 维护 signal 状态流转
核心输出:
- `bias_snapshots`
- `trade_signals`
---
## 5.4 execution-service
职责:
- 风控检查
- 模拟盘执行
- 半自动下单编排
- 持仓与订单状态更新
核心输出:
- `trade_executions`
- 风控结论
- 审计记录
---
## 5.5 report-service
职责:
- 盘前计划
- 盘中解释
- 盘后复盘
- 回测摘要
- 为 LLM 准备结构化输入
核心要求:
- 只解释规则层和执行层的结构化结果
- 不绕过规则直接生成裸建议
---
## 6. 配置文件与环境变量建议
第一阶段建议至少具备以下配置文件:
### 6.1 `.env.example`
建议包含:
```text
DATABASE_URL=
OKX_API_KEY=
OKX_API_SECRET=
OKX_PASSPHRASE=
LMAX_BASE_URL=
APP_TIMEZONE=UTC
DEFAULT_SESSION_TIMEZONE=America/New_York
LOG_LEVEL=info
```
说明:
- 第一阶段至少把数据库和 OKX 接入所需变量明确下来
- 未实际接入的供应商变量也可以预留,但不要提前绑定太多无用项
### 6.2 `package.json`
建议承载:
- dev 启动命令
- migration 命令
- seed 命令
- ingest 命令
- rule rebuild 命令
- backtest 命令
### 6.3 `tsconfig.json`
建议固定:
- 路径别名
- strict 模式
- source map
- 测试目录纳入方式
### 6.4 日志与格式配置
若使用:
- ESLint / Biome
- Prettier
- Vitest / Jest
应在项目启动初期固定,避免中期频繁改工程规范。
---
## 7. 数据库层与代码层映射建议
第一阶段建议先把数据库对象与代码模块稳定映射:
| 数据表 | repository | 主要服务 | 主要规则 |
|---|---|---|---|
| `venues` | `venues-repository.ts` | `sync-instruments.ts` | - |
| `instruments` | `instruments-repository.ts` | `sync-instruments.ts` | - |
| `candles` | `candles-repository.ts` | `ingest-okx-candles.ts` | - |
| `market_sessions` | `market-sessions-repository.ts` | `generate-market-sessions.ts` | - |
| `swings` | `swings-repository.ts` | `run-swing-pipeline.ts` | `detect-swings.ts` |
| `dealing_ranges` | `dealing-ranges-repository.ts` | `run-structure-pipeline.ts` | `detect-dealing-range.ts` |
| `liquidity_pools` | `liquidity-pools-repository.ts` | `run-liquidity-pipeline.ts` | `detect-liquidity-pools.ts` |
| `displacement_events` | `displacement-events-repository.ts` | `run-structure-pipeline.ts` | `detect-displacement.ts` |
| `structure_events` | `structure-events-repository.ts` | `run-structure-pipeline.ts` | `detect-msb.ts` / `detect-mss.ts` |
| `fvg_zones` | `fvg-zones-repository.ts` | `run-structure-pipeline.ts` | `detect-fvg.ts` |
| `bias_snapshots` | `bias-snapshots-repository.ts` | `build-bias.ts` | `build-bias-snapshot.ts` |
| `trade_signals` | `trade-signals-repository.ts` | `generate-trade-signals.ts` | 组合规则 |
| `trade_executions` | `trade-executions-repository.ts` | `create-paper-order.ts` | 风控规则 |
| `backtest_runs` | `backtest-runs-repository.ts` | `run-backtest.ts` | - |
| `backtest_results` | `backtest-results-repository.ts` | `run-backtest.ts` | 回放逻辑 |
这样可以避免后续代码增长后职责混乱。
---
## 8. 运行入口建议
第一阶段建议至少提供以下运行入口。
## 8.1 API server
用途:
- 健康检查
- 查询当前市场结构
- 查询候选信号
- 查询回测摘要
建议入口:
- `src/runtime/api-server.ts`
---
## 8.2 market data runner
用途:
- 定时拉取 OKX candles
- 同步 instruments
- 驱动 session 生成
建议入口:
- `src/runtime/market-data-runner.ts`
---
## 8.3 rule engine runner
用途:
- 重算 swings
- 重算 dealing range
- 重算 liquidity
- 重算 displacement / MSS / FVG
建议入口:
- `src/runtime/rule-engine-runner.ts`
---
## 8.4 signal runner
用途:
- 生成 bias snapshot
- 生成 trade signal
- 更新 signal 状态
建议入口:
- `src/runtime/signal-runner.ts`
---
## 8.5 backtest runner
用途:
- 执行指定模型、品种、周期范围的回测
- 输出 backtest summary
建议入口:
- `src/runtime/backtest-runner.ts`
---
## 9. 第一轮实现顺序
结合路线图与总体设计,建议第一轮按以下顺序实现。
## 9.1 Step 1:项目骨架初始化
先完成:
- `src/config/`
- `src/db/client.ts`
- `src/runtime/`
- `src/services/market-data/` 基础空壳
- `.env.example`
- 日志模块
完成标准:
- 项目可启动
- 配置可加载
- PostgreSQL 可连接
---
## 9.2 Step 2:基础 migration 落地
先建:
- `venues`
- `instruments`
- `candles`
- `market_sessions`
- `swings`
- `dealing_ranges`
- `liquidity_pools`
说明:
- 这是第一阶段共享底座
- 后续所有规则与信号都依赖这些表
---
## 9.3 Step 3OKX 数据接入
优先接通:
- BTC
- ETH
完成内容:
- candles 拉取
- 标准化
- 入库
- 去重
---
## 9.4 Step 4session generator
完成内容:
- ASIA / LONDON / NY_AM / NY_LUNCH / NY_PM 切分
- session high / low / midpoint 生成
- 状态更新
---
## 9.5 Step 5:基础规则层
优先实现:
1. swings
2. dealing_ranges
3. liquidity_pools
原因:
- 它们是 sweep / MSS / FVG 的共同前提
---
## 9.6 Step 6:核心信号链
按链路推进:
- sweep
- displacement
- MSB / MSS
- FVG
- bias snapshot
- trade signal
主链路保持为:
`Sweep -> MSS -> FVG Retrace -> IRL/ERL`
---
## 9.7 Step 7:回测与执行
在信号链稳定后,再进入:
- backtest
- paper trading
- semi-auto execution
- report generation
不建议跳过回测直接做执行自动化。
---
## 10. 第一阶段最小启动文件清单
若现在要直接开始建项目,建议先落以下最小文件:
```text
src/config/env.ts
src/config/database.ts
src/config/sessions.ts
src/db/client.ts
src/db/migrations/001_create_venues.sql
src/db/migrations/002_create_instruments.sql
src/db/migrations/003_create_candles.sql
src/db/migrations/004_create_candles_partitions_and_indexes.sql
src/db/migrations/005_create_market_sessions.sql
src/services/market-data/ingest-okx-candles.ts
src/services/market-data/generate-market-sessions.ts
src/runtime/market-data-runner.ts
src/runtime/api-server.ts
.env.example
```
若以上文件稳定,再继续进入 swing / dealing range 实现。
---
## 11. 验证与关闭标准
根据执行检查清单,代码骨架文档完成至少应满足:
- 文档已写入本地 `.md`
- 路径存在
- 已读取前部内容检查格式正常
- 标题、章节结构、核心内容完整
后续代码真正开始落地时,每个骨架模块也应满足:
- 有目标文件
- 有最小启动或导入验证
- 与当前阶段目标直接相关
---
## 12. 当前结论
AI ICT 项目第一阶段最合适的代码组织方式,不是上来拆成很多独立服务,而是:
> 先建立单仓库、清晰分层、可多入口运行的工程骨架,再按 `数据 -> 基础规则 -> 核心信号 -> 回测 -> 执行 -> 报告` 顺序推进。
只要骨架按本文档固定下来,后续 migration、OKX 接入、session generator、swings、dealing range、liquidity、MSS、FVG 和 signal 都可以在同一套结构下稳定扩展。
-584
View File
@@ -1,584 +0,0 @@
# AI ICT Dealing Range 识别规格书 v1
## 1. 文档目标
本文档定义 AI ICT 项目第一阶段 MVP 的 dealing range identification 规则,用于指导:
- `dealing_ranges` 表设计与落库
- premium / discount / equilibrium 位置判定
- bias、liquidity、signal 等下游模块引用
- 本地验证、抽样复盘与后续回测
本文档只覆盖 dealing range 的识别与维护,不覆盖 MSS、FVG、signal 组装等后续规则。
关联文档:
1. `AI_ICT_MVP_规则规格书_v1.md`
2. `AI_ICT_PostgreSQL_Schema_v1.md`
3. `AI_ICT_开发路线图_v1.md`
4. `AI_ICT_执行检查清单_v1.md`
5. `AI_ICT_Swing_Identification_Spec_v1.md`
---
## 2. 设计目标
dealing range 模块需要满足以下目标:
1. 输出稳定,可重复生成
2. range 锚点来源清晰,可审计
3. 能稳定计算 equilibrium
4. 能对当前价格给出 premium / discount / equilibrium 标注
5. 能在同一 timeframe 历史上保留多个 range 记录
6. 能明确当前 active range 的选择逻辑
7. 具备可控的重算与失效规则
---
## 3. 核心定义
## 3.1 Dealing Range
Dealing range 是由一组有效结构高低点定义出的当前工作区间,用于建立价格所处位置坐标。
第一版中,一个 dealing range 由:
- 一个有效高点锚 `high_swing`
- 一个有效低点锚 `low_swing`
- 基于两者计算的 `equilibrium`
共同组成。
## 3.2 Range Anchor
range anchor 指用于定义当前 range 边界的 swing 锚点:
- `high_swing_id`
- `low_swing_id`
锚点必须来自已确认的 swing,且默认优先使用与当前 timeframe 对齐的 confirmed swing。
## 3.3 Equilibrium
`equilibrium` 是 dealing range 中点,计算方式为:
```text
equilibrium = (high + low) / 2
```
第一版使用简单中点,不引入成交量、时间权重或 VWAP 修正。
## 3.4 Premium / Discount / Equilibrium Zone
给定当前价格 `p`
-`p > equilibrium`,则价格位于 `premium`
-`p < equilibrium`,则价格位于 `discount`
-`p == equilibrium` 或落在定义的中点容忍带内,则位于 `equilibrium`
---
## 4. range 锚点规则
## 4.1 第一版锚点来源
第一版 dealing range 的锚点只来源于 confirmed swing,不直接来源于 candle、session high/low 或 liquidity pool。
原因:
- 与结构识别链路一致
- 易于审计与复现
- 可直接复用 `swings.id`
## 4.2 锚点类型
一个有效 range 必须由:
- 1 个 `swing_high`
- 1 个 `swing_low`
组成。
不允许:
- high/high 组成 range
- low/low 组成 range
- 未确认 swing 组成正式 range
## 4.3 锚点确认要求
用于正式 range 的锚点必须满足:
- `confirmed = true`
- 与 range 相同 `instrument_id`
- 与 range 相同 `timeframe`
- `strength` 符合当前 timeframe 的默认使用层级
## 4.4 默认层级选择
第一版建议:
- `trigger_tf` / `setup_tf` 上优先使用 ST swing 生成工作 range
- `range_tf` 上可优先使用 IT swing 生成更稳定 range
- `bias_tf` 若需要生成背景 range,可优先使用 IT 或 LT swing
但在 MVP 第一阶段,为了降低复杂度:
- dealing range 模块默认先支持“单一 timeframe 内的 confirmed ST swing 组合”
- IT / LT range 作为增强项,在基础 ST range 稳定后再扩展
## 4.5 时间顺序要求
锚点必须满足以下条件:
- `high_swing_id != low_swing_id`
- 两个锚点都已有明确 `ts`
- 两个锚点共同界定的区间必须覆盖当前工作段
第一版允许 high swing 早于 low swing,也允许 low swing 早于 high swing,不强制某个方向先出现。
原因:
- dealing range 本质是区间坐标,不是方向判断本身
- 方向与叙事由 bias / structure 模块补充
---
## 5. range 构建规则
## 5.1 基础构建公式
给定一组有效锚点:
- `high = high_swing.price`
- `low = low_swing.price`
- `equilibrium = (high + low) / 2`
则生成一条 dealing range。
## 5.2 合法性要求
有效 range 必须满足:
- `high > low`
- `equilibrium > low`
- `equilibrium < high`
若不满足,说明锚点或输入数据异常,不得落为正式 range。
## 5.3 ts_start 与 ts_end
第一版建议:
- `ts_start = min(high_swing.ts, low_swing.ts)`
- `ts_end` 在 range 仍 active 时为空
- 当 range 失效或被新 range 替代时,写入 `ts_end`
## 5.4 价格突破处理
价格突破 range 的高低边界,不立即删除历史 range。
处理方式:
- 原 range 保留历史记录
- 根据 active-range 规则决定是否失效
- 若产生新锚点组合,则新建一条 range 记录
---
## 6. equilibrium 与位置标注规则
## 6.1 equilibrium 计算
第一版固定为:
```text
equilibrium = (high + low) / 2
```
不做:
- Fibonacci 0.5 以外的扩展坐标
- 基于成交量的权重均值
- 动态 session 调整
## 6.2 位置标注
给定价格 `p`
- `p > equilibrium` => `premium`
- `p < equilibrium` => `discount`
- `p == equilibrium` => `equilibrium`
## 6.3 equilibrium 容忍带
由于实际价格精度与不同品种 tick size 不同,第一版建议加入容忍带:
```text
equilibrium_band = max(tick_size, (high - low) * 0.001)
```
位置判定:
-`abs(p - equilibrium) <= equilibrium_band`,标记为 `equilibrium`
- 否则按 premium / discount 判定
说明:
- 这是工程容忍带,不是交易策略本身
- 主要用于减少价格刚好贴近中点时的标签抖动
## 6.4 输出内容
dealing range 模块至少应输出:
- `high`
- `low`
- `equilibrium`
- `location_label`premium / discount / equilibrium
- `distance_to_eq`
- `distance_to_range_high`
- `distance_to_range_low`
其中距离字段可先作为运行时输出,不强制第一版全部落库。
---
## 7. active range 选择规则
## 7.1 为什么需要 active range
同一 instrument + timeframe 历史上可能存在多条 range 记录,因此需要明确当前哪一条才是正式工作区间。
## 7.2 第一版 active range 原则
第一版在同一 `instrument_id + timeframe` 下,最多只认一条 `state = 'valid'` 的当前主 range。
其余历史 range
- 保留为历史记录
- `state` 改为非当前状态
## 7.3 默认选取规则
若存在多个候选 range,按以下优先级选择 active range
1. 锚点均为 confirmed swing
2. 锚点时间较新
3. 覆盖当前价格结构更完整
4. 最近一次被结构事件引用的 range 优先
5. 若仍冲突,则取 `ts_start` 更晚者
第一版最小实现建议:
- 直接选择最近生成且状态为 `valid` 的一条
- 同时保证旧 range 被置为非 active
## 7.4 状态建议
第一版建议 `state` 取值:
- `valid`:当前 active range
- `superseded`:已被新 range 替代
- `invalidated`:因结构失效而不再有效
- `archived`:历史保留,不参与当前选择
若暂时不做完整状态机,至少要支持:
- `valid`
- `superseded`
## 7.5 多 timeframe 并存
允许多个 timeframe 同时各自拥有 active range。
例如:
- H1 有一条 active range
- M15 也有一条 active range
这不冲突,因为它们服务于不同层级判断。
---
## 8. 重算与更新规则
## 8.1 重算触发条件
当出现以下情况时,应触发 dealing range 重算:
1. 新 confirmed swing 写入
2. 既有 provisional swing 转为 confirmed
3. 历史数据补齐导致 swing 结果变化
4. 参数变更导致 swing 识别结果变化
5. 手动触发全量或分段重算
## 8.2 重算范围
第一版建议按 `instrument_id + timeframe` 做局部重算,而不是全市场全量重算。
如需更保守,可按时间窗口重算:
- 受影响 swing 附近 N 根 candle 对应区间
- 再向后重建 active range
## 8.3 更新策略
建议:
- 不直接覆盖历史 dealing range 记录
- 旧 range 改 `state`
- 新 range 新增一条记录
- `updated_at` 仅用于状态变更或元数据更新
这样做的好处:
- 历史轨迹清晰
- 回测更易复现
- 审计更容易
## 8.4 何时 superseded
若出现更新、更合理的锚点组合,且新 range 被判定为当前主 range,则旧 range 设为 `superseded`
## 8.5 何时 invalidated
以下情况可将 range 标记为 `invalidated`
- 锚点 swing 后续被证明无效
- 数据缺口导致锚点可靠性丧失
- 结构上下文已切换,旧 range 不再适合作为坐标参考
第一版如不实现 `invalidated`,可先统一用 `superseded` 处理。
---
## 9. 边界与异常情况
## 9.1 锚点价格相等
`high_swing.price == low_swing.price`,不得生成 range。
处理:
- 标记异常
- 跳过写入
- `meta.error = 'flat_range_anchor'`
## 9.2 锚点顺序反转
若 low swing 的时间晚于 high swing,或 high swing 的时间晚于 low swing,不构成错误。
第一版只要价格边界合法即可。
## 9.3 锚点过旧
若锚点距离当前时间过远,可能导致 range 与当前结构脱节。
第一版不强制设置最大年龄限制,但建议在 `meta` 中记录:
- `anchor_age_bars`
- `anchor_age_minutes`
后续可用于优化 active-range 选择。
## 9.4 多组锚点同时成立
若同一时刻存在多组合法锚点:
- 第一版按 active-range 默认选取规则择一
- 其余记录可写为历史候选或不落库
为了降低复杂度,MVP 第一版建议只落库最终被选中的主 range。
## 9.5 数据缺口
若两个锚点之间存在明显缺口:
- 可暂不生成正式 range
- 或写入但 `meta.data_gap = true`
- 下游默认不消费含 `data_gap` 的 range
第一版更建议:缺口存在时不生成 active range。
## 9.6 极端宽 range
`high - low` 远大于当前 timeframe 的正常波动,可能说明锚点选取过粗。
第一版不直接拒绝,但建议记录:
- `meta.range_width`
- `meta.range_width_pct`
供后续过滤与回测分析使用。
---
## 10. 存储模型
## 10.1 对应表
`dealing range` 结果落到 `dealing_ranges` 表。
建议字段:
- `instrument_id`
- `timeframe`
- `high`
- `low`
- `equilibrium`
- `high_swing_id`
- `low_swing_id`
- `state`
- `ts_start`
- `ts_end`
- `meta`
- `created_at`
- `updated_at`
## 10.2 字段语义
- `high`range 上边界
- `low`range 下边界
- `equilibrium`:中点
- `high_swing_id`:上边界来源 swing
- `low_swing_id`:下边界来源 swing
- `state`:当前有效性状态
- `ts_start`range 起始时间
- `ts_end`range 结束时间
## 10.3 meta 建议内容
适合写入 `meta` 的字段:
- `anchor_strength_high`
- `anchor_strength_low`
- `anchor_high_ts`
- `anchor_low_ts`
- `location_label`
- `equilibrium_band`
- `data_gap`
- `selection_reason`
- `recalc_batch_id`
- `range_width`
- `range_width_pct`
不应只放在 `meta` 的字段:
- `high`
- `low`
- `equilibrium`
- `high_swing_id`
- `low_swing_id`
- `state`
- `ts_start`
## 10.4 更新策略
建议:
- 新主 range 产生时新增记录
- 旧记录更新 `state``ts_end`
- 不静默覆盖历史 high / low / equilibrium
---
## 11. 处理流程建议
## 11.1 基础计算流程
1. 读取指定 instrument + timeframe 的 confirmed swings
2. 筛选当前可用的 swing_high 与 swing_low
3. 按规则组合候选锚点
4. 计算 `high / low / equilibrium`
5. 校验合法性
6. 选出当前 active range
7. 落库并更新旧 range 状态
## 11.2 最小 MVP 实现
建议先做:
1. confirmed ST swings 驱动的单 timeframe range
2. equilibrium 计算
3. premium / discount / equilibrium 标注
4. 单一 active range 管理
5. superseded 历史保留
之后再增强:
- IT / LT range
- 更复杂的 active-range 选择
- 数据缺口与异常过滤
- 更精细的状态机
---
## 12. 验证检查点
## 12.1 功能验证
- [ ] 能按 timeframe 产出当前主 range
- [ ] 同一批输入重复运行结果一致
- [ ] active range 可稳定查询
- [ ] premium / discount / equilibrium 输出稳定
## 12.2 规则验证
- [ ] range 必须由 1 个 confirmed high swing + 1 个 confirmed low swing 组成
- [ ] `high > low` 才允许落库
- [ ] `equilibrium = (high + low) / 2` 计算正确
- [ ] 旧 range 被替代时会正确转为非 active
- [ ] 当前价格位置标签与手工计算一致
## 12.3 存储验证
- [ ] `high_swing_id` / `low_swing_id` 可正确关联 `swings`
- [ ] `state` 可区分当前与历史 range
- [ ] `ts_start / ts_end` 逻辑正确
- [ ] `meta` 可记录 selection reason 和异常信息
## 12.4 人工抽样验证
每个核心 timeframe 至少抽查:
- 1 个标准 bullish range 样例
- 1 个标准 bearish range 样例
- 1 个当前价格位于 premium 的样例
- 1 个当前价格位于 discount 的样例
- 1 个旧 range 被 superseded 的样例
- 1 个锚点异常未落库的反例
## 12.5 与路线图对齐的完成标准
满足以下条件才算完成:
- 当前主 range 可查询
- 可输出当前价格所处分区
- 结果可重复生成
- 可作为 bias / liquidity / signal 的上游输入
---
## 13. MVP 范围内不做的事
第一版暂不包含:
- 多 session 拼接式 range
- AI 自由解释生成 range 锚点
- 基于外部订单流的 range 加权
- 自动合并多个候选 range
- Fibonacci 多层分区扩展
- 不同品种联动生成共享 range
---
## 14. 推荐的最小实现顺序
1. 先实现 confirmed ST swing 驱动的 range 构建
2. 再实现 equilibrium 与位置标签
3. 再实现 active range 的单一选择
4. 再实现 superseded 历史保留
5. 最后补充异常标记、重算批次和增强状态机
这样可以先满足阶段 2 的最小可用目标,再逐步增强。
@@ -1,549 +0,0 @@
# AI ICT PostgreSQL Migration SQL Drafts v1
## 1. 文档目标
本文档提供 AI ICT 项目第一波 PostgreSQL migration 的 SQL 草案,用于直接指导:
- 基础表初版建表
- 主键 / 外键 / 唯一约束落地
- 核心查询索引落地
- `candles` 月分区初始化
- 后续正式 migration 文件编写
本文档聚焦第一实现波次,不覆盖全部事件表,只覆盖:
1. `venues`
2. `instruments`
3. `candles`
4. `market_sessions`
5. `swings`
关联文档:
1. `AI_ICT_PostgreSQL_Schema_v1.md`
2. `AI_ICT_PostgreSQL_Migration_Split_Plan_v1.md`
3. `AI_ICT_开发路线图_v1.md`
4. `AI_ICT_执行检查清单_v1.md`
---
## 2. 设计边界与约定
第一波 migration 草案遵循以下原则:
1. 优先让基础数据层可落地
2. 使用 `text + check constraint`,不先引入数据库 enum
3. 高增长表优先做好唯一键与索引
4. 只有 `candles` 先做分区,其他表先普通表
5. SQL 草案以 PostgreSQL 15+ 常见写法为基准
统一约定:
- 主键使用 `bigserial`
- 时间字段统一使用 `timestamptz`
- 默认 schema 先按 `public` 处理
- 金额/价格统一使用 `numeric(20,10)` 或更高精度
- JSON 扩展字段使用 `jsonb not null default '{}'::jsonb`
---
## 3. 建议 migration 文件顺序
```text
001_base_conventions.sql
002_create_venues.sql
003_create_instruments.sql
004_create_candles.sql
005_create_candles_partitions.sql
006_create_market_sessions.sql
007_create_swings.sql
```
说明:
- `candles` 主表和分区单独拆文件,便于验证与回滚
- 若团队偏向更细粒度,也可把索引拆到各自 `*_indexes.sql`
- 第一波先不把 seed 数据硬编码进 DDL migration,可单独写 `seed_base_data.sql`
---
## 4. 基础公共 SQL 草案
## 4.1 `001_base_conventions.sql`
建议先放公共 trigger function
```sql
create or replace function set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
```
用途:
- 给存在 `updated_at` 的基础表复用
- 减少每张表单独手写更新时间逻辑的重复
说明:
- `candles` 第一版没有 `updated_at`,通常不需要该 trigger
- 若团队想保持 migration 最小化,也可先不加 trigger,改由应用层更新
---
## 5. `venues` SQL 草案
## 5.1 建表 SQL
```sql
create table if not exists venues (
id bigserial primary key,
code text not null,
name text not null,
timezone text not null,
status text not null default 'active',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint venues_code_key unique (code),
constraint venues_status_check check (status in ('active', 'inactive'))
);
```
## 5.2 索引与 trigger
```sql
create index if not exists idx_venues_status
on venues (status);
create trigger trg_venues_set_updated_at
before update on venues
for each row
execute function set_updated_at();
```
## 5.3 说明
- `code` 用于业务稳定引用,如 `OKX`
- `timezone` 保留交易所默认时区配置
- `status` 先保留最小集合:`active | inactive`
---
## 6. `instruments` SQL 草案
## 6.1 建表 SQL
```sql
create table if not exists instruments (
id bigserial primary key,
venue_id bigint not null references venues(id),
symbol text not null,
asset_class text not null,
base_asset text,
quote_asset text,
price_scale integer not null,
qty_scale integer not null,
tick_size numeric(20,10) not null,
lot_size numeric(20,10),
active boolean not null default true,
meta jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint instruments_venue_symbol_key unique (venue_id, symbol),
constraint instruments_price_scale_check check (price_scale >= 0),
constraint instruments_qty_scale_check check (qty_scale >= 0),
constraint instruments_tick_size_check check (tick_size > 0),
constraint instruments_lot_size_check check (lot_size is null or lot_size > 0)
);
```
## 6.2 索引与 trigger
```sql
create index if not exists idx_instruments_venue_id
on instruments (venue_id);
create index if not exists idx_instruments_active
on instruments (active);
create index if not exists idx_instruments_asset_class
on instruments (asset_class);
create trigger trg_instruments_set_updated_at
before update on instruments
for each row
execute function set_updated_at();
```
## 6.3 说明
-`unique (venue_id, symbol)` 保证交易所内品种唯一
- `meta` 用于附加合约规则、别名、外部映射等
- `asset_class` 暂时不强加 check,给现货、永续、CFD、贵金属留空间
---
## 7. `candles` SQL 草案
## 7.1 主表建表 SQL
第一版建议使用按 `ts_open` 的 range partition
```sql
create table if not exists candles (
id bigserial,
instrument_id bigint not null references instruments(id),
timeframe text not null,
ts_open timestamptz not null,
ts_close timestamptz not null,
open numeric(20,10) not null,
high numeric(20,10) not null,
low numeric(20,10) not null,
close numeric(20,10) not null,
volume numeric(28,10),
source text not null default 'api',
created_at timestamptz not null default now(),
constraint candles_pk primary key (id, ts_open),
constraint candles_timeframe_check check (timeframe in ('1m', '5m', '15m', '1h', '4h', '1d')),
constraint candles_price_order_check check (high >= low),
constraint candles_open_range_check check (open <= high and open >= low),
constraint candles_close_range_check check (close <= high and close >= low),
constraint candles_time_order_check check (ts_close > ts_open),
constraint candles_unique_bar unique (instrument_id, timeframe, ts_open)
) partition by range (ts_open);
```
## 7.2 为什么主键这样定义
由于 PostgreSQL 分区表上的唯一约束/主键需要包含分区键,第一版草案建议:
- 主键使用 `(id, ts_open)`
- 真正业务幂等约束使用 `unique (instrument_id, timeframe, ts_open)`
这样可以同时满足:
- 保留自增主键
- 兼容分区表限制
- 保证单根 K 线不会重复写入
## 7.3 主表索引 SQL
```sql
create index if not exists idx_candles_instrument_tf_ts_open_desc
on candles (instrument_id, timeframe, ts_open desc);
create index if not exists idx_candles_instrument_tf_ts_close_desc
on candles (instrument_id, timeframe, ts_close desc);
create index if not exists idx_candles_instrument_tf_source_ts_open_desc
on candles (instrument_id, timeframe, source, ts_open desc);
```
## 7.4 分区初始化 SQL 草案
例如先建立 2026-04 与 2026-05 两个分区:
```sql
create table if not exists candles_2026_04
partition of candles
for values from ('2026-04-01 00:00:00+00') to ('2026-05-01 00:00:00+00');
create table if not exists candles_2026_05
partition of candles
for values from ('2026-05-01 00:00:00+00') to ('2026-06-01 00:00:00+00');
```
## 7.5 分区级索引补充
若实际运行中发现分区继承索引不足以覆盖主要查询,可显式补:
```sql
create index if not exists idx_candles_2026_04_instrument_tf_ts_open_desc
on candles_2026_04 (instrument_id, timeframe, ts_open desc);
create index if not exists idx_candles_2026_05_instrument_tf_ts_open_desc
on candles_2026_05 (instrument_id, timeframe, ts_open desc);
```
## 7.6 后续自动建分区建议
后续可以增加一个月度分区初始化脚本,例如:
- 每月月底预建下两个月分区
- 避免行情入库时因缺少分区而失败
但这不属于第一波 migration DDL 主体,可放到运维脚本或 cron 任务中。
---
## 8. `market_sessions` SQL 草案
## 8.1 建表 SQL
```sql
create table if not exists market_sessions (
id bigserial primary key,
instrument_id bigint not null references instruments(id),
session_date date not null,
timezone text not null,
session_code text not null,
start_ts timestamptz not null,
end_ts timestamptz not null,
high numeric(20,10),
low numeric(20,10),
midpoint numeric(20,10),
status text not null default 'closed',
meta jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint market_sessions_unique_key unique (instrument_id, session_date, session_code, timezone),
constraint market_sessions_status_check check (status in ('open', 'closed', 'incomplete', 'invalid')),
constraint market_sessions_code_check check (session_code in ('ASIA', 'LONDON', 'NY_AM', 'NY_LUNCH', 'NY_PM')),
constraint market_sessions_time_order_check check (end_ts > start_ts),
constraint market_sessions_price_order_check check (
high is null or low is null or high >= low
)
);
```
## 8.2 索引与 trigger
```sql
create index if not exists idx_market_sessions_instrument_date
on market_sessions (instrument_id, session_date desc);
create index if not exists idx_market_sessions_instrument_code_date
on market_sessions (instrument_id, session_code, session_date desc);
create index if not exists idx_market_sessions_status
on market_sessions (status);
create trigger trg_market_sessions_set_updated_at
before update on market_sessions
for each row
execute function set_updated_at();
```
## 8.3 说明
- `session_date` 是业务会话所属日,不等同于 `start_ts::date`
- `midpoint` 可存 session 中点,减少重复计算
- `status` 预留不完整或异常会话标记
---
## 9. `swings` SQL 草案
## 9.1 建表 SQL
```sql
create table if not exists swings (
id bigserial primary key,
instrument_id bigint not null references instruments(id),
timeframe text not null,
kind text not null,
strength text not null,
price numeric(20,10) not null,
ts timestamptz not null,
left_bars integer not null,
right_bars integer not null,
confirmed boolean not null default true,
meta jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
constraint swings_kind_check check (kind in ('swing_high', 'swing_low')),
constraint swings_strength_check check (strength in ('ST', 'IT', 'LT')),
constraint swings_left_bars_check check (left_bars > 0),
constraint swings_right_bars_check check (right_bars > 0)
);
```
## 9.2 索引 SQL
```sql
create index if not exists idx_swings_instrument_tf_ts_desc
on swings (instrument_id, timeframe, ts desc);
create index if not exists idx_swings_instrument_tf_kind_ts_desc
on swings (instrument_id, timeframe, kind, ts desc);
create index if not exists idx_swings_instrument_tf_strength_ts_desc
on swings (instrument_id, timeframe, strength, ts desc);
create index if not exists idx_swings_confirmed_ts_desc
on swings (instrument_id, timeframe, confirmed, ts desc);
```
## 9.3 可选幂等约束建议
若后续想减少同一 swing 被重复写入,可考虑增加唯一约束:
```sql
unique (instrument_id, timeframe, kind, strength, ts, price)
```
但第一版建议先不急着加,原因:
- 不同算法版本可能在同一时间点重建结构
- 后续也可能需要靠 `meta.recalc_batch_id` 区分重算来源
- 先观察真实数据再决定是否收紧
---
## 10. 推荐 seed SQL 草案
## 10.1 `venues` seed
```sql
insert into venues (code, name, timezone)
values
('OKX', 'OKX', 'UTC'),
('LMAX', 'LMAX', 'Europe/London')
on conflict (code) do nothing;
```
## 10.2 `instruments` seed
```sql
insert into instruments (
venue_id,
symbol,
asset_class,
base_asset,
quote_asset,
price_scale,
qty_scale,
tick_size,
lot_size
)
select v.id, x.symbol, x.asset_class, x.base_asset, x.quote_asset, x.price_scale, x.qty_scale, x.tick_size, x.lot_size
from venues v
join (
values
('OKX', 'BTC-USDT-SWAP', 'perpetual', 'BTC', 'USDT', 1, 3, 0.1::numeric, 0.001::numeric),
('OKX', 'ETH-USDT-SWAP', 'perpetual', 'ETH', 'USDT', 2, 3, 0.01::numeric, 0.001::numeric),
('LMAX', 'XAUUSD', 'spot', 'XAU', 'USD', 2, 2, 0.01::numeric, 0.01::numeric)
) as x(venue_code, symbol, asset_class, base_asset, quote_asset, price_scale, qty_scale, tick_size, lot_size)
on v.code = x.venue_code
on conflict (venue_id, symbol) do nothing;
```
---
## 11. 第一波最小验证 SQL 草案
## 11.1 唯一约束验证
```sql
-- candles 重复写入应失败
insert into candles (
instrument_id, timeframe, ts_open, ts_close, open, high, low, close, volume
) values (
1, '1m', '2026-04-01 00:00:00+00', '2026-04-01 00:01:00+00',
100, 101, 99, 100.5, 10
);
insert into candles (
instrument_id, timeframe, ts_open, ts_close, open, high, low, close, volume
) values (
1, '1m', '2026-04-01 00:00:00+00', '2026-04-01 00:01:00+00',
100, 101, 99, 100.5, 10
);
```
第二条应因 `candles_unique_bar` 失败。
## 11.2 约束验证
```sql
-- 非法 timeframe 应失败
insert into candles (
instrument_id, timeframe, ts_open, ts_close, open, high, low, close
) values (
1, '2m', '2026-04-01 00:00:00+00', '2026-04-01 00:02:00+00',
100, 101, 99, 100
);
```
```sql
-- 非法 swing kind 应失败
insert into swings (
instrument_id, timeframe, kind, strength, price, ts, left_bars, right_bars
) values (
1, '1m', 'high', 'ST', 100, '2026-04-01 00:10:00+00', 2, 2
);
```
## 11.3 分区验证
```sql
select tableoid::regclass, instrument_id, timeframe, ts_open
from candles
where ts_open = '2026-04-01 00:00:00+00';
```
应看到记录落到 `candles_2026_04`
---
## 12. 回滚草案建议
第一波回滚顺序建议:
```text
swings
-> market_sessions
-> candles partitions
-> candles
-> instruments
-> venues
-> set_updated_at function
```
回滚示例:
```sql
drop table if exists swings;
drop table if exists market_sessions;
drop table if exists candles_2026_05;
drop table if exists candles_2026_04;
drop table if exists candles;
drop table if exists instruments;
drop table if exists venues;
drop function if exists set_updated_at();
```
说明:
- 实际生产环境不应直接照抄物理删除
- 先停写入任务,再确认无下游依赖,再执行回滚
- 若已有真实数据,应先备份再处理
---
## 13. 第一波完成标准
满足以下条件后,可认为 migration SQL draft 文档完成:
- 已给出基础表的建表 SQL 草案
- 已给出唯一约束和 check constraint 草案
- 已给出核心索引草案
- 已给出 `candles` 分区建表与初始化草案
- 已给出最小 seed 与验证 SQL 草案
- 已能直接作为正式 migration 编写参考
---
## 14. 推荐的下一步
完成本文件后,建议立即继续:
1. 把上述 SQL 草案拆成正式 migration 文件
2. 单独补一份 `verify_base_tables.sql`
3. 单独补一份 `rollback_base_tables.sql`
4. 再进入 `dealing_ranges` / `liquidity_pools` 的第二波 migration 草案
@@ -1,711 +0,0 @@
# AI ICT PostgreSQL Migration 拆分执行方案 v1
## 1. 文档目标
本文档把 `AI_ICT_PostgreSQL_Schema_v1.md` 中定义的数据库设计拆分为可执行的 migration 批次,确保:
- 按依赖顺序落地
- 每一批都可单独验证
- 出问题时可以局部回滚
- 不让 migration 任务停留在“已开始但不可执行”的状态
适用范围:AI ICT 项目第一阶段 MVP。
关联文档:
1. `AI_ICT_总体设计文档_v1.md`
2. `AI_ICT_PostgreSQL_Schema_v1.md`
3. `AI_ICT_MVP_规则规格书_v1.md`
4. `AI_ICT_开发路线图_v1.md`
5. `AI_ICT_执行检查清单_v1.md`
---
## 2. 拆分原则
1. 先基础字典,再时间序列,再结构事件,再信号执行,再回测分析
2. 每个 batch 只处理一个明确依赖层级,避免一次 migration 混入过多对象
3. 高数据量对象优先独立拆分,尤其是 `candles`
4. 每个 batch 都要配套验证 SQL / 回滚说明 / 必要 seed 数据
5. 第一版优先可落地,不对全部事件表过早分区
---
## 3. 总体依赖顺序
```text
B0 基础约定/公共函数
-> B1 venues
-> B2 instruments
-> B3 candles
-> B4 market_sessions
-> B5 swings
-> B6 dealing_ranges + liquidity_pools
-> B7 displacement_events
-> B8 structure_events + fvg_zones
-> B9 bias_snapshots
-> B10 trade_signals
-> B11 trade_executions
-> B12 backtest_runs
-> B13 backtest_results
```
依赖说明:
- `instruments` 依赖 `venues`
- `candles``market_sessions``swings` 依赖 `instruments`
- `dealing_ranges` 依赖 `instruments`,并引用 `swings`
- `structure_events` 引用 `swings``displacement_events`
- `fvg_zones` 引用 `displacement_events`
- `bias_snapshots` 引用 `dealing_ranges`
- `trade_signals` 引用 `bias_snapshots`
- `trade_executions` 引用 `trade_signals`
- `backtest_runs` 依赖 `instruments`
- `backtest_results` 依赖 `backtest_runs`,并可引用 `trade_signals`
---
## 4. migration 批次拆分
## B0. 基础约定与公共函数
### 目标
建立后续所有 migration 共享的数据库前置条件。
### 内容
- 若项目决定使用非 `public`,先建立目标 schema
- 建立 `updated_at` 自动更新时间的公共 trigger function(若采用)
- 明确第一版统一使用 `text + check constraint` 替代数据库 enum
- 明确所有时间字段使用 `timestamptz`
### 验证检查点
- 公共函数可创建成功
- 后续表可以复用这些函数/约定
- 时区约定已在 migration 注释中写明
### 回滚说明
- 删除本批次新增函数、trigger 模板和非业务 schema
- 本批不涉及业务数据,回滚成本最低
---
## B1. `venues`
### 目标
建立交易所字典表。
### 内容
- 创建 `venues`
- 主键 `id`
- 唯一键 `code`
- 默认状态 `status = 'active'`
- 可插入初始 seed`OKX``LMAX`
### 依赖
- B0
### 验证检查点
- 表可成功创建
- `code` 唯一约束生效
- 基础 seed 可查询
### 回滚说明
- 若无下游依赖,可先删 seed 再 drop 表
-`instruments` 已创建,必须先回退下游表
---
## B2. `instruments`
### 目标
建立统一交易品种表。
### 内容
- 创建 `instruments`
- 外键 `venue_id references venues(id)`
- 唯一键 `unique (venue_id, symbol)`
- `meta jsonb default '{}'`
- 插入 BTC / ETH / XAU 的基础 instrument seed
### 依赖
- B1
### 验证检查点
- `venue + symbol` 可唯一查询
- 不存在的 `venue_id` 无法插入
- `meta` 默认值生效
### 回滚说明
- 若无下游表,可删 seed 后 drop
- 若已有 `candles``market_sessions``swings` 等表,需先逆序回滚下游
---
## B3. `candles`
### 目标
建立时间序列主表与首批月分区。
### 内容
- 创建 `candles` 主表
- 唯一键 `unique (instrument_id, timeframe, ts_open)`
- 索引:
- `(instrument_id, timeframe, ts_open desc)`
- `(instrument_id, timeframe, ts_close desc)`
-`ts_open` 建月分区
- 至少预建当前月、下月分区
### 为什么单独拆批
- 数据量最大
- 分区和唯一键策略最关键
- 后续行情入库直接依赖这一批
### 依赖
- B2
### 验证检查点
- 可插入样例 K 线
- 重复 `(instrument_id, timeframe, ts_open)` 会被拒绝
- 数据能正确进入对应月分区
- 关键查询能命中索引
### 回滚说明
- 先删测试数据
- 先 drop 子分区,再 drop 主表
- 若已有采集任务写入,生产环境不应直接回滚,应停写后重建
---
## B4. `market_sessions`
### 目标
建立 session 切分与 pivot 数据表。
### 内容
- 创建 `market_sessions`
- 唯一键 `unique (instrument_id, session_date, session_code, timezone)`
- `meta jsonb default '{}'`
- 约定 session code`ASIA``LONDON``NY_AM``NY_LUNCH``NY_PM`
### 依赖
- B2
### 验证检查点
- 同一 instrument/date/session/timezone 不能重复写入
- 可查询 `high` / `low` / `midpoint`
- 时区与会话区间表达一致
### 回滚说明
- 若未被上游程序依赖,可直接删测试数据并 drop
- 若已有 session 生成器运行,先停任务再回滚
---
## B5. `swings`
### 目标
建立结构高低点表。
### 内容
- 创建 `swings`
- check constraint
- `kind in ('swing_high', 'swing_low')`
- `strength in ('ST', 'IT', 'LT')`
- 索引:
- `(instrument_id, timeframe, ts desc)`
- `(instrument_id, timeframe, kind, ts desc)`
- `(instrument_id, timeframe, strength, ts desc)`
### 依赖
- B2
### 验证检查点
- `kind` / `strength` 约束生效
- 能按 timeframe 查询最近 swing
- 非法枚举值会被拒绝
### 回滚说明
-`dealing_ranges` / `structure_events` 尚未创建,可直接回滚
- 否则需先回滚依赖表
---
## B6. `dealing_ranges` + `liquidity_pools`
### 目标
建立基础规则层中最核心的两个结果表。
### 合并原因
- 同属基础规则层
- 通常在同一轮规则计算中产出和消费
- 依赖层级接近
### 内容
#### `dealing_ranges`
- 创建表
- 引用 `swings(id)`
- 索引:
- `(instrument_id, timeframe, ts_start desc)`
- `(instrument_id, timeframe, state, ts_start desc)`
#### `liquidity_pools`
- 创建表
- check constraint
- `side in ('buy_side', 'sell_side')`
- `status in ('untouched', 'swept', 'consumed')`
- 索引:
- `(instrument_id, timeframe, status, created_ts desc)`
- `(instrument_id, timeframe, side, created_ts desc)`
### 依赖
- B5
### 验证检查点
- `dealing_ranges.high_swing_id` / `low_swing_id` 能关联 `swings`
- `liquidity_pools` 状态和方向约束生效
- 可查询当前有效 range
- 可查询当前流动性池
### 回滚说明
- 先停基础规则重算任务
- 再按 `liquidity_pools -> dealing_ranges` 顺序回滚
-`bias_snapshots` 已存在,先回退下游
---
## B7. `displacement_events`
### 目标
建立有效位移事件表。
### 内容
- 创建 `displacement_events`
- 添加 `direction``has_fvg``is_valid`
- 索引:
- `(instrument_id, timeframe, end_ts desc)`
- `(instrument_id, timeframe, direction, end_ts desc)`
### 依赖
- B2
### 验证检查点
- 可查询最近 displacement
- 默认值正常
- 可被 `structure_events``fvg_zones` 引用
### 回滚说明
- 若 B8 尚未执行,可直接回滚
- 否则先回滚 B8
---
## B8. `structure_events` + `fvg_zones`
### 目标
建立核心信号链路中的结构事件与 FVG 区域表。
### 合并原因
- 都属于信号上游对象
- 都依赖 `displacement_events`
- 一般同一轮规则识别会一起生成
### 内容
#### `structure_events`
- 创建表
- check constraint
- `event_type in ('MSB', 'MSS')`
- `direction in ('bullish', 'bearish')`
- 引用 `swings(id)``displacement_events(id)`
#### `fvg_zones`
- 创建表
- check constraint
- `direction in ('bullish', 'bearish')`
- `status in ('open', 'touched', 'mitigated', 'invalidated')`
- 引用 `displacement_events(id)`
### 依赖
- B5、B7
### 验证检查点
- `structure_events` 能关联 swing 与 displacement
- `fvg_zones` 能关联 displacement
- 状态与方向约束生效
- 可查询最近 MSS/MSB 与 open FVG
### 回滚说明
- 先删测试事件
- 先回退信号层依赖,再回滚本批次
---
## B9. `bias_snapshots`
### 目标
建立 bias 快照表。
### 内容
- 创建 `bias_snapshots`
- check constraint
- `daily_bias in ('bullish', 'bearish', 'neutral')`
- `execution_intensity in ('low', 'medium', 'high')`
- 引用 `dealing_ranges(id)`
- 索引:
- `(instrument_id, snapshot_ts desc)`
- `(instrument_id, daily_bias, snapshot_ts desc)`
### 依赖
- B6
### 验证检查点
- 能关联有效 `range_id`
- `reason_codes` / `meta` 默认值正常
- 可按 instrument 查询最近 bias
### 回滚说明
-`trade_signals` 尚未创建,可直接回滚
- 否则先回滚 B10
---
## B10. `trade_signals`
### 目标
建立候选交易信号主表。
### 内容
- 创建 `trade_signals`
- 引用 `bias_snapshots(id)`
- 索引:
- `(instrument_id, created_ts desc)`
- `(instrument_id, status, created_ts desc)`
- `(instrument_id, model_code, created_ts desc)`
- 保留 `invalidations jsonb``evidence jsonb`
- 核心字段显式建列:entry/stop/tp/rr/status/side
### 依赖
- B9
### 验证检查点
- 信号可关联 bias snapshot
- 能按 `status` 查询待确认/active 信号
- `evidence` 可承载结构化引用
### 回滚说明
-`trade_executions``backtest_results` 尚未创建,可直接回滚
- 否则先回退下游依赖
---
## B11. `trade_executions`
### 目标
建立半自动执行记录表。
### 内容
- 创建 `trade_executions`
- 引用 `trade_signals(id)`
- check constraint
- `execution_mode in ('paper', 'semi_auto', 'live')`
- `status in ('pending', 'open', 'partially_closed', 'closed', 'cancelled', 'rejected')`
- 索引:
- `(signal_id)`
- `(status, created_at desc)`
- `(execution_mode, created_at desc)`
### 依赖
- B10
### 验证检查点
- execution 必须关联有效 signal
- 可查询 open / closed / cancelled 状态
- entry、stop、tp、size、risk_pct 可完整记录
### 回滚说明
- 先停执行写入流程
- 删测试执行记录
- 再回滚结构
---
## B12. `backtest_runs`
### 目标
建立回测任务元数据与结果摘要表。
### 内容
- 创建 `backtest_runs`
- 引用 `instruments(id)`
- 索引:
- `(model_code, started_ts desc)`
- `(instrument_id, started_ts desc)`
- `config``summary` 使用 `jsonb`
### 依赖
- B2
- 执行顺序上建议放在 B11 之后,以便阶段验收统一
### 验证检查点
- 可记录一次回测启动/结束
- 可按 model 查询最近回测
- `summary` 可保存聚合统计
### 回滚说明
-`backtest_results` 尚未创建,可直接回滚
- 否则先回滚 B13
---
## B13. `backtest_results`
### 目标
建立回测明细结果表。
### 内容
- 创建 `backtest_results`
- 引用 `backtest_runs(id)`
- 可空引用 `trade_signals(id)`
- check constraint
- `outcome in ('win', 'loss', 'breakeven', 'missed')`
- 索引:
- `(backtest_run_id)`
- `(outcome)`
### 依赖
- B12
- 若引用真实 signal,还依赖 B10
### 验证检查点
- 可按 `backtest_run_id` 查询所有结果
- outcome 约束生效
- `tp1_hit``tp2_hit``invalidated_before_entry` 可正确记录
### 回滚说明
- 先删除明细结果
- 再回滚 `backtest_runs`
- 生产环境优先逻辑废弃而非直接物理删除历史
---
## 5. 建议的 migration 文件命名
```text
001_base_conventions.sql
002_create_venues.sql
003_create_instruments.sql
004_create_candles.sql
005_create_candles_partitions.sql
006_create_market_sessions.sql
007_create_swings.sql
008_create_dealing_ranges.sql
009_create_liquidity_pools.sql
010_create_displacement_events.sql
011_create_structure_events.sql
012_create_fvg_zones.sql
013_create_bias_snapshots.sql
014_create_trade_signals.sql
015_create_trade_executions.sql
016_create_backtest_runs.sql
017_create_backtest_results.sql
```
说明:
- 文档按 batch 归类
- 实际文件可在同一 batch 内继续细拆
- `candles` 主表和分区建议拆成两个 migration 文件
---
## 6. 每批次统一验证模板
每个 batch 执行后至少完成以下检查:
1. 表是否创建成功
2. 主键/唯一键/外键是否生效
3. check constraint 是否生效
4. 默认值是否符合设计
5. 一条合法样例插入是否成功
6. 一条非法样例插入是否被拒绝
7. 关键查询是否命中预期索引
8. 验证结果是否记录到执行检查清单
建议每批配套文件:
- `verify_XXX.sql`
- `rollback_XXX.sql`
- `seed_XXX.sql`(如需要)
---
## 7. 回滚策略总则
1. 严格按依赖逆序回滚
2. 有真实业务数据后,先停写、备份、评估,再做物理回滚
3. seed 与测试数据分开管理
4. 分区表先回滚子分区,再回滚主表
5. 代码已依赖该 migration 时,回滚前必须同步停用相关任务或服务
推荐逆序:
```text
B13 -> B12 -> B11 -> B10 -> B9 -> B8 -> B7 -> B6 -> B5 -> B4 -> B3 -> B2 -> B1 -> B0
```
---
## 8. 与开发阶段的对应关系
### 阶段 0:项目基础设施初始化
- B0
- B1
- B2
### 阶段 1:基础数据层
- B3
- B4
### 阶段 2:基础规则层
- B5
- B6
### 阶段 3:核心信号层
- B7
- B8
- B9
- B10
### 阶段 4:回测与验证层
- B12
- B13
### 阶段 5:半自动执行层
- B11
说明:
- 从纯数据库依赖上,`backtest_runs` 可更早建
- 从交付节奏和验收上,按上面顺序更清晰
---
## 9. 最小可用落地顺序
若当前目标是尽快启动 MVP,可先完成:
1. B0
2. B1
3. B2
4. B3
5. B4
6. B5
7. B6
完成后即可支撑:
- 行情入库
- session 生成
- swing 识别
- dealing range 识别
- liquidity pool 识别
之后继续:
8. B7
9. B8
10. B9
11. B10
12. B11
13. B12
14. B13
---
## 10. 任务关闭标准
本 migration 拆分文档完成后,应满足:
- 已写入本地 `.md`
- 路径存在且可读取
- 批次顺序与 schema 依赖一致
- 每个批次都包含验证检查点
- 每个批次都包含回滚说明
- 能直接作为后续 migration 编写参考
-518
View File
@@ -1,518 +0,0 @@
# AI ICT Session Generator 规格说明 v1
## 1. 文档目标
本文档定义 market session generator 的输入、时间规则、切分逻辑、计算方法、完整性检查和落库输出,用于指导 `market_sessions` 相关开发实现。
目标:
- 统一时区与交易日边界
- 生成稳定、可重复的 session segmentation
- 计算 session high / low / midpoint
- 识别数据缺失与异常情况
- 结果结构化输出到 `market_sessions`
适用范围:AI ICT 项目第一阶段 MVP。
关联文档:
1. `AI_ICT_总体设计文档_v1.md`
2. `AI_ICT_PostgreSQL_Schema_v1.md`
3. `AI_ICT_MVP_规则规格书_v1.md`
4. `AI_ICT_开发路线图_v1.md`
5. `AI_ICT_执行检查清单_v1.md`
---
## 2. 设计目标与边界
### 2.1 目标
session generator 负责把标准化 candles 按固定会话切分,生成后续规则与执行依赖的 session 数据。
输出必须支持:
- session high
- session low
- midpoint
- session 状态
- 完整性元数据
- 后续 rule-engine 的 session pivot 查询
### 2.2 不负责的内容
第一版 session generator 不负责:
- 自动判断 bias
- 自动判断 sweep / MSS / FVG
- 直接产生交易信号
- 直接生成 execution permission 最终结论
这些能力由后续 rule-engine / signal-service 基于 `market_sessions` 再加工。
---
## 3. 输入与输出
### 3.1 输入
最小输入:
- `instrument_id`
- `symbol`
- `timezone config`
- 标准化 `candles`
- 指定 `session_date` 或待重算的日期范围
candles 最低要求字段:
- `instrument_id`
- `timeframe`
- `ts_open`
- `ts_close`
- `open`
- `high`
- `low`
- `close`
### 3.2 输出
输出到 `market_sessions` 表,字段遵循已有 schema
- `instrument_id`
- `session_date`
- `timezone`
- `session_code`
- `start_ts`
- `end_ts`
- `high`
- `low`
- `midpoint`
- `status`
- `meta`
---
## 4. 时间与时区规范
## 4.1 存储原则
- 数据库存储统一使用 `timestamptz`
- 所有 `start_ts` / `end_ts` 均保存为 UTC 对应绝对时间
- session 定义使用业务时区解释,不直接使用服务器本地时区
## 4.2 第一版默认业务时区
第一版默认业务时区统一为:
- `America/New_York`
原因:
- 当前默认 session 模型围绕纽约交易时段定义
- LONDON / NY_AM / NY_LUNCH / NY_PM 都依赖纽约时区解释
- 能统一 crypto 与 XAU 的 session 观察口径
## 4.3 交易日定义
第一版 `session_date` 定义为“业务时区下该组 session 所属日期”。
例如:
- `ASIA 2026-04-23` 指的是在 `America/New_York` 语义下,属于 2026-04-23 交易日的 ASIA 会话
- 虽然其 UTC 时间可能跨自然日,但 `session_date` 不按 UTC 日历切换,而按业务时区日历归属
## 4.4 DST 处理原则
必须支持 `America/New_York` 夏令时切换。
规则:
1. session 定义始终以业务时区本地时钟解释
2. 转换到数据库时,再映射为 UTC `start_ts` / `end_ts`
3. 不允许通过手写固定 UTC 偏移硬编码 session
4. DST 切换日必须通过时区库计算,不能人工推导
---
## 5. Session 定义与切分规则
### 5.1 默认 session 列表
第一版采用以下默认 session
- ASIA: 20:00 - 00:00
- LONDON: 02:00 - 05:00
- NY_AM: 09:30 - 11:00
- NY_LUNCH: 12:00 - 13:00
- NY_PM: 13:30 - 16:00
时区基准:`America/New_York`
### 5.2 Session 边界约定
统一采用:
- `start_ts` inclusive
- `end_ts` exclusive
即:
```text
start_ts <= candle.ts_open < end_ts
```
这样可以避免相邻 session 在边界 candle 上重复归属。
### 5.3 跨午夜处理
`ASIA: 20:00 - 00:00` 会跨本地日内晚间到午夜边界。
处理规则:
- 如果 session 结束时间小于等于开始时间,则视为跨日 session
- `end_ts` 应自动落到下一自然日的 00:00
- `session_date` 仍归属该交易日定义,不因为 UTC 或本地午夜而拆成两段
### 5.4 单 candle 归属原则
一根 candle 在第一版中只能归属一个 session。
归属依据:
-`candle.ts_open` 判断所属 session
- 不使用 `ts_close` 参与归属判定
- 若某 candle 不落在任何 session 区间,则不进入 `market_sessions` 聚合
### 5.5 推荐运行粒度
第一版建议以基础数据主时间框架生成 session,推荐:
- `1m` 为最优
- `5m` 可作为退化版本
原因:
- NY_AM、NY_LUNCH 等窗口较短
- 更细粒度有利于后续 pivot、sweep、session high/low 分析
---
## 6. Session 聚合逻辑
### 6.1 选取数据范围
给定 `instrument_id + session_date + session_code + timezone`
1. 计算业务时区下的本地 `start_local` / `end_local`
2. 转换成 UTC `start_ts` / `end_ts`
3. 查询满足以下条件的 candles:
```text
instrument_id = ?
timeframe = configured_base_tf
candle.ts_open >= start_ts
candle.ts_open < end_ts
```
### 6.2 high / low 计算
- `high = max(candle.high)`
- `low = min(candle.low)`
前提:至少存在一根有效 candle。
### 6.3 midpoint 计算
第一版 midpoint 定义为:
```text
midpoint = (high + low) / 2
```
注意:
- midpoint 不是 VWAP
- midpoint 不是 open/close 平均值
- midpoint 是 session range 的几何中点
### 6.4 扩展元数据
建议在 `meta` 写入:
- `timeframe_used`
- `candle_count`
- `expected_candle_count`
- `completeness_ratio`
- `is_dst`
- `source`
- `generated_at`
- `data_gaps`
- `rebuild_reason`
---
## 7. 数据完整性检查
### 7.1 检查目标
session generator 不能只算出 high / low,还必须判断该 session 是否足够完整,避免缺失 candles 导致 pivot 失真。
### 7.2 期望 K 线数量
应按 session 时长与 `configured_base_tf` 计算理论 candle 数量。
示例:
- `ASIA 20:00 - 00:00`,若 `1m` 粒度,理论 240 根
- `LONDON 02:00 - 05:00`,若 `1m` 粒度,理论 180 根
- `NY_AM 09:30 - 11:00`,若 `1m` 粒度,理论 90 根
### 7.3 完整性比率
```text
completeness_ratio = actual_candle_count / expected_candle_count
```
### 7.4 第一版状态建议
`status` 建议值:
- `closed`:会话已结束且完整性达标
- `partial`:会话已结束但数据缺失
- `open`:会话尚未结束,仍在生成中
- `empty`:窗口内无有效 candle
- `invalid`:时间配置或输入异常,结果不可用
### 7.5 第一版完整性阈值
建议:
- `>= 0.98` 视为 `closed`
- `> 0 and < 0.98` 视为 `partial`
- `= 0` 视为 `empty`
是否采用 0.98 可配置,但第一版文档默认使用该值。
### 7.6 Gap 检查
除了 candle 数量,还应检查时间连续性:
- 相邻 `ts_open` 间隔应等于 timeframe 步长
- 若出现跳点,则记录 gap 段
- 若出现重复 candle,应在上游 `candles` 唯一约束阶段拦截;session generator 不负责去重修复
建议记录到 `meta.data_gaps`
```json
[
{
"expected": "2026-04-23T13:15:00Z",
"actual": "2026-04-23T13:17:00Z",
"missing_count": 1
}
]
```
---
## 8. 生成流程规范
### 8.1 全量重算
适用场景:
- 初始化历史数据
- 修复 session 定义
- 迁移后重建 session 数据
流程:
1. 读取日期范围
2. 按 instrument 逐日生成 session 窗口
3. 查询 candles
4. 计算 high / low / midpoint
5. 执行完整性检查
6. upsert 到 `market_sessions`
### 8.2 增量更新
适用场景:
- 新 candles 持续进入
- 当天 session 持续刷新
流程:
1. 找到受影响 `instrument + session_date + session_code`
2. 重新聚合整个 session 窗口
3. 用 upsert 覆盖原记录
4. 若会话尚未结束,状态保持 `open`
5. 会话结束后再转为 `closed` / `partial` / `empty`
### 8.3 Upsert 规则
基于唯一键:
```text
(instrument_id, session_date, session_code, timezone)
```
建议行为:
- 不做 append-only
- 直接 upsert 最新聚合结果
- `updated_at` 反映最近刷新时间
---
## 9. `market_sessions` 输出规范
### 9.1 字段映射
- `instrument_id`:来自上游 instrument
- `session_date`:业务时区下的交易日
- `timezone`:第一版固定 `America/New_York`
- `session_code`ASIA / LONDON / NY_AM / NY_LUNCH / NY_PM
- `start_ts`:本地 session 起点转 UTC
- `end_ts`:本地 session 终点转 UTC
- `high`session 期间最高价
- `low`session 期间最低价
- `midpoint``(high + low) / 2`
- `status``open` / `closed` / `partial` / `empty` / `invalid`
- `meta`:完整性与生成元数据
### 9.2 约束要求
写入前必须保证:
- `high >= low`
-`status in ('closed', 'partial', 'open')` 时,`high``low` 不应为空
-`status in ('empty', 'invalid')` 时,`high` / `low` / `midpoint` 可为空
### 9.3 推荐 meta 结构
```json
{
"timeframe_used": "1m",
"expected_candle_count": 90,
"actual_candle_count": 89,
"completeness_ratio": 0.9889,
"is_dst": true,
"source": "session-generator",
"generated_at": "2026-04-23T14:05:00Z",
"data_gaps": []
}
```
---
## 10. 异常与边界情况
### 10.1 空窗口
若 session 时间窗口内完全没有 candle:
- 写入一条 `status = empty` 的记录
- `high` / `low` / `midpoint` 留空
-`meta` 记录原因
### 10.2 未结束会话
若当前时间仍早于 `end_ts`
- 允许生成实时中的 session
- `status = open`
- 可用于盘中监控,但不能视为最终 session pivot
### 10.3 DST 切换日
DST 切换导致 UTC 映射变化时:
- 仍按本地 session 时钟定义生成
- 允许 `expected_candle_count` 因本地时间异常而变化,但必须有明确计算依据
- `meta.is_dst` 与相关说明必须保留
### 10.4 上游 candle 重建
若 candles 历史数据被修复或补齐:
- 必须触发对应日期 session 重算
- 不允许沿用旧 session 结果
### 10.5 非连续交易市场
若后续接入存在休市机制的 market:
- 第一版仍沿用统一 session 规则
- 节假日与休市判断暂由外部日历模块处理
- session generator 只消费“应该参与生成的日期集合”
---
## 11. 验证与验收要求
### 11.1 功能验证
至少验证:
- 能为同一 instrument 连续生成 5 个 session_code
- 同一天查询能拿到完整 session 列表
- midpoint 计算正确
- 重复执行生成任务结果稳定
### 11.2 数据验证
至少验证:
- `session_date` 归属正确
- `start_ts` / `end_ts` 对应的业务时区时间正确
- `high` / `low` 与原始 candle 对齐
- partial / empty 状态能正确触发
### 11.3 人工抽查
至少抽查:
- 一个正常交易日
- 一个存在 gap 的交易日
- 一个 DST 切换附近交易日
### 11.4 与检查清单对齐
任务关闭前至少满足:
- session generator 规格已写入本地 `.md`
- 路径存在
- 已检查标题、章节、关键内容
- 规格足以指导后续实现与测试
---
## 12. 推荐实现顺序
建议按以下顺序落地:
1. 固定 `America/New_York` session 定义
2. 完成窗口计算与 UTC 转换
3. 完成 candles 聚合
4. 完成 high / low / midpoint 计算
5. 完成 completeness / gap 检查
6. 完成 `market_sessions` upsert
7. 再补增量重算与异常记录
这样可以先让 session 数据可落库,再逐步增强完整性判断。
---
## 13. 最终结论
session generator 是全部后续 ICT 结构识别的重要上游。
第一版实现必须保证三件事:
1. 时间边界稳定
2. 聚合结果可重复
3. 缺失数据可识别
只有在这三点成立后,`market_sessions` 才能可靠支撑 liquidity pools、session pivots、sweep 判定和执行窗口过滤。
-590
View File
@@ -1,590 +0,0 @@
# AI ICT Sweep 识别规格书 v1
## 1. 文档目标
本文档定义 AI ICT 项目第一阶段 MVP 的 sweep identification 规则,用于指导:
- `sweep_events` 识别模块开发
- liquidity pool 与 sweep 的交互判定
- wick / close 行为判断
- displacement、MSS、signal 等下游模块引用
- 本地验证、抽样复盘与后续回测
本文档只覆盖 sweep 的识别与状态维护,不覆盖 displacement、MSS、FVG 的完整规则实现。
关联文档:
1. `AI_ICT_MVP_规则规格书_v1.md`
2. `AI_ICT_PostgreSQL_Schema_v1.md`
3. `AI_ICT_开发路线图_v1.md`
4. `AI_ICT_执行检查清单_v1.md`
5. `AI_ICT_Dealing_Range_Spec_v1.md`
---
## 2. 设计目标
sweep 模块需要满足以下目标:
1. 只围绕结构化 liquidity pool 识别,不做自由解释
2. 能区分 wick-only 刺穿与 close-back-inside 的行为差异
3. 能对 sweep 做分层验证,而不是一刀切二元判断
4. 能与后续 displacement 事件建立明确链接
5. 能维护 sweep 的状态流转,避免同一 pool 被重复误判
6. 输出结构化、可回测、可审计
---
## 3. 核心定义
## 3.1 Liquidity Pool
sweep 的前提是存在已识别的 liquidity pool。
第一版 pool 来源:
- `prior_high`
- `prior_low`
- `equal_highs`
- `equal_lows`
- `session_high`
- `session_low`
每个 pool 至少有:
- `side`
- `price_low`
- `price_high`
- `status`
- `timeframe`
## 3.2 Sweep
sweep 是价格对某一侧 liquidity pool 的刺穿与清理行为。
第一版中,sweep 必须满足:
- 有明确目标 pool
- 价格对 pool 发生有效交互
- 交互后能够输出结构化结果
## 3.3 Side Swept
`sweep.side_swept` 表示被清理的是哪一侧流动性:
- `buy_side`:上方流动性被清理,通常对应扫高
- `sell_side`:下方流动性被清理,通常对应扫低
## 3.4 Wick Sweep
价格仅用 wick 刺穿 pool 区间,但收盘没有稳定站在区间外,可记为 wick sweep 候选。
## 3.5 Close Back Inside
价格刺穿 pool 后,最终收盘重新回到 pool 内侧或原结构区间内,记为 `close_back_inside = true`
这通常是第一版更优先关注的 sweep 形态。
---
## 4. pool 交互规则
## 4.1 sweep 必须依赖 pool
第一版不允许没有 pool 就直接定义 sweep。
即:
- 先有 `liquidity_pools`
- 再判断 candles 是否与 pool 发生 sweep 交互
## 4.2 交互方向
### 扫 buy-side liquidity
若目标 pool 的 `side = buy_side`,则价格必须向上刺穿 pool
- `high` 触及或突破 `pool.price_low`
- 并进一步进入或突破 `pool.price_high`
### 扫 sell-side liquidity
若目标 pool 的 `side = sell_side`,则价格必须向下刺穿 pool
- `low` 触及或跌破 `pool.price_high`
- 并进一步进入或跌破 `pool.price_low`
## 4.3 第一版触发判定
为了兼容区间型 pool,第一版将“发生交互”定义为:
### 对 buy_side pool
候选 candle 满足:
- `high >= pool.price_low`
- 且至少有一部分价格行为进入或突破 `pool.price_high`
### 对 sell_side pool
候选 candle 满足:
- `low <= pool.price_high`
- 且至少有一部分价格行为进入或突破 `pool.price_low`
更简化地说:
- 上方池要被“扫进去/扫过去”
- 下方池要被“打进去/打过去”
## 4.4 pool 优先级
若同一 candle 同时与多个 pool 交互,第一版建议按如下优先级选择主 sweep 对象:
1. 时间更近的 pool
2. timeframe 更接近当前规则处理周期的 pool
3. `equal_highs / equal_lows` 优先于普通 `prior_high / prior_low`
4. session pool 在 session 相关策略中可优先
5. 若仍冲突,取离当前价格更近者
MVP 最小实现建议:
- 一根 candle 只认一个主 pool
- 其余命中记录到 `meta.candidate_pool_ids`
---
## 5. wick / close 行为规则
## 5.1 wick 刺穿
候选 candle 只要 wick 进入或越过目标 pool,即可形成 sweep 候选。
例如:
- 扫 buy-side`high` 突破 pool 上沿
- 扫 sell-side`low` 跌破 pool 下沿
## 5.2 close_back_inside
第一版更强的 sweep 条件是 `close_back_inside = true`
### 对 buy_side pool
候选 candle 扫高后,收盘重新回到 pool 下方或回到结构内部,记为 close back inside。
### 对 sell_side pool
候选 candle 扫低后,收盘重新回到 pool 上方或回到结构内部,记为 close back inside。
## 5.3 close-outside 的处理
若价格刺穿 pool 后收盘仍留在外部:
- 可视为已发生 liquidity interaction
- 但第一版不优先定义为高质量 sweep
- 更可能属于延续突破,而不是清扫后回收
因此:
- wick sweep + close outside = 弱候选
- wick sweep + close back inside = 更强候选
## 5.4 多根 K 线确认
第一版 sweep 的发生时点以首根有效交互 candle 为准,不要求等待多根 K 线才能记录 sweep event。
但后续验证等级可以引用后续 1~N 根 candle 的行为。
---
## 6. 验证分层
## 6.1 设计原则
第一版不把所有 sweep 都视为同质量信号,而是分层:
- L1:弱 sweep
- L2:有效 sweep
- L3:强 sweep
## 6.2 L1 弱 sweep
满足:
- 与 pool 有明确 wick 交互
- 但未出现 close back inside
- 或缺少后续 displacement 支持
用途:
- 可记录
- 默认不直接作为入场依据
## 6.3 L2 有效 sweep
满足:
- 与 pool 发生明确刺穿
- `close_back_inside = true`
- pool 原始状态为 `untouched`
- 没有明显数据缺口或结构冲突
用途:
- 可进入后续 displacement / MSS 观察链路
## 6.4 L3 强 sweep
满足:
- 已满足 L2
- 在限定窗口内出现有效 displacement
- displacement 方向与 sweep 后预期反应一致
- 可形成后续 MSS / FVG 链路候选
用途:
- 可作为 `SWEEP_MSS_FVG` 模型的高质量前置条件
## 6.5 第一版最小实现建议
若系统复杂度有限,可先实现:
- `weak`
- `valid`
两层,然后把强 sweep 通过 `displacement_after = true` 额外标记。
---
## 7. displacement linkage 规则
## 7.1 sweep 与 displacement 的关系
sweep 本身只表示流动性被清理;是否具有更高质量,要看后续是否出现 displacement。
## 7.2 方向一致性
### 扫 buy-side 之后
若 sweep 清理的是上方 buy-side liquidity,则高质量后续反应通常是向下 displacement。
### 扫 sell-side 之后
若 sweep 清理的是下方 sell-side liquidity,则高质量后续反应通常是向上 displacement。
## 7.3 displacement 窗口
第一版建议在 sweep 之后的有限窗口内寻找 displacement
- M1 / M5:后续 3~5 根 candle
- M15 / H1:后续 2~4 根 candle
- H4 及以上:后续 1~3 根 candle
若窗口内没有有效 displacement
- sweep 仍可保留
- 但验证等级不升为强 sweep
## 7.4 链接方式
若 sweep 已匹配 displacement,则建议记录:
- `displacement_after = true`
- `meta.displacement_event_id`
- `meta.displacement_delay_bars`
若未来正式建 `sweep_events` 表,也建议保留显式 `displacement_event_id` 字段。
---
## 8. 状态流转规则
## 8.1 pool 状态
pool 原始状态:
- `untouched`
- `swept`
- `consumed`
## 8.2 sweep 对 pool 的影响
第一版建议:
- 识别到有效 sweep 后,目标 pool 更新为 `swept`
- 若后续结构继续完全消耗该区域,可进一步更新为 `consumed`
## 8.3 sweep 自身状态建议
若使用独立 `sweep_events` 存储,建议状态:
- `candidate`
- `weak`
- `valid`
- `confirmed`
- `invalidated`
- `archived`
### 含义
- `candidate`:仅发生初步 pool interaction
- `weak`:发生 wick sweep,但不够强
- `valid`close back inside 成立
- `confirmed`:已获得 displacement 或后续链路确认
- `invalidated`:后续价格行为证明该 sweep 不再有参考意义
- `archived`:历史保留
## 8.4 最小状态机
MVP 第一版若要降复杂度,至少支持:
- `weak`
- `valid`
- `confirmed`
并允许:
```text
weak -> valid -> confirmed
weak -> invalidated
valid -> invalidated
confirmed -> archived
```
## 8.5 防重规则
同一 pool 在未重置前,不应被重复生成多条同向主 sweep 事件。
第一版建议:
- 同一 `pool_id + direction_window` 只保留一条主 sweep
- 若后续出现更高质量版本,可更新状态或新增更高版本并归档旧记录
---
## 9. 边界与异常情况
## 9.1 只触及不刺穿
若价格只触碰 pool 边缘但没有进入区间,不记 sweep。
## 9.2 同时扫上下两侧
极端大波动 candle 可能同时扫到上下两侧 pool。
第一版处理:
- 不在同一根 candle 上同时生成两个主 sweep
- 标记为冲突场景
- `meta.conflict = 'dual_side_sweep'`
- 默认不作为高质量 sweep
## 9.3 已被 swept 的 pool
若 pool 已是 `swept` 状态,第一版默认不重复生成新的主 sweep。
除非:
- 明确发生新一轮结构重置
- 或 pool 被重新生成了新 id
## 9.4 数据缺口
若 sweep 发生窗口存在明显缺失 candle:
- 可记录 candidate
- 但不升级为高质量 sweep
- `meta.data_gap = true`
## 9.5 假突破与延续突破
若刺穿后持续单边离开 pool,且没有 close back inside
- 更偏向 breakout / continuation
- 仍可记为弱 interaction
- 默认不视为有效 sweep
## 9.6 多个 timeframe 冲突
若低周期识别 sweep,但高周期叙事不支持:
- sweep 事件本身仍可记录
- 是否进入 signal 链路交给 bias / structure 层过滤
---
## 10. 存储模型
## 10.1 MVP 第一版建议
虽然当前 schema 文档未正式定义 `sweep_events` 表,但 sweep 属于核心信号层,建议新增独立存储模型,而不是只塞进 JSON。
建议新增 `sweep_events` 表,至少包含:
- `instrument_id`
- `timeframe`
- `pool_id`
- `side_swept`
- `sweep_high`
- `sweep_low`
- `close_back_inside`
- `validation_tier`
- `status`
- `ts`
- `meta`
- `created_at`
- `updated_at`
## 10.2 字段语义
- `pool_id`:被扫 liquidity pool
- `side_swept`buy_side / sell_side
- `sweep_high`:本次 sweep 涉及的最高价格
- `sweep_low`:本次 sweep 涉及的最低价格
- `close_back_inside`:是否收回结构内部
- `validation_tier`weak / valid / strong
- `status`:事件当前状态
- `ts`:首个成立 sweep 的 candle 时间
## 10.3 meta 建议内容
适合放入 `meta`
- `candidate_pool_ids`
- `pool_type`
- `close_outside`
- `displacement_event_id`
- `displacement_delay_bars`
- `data_gap`
- `conflict`
- `selection_reason`
- `recalc_batch_id`
不应只放在 `meta`
- `pool_id`
- `side_swept`
- `close_back_inside`
- `validation_tier`
- `status`
- `ts`
## 10.4 若暂不建独立表
若第一版暂时不能新增 `sweep_events` 表,可临时把 sweep 结果写入:
- `trade_signals.evidence`
- 或规则引擎中间结果存储
但这只应作为过渡方案,不应作为长期主方案。
---
## 11. 处理流程建议
## 11.1 基础识别流程
1. 读取当前 timeframe 的 active liquidity pools
2. 检查新 candle 是否与某个 pool 发生交互
3. 判断是否进入/突破 pool 区间
4. 记录 wick 行为与 close 行为
5. 生成 candidate / weak / valid sweep
6. 在后续窗口中检查 displacement
7. 更新 validation tier 与 status
8. 同步更新目标 pool 状态
## 11.2 最小 MVP 实现
建议先做:
1. pool interaction 判定
2. wick 与 close back inside 判定
3. weak / valid 两层验证
4. displacement_after 标记
5. pool 状态从 untouched 到 swept 的更新
再增强:
- 独立 `sweep_events`
- 更完整状态机
- 多 pool 冲突管理
- 与 MSS / FVG 的强绑定引用
---
## 12. 验证检查点
## 12.1 功能验证
- [ ] 能从已有 liquidity pool 中识别 sweep
- [ ] 同一批输入重复运行结果一致
- [ ] wick 行为与 close 行为能正确区分
- [ ] displacement linkage 可正确标记
## 12.2 规则验证
- [ ] 没有 pool 不会生成正式 sweep
- [ ] 只触碰不进入 pool 区间不会误判为 sweep
- [ ] close back inside 成立时会升级验证等级
- [ ] close outside 默认不会误判为高质量 sweep
- [ ] pool 已 swept 时不会重复生成主 sweep
## 12.3 状态验证
- [ ] sweep 生成后 pool 状态可更新为 `swept`
- [ ] 高质量 sweep 可升级为 confirmed
- [ ] 无效后续行为可把 sweep 标记为 invalidated
- [ ] 历史 sweep 可归档但不丢失
## 12.4 存储验证
- [ ] `pool_id` 能正确关联 liquidity pool
- [ ] `side_swept` 与 pool.side 逻辑一致
- [ ] `validation_tier``status` 可正确查询
- [ ] `meta` 能记录 displacement 和异常信息
## 12.5 人工抽样验证
每个核心 timeframe 至少抽查:
- 1 个扫 buy-side 的有效样例
- 1 个扫 sell-side 的有效样例
- 1 个 wick-only 弱 sweep 样例
- 1 个 close back inside 的有效 sweep 样例
- 1 个后续带 displacement 的强样例
- 1 个假突破但非有效 sweep 的反例
## 12.6 与路线图对齐的完成标准
满足以下条件才算完成:
- 能识别流动性刺穿
- 能识别 close back inside
- 能区分弱 sweep 与有效 sweep
- 可作为 displacement / MSS / signal 的上游输入
---
## 13. MVP 范围内不做的事
第一版暂不包含:
- 基于订单流或逐笔成交的微观 sweep 识别
- AI 自由叙事判定 sweep
- 多市场 SMT 联动 sweep 作为核心条件
- 同一事件跨多个 timeframe 自动合并
- 复杂置信度模型打分
---
## 14. 推荐的最小实现顺序
1. 先实现 pool interaction
2. 再实现 wick / close back inside 判定
3. 再实现 weak / valid 分层
4. 再实现 displacement_after 标记
5. 最后补 sweep 独立存储与完整状态机
这样可以先满足阶段 3 的最小可用目标,再逐步增强。
-491
View File
@@ -1,491 +0,0 @@
# AI ICT Swing Identification 规格说明 v1
## 1. 文档目标
本文档定义 swing identification 模块的识别规则、强度分层、确认时机、参数化方式、边界情况、落库结构与验证要求,用于指导 `swings` 表和相关规则引擎实现。
目标:
- 统一 swing high / swing low 判定口径
- 建立 ST / IT / LT 强度层级
- 明确 provisional / confirmed 确认时机
- 支持按 timeframe 参数化
- 为 dealing range、liquidity、MSS/MSB 提供稳定上游结构点
适用范围:AI ICT 项目第一阶段 MVP。
关联文档:
1. `AI_ICT_总体设计文档_v1.md`
2. `AI_ICT_PostgreSQL_Schema_v1.md`
3. `AI_ICT_MVP_规则规格书_v1.md`
4. `AI_ICT_开发路线图_v1.md`
5. `AI_ICT_执行检查清单_v1.md`
---
## 2. 设计目标与边界
### 2.1 目标
swing identification 负责从标准化 candles 中提取结构高低点,作为后续 dealing range、liquidity pools、structure events 的基础输入。
输出必须支持:
- `swing_high`
- `swing_low`
- `ST / IT / LT` 强度分层
- provisional / confirmed 状态
- 多 timeframe 稳定重算
### 2.2 不负责的内容
第一版 swing 模块不负责:
- 直接计算 dealing range
- 直接判定 liquidity pool
- 直接判定 MSB / MSS
- 直接生成 trade signal
这些能力依赖 swing 结果,但由后续模块单独实现。
---
## 3. 核心概念
### 3.1 Swing High
`swing_high` 指某一根 candle 的高点,相对于其左右观察窗口内的其他 candles,构成局部显著高点。
### 3.2 Swing Low
`swing_low` 指某一根 candle 的低点,相对于其左右观察窗口内的其他 candles,构成局部显著低点。
### 3.3 Left / Right Bars
- `left_bars`:候选 pivot 左侧参与比较的 bars 数量
- `right_bars`:候选 pivot 右侧参与比较的 bars 数量
### 3.4 Provisional / Confirmed
- `provisional`:左侧条件满足,但右侧确认窗口尚未走完
- `confirmed`:左右侧窗口都满足条件,pivot 已确认
---
## 4. 输入与输出
### 4.1 输入
最小输入:
- `instrument_id`
- `timeframe`
- 标准化 `candles`
- 当前 swing 参数配置
- 待重算时间范围
candles 最低要求字段:
- `ts_open`
- `high`
- `low`
- `close`
### 4.2 输出
输出到 `swings` 表,遵循既有 schema
- `instrument_id`
- `timeframe`
- `kind`
- `strength`
- `price`
- `ts`
- `left_bars`
- `right_bars`
- `confirmed`
- `meta`
---
## 5. Pivot 判定规则
### 5.1 基础判定窗口
给定某根 candle `i`,若要判断它是否为 pivot
- 左侧比较范围:`i - left_bars``i - 1`
- 右侧比较范围:`i + 1``i + right_bars`
### 5.2 Swing High 判定
第一版推荐规则:
```text
candle[i].high > max(left window highs)
and
candle[i].high >= max(right window highs)
```
即:
- 对左侧使用严格大于
- 对右侧使用大于等于
这样做的目的:
- 避免在左侧已有同价高点时重复生成 pivot
- 在右侧出现相同高点时,优先保留更早出现的候选点
### 5.3 Swing Low 判定
第一版推荐规则:
```text
candle[i].low < min(left window lows)
and
candle[i].low <= min(right window lows)
```
即:
- 对左侧使用严格小于
- 对右侧使用小于等于
### 5.4 使用 wick 还是 close
第一版 swing 判定统一基于:
- high pivot 使用 `high`
- low pivot 使用 `low`
不使用 close 来定义 swing 本身。
原因:
- swing 的本质是结构极值点
- wick 对流动性与局部极值更敏感
- close 更适合后续 displacement / MSS 过滤,而不是 swing 原始识别
---
## 6. ST / IT / LT 强度层级
### 6.1 ST 定义
`ST`Short-Term)是最基础的局部 pivot。
第一版默认:
- 先识别全部 `ST` swings
- 所有更高层级都建立在 `ST` 基础上
### 6.2 IT 定义
`IT`Intermediate-Term)是对 `ST` 的再筛选。
推荐规则:
- 某个 `ST swing_high` 若高于其相邻的前后 `ST swing_high`,可升级为 `IT swing_high`
- 某个 `ST swing_low` 若低于其相邻的前后 `ST swing_low`,可升级为 `IT swing_low`
也就是:
- `IT` 不是直接从 candles 识别
- `IT` 从已确认的 `ST` 序列递归提取
### 6.3 LT 定义
`LT`Long-Term)是对 `IT` 的再筛选。
推荐规则:
- 某个 `IT swing_high` 若高于其相邻的前后 `IT swing_high`,升级为 `LT swing_high`
- 某个 `IT swing_low` 若低于其相邻的前后 `IT swing_low`,升级为 `LT swing_low`
### 6.4 层级生成顺序
统一采用:
```text
candles -> ST -> IT -> LT
```
不允许跳层直接从 candles 识别 LT。
### 6.5 层级意义
- `ST`:局部结构、短期波动、触发细节
- `IT`:中层结构、主工作区间候选
- `LT`:大级别结构、主 draw 与大范围边界参考
---
## 7. 确认时机
### 7.1 ST 确认
某个 ST pivot 只有在右侧 `right_bars` 数量的 candles 都已经存在后,才能标记 `confirmed = true`
因此:
- 当前最新若干根 bars 附近的 pivot 只能是 provisional
- 不能提前把未走完右窗的点当成 confirmed
### 7.2 IT / LT 确认
`IT` / `LT` 的确认建立在下一级 swing 已确认的前提上。
即:
- 未确认的 ST 不能用于确认 IT
- 未确认的 IT 不能用于确认 LT
### 7.3 实时与历史差异
- 历史回算时,可一次性给出完整 confirmed 结果
- 实时增量时,最新区域必须允许 provisional 存在
- 后续新增 candles 到来后,允许 provisional 升级为 confirmed
---
## 8. Timeframe 参数化
### 8.1 参数化原则
不同 timeframe 的波动噪声不同,pivot 窗口必须参数化。
建议按 timeframe 单独配置:
- `st_left_bars`
- `st_right_bars`
- `it_neighbor_span`
- `lt_neighbor_span`
- `min_price_distance`(可选)
- `min_bar_distance`(可选)
### 8.2 第一版建议参数
以下为建议默认值,后续可调:
```text
M1: ST = 2/2
M5: ST = 2/2
M15: ST = 3/3
H1: ST = 3/3
H4: ST = 4/4
D1: ST = 5/5
```
IT / LT 默认可先使用“前后各 1 个同级 swing 比较”的方式生成。
### 8.3 噪声过滤参数
第一版可选增加两类过滤:
1. `min_bar_distance`
- 两个同向 swing 之间至少相隔若干 bars
2. `min_price_distance`
- 两个同向 swing 之间至少相隔最小价格距离或最小 ATR 比例
若未启用过滤,第一版仍可先按纯 pivot 规则运行。
---
## 9. 边界与异常情况
### 9.1 数据不足
若当前窗口不足以覆盖 `left_bars + right_bars + 1`
- 不生成 confirmed swing
- 可选择不输出,或仅输出 provisional
- 第一版更建议不输出,避免过早污染结构
### 9.2 相等高点 / 相等低点
当多个相邻 candles 出现相同 high 或 low
- 按 5.2 / 5.3 的不对称比较规则处理
- 若仍无法唯一确定,优先保留更早出现的 candle
- 同一价格平台不应生成多个完全重复的同级 swing
### 9.3 外包 K 线
若某一根 candle 同时包住左右多根 bars
- 只要满足 pivot 条件,仍允许成为 swing
- 不因实体大小过大而自动剔除
- 但可在 `meta` 中记录 bar range 异常信息
### 9.4 极端噪声行情
若短时间内产生大量相邻 swings:
- 先按规则落地 ST
- 通过 IT / LT 递归筛选降低噪声
- 如仍过密,再启用 `min_bar_distance` / `min_price_distance`
### 9.5 上游 candle 修复
若 candles 历史数据被补齐、修正或重采样:
- 必须触发对应 timeframe swing 重算
- 不允许保留旧 swing 结果
### 9.6 跨 timeframe 独立性
不同 timeframe 的 swing 独立生成:
- M15 swing 不直接映射为 H1 swing
- 高 timeframe 必须基于该 timeframe 自己的 candles 计算
---
## 10. 存储模型
### 10.1 `swings` 字段映射
- `instrument_id`:所属交易品种
- `timeframe`:对应时间框架
- `kind``swing_high` / `swing_low`
- `strength``ST` / `IT` / `LT`
- `price`pivot 价格;high 用 candle.highlow 用 candle.low
- `ts`pivot candle 的时间戳,建议使用 `ts_open`
- `left_bars`:左窗参数
- `right_bars`:右窗参数
- `confirmed`:是否确认
- `meta`:额外说明
### 10.2 推荐 meta 内容
建议在 `meta` 中记录:
- `source_timeframe`
- `pivot_index`
- `calc_version`
- `is_provisional`
- `neighbor_refs`
- `price_distance`
- `bar_distance`
- `rebuild_reason`
### 10.3 唯一性建议
第一版建议逻辑唯一键为:
```text
(instrument_id, timeframe, strength, kind, ts)
```
如暂不建数据库唯一约束,也应在写入层保证 upsert 口径一致。
### 10.4 更新策略
建议:
- 历史 confirmed 区域使用幂等 upsert
- 最新 provisional 区域允许被覆盖更新
- 若同一 `ts` 的 provisional 后续转 confirmed,应更新而不是插入新记录
---
## 11. 生成流程规范
### 11.1 历史全量重算
流程:
1. 读取指定 `instrument + timeframe + date range`
2. 生成 ST 候选并确认
3. 从 ST 提取 IT
4. 从 IT 提取 LT
5. 写入 `swings`
6. 对结果做去重与顺序检查
### 11.2 增量刷新
流程:
1. 只拉取受影响的最近窗口 candles
2. 重算尾部 ST
3. 级联刷新受影响的 IT / LT
4. upsert 到 `swings`
### 11.3 结果顺序要求
同一 `instrument + timeframe + strength` 下:
- swings 必须按时间可排序
- 不要求 high / low 严格交替,但若连续同向 swing 过多,应能被后续过滤发现
---
## 12. 验证与验收要求
### 12.1 功能验证
至少验证:
- 能按 timeframe 产出稳定 swing 列表
- 同一份 candles 重复计算结果一致
- 最新尾部区域能正确区分 provisional / confirmed
- ST / IT / LT 之间数量呈递减关系
### 12.2 数据验证
至少验证:
- `swing_high` 的价格等于对应 candle.high
- `swing_low` 的价格等于对应 candle.low
- `confirmed` 只在右侧窗口完成后出现
- 不同 timeframe 结果互不污染
### 12.3 人工抽查
至少抽查:
- 一个趋势行情样本
- 一个震荡行情样本
- 一个 equal highs / equal lows 附近样本
- 一个最新尾部存在 provisional 的样本
### 12.4 与下游一致性检查
至少确认:
- dealing range 可引用 swing 结果
- liquidity pools 可从 prior highs / lows 提取候选
- MSS / MSB 使用的参考 swing 与图表直觉一致
---
## 13. 推荐实现顺序
建议按以下顺序实现:
1. 完成 ST 基础 pivot 识别
2. 完成 confirmed / provisional 状态管理
3. 完成 ST 结果幂等落库
4. 完成 IT 递归提取
5. 完成 LT 递归提取
6. 再补噪声过滤参数
这样可以先保证下游有可用的基础结构点,再逐步增加层级复杂度。
---
## 14. 最终结论
swing identification 是全部结构分析的基础层。
第一版必须保证四件事:
1. pivot 口径明确
2. 确认时机严格
3. 多层级生成可重复
4. 写入结果可稳定重算
只有这四点成立后,dealing range、liquidity pools、MSS/MSB 才有稳定可靠的结构基础。
+229
View File
@@ -0,0 +1,229 @@
# AI ICT 当前开发方案 v2
## 1. 方案目标
本文档用于替代已经过时的“项目启动期”规划文档,在当前代码现实下重新定义项目需求边界、优先级和开发顺序。
当前项目不是从零开始,而是已经形成两条主线:
- 交易主线:已打通从市场数据到回测、报告、paper/semi-auto execution 的最小闭环。
- 训练主线:已形成对外产品模块,公开成熟度通过 M40。
因此,新方案必须以“质量增强、状态收敛、可验证推进”为主,而不是重复基础搭建。
## 2. 当前项目现实
### 2.1 交易主线现状
已具备:
- `OKX -> candles -> market_sessions -> swings -> dealing_ranges -> liquidity_pools -> sweep_events -> displacement_events -> MSS -> FVG -> bias -> signals -> backtest`
- execution / settlement / report / dashboard 的第一版闭环
- offline checks、质量门槛、脚本静态检查和无人流程入口
当前主要问题不再是“有没有模块”,而是:
1. 回测是否真正无未来函数、足够接近真实决策过程。
2. 结构状态是否足够稳定,便于 signal 和 training 复用。
3. execution / report 是否已经建立可信的后验解释链。
### 2.2 训练主线现状
已具备:
- 公开 API 路由
- Training dashboard 面板
- offline package import/export
- public error contract
- privacy contract coverage
- 文档成熟度通过 `M40`
结论:
- Training 不是空白模块。
- 当前不应优先再扩大量新功能。
- Training 的新增能力应建立在交易规则语义稳定的前提上。
## 3. 对旧方案的判断
### 3.1 仍然合理、继续保留的部分
以下原则继续成立:
1. 双主线边界必须严格保持:Training 只读消费 Trading,上游和下游不能反向污染。
2. 先规则、再信号、再回测、再执行的工程顺序是对的。
3. offline validation 和可回滚原则必须继续保持。
4. 当前阶段仍不应进入真实全自动交易。
### 3.2 需要明确修正的部分
以下旧文档判断已不再合理:
1. 把项目视作“刚启动,需要先建骨架和第一批表”的规划已过期。
2. 把 Training 当作尚未落地的 MVP 0->1 项目已不准确;它已是已交付模块,当前应以维护和输入质量提升为主。
3. 继续优先补更多 planning/checklist 文档,不如直接提升交易主线的可验证质量。
4. 在 replay/backtest 可信度不足前,继续扩展 execution 自动化或大型 Training 新功能,收益低且风险高。
## 4. 新的开发优先级
当前建议优先级:
### P0 文档治理与当前入口收敛
目标:
- 清理重复、过期、历史快照文档
- 只保留当前有效入口
- 固定 active / archive 边界
状态:
- 本轮执行
### P1 交易主线回测真实性
目标:
- 让回测从“结果可算”提升到“过程可信”
重点:
1. 把 replay/no-lookahead 能力接入 backtest 路径
2. 明确 frame、decision point、confirmation window 的统一口径
3. 让 backtest 结果可解释地对应当时可见的信息集合
为什么先做这个:
- 这是交易主线当前最大真实性缺口
- 也是 Training 案例生成和评分可信度的上游基础
### P2 交易规则状态质量
目标:
- 提高结构事件的稳定性和下游可用性
重点:
1. liquidity equal highs / lows merge 与去重
2. MSS accepted / rejected / invalidated 语义收敛
3. FVG mitigation / invalidation / gap warning 元数据向下游传播
4. signal evidence completeness 与 session gate 继续收紧
### P3 执行与报告语义闭环
目标:
- 让 execution 和 report 更贴近真实后验复盘
重点:
1. position lifecycle / settlement 语义继续细化
2. backtest / execution / report 的指标口径统一
3. 增加按 session、weekday、symbol 的质量拆分统计
### P4 Training 维护与小步增强
目标:
- 保持 M40 公开契约稳定,同时只做低风险增强
重点:
1. 保持 dashboard / API / package / privacy / error contract 绿色
2. 优先做 case quality 提升,而不是扩更多前台功能
3. 只有当 P1-P2 稳定后,才扩展新的自动 case generator 能力
## 5. 新阶段划分
## 阶段 A:文档治理
交付物:
- 新文档索引
- archive 结构
- 过期/重复/历史文档归档
验收:
- `docs/` 根目录阅读路径清晰
- active 文档与 archive 文档边界明确
## 阶段 BReplay-backed Backtest
交付物:
- replay/no-lookahead 路径接入 backtest 服务或独立 backtest 入口
- 定向单元测试
- offline 验证命令
验收:
- 回测在 decision point 之后不读取未来信息
- replay 结果可输出质量门槛与失败原因
## 阶段 CRule State Hardening
交付物:
- liquidity / MSS / FVG 状态增强
- signal evidence / session gating 补强
验收:
- 结构状态更可追踪
- signal 过滤理由更清晰
## 阶段 DExecution / Report Hardening
交付物:
- 更细 execution lifecycle
- 更一致的 backtest/report 统计
验收:
- report 能更直接对应 backtest 与 execution 结果
## 阶段 ETraining Upstream Quality Lift
交付物:
- 基于更稳定规则事件的 case quality 增强
- 必要时扩展训练案例生成器
验收:
- 不破坏 M40 公开契约
- 训练案例更贴近真实高质量结构事件
## 6. 当前非目标
当前明确不做:
1. 真实全自动下单
2. 以 Training 用户行为反向影响交易决策
3. 大规模新增 Training 产品面功能
4. 仅为“文档完整”而继续堆积启动期/计划期文档
## 7. 当前建议的下一步开发任务
按照本方案,下一步应该直接进入:
`Replay-backed Backtest`
具体第一刀:
1. 为 backtest 增加 replay/no-lookahead 入口或模式。
2. 让回测在每个 decision point 只看到当时可见 candles / swings / 结构状态。
3. 为该路径补 focused unit tests 和 offline validation。
## 8. 当前验证基线
在进入下一轮代码开发前,继续以以下命令作为最小验证基线:
```bash
.venv/bin/python3 -m unittest discover -s tests/unit -p "test_*.py"
.venv/bin/python3 scripts/run_offline_checks.py
.venv/bin/python3 scripts/check_local_demo_readiness.py
```
@@ -0,0 +1,153 @@
# AI ICT 文档索引与命名规范 v2
## 1. 文档目标
本文档用于在当前代码现实下重建一份清晰、可维护的文档入口,解决以下问题:
1. 根目录 `docs/` 文件过多,阅读入口不清晰。
2. 存在重复命名、双语平行版本、历史快照和过期规划混放的问题。
3. 旧规划文档仍停留在“项目启动期”,和当前代码已进入交易闭环与 Training M40 阶段不一致。
本版规则:
- `docs/` 根目录只保留当前仍有直接指导意义的 active / canonical 项目文档。
- 历史和过期规划统一进入 `docs/archive/`
- 通用工作流辅助文档统一进入 `docs/workflow/`
- 同一主题只允许一个当前主文件名。
## 2. 当前推荐阅读顺序
第一次进入项目,按以下顺序阅读:
1. `AI_ICT_当前开发方案_v2.md`
2. `AI_ICT_双主线研发治理计划_v1.md`
3. `AI_ICT_主线进度摘要_v1.md`
4. `training_case_engine_m1_m6_product_readme.md`
5. `AI_ICT_总体设计文档_v1.md`
6. `AI_ICT_PostgreSQL_Schema_v1.md`
7. `AI_ICT_MVP_规则规格书_v1.md`
如果要进入具体模块开发,再按模块规格书继续读:
- `AI_ICT_OKX_数据接入规格书_v1.md`
- `AI_ICT_Session_生成器规格书_v1.md`
- `AI_ICT_Swing_识别规格书_v1.md`
- `AI_ICT_Dealing_Range_规格书_v1.md`
- `AI_ICT_Liquidity_Pool_规格书_v1.md`
- `AI_ICT_Sweep_识别规格书_v1.md`
- `AI_ICT_Displacement_规格书_v1.md`
- `AI_ICT_MSS_判定规格书_v1.md`
- `AI_ICT_FVG_规格书_v1.md`
## 3. 当前 active / canonical 文档
### 3.1 项目治理与当前主计划
- `AI_ICT_当前开发方案_v2.md`
- 当前唯一的开发优先级与阶段计划入口。
- `AI_ICT_双主线研发治理计划_v1.md`
- 交易主线和训练主线的边界治理规则。
- `AI_ICT_主线进度摘要_v1.md`
- 当前代码闭环和结构状态的高层摘要。
### 3.2 训练产品当前入口
- `training_case_engine_m1_m6_product_readme.md`
- 当前对外可见的 Training M1-M40 成熟度、验证方式、公开契约入口。
- `AI_ICT_Training_Case_Engine_外部产品化MVP计划_v1.md`
- Training 的产品边界和核心需求来源。
- `AI_ICT_Training_Case_Engine_开发方案_v1.md`
- Training 模块内部结构与实体设计背景。
### 3.3 系统设计与数据库/规则主参考
- `AI_ICT_总体设计文档_v1.md`
- `AI_ICT_PostgreSQL_Schema_v1.md`
- `AI_ICT_MVP_规则规格书_v1.md`
- `AI_ICT_Migration_拆分计划_v1.md`
- `AI_ICT_数据库_Migration_SQL_草案_v1.md`
- `AI_ICT_第一批正式_Migration_文件草案_v1.md`
- `AI_ICT_第二批正式_Migration_文件草案_v1.md`
### 3.4 交易模块规格书主参考
- `AI_ICT_OKX_数据接入规格书_v1.md`
- `AI_ICT_Session_生成器规格书_v1.md`
- `AI_ICT_Swing_识别规格书_v1.md`
- `AI_ICT_Dealing_Range_规格书_v1.md`
- `AI_ICT_Liquidity_Pool_规格书_v1.md`
- `AI_ICT_Sweep_识别规格书_v1.md`
- `AI_ICT_Displacement_规格书_v1.md`
- `AI_ICT_MSS_判定规格书_v1.md`
- `AI_ICT_FVG_规格书_v1.md`
### 3.5 工作流辅助文档
以下文档仍保留,但已移出项目主阅读路径,统一放入 `docs/workflow/`
- `AI_协作自动化工作规程_v1.md`
- `AI_自动化协作迁移清单_v1.md`
- `CLAUDE_template_autonomy_first.md`
- `automation_workflow_migration_checklist.md`
- `标准_CLAUDE_md_模板_v1.md`
## 4. 已归档类别
### 4.1 deleted duplicate-language / duplicate-theme files
以下重复文件已直接删除,不再保留归档副本:
- `AI_ICT_Codebase_Skeleton_Spec_v1.md`
- `AI_ICT_Dealing_Range_Spec_v1.md`
- `AI_ICT_PostgreSQL_Migration_SQL_Drafts_v1.md`
- `AI_ICT_PostgreSQL_Migration_Split_Plan_v1.md`
- `AI_ICT_Session_Generator_Spec_v1.md`
- `AI_ICT_Sweep_Identification_Spec_v1.md`
- `AI_ICT_Swing_Identification_Spec_v1.md`
对应 canonical
- `AI_ICT_项目代码骨架规范_v1.md`
- `AI_ICT_Dealing_Range_规格书_v1.md`
- `AI_ICT_数据库_Migration_SQL_草案_v1.md`
- `AI_ICT_Migration_拆分计划_v1.md`
- `AI_ICT_Session_生成器规格书_v1.md`
- `AI_ICT_Sweep_识别规格书_v1.md`
- `AI_ICT_Swing_识别规格书_v1.md`
删除依据:
- 当前仓库已有明确主版本。
- Git 历史已经保留被删版本。
- 再保留物理副本只会继续制造阅读噪音。
### 4.2 obsolete plans
以下文件已进入 `docs/archive/obsolete_plans/`
- `AI_ICT_开发路线图_v1.md`
- `AI_ICT_项目启动清单_v1.md`
- `AI_ICT_执行检查清单_v1.md`
- `AI_ICT_文档索引与命名规范_v1.md`
- `AI_ICT_重复文档清理计划_v1.md`
这些文件的问题不是“没有价值”,而是都建立在项目尚处启动期或清理前的状态上,已不适合作为当前入口。
### 4.3 history
以下文件已进入 `docs/archive/history/`
- `AI_ICT_主线开发检查点_v1.md`
- `AI_ICT_当前工作断点_2026-04-24.md`
- `AI_ICT_报告样本_2026-04-24.md`
- `AI_ICT_离线自动化推进记录_2026-04-29.md`
这些文件保留历史上下文,但不再作为当前需求和计划入口。
## 5. 后续命名规则
1. 同一主题只保留一个当前 canonical 文件名。
2. 重大重写使用 `v2``v3`,不要再创建平行同义文件。
3. 双语平行版本不再在根目录长期并存;确定为重复后直接删除。
4. 含明确日期的工作断点、报告样本、推进记录,默认进入 `archive/history/`
5. 只要文档不再匹配当前代码阶段,就必须归档或重写,不能继续留在根目录冒充当前计划。
+15
View File
@@ -0,0 +1,15 @@
# Documentation Archive
This directory keeps files that are no longer part of the active project reading path.
Archive groups:
- `history/`: dated snapshots, temporary reports, and one-off continuity notes.
- `obsolete_plans/`: planning documents that no longer match the current code and product stage.
Archive rules:
- Archived files are kept for traceability and historical reference.
- Pure duplicate documents are deleted instead of archived once their canonical replacements are recorded in `docs/AI_ICT_文档索引与命名规范_v2.md`.
- New project work should not cite archived files as canonical requirements.
- Active project reading should start from `docs/AI_ICT_文档索引与命名规范_v2.md`.
+7
View File
@@ -0,0 +1,7 @@
# Workflow Docs
This directory contains generic Codex / Claude workflow helpers rather than AI ICT product requirements.
Contents here are still useful for team workflow migration and prompt/template reuse, but they are not part of the current project development reading path.
For project requirements and engineering plans, start from `docs/AI_ICT_文档索引与命名规范_v2.md`.
+23
View File
@@ -11,8 +11,10 @@ if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from src.services.backtest import (
BACKTEST_MODE_ENTRY_THEN_OUTCOME,
BacktestCostConfig,
BacktestQualityGate,
BacktestReplayConfig,
BacktestService,
evaluate_backtest_quality,
summarize_backtest_outcomes,
@@ -47,6 +49,21 @@ def build_quality_gate_from_env(env=None) -> BacktestQualityGate:
)
def build_backtest_mode_from_env(env=None) -> str:
env = os.environ if env is None else env
value = str(env.get("BACKTEST_MODE") or "").strip()
return value or BACKTEST_MODE_ENTRY_THEN_OUTCOME
def build_replay_config_from_env(env=None) -> BacktestReplayConfig:
env = os.environ if env is None else env
defaults = BacktestReplayConfig()
return BacktestReplayConfig(
left_bars=_get_int(env, "BACKTEST_REPLAY_LEFT_BARS", defaults.left_bars),
right_bars=_get_int(env, "BACKTEST_REPLAY_RIGHT_BARS", defaults.right_bars),
)
def run_backtest_once(
service: BacktestService,
instrument_id: int = 1,
@@ -55,12 +72,16 @@ def run_backtest_once(
quality_gate: Optional[BacktestQualityGate] = None,
require_quality_gate: bool = False,
output_json: bool = False,
backtest_mode: str = BACKTEST_MODE_ENTRY_THEN_OUTCOME,
replay_config: Optional[BacktestReplayConfig] = None,
) -> int:
run_id, outcomes = service.run_backtest(
instrument_id=instrument_id,
limit=limit,
cost_config=cost_config,
quality_gate=quality_gate,
mode=backtest_mode,
replay_config=replay_config,
)
summary = summarize_backtest_outcomes(outcomes)
quality_gate_result = evaluate_backtest_quality(summary, gate=quality_gate)
@@ -96,6 +117,8 @@ def main(env=None) -> int:
quality_gate=build_quality_gate_from_env(env=env),
require_quality_gate=build_require_quality_gate_from_env(env=env),
output_json=output_json,
backtest_mode=build_backtest_mode_from_env(env=env),
replay_config=build_replay_config_from_env(env=env),
)
except SQLAlchemyError as exc:
summary = {
+7 -3
View File
@@ -1,15 +1,19 @@
from pathlib import Path
import os
import sys
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from src.main import main as app_main
from src.api.server import serve_api
def main() -> int:
app_main()
def main(env=None) -> int:
env = os.environ if env is None else env
host = env.get("AI_ICT_API_HOST", "0.0.0.0")
port = int(env.get("AI_ICT_API_PORT", "8000"))
serve_api(host=host, port=port)
return 0
+4
View File
@@ -12,7 +12,9 @@ if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts.run_backtest import (
build_backtest_mode_from_env,
build_cost_config_from_env,
build_replay_config_from_env,
build_quality_gate_from_env,
build_require_quality_gate_from_env,
)
@@ -32,6 +34,8 @@ def main(env=None) -> int:
run_execution=True,
backtest_cost_config=build_cost_config_from_env(env=env),
backtest_quality_gate=build_quality_gate_from_env(env=env),
backtest_mode=build_backtest_mode_from_env(env=env),
backtest_replay_config=build_replay_config_from_env(env=env),
signal_required_evidence_keys=build_signal_evidence_keys_from_env(env=env),
execution_account_state=build_account_risk_state_from_env(env=env),
)
+16 -1
View File
@@ -16,4 +16,19 @@ def get_recent_signals(instrument_id: int, limit: int = 20) -> list[dict]:
)
with SessionLocal() as session:
rows = session.execute(sql, {"instrument_id": instrument_id, "limit": limit}).mappings().all()
return [dict(row) for row in rows]
return [normalize_signal_row(dict(row)) for row in rows]
def normalize_signal_row(row: dict) -> dict:
payload = dict(row)
meta = dict(payload.get("meta") or {})
payload["structure_state"] = meta.get("structure_state")
payload["mss_state"] = meta.get("mss_state")
payload["mss_status_reason"] = meta.get("mss_status_reason")
payload["fvg_status"] = meta.get("fvg_status")
payload["fvg_status_reason"] = meta.get("fvg_status_reason")
payload["execution_permission"] = meta.get("execution_permission")
payload["fvg_fill_pct"] = float(meta.get("fvg_fill_pct") or 0.0)
payload["fvg_touch_count"] = int(meta.get("fvg_touch_count") or 0)
payload["fvg_data_quality_warnings"] = list(meta.get("fvg_data_quality_warnings") or [])
return payload
+6
View File
@@ -4,8 +4,10 @@ from typing import Optional
from src.api import get_health, get_recent_backtest_summaries, get_recent_signals
from src.services.backtest import (
BACKTEST_MODE_ENTRY_THEN_OUTCOME,
BacktestCostConfig,
BacktestQualityGate,
BacktestReplayConfig,
BacktestService,
evaluate_backtest_quality,
summarize_backtest_outcomes,
@@ -53,6 +55,8 @@ def run_closed_loop(
run_execution: bool = True,
backtest_cost_config: Optional[BacktestCostConfig] = None,
backtest_quality_gate: Optional[BacktestQualityGate] = None,
backtest_mode: str = BACKTEST_MODE_ENTRY_THEN_OUTCOME,
backtest_replay_config: Optional[BacktestReplayConfig] = None,
signal_required_evidence_keys: tuple[str, ...] = MINIMAL_SIGNAL_EVIDENCE_KEYS,
execution_account_state: Optional[AccountRiskState] = None,
dependencies: Optional[dict] = None,
@@ -123,6 +127,8 @@ def run_closed_loop(
limit=backtest_limit,
cost_config=backtest_cost_config,
quality_gate=backtest_quality_gate,
mode=backtest_mode,
replay_config=backtest_replay_config,
)
backtest_summary = summarize_backtest_outcomes(outcomes)
backtest_quality_gate_result = evaluate_backtest_quality(backtest_summary, gate=backtest_quality_gate)
+1 -1
View File
@@ -30,7 +30,7 @@ class BiasRepository:
where instrument_id = :instrument_id
and timeframe = :timeframe
and event_type = 'MSS'
order by ts desc
order by ts desc, id desc
limit 1
"""
)
@@ -80,6 +80,43 @@ class LiquidityPoolRepository:
).mappings().first()
return dict(row) if row else None
def fetch_recent_pools_at_level(
self,
instrument_id: int,
timeframe: str,
side: str,
price_low: Decimal,
price_high: Decimal,
limit: int = 10,
) -> list[dict]:
sql = text(
"""
select id, side, pool_type, price_low, price_high, reference_ids, created_ts, meta
from liquidity_pools
where instrument_id = :instrument_id
and timeframe = :timeframe
and status = 'untouched'
and side = :side
and price_low = :price_low
and price_high = :price_high
order by created_ts desc, id desc
limit :limit
"""
)
with SessionLocal() as session:
rows = session.execute(
sql,
{
"instrument_id": instrument_id,
"timeframe": timeframe,
"side": side,
"price_low": price_low,
"price_high": price_high,
"limit": limit,
},
).mappings().all()
return [dict(row) for row in rows]
def insert_pool(
self,
instrument_id: int,
+3 -4
View File
@@ -32,10 +32,10 @@ class SignalRepository:
from fvg_zones
where instrument_id = :instrument_id
and timeframe = :timeframe
and status in ('open', 'touched')
order by
case when related_displacement_id is not null then 0 else 1 end,
end_ts desc
end_ts desc,
id desc
limit 1
"""
)
@@ -51,8 +51,7 @@ class SignalRepository:
where instrument_id = :instrument_id
and timeframe = :timeframe
and event_type = 'MSS'
and accepted = true
order by ts desc
order by ts desc, id desc
limit 1
"""
)
-1
View File
@@ -70,7 +70,6 @@ class StructureRepository:
where instrument_id = :instrument_id
and timeframe = :timeframe
and event_type = 'MSS'
and accepted = true
order by ts desc, id desc
limit 1
"""
+8
View File
@@ -1,8 +1,11 @@
"""Backtest service package."""
from src.services.backtest.backtest_service import (
BACKTEST_MODE_ENTRY_THEN_OUTCOME,
BACKTEST_MODE_REPLAY_NO_LOOKAHEAD,
BacktestOutcome,
BacktestCostConfig,
BacktestReplayConfig,
BacktestService,
BacktestQualityGate,
BacktestSignal,
@@ -13,6 +16,7 @@ from src.services.backtest.backtest_service import (
estimate_trade_cost_r,
evaluate_backtest_quality,
evaluate_signal_outcome,
evaluate_signal_outcome_with_replay,
summarize_backtest_outcomes,
)
from src.services.backtest.replay import (
@@ -28,7 +32,10 @@ from src.services.backtest.replay import (
__all__ = [
"BacktestOutcome",
"BACKTEST_MODE_ENTRY_THEN_OUTCOME",
"BACKTEST_MODE_REPLAY_NO_LOOKAHEAD",
"BacktestCostConfig",
"BacktestReplayConfig",
"BacktestService",
"BacktestQualityGate",
"BacktestSignal",
@@ -39,6 +46,7 @@ __all__ = [
"estimate_trade_cost_r",
"evaluate_backtest_quality",
"evaluate_signal_outcome",
"evaluate_signal_outcome_with_replay",
"REPLAY_REQUIRED_CANDLE_FIELDS",
"ReplayFrame",
"ReplaySwing",
+211 -15
View File
@@ -3,9 +3,16 @@ from datetime import datetime
from typing import Optional
from src.repositories.backtest_repository import BacktestRepository
from src.services.backtest.replay import ReplaySwing, iter_historical_replay
SAME_CANDLE_PRIORITY = "stop_first"
BACKTEST_MODE_ENTRY_THEN_OUTCOME = "entry_then_outcome"
BACKTEST_MODE_REPLAY_NO_LOOKAHEAD = "replay_no_lookahead"
SUPPORTED_BACKTEST_MODES = {
BACKTEST_MODE_ENTRY_THEN_OUTCOME,
BACKTEST_MODE_REPLAY_NO_LOOKAHEAD,
}
@dataclass(frozen=True)
@@ -41,6 +48,12 @@ class BacktestCostConfig:
fixed_cost_r: float = 0.0
@dataclass(frozen=True)
class BacktestReplayConfig:
left_bars: int = 2
right_bars: int = 2
@dataclass(frozen=True)
class BacktestQualityGate:
min_total_signals: int = 100
@@ -61,9 +74,14 @@ class BacktestService:
limit: int = 100,
cost_config: Optional[BacktestCostConfig] = None,
quality_gate: Optional[BacktestQualityGate] = None,
mode: str = BACKTEST_MODE_ENTRY_THEN_OUTCOME,
replay_config: Optional[BacktestReplayConfig] = None,
) -> tuple[int, list[BacktestOutcome]]:
cost_config = cost_config or BacktestCostConfig()
quality_gate = quality_gate or BacktestQualityGate()
replay_config = replay_config or BacktestReplayConfig()
if mode not in SUPPORTED_BACKTEST_MODES:
raise ValueError(f"invalid_backtest_mode:{mode}")
signals = self.repository.fetch_candidate_signals(instrument_id=instrument_id, limit=limit)
run_id = self.repository.insert_backtest_run(
model_code='SWEEP_MSS_FVG',
@@ -71,11 +89,12 @@ class BacktestService:
timeframe_set={'bias_tf': '1m', 'setup_tf': '1m', 'trigger_tf': '1m'},
config={
'limit': limit,
'mode': 'entry_then_outcome',
'mode': mode,
'same_candle_priority': 'stop_first',
'session_filter': 'mark_outside',
'cost_model': cost_config.__dict__,
'quality_gate': quality_gate.__dict__,
'replay': replay_config.__dict__ if mode == BACKTEST_MODE_REPLAY_NO_LOOKAHEAD else None,
},
)
outcomes: list[BacktestOutcome] = []
@@ -114,8 +133,11 @@ class BacktestService:
'rr_tp2': float(signal.get('rr_tp2') or 0.0),
'cost_r': 0.0,
'cost_model': cost_config.__dict__,
'backtest_mode': mode,
'r_multiple': 0.0,
'weekday': _extract_weekday(signal['created_ts']),
'replay_left_bars': replay_config.left_bars if mode == BACKTEST_MODE_REPLAY_NO_LOOKAHEAD else None,
'replay_right_bars': replay_config.right_bars if mode == BACKTEST_MODE_REPLAY_NO_LOOKAHEAD else None,
},
)
outcomes.append(record)
@@ -148,6 +170,8 @@ class BacktestService:
),
future,
cost_config=cost_config,
mode=mode,
replay_config=replay_config,
)
if session_filtered:
record.error_tags.append('outside_session_window')
@@ -186,8 +210,133 @@ def evaluate_signal_outcome(
signal: BacktestSignal,
future_candles: list[dict],
cost_config: Optional[BacktestCostConfig] = None,
mode: str = BACKTEST_MODE_ENTRY_THEN_OUTCOME,
replay_config: Optional[BacktestReplayConfig] = None,
) -> BacktestOutcome:
if mode == BACKTEST_MODE_REPLAY_NO_LOOKAHEAD:
return evaluate_signal_outcome_with_replay(
signal=signal,
future_candles=future_candles,
cost_config=cost_config,
replay_config=replay_config,
)
cost_config = cost_config or BacktestCostConfig()
steps = [
_BacktestEvaluationStep(
index=index,
candle=candle,
ts=_extract_candle_ts(candle),
known_candles_count=index + 1,
confirmed_swings_available=0,
last_confirmed_swing=None,
)
for index, candle in enumerate(future_candles)
]
return _evaluate_signal_outcome_steps(
signal=signal,
steps=steps,
cost_config=cost_config,
mode=BACKTEST_MODE_ENTRY_THEN_OUTCOME,
replay_config=None,
)
def evaluate_signal_outcome_with_replay(
signal: BacktestSignal,
future_candles: list[dict],
cost_config: Optional[BacktestCostConfig] = None,
replay_config: Optional[BacktestReplayConfig] = None,
) -> BacktestOutcome:
cost_config = cost_config or BacktestCostConfig()
replay_config = replay_config or BacktestReplayConfig()
try:
steps = _build_replay_evaluation_steps(future_candles=future_candles, replay_config=replay_config)
except ValueError as exc:
return BacktestOutcome(
signal_id=signal.signal_id,
outcome='missed',
mfe=0.0,
mae=0.0,
tp1_hit=False,
tp2_hit=False,
invalidated_before_entry=True,
error_tags=['invalid_replay_candles'],
meta={
'entry_triggered': False,
'same_candle_priority': SAME_CANDLE_PRIORITY,
'same_candle_ambiguous': False,
'signal_side': signal.side,
'rr_tp1': signal.rr_tp1,
'rr_tp2': signal.rr_tp2,
'mfe_r': 0.0,
'mae_r': 0.0,
'cost_r': 0.0,
'cost_model': cost_config.__dict__,
'backtest_mode': BACKTEST_MODE_REPLAY_NO_LOOKAHEAD,
'r_multiple': 0.0,
'replay_error': str(exc),
'replay_frame_count': 0,
'replay_entry_frame_index': None,
'replay_exit_frame_index': None,
'replay_known_candles_at_entry': 0,
'replay_confirmed_swing_count_at_entry': 0,
'replay_last_confirmed_swing_at_entry': None,
'replay_confirmed_swing_count_total': 0,
'replay_left_bars': replay_config.left_bars,
'replay_right_bars': replay_config.right_bars,
},
)
return _evaluate_signal_outcome_steps(
signal=signal,
steps=steps,
cost_config=cost_config,
mode=BACKTEST_MODE_REPLAY_NO_LOOKAHEAD,
replay_config=replay_config,
)
@dataclass(frozen=True)
class _BacktestEvaluationStep:
index: int
candle: dict
ts: object
known_candles_count: int
confirmed_swings_available: int
last_confirmed_swing: Optional[dict]
def _build_replay_evaluation_steps(
future_candles: list[dict],
replay_config: BacktestReplayConfig,
) -> list[_BacktestEvaluationStep]:
steps: list[_BacktestEvaluationStep] = []
confirmed_swings: list[ReplaySwing] = []
for frame in iter_historical_replay(
future_candles,
left_bars=replay_config.left_bars,
right_bars=replay_config.right_bars,
):
confirmed_swings.extend(frame.new_swings)
steps.append(
_BacktestEvaluationStep(
index=frame.index,
candle=frame.candle,
ts=_extract_candle_ts(frame.candle),
known_candles_count=len(frame.known_candles),
confirmed_swings_available=len(confirmed_swings),
last_confirmed_swing=_serialize_replay_swing(confirmed_swings[-1]) if confirmed_swings else None,
)
)
return steps
def _evaluate_signal_outcome_steps(
signal: BacktestSignal,
steps: list[_BacktestEvaluationStep],
cost_config: BacktestCostConfig,
mode: str,
replay_config: Optional[BacktestReplayConfig],
) -> BacktestOutcome:
entry_mid = (signal.entry_low + signal.entry_high) / 2
entry_triggered = False
tp1_hit = False
@@ -196,8 +345,11 @@ def evaluate_signal_outcome(
same_candle_ambiguous = False
mfe = 0.0
mae = 0.0
entry_step: Optional[_BacktestEvaluationStep] = None
exit_step: Optional[_BacktestEvaluationStep] = None
for candle in future_candles:
for step in steps:
candle = step.candle
high = float(candle['high'])
low = float(candle['low'])
entry_now = low <= signal.entry_high and high >= signal.entry_low
@@ -207,6 +359,7 @@ def evaluate_signal_outcome(
if entry_now:
entry_triggered = True
entry_triggered_this_candle = True
entry_step = step
else:
continue
@@ -230,11 +383,13 @@ def evaluate_signal_outcome(
if stop_hit:
stopped_out = True
exit_step = step
break
if tp1_now:
tp1_hit = True
if tp2_now:
tp2_hit = True
exit_step = step
break
error_tags: list[str] = []
@@ -271,6 +426,37 @@ def evaluate_signal_outcome(
cost_r=cost_r,
)
meta = {
'entry_triggered': entry_triggered,
'same_candle_priority': SAME_CANDLE_PRIORITY,
'same_candle_ambiguous': same_candle_ambiguous,
'signal_side': signal.side,
'rr_tp1': signal.rr_tp1,
'rr_tp2': signal.rr_tp2,
'mfe_r': mfe_r,
'mae_r': mae_r,
'cost_r': cost_r,
'cost_model': cost_config.__dict__,
'backtest_mode': mode,
'r_multiple': r_multiple,
}
if mode == BACKTEST_MODE_REPLAY_NO_LOOKAHEAD:
meta.update(
{
'replay_frame_count': len(steps),
'replay_entry_frame_index': entry_step.index if entry_step else None,
'replay_exit_frame_index': exit_step.index if exit_step else None,
'replay_entry_ts': _normalize_meta_ts(entry_step.ts) if entry_step else None,
'replay_exit_ts': _normalize_meta_ts(exit_step.ts) if exit_step else None,
'replay_known_candles_at_entry': entry_step.known_candles_count if entry_step else 0,
'replay_confirmed_swing_count_at_entry': entry_step.confirmed_swings_available if entry_step else 0,
'replay_last_confirmed_swing_at_entry': entry_step.last_confirmed_swing if entry_step else None,
'replay_confirmed_swing_count_total': steps[-1].confirmed_swings_available if steps else 0,
'replay_left_bars': replay_config.left_bars if replay_config else None,
'replay_right_bars': replay_config.right_bars if replay_config else None,
}
)
return BacktestOutcome(
signal_id=signal.signal_id,
outcome=outcome,
@@ -280,22 +466,32 @@ def evaluate_signal_outcome(
tp2_hit=tp2_hit,
invalidated_before_entry=False,
error_tags=error_tags,
meta={
'entry_triggered': entry_triggered,
'same_candle_priority': SAME_CANDLE_PRIORITY,
'same_candle_ambiguous': same_candle_ambiguous,
'signal_side': signal.side,
'rr_tp1': signal.rr_tp1,
'rr_tp2': signal.rr_tp2,
'mfe_r': mfe_r,
'mae_r': mae_r,
'cost_r': cost_r,
'cost_model': cost_config.__dict__,
'r_multiple': r_multiple,
},
meta=meta,
)
def _serialize_replay_swing(swing: ReplaySwing) -> dict:
return {
'kind': swing.kind,
'price': swing.price,
'ts': _normalize_meta_ts(swing.ts),
'source_index': swing.source_index,
'confirmed_at_index': swing.confirmed_at_index,
'left_bars': swing.left_bars,
'right_bars': swing.right_bars,
}
def _extract_candle_ts(candle: dict):
return candle.get('ts_open') or candle.get('ts_close')
def _normalize_meta_ts(value):
if hasattr(value, 'isoformat'):
return value.isoformat()
return value
def estimate_trade_cost_r(
signal: BacktestSignal,
outcome: str,
+114 -17
View File
@@ -1,4 +1,5 @@
from dataclasses import dataclass
from datetime import datetime
import math
from typing import Optional
@@ -26,7 +27,7 @@ class FVGService:
def detect_fvg(self, instrument_id: int, timeframe: str, limit: int = 200) -> list[FVGCandidate]:
candles = self.repository.fetch_recent_candles(instrument_id=instrument_id, timeframe=timeframe, limit=limit)
displacements = self.repository.fetch_recent_displacement(instrument_id=instrument_id, timeframe=timeframe, limit=50)
displacement_by_end = {row['end_ts']: row['id'] for row in displacements}
displacement_by_end = _build_displacement_context_index(displacements)
candidates: list[FVGCandidate] = []
if len(candles) < 3:
return candidates
@@ -169,9 +170,16 @@ def compute_fvg_fill_state(
width = max(upper - lower, 1e-9)
fill_max = 0.0
first_touch_ts = None
last_touch_ts = None
touch_count = 0
invalidated = False
gap_count = 0
previous_candle = None
for candle in candles:
if _has_candle_gap(previous_candle, candle):
gap_count += 1
high = float(candle['high'])
low = float(candle['low'])
close = float(candle['close'])
@@ -179,9 +187,12 @@ def compute_fvg_fill_state(
overlap_high = min(upper, high)
overlap = max(0.0, overlap_high - overlap_low)
fill_pct = min(1.0, overlap / width)
current_touch_ts = candle.get('ts_open') or candle.get('ts_close')
current_touch_ts = _normalize_meta_ts(candle.get('ts_open') or candle.get('ts_close'))
if fill_pct > 0 and first_touch_ts is None:
first_touch_ts = _normalize_meta_ts(current_touch_ts)
first_touch_ts = current_touch_ts
if fill_pct > 0:
last_touch_ts = current_touch_ts
touch_count += 1
fill_max = max(fill_max, fill_pct)
if direction == 'bullish' and close < lower:
@@ -190,26 +201,64 @@ def compute_fvg_fill_state(
invalidated = True
if invalidated:
return fill_max, 'invalidated', {
'first_touch_ts': first_touch_ts,
'mitigation_threshold': mitigation_threshold,
'invalidated': True,
'width': width,
}
return fill_max, 'invalidated', _build_fvg_state_meta(
first_touch_ts=first_touch_ts,
last_touch_ts=last_touch_ts,
touch_count=touch_count,
mitigation_threshold=mitigation_threshold,
invalidated=True,
width=width,
status_reason='structure_break',
gap_count=gap_count,
)
if fill_max >= mitigation_threshold:
return fill_max, 'mitigated', {
'first_touch_ts': first_touch_ts,
'mitigation_threshold': mitigation_threshold,
'invalidated': False,
'width': width,
}
return fill_max, 'mitigated', _build_fvg_state_meta(
first_touch_ts=first_touch_ts,
last_touch_ts=last_touch_ts,
touch_count=touch_count,
mitigation_threshold=mitigation_threshold,
invalidated=False,
width=width,
status_reason='mitigation_threshold_reached',
gap_count=gap_count,
)
previous_candle = candle
status = 'touched' if fill_max > 0 else 'open'
return fill_max, status, {
return fill_max, status, _build_fvg_state_meta(
first_touch_ts=first_touch_ts,
last_touch_ts=last_touch_ts,
touch_count=touch_count,
mitigation_threshold=mitigation_threshold,
invalidated=False,
width=width,
status_reason='partial_fill' if fill_max > 0 else 'no_touch',
gap_count=gap_count,
)
def _build_fvg_state_meta(
*,
first_touch_ts,
last_touch_ts,
touch_count: int,
mitigation_threshold: float,
invalidated: bool,
width: float,
status_reason: str,
gap_count: int,
) -> dict:
return {
'first_touch_ts': first_touch_ts,
'last_touch_ts': last_touch_ts,
'touch_count': touch_count,
'mitigation_threshold': mitigation_threshold,
'invalidated': False,
'invalidated': invalidated,
'width': width,
'status_reason': status_reason,
'has_data_gap': gap_count > 0,
'gap_count': gap_count,
'data_quality_warnings': ['candle_gap_detected'] if gap_count > 0 else [],
}
@@ -220,5 +269,53 @@ def _normalize_meta_ts(value):
return value
def _build_displacement_context_index(displacements: list[dict]) -> dict[object, int]:
by_end_ts: dict[object, dict] = {}
for row in displacements:
end_ts = row.get('end_ts')
if end_ts is None:
continue
existing = by_end_ts.get(end_ts)
if existing is None or int(row.get('id') or 0) > int(existing.get('id') or 0):
by_end_ts[end_ts] = row
return {end_ts: int(row['id']) for end_ts, row in by_end_ts.items()}
def _has_candle_gap(previous_candle: Optional[dict], candle: dict) -> bool:
if not previous_candle:
return False
previous_close = _coerce_ts_ordering_value(previous_candle.get('ts_close'))
current_open = _coerce_ts_ordering_value(candle.get('ts_open'))
if previous_close is None or current_open is None:
return False
return current_open > previous_close
def _coerce_ts_ordering_value(value) -> Optional[float]:
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, datetime):
return value.timestamp()
if isinstance(value, str):
normalized = value.strip()
if normalized.endswith('Z'):
normalized = normalized[:-1] + '+00:00'
try:
return datetime.fromisoformat(normalized).timestamp()
except ValueError:
try:
return float(normalized)
except ValueError:
return None
if hasattr(value, 'timestamp'):
try:
return float(value.timestamp())
except (TypeError, ValueError):
return None
return None
def _same_number(left, right) -> bool:
return math.isclose(float(left or 0.0), float(right or 0.0), rel_tol=1e-4, abs_tol=1e-4)
@@ -106,15 +106,14 @@ class LiquidityPoolService:
def persist_candidates(self, instrument_id: int, timeframe: str, candidates: list[LiquidityPoolCandidate]) -> int:
persisted = 0
for candidate in candidates:
latest = self.repository.fetch_latest_pool(
matching_pools = self.repository.fetch_recent_pools_at_level(
instrument_id=instrument_id,
timeframe=timeframe,
side=candidate.side,
pool_type=candidate.pool_type,
price_low=candidate.price_low,
price_high=candidate.price_high,
)
if latest and pool_matches_candidate(latest, candidate):
if any(pool_matches_candidate(pool, candidate) for pool in matching_pools):
continue
self.repository.insert_pool(
instrument_id=instrument_id,
@@ -212,7 +211,7 @@ def _dedupe_exact_candidates(candidates: list[LiquidityPoolCandidate]) -> list[L
candidate.pool_type,
str(candidate.price_low),
str(candidate.price_high),
tuple(candidate.reference_ids),
tuple(sorted(set(candidate.reference_ids))),
)
if key in seen:
continue
@@ -9,13 +9,26 @@ class IntradayReportService:
fvg_zone: Optional[dict],
trade_signal: Optional[dict],
) -> dict:
structure_state, structure_status_reason = _resolve_structure_state(structure_event)
fvg_status, fvg_status_reason = _resolve_fvg_state(fvg_zone)
signal_structure_state = _get_signal_context_value(trade_signal, "structure_state")
signal_mss_status_reason = _get_signal_context_value(trade_signal, "mss_status_reason")
signal_fvg_status_reason = _get_signal_context_value(trade_signal, "fvg_status_reason")
signal_fvg_data_quality_warnings = list(_get_signal_context_value(trade_signal, "fvg_data_quality_warnings", []))
invalidations: list[str] = []
if trade_signal:
invalidations.extend(trade_signal.get("invalidations") or [])
if fvg_zone and (fvg_zone.get("status") in {"mitigated", "invalidated"} or (fvg_zone.get("meta") or {}).get("invalidated")):
invalidations.append("fvg_no_longer_actionable")
if structure_event and not structure_event.get("accepted", True):
invalidations.append("structure_not_accepted")
if fvg_status == "mitigated":
invalidations.append("fvg_mitigated")
if fvg_status == "invalidated":
invalidations.append("fvg_invalidated")
if structure_state == "rejected":
invalidations.append("mss_rejected")
if structure_state == "invalidated":
invalidations.append("mss_invalidated")
if signal_structure_state not in {None, "aligned"}:
invalidations.append(signal_structure_state)
invalidations.extend(signal_fvg_data_quality_warnings)
return {
"report_type": "intraday_explanation",
@@ -28,18 +41,76 @@ class IntradayReportService:
"event_type": structure_event.get("event_type") if structure_event else None,
"direction": structure_event.get("direction") if structure_event else None,
"accepted": structure_event.get("accepted") if structure_event else None,
"state": structure_state,
"status_reason": structure_status_reason,
},
"fvg": {
"direction": fvg_zone.get("direction") if fvg_zone else None,
"status": fvg_zone.get("status") if fvg_zone else None,
"status": fvg_status,
"status_reason": fvg_status_reason,
"fill_pct": float(fvg_zone.get("fill_pct") or 0.0) if fvg_zone else None,
"data_quality_warnings": list(dict((fvg_zone or {}).get("meta") or {}).get("data_quality_warnings") or []),
},
"signal": {
"present": trade_signal is not None,
"status": trade_signal.get("status") if trade_signal else None,
"side": trade_signal.get("side") if trade_signal else None,
"session_code": trade_signal.get("session_code") if trade_signal else None,
"structure_state": signal_structure_state,
"mss_status_reason": signal_mss_status_reason,
"fvg_status_reason": signal_fvg_status_reason,
"fvg_data_quality_warnings": signal_fvg_data_quality_warnings,
"is_valid": trade_signal is not None and len(invalidations) == 0,
},
"invalidations": sorted(set(invalidations)),
}
def _resolve_structure_state(structure_event: Optional[dict]) -> tuple[Optional[str], Optional[str]]:
if not structure_event:
return None, None
meta = dict(structure_event.get("meta") or {})
state = meta.get("state")
status_reason = meta.get("status_reason")
if state not in {"accepted", "rejected", "invalidated"}:
if structure_event.get("accepted") is True:
state = "accepted"
status_reason = status_reason or "retest_held"
elif meta.get("reclaimed_old_control"):
state = "invalidated"
status_reason = status_reason or "old_control_reclaimed"
elif structure_event.get("accepted") is False:
state = "rejected"
status_reason = status_reason or "retest_not_observed"
return state, status_reason
def _resolve_fvg_state(fvg_zone: Optional[dict]) -> tuple[Optional[str], Optional[str]]:
if not fvg_zone:
return None, None
meta = dict(fvg_zone.get("meta") or {})
invalidated = bool(meta.get("invalidated")) or fvg_zone.get("status") == "invalidated"
if invalidated:
status = "invalidated"
else:
status = fvg_zone.get("status")
status_reason = meta.get("status_reason")
if not status_reason:
if status == "invalidated":
status_reason = "structure_break"
elif status == "mitigated":
status_reason = "mitigation_threshold_reached"
elif status == "touched":
status_reason = "partial_fill"
elif status == "open":
status_reason = "no_touch"
return status, status_reason
def _get_signal_context_value(trade_signal: Optional[dict], key: str, default=None):
if not trade_signal:
return default
if key in trade_signal:
return trade_signal.get(key, default)
meta = dict(trade_signal.get("meta") or {})
return meta.get(key, default)
@@ -33,6 +33,10 @@ class PostTradeReviewService:
improvement_notes.append("review_signal_invalidations")
if trade_signal.get("status") != "awaiting_confirmation":
improvement_notes.append("prefer_confirmed_execution_window")
if _get_signal_context_value(trade_signal, "structure_state") not in {None, "aligned"}:
improvement_notes.append("review_signal_structure_state")
if _get_signal_context_value(trade_signal, "fvg_data_quality_warnings", []):
improvement_notes.append("review_signal_data_quality_warnings")
if backtest_result:
if backtest_result.get("outcome") == "loss":
@@ -48,7 +52,24 @@ class PostTradeReviewService:
"execution_status": trade_execution.get("status") if trade_execution else None,
"signal_status": trade_signal.get("status") if trade_signal else None,
"backtest_outcome": backtest_result.get("outcome") if backtest_result else None,
"signal_context": {
"structure_state": _get_signal_context_value(trade_signal, "structure_state"),
"mss_state": _get_signal_context_value(trade_signal, "mss_state"),
"mss_status_reason": _get_signal_context_value(trade_signal, "mss_status_reason"),
"fvg_status": _get_signal_context_value(trade_signal, "fvg_status"),
"fvg_status_reason": _get_signal_context_value(trade_signal, "fvg_status_reason"),
"fvg_data_quality_warnings": list(_get_signal_context_value(trade_signal, "fvg_data_quality_warnings", [])),
},
"execution_audit": execution_audit,
"error_types": sorted(set(error_types)),
"improvement_notes": sorted(set(improvement_notes)),
}
def _get_signal_context_value(trade_signal: Optional[dict], key: str, default=None):
if not trade_signal:
return default
if key in trade_signal:
return trade_signal.get(key, default)
meta = dict(trade_signal.get("meta") or {})
return meta.get(key, default)
+160 -13
View File
@@ -83,6 +83,7 @@ class SignalService:
invalidations = ['mss invalidated', 'fvg fully mitigated']
if not session_decision['execution_permission']:
invalidations.append('session_mismatch')
fvg_meta = dict(fvg.get('meta') or {})
evidence = {
'bias_snapshot_id': bias['id'],
'fvg_id': fvg['id'],
@@ -110,9 +111,15 @@ class SignalService:
'session_filter': session_decision['reason'],
'execution_permission': session_decision['execution_permission'],
'structure_quality': structure_alignment['reason'],
'structure_state': structure_alignment['structure_state'],
'mss_state': structure_alignment['mss_state'],
'mss_status_reason': structure_alignment['mss_status_reason'],
'evidence_quality': evidence_quality,
'fvg_status': fvg['status'],
'fvg_status': structure_alignment['fvg_status'],
'fvg_status_reason': structure_alignment['fvg_status_reason'],
'fvg_fill_pct': float(fvg.get('fill_pct') or 0.0),
'fvg_touch_count': int(fvg_meta.get('touch_count') or 0),
'fvg_data_quality_warnings': list(fvg_meta.get('data_quality_warnings') or []),
'mss_displacement_event_id': mss.get('displacement_event_id'),
'rr_filter': rr_decision,
},
@@ -175,29 +182,169 @@ def _same_number(left, right) -> bool:
return math.isclose(float(left or 0.0), float(right or 0.0), rel_tol=1e-4, abs_tol=1e-4)
def _build_signal_context_result(
*,
is_actionable: bool,
reason: str,
structure_state: str,
mss_state: str,
mss_status_reason: str,
fvg_status: str,
fvg_status_reason: str,
side: Optional[str] = None,
) -> dict[str, object]:
result = {
'is_actionable': is_actionable,
'reason': reason,
'structure_state': structure_state,
'mss_state': mss_state,
'mss_status_reason': mss_status_reason,
'fvg_status': fvg_status,
'fvg_status_reason': fvg_status_reason,
}
if side is not None:
result['side'] = side
return result
def _resolve_mss_state(mss: dict) -> tuple[str, str]:
meta = dict(mss.get('meta') or {})
state = meta.get('state')
status_reason = meta.get('status_reason')
if state not in {'accepted', 'rejected', 'invalidated'}:
if bool(mss.get('accepted')):
state = 'accepted'
status_reason = status_reason or 'retest_held'
elif meta.get('reclaimed_old_control'):
state = 'invalidated'
status_reason = status_reason or 'old_control_reclaimed'
else:
state = 'rejected'
status_reason = status_reason or 'retest_not_observed'
return str(state), str(status_reason or 'unknown')
def _resolve_fvg_state(fvg: dict) -> tuple[str, str]:
meta = dict(fvg.get('meta') or {})
invalidated = bool(meta.get('invalidated')) or fvg.get('status') == 'invalidated'
if invalidated:
status = 'invalidated'
else:
status = str(fvg.get('status') or 'open')
status_reason = meta.get('status_reason')
if not status_reason:
if status == 'invalidated':
status_reason = 'structure_break'
elif status == 'mitigated':
status_reason = 'mitigation_threshold_reached'
elif status == 'touched':
status_reason = 'partial_fill'
else:
status_reason = 'no_touch'
return status, str(status_reason)
def validate_signal_context(bias: dict, fvg: dict, mss: dict) -> dict[str, object]:
if not mss.get('accepted'):
return {'is_actionable': False, 'reason': 'mss_not_accepted'}
mss_state, mss_status_reason = _resolve_mss_state(mss)
fvg_status, fvg_status_reason = _resolve_fvg_state(fvg)
if mss_state == 'invalidated':
return _build_signal_context_result(
is_actionable=False,
reason='mss_invalidated',
structure_state='mss_invalidated',
mss_state=mss_state,
mss_status_reason=mss_status_reason,
fvg_status=fvg_status,
fvg_status_reason=fvg_status_reason,
)
if mss_state != 'accepted' or not mss.get('accepted'):
return _build_signal_context_result(
is_actionable=False,
reason='mss_rejected',
structure_state='mss_rejected',
mss_state=mss_state,
mss_status_reason=mss_status_reason,
fvg_status=fvg_status,
fvg_status_reason=fvg_status_reason,
)
expected_side = 'buy' if mss['direction'] == 'bullish' else 'sell'
if fvg['direction'] != mss['direction']:
return {'is_actionable': False, 'reason': 'fvg_mss_direction_mismatch'}
return _build_signal_context_result(
is_actionable=False,
reason='fvg_mss_direction_mismatch',
structure_state='fvg_mss_direction_mismatch',
mss_state=mss_state,
mss_status_reason=mss_status_reason,
fvg_status=fvg_status,
fvg_status_reason=fvg_status_reason,
)
draw_side = bias.get('draw_side')
if draw_side and ((expected_side == 'buy' and draw_side != 'buy_side') or (expected_side == 'sell' and draw_side != 'sell_side')):
return {'is_actionable': False, 'reason': 'bias_structure_mismatch'}
return _build_signal_context_result(
is_actionable=False,
reason='bias_structure_mismatch',
structure_state='bias_structure_mismatch',
mss_state=mss_state,
mss_status_reason=mss_status_reason,
fvg_status=fvg_status,
fvg_status_reason=fvg_status_reason,
)
if fvg['status'] not in {'open', 'touched'}:
return {'is_actionable': False, 'reason': 'fvg_not_actionable'}
fvg_meta = fvg.get('meta') or {}
if fvg_meta.get('invalidated'):
return {'is_actionable': False, 'reason': 'fvg_invalidated'}
if fvg_status == 'invalidated':
return _build_signal_context_result(
is_actionable=False,
reason='fvg_invalidated',
structure_state='fvg_invalidated',
mss_state=mss_state,
mss_status_reason=mss_status_reason,
fvg_status=fvg_status,
fvg_status_reason=fvg_status_reason,
)
if fvg_status == 'mitigated':
return _build_signal_context_result(
is_actionable=False,
reason='fvg_mitigated',
structure_state='fvg_mitigated',
mss_state=mss_state,
mss_status_reason=mss_status_reason,
fvg_status=fvg_status,
fvg_status_reason=fvg_status_reason,
)
if fvg_status not in {'open', 'touched'}:
return _build_signal_context_result(
is_actionable=False,
reason='fvg_not_actionable',
structure_state='fvg_not_actionable',
mss_state=mss_state,
mss_status_reason=mss_status_reason,
fvg_status=fvg_status,
fvg_status_reason=fvg_status_reason,
)
if mss.get('displacement_event_id') and fvg.get('related_displacement_id') and mss['displacement_event_id'] != fvg['related_displacement_id']:
return {'is_actionable': False, 'reason': 'displacement_context_mismatch'}
return _build_signal_context_result(
is_actionable=False,
reason='displacement_context_mismatch',
structure_state='displacement_context_mismatch',
mss_state=mss_state,
mss_status_reason=mss_status_reason,
fvg_status=fvg_status,
fvg_status_reason=fvg_status_reason,
)
return {'is_actionable': True, 'reason': 'aligned', 'side': expected_side}
return _build_signal_context_result(
is_actionable=True,
reason='aligned',
structure_state='aligned',
mss_state=mss_state,
mss_status_reason=mss_status_reason,
fvg_status=fvg_status,
fvg_status_reason=fvg_status_reason,
side=expected_side,
)
def validate_rr_thresholds(rr_tp1: float, rr_tp2: float) -> dict[str, object]:
+29 -7
View File
@@ -36,8 +36,6 @@ class MSSService:
persisted = 0
latest = self.repository.fetch_latest_mss_event(instrument_id=instrument_id, timeframe=timeframe)
for candidate in candidates:
if not candidate.accepted:
continue
if latest and mss_event_matches_candidate(latest, candidate):
continue
self.repository.insert_mss_event(
@@ -84,7 +82,18 @@ def detect_mss_candidates(
ordered_candles = sorted(candles, key=lambda row: row['ts_open'])
candle_index_by_close = {row['ts_close']: idx for idx, row in enumerate(ordered_candles)}
for displacement in sorted(displacements, key=lambda row: row['end_ts'], reverse=True):
seen_displacement_keys: set[tuple[object, object]] = set()
ordered_displacements = sorted(
displacements,
key=lambda row: (row['end_ts'], int(row.get('id') or 0)),
reverse=True,
)
for displacement in ordered_displacements:
displacement_key = (displacement.get('direction'), displacement.get('end_ts'))
if displacement_key in seen_displacement_keys:
continue
seen_displacement_keys.add(displacement_key)
direction = displacement['direction']
if direction == 'bullish':
reference_swing = _latest_swing_before_ts(swing_highs, displacement['end_ts'])
@@ -110,7 +119,7 @@ def detect_mss_candidates(
candle_index_by_close=candle_index_by_close,
acceptance_window=acceptance_window,
)
if candidate and candidate.accepted:
if candidate:
candidates.append(candidate)
break
return candidates
@@ -138,8 +147,6 @@ def _build_mss_candidate(
retest_candles = ordered_candles[displacement_idx + 1 : displacement_idx + 1 + acceptance_window]
acceptance = evaluate_mss_acceptance(direction, broken_level, retest_candles)
if not acceptance['has_retest']:
return None
return MSSCandidate(
direction=direction,
@@ -150,10 +157,13 @@ def _build_mss_candidate(
ts=displacement['end_ts'],
meta={
'acceptance_window_candles': acceptance_window,
'acceptance_window_complete': len(retest_candles) >= acceptance_window,
'has_retest': acceptance['has_retest'],
'retest_count': acceptance['retest_count'],
'held_boundary': acceptance['held_boundary'],
'reclaimed_old_control': acceptance['reclaimed_old_control'],
'state': acceptance['state'],
'status_reason': acceptance['status_reason'],
},
)
@@ -187,12 +197,24 @@ def evaluate_mss_acceptance(direction: str, broken_level: float, candles: list[d
held_boundary = False
break
if reclaimed_old_control:
state = 'invalidated'
status_reason = 'old_control_reclaimed'
elif has_retest:
state = 'accepted'
status_reason = 'retest_held'
else:
state = 'rejected'
status_reason = 'retest_not_observed'
return {
'accepted': has_retest and held_boundary and not reclaimed_old_control,
'accepted': state == 'accepted',
'has_retest': has_retest,
'retest_count': retest_count,
'held_boundary': held_boundary,
'reclaimed_old_control': reclaimed_old_control,
'state': state,
'status_reason': status_reason,
}
+63 -3
View File
@@ -8,7 +8,7 @@ from src.api import get_health
from src.api.backtest import get_recent_backtest_summaries, normalize_backtest_summary_row
from src.api.server import route_request
from src.api.signals import get_recent_signals
from src.services.backtest import BacktestCostConfig, BacktestQualityGate
from src.services.backtest import BacktestCostConfig, BacktestQualityGate, BacktestReplayConfig
from src.services.execution import AccountRiskState
from src.main import run_closed_loop
@@ -47,11 +47,35 @@ class ApiTests(unittest.TestCase):
self.assertIn("session_timezone", health)
def test_get_recent_signals_uses_session_query(self) -> None:
rows = [{"id": 1, "side": "buy", "status": "awaiting_confirmation"}]
rows = [{
"id": 1,
"side": "buy",
"status": "awaiting_confirmation",
"meta": {
"structure_state": "aligned",
"mss_state": "accepted",
"mss_status_reason": "retest_held",
"fvg_status": "open",
"fvg_status_reason": "no_touch",
"execution_permission": True,
"fvg_fill_pct": 0.0,
"fvg_touch_count": 0,
"fvg_data_quality_warnings": [],
},
}]
with patch("src.api.signals.SessionLocal", return_value=FakeSession(rows)):
result = get_recent_signals(instrument_id=1, limit=1)
self.assertEqual(result, rows)
self.assertEqual(result[0]["id"], 1)
self.assertEqual(result[0]["structure_state"], "aligned")
self.assertEqual(result[0]["mss_state"], "accepted")
self.assertEqual(result[0]["mss_status_reason"], "retest_held")
self.assertEqual(result[0]["fvg_status"], "open")
self.assertEqual(result[0]["fvg_status_reason"], "no_touch")
self.assertTrue(result[0]["execution_permission"])
self.assertEqual(result[0]["fvg_fill_pct"], 0.0)
self.assertEqual(result[0]["fvg_touch_count"], 0)
self.assertEqual(result[0]["fvg_data_quality_warnings"], [])
def test_get_recent_backtest_summaries_uses_session_query(self) -> None:
rows = [{"id": 10, "summary": {"total_signals": 3}}]
@@ -365,6 +389,42 @@ class ClosedLoopTests(unittest.TestCase):
self.assertEqual(backtest_service.last_kwargs["quality_gate"], quality_gate)
def test_run_closed_loop_passes_backtest_replay_mode_and_config(self) -> None:
backtest_service = FakeBacktestService()
replay_config = BacktestReplayConfig(left_bars=1, right_bars=1)
deps = {
"session_generator": FakeSessionGenerator(),
"swing_service": FakeSwingService(),
"range_service": FakeRangeService(),
"liquidity_service": FakeLiquidityService(),
"sweep_service": FakeSweepService(),
"displacement_service": FakeDisplacementService(),
"mss_service": FakeMSSService(),
"fvg_service": FakeFVGService(),
"bias_service": FakeBiasService(),
"signal_service": FakeSignalService(),
"execution_service": FakeExecutionService(),
"backtest_service": backtest_service,
"pre_market_report_service": FakePreMarketReportService(),
"intraday_report_service": FakeIntradayReportService(),
"post_trade_review_service": FakePostTradeReviewService(),
"get_recent_signals": lambda instrument_id, limit: [{"id": 1, "session_code": "LONDON", "status": "awaiting_confirmation", "side": "buy", "invalidations": []}],
"get_recent_backtest_summaries": lambda instrument_id, limit: [{"id": 55, "summary": {"total_signals": 1}}],
}
run_closed_loop(
instrument_id=1,
timeframe="1m",
backtest_limit=10,
run_execution=True,
backtest_mode="replay_no_lookahead",
backtest_replay_config=replay_config,
dependencies=deps,
)
self.assertEqual(backtest_service.last_kwargs["mode"], "replay_no_lookahead")
self.assertEqual(backtest_service.last_kwargs["replay_config"], replay_config)
def test_run_closed_loop_passes_execution_account_state(self) -> None:
execution_service = FakeExecutionService()
account_state = AccountRiskState(daily_pnl_pct=-0.01, consecutive_losses=1)
+20
View File
@@ -323,6 +323,26 @@ class ScriptFallbackTests(unittest.TestCase):
self.assertEqual(quality_gate.max_drawdown_r, 6.0)
self.assertEqual(quality_gate.max_consecutive_losses, 3)
def test_run_pipeline_passes_backtest_replay_mode_and_config(self) -> None:
captured = {}
def fake_run_closed_loop(**kwargs):
captured.update(kwargs)
return {"ok": True, "backtest_quality_gate": {"passed": True}}
output = io.StringIO()
env = {
"BACKTEST_MODE": "replay_no_lookahead",
"BACKTEST_REPLAY_LEFT_BARS": "1",
"BACKTEST_REPLAY_RIGHT_BARS": "3",
}
with patch("scripts.run_pipeline.run_closed_loop", side_effect=fake_run_closed_loop), patch.dict(os.environ, env, clear=False), contextlib.redirect_stdout(output):
run_pipeline.main()
self.assertEqual(captured["backtest_mode"], "replay_no_lookahead")
self.assertEqual(captured["backtest_replay_config"].left_bars, 1)
self.assertEqual(captured["backtest_replay_config"].right_bars, 3)
def test_run_pipeline_passes_signal_evidence_mode(self) -> None:
captured = {}
+44 -1
View File
@@ -10,7 +10,14 @@ from sqlalchemy.exc import OperationalError
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test")
from scripts import run_backtest
from scripts.run_backtest import build_cost_config_from_env, build_quality_gate_from_env, build_require_quality_gate_from_env, run_backtest_once
from scripts.run_backtest import (
build_backtest_mode_from_env,
build_cost_config_from_env,
build_quality_gate_from_env,
build_replay_config_from_env,
build_require_quality_gate_from_env,
run_backtest_once,
)
from src.services.backtest import BacktestOutcome
@@ -80,6 +87,20 @@ class RunBacktestScriptTests(unittest.TestCase):
self.assertEqual(gate.max_drawdown_r, 6.0)
self.assertEqual(gate.max_consecutive_losses, 3)
def test_build_backtest_mode_and_replay_config_from_env(self) -> None:
self.assertEqual(build_backtest_mode_from_env({}), "entry_then_outcome")
self.assertEqual(build_backtest_mode_from_env({"BACKTEST_MODE": "replay_no_lookahead"}), "replay_no_lookahead")
replay_config = build_replay_config_from_env(
{
"BACKTEST_REPLAY_LEFT_BARS": "1",
"BACKTEST_REPLAY_RIGHT_BARS": "3",
}
)
self.assertEqual(replay_config.left_bars, 1)
self.assertEqual(replay_config.right_bars, 3)
def test_run_backtest_once_allows_failed_quality_gate_when_not_required(self) -> None:
service = FakeBacktestService([])
output = io.StringIO()
@@ -140,6 +161,28 @@ class RunBacktestScriptTests(unittest.TestCase):
self.assertFalse(payload["quality_gate"]["passed"])
self.assertIn("sample_size_too_small", payload["quality_gate"]["reasons"])
def test_run_backtest_once_passes_replay_mode_and_config(self) -> None:
service = FakeBacktestService([])
output = io.StringIO()
replay_config = build_replay_config_from_env(
{
"BACKTEST_REPLAY_LEFT_BARS": "1",
"BACKTEST_REPLAY_RIGHT_BARS": "1",
}
)
with contextlib.redirect_stdout(output):
exit_code = run_backtest_once(
service=service,
backtest_mode="replay_no_lookahead",
replay_config=replay_config,
)
self.assertEqual(exit_code, 0)
self.assertEqual(service.calls[0]["mode"], "replay_no_lookahead")
self.assertEqual(service.calls[0]["replay_config"].left_bars, 1)
self.assertEqual(service.calls[0]["replay_config"].right_bars, 1)
def test_main_reports_database_unavailable(self) -> None:
output = io.StringIO()
+103
View File
@@ -8,6 +8,7 @@ from src.services.backtest import (
BacktestCostConfig,
BacktestOutcome,
BacktestQualityGate,
BacktestReplayConfig,
BacktestService,
BacktestSignal,
build_cumulative_r_curve,
@@ -17,6 +18,7 @@ from src.services.backtest import (
estimate_trade_cost_r,
evaluate_backtest_quality,
evaluate_signal_outcome,
evaluate_signal_outcome_with_replay,
summarize_backtest_outcomes,
)
from src.repositories.backtest_repository import BacktestRepository
@@ -133,6 +135,41 @@ class BacktestOutcomeTests(unittest.TestCase):
self.assertAlmostEqual(outcome.meta["r_multiple"], 2.5 - expected_cost_r)
self.assertEqual(outcome.meta["cost_model"]["fee_rate"], 0.0005)
def test_replay_no_lookahead_tracks_visible_candles_and_confirmed_swings_at_entry(self) -> None:
signal = BacktestSignal(
signal_id=5,
side="buy",
entry_low=96.0,
entry_high=97.0,
stop_loss=95.0,
tp1=100.0,
tp2=103.0,
rr_tp1=1.0,
rr_tp2=3.0,
)
outcome = evaluate_signal_outcome_with_replay(
signal,
[
{"ts_open": 1, "ts_close": 2, "open": 97.7, "high": 98.0, "low": 97.5, "close": 97.8},
{"ts_open": 2, "ts_close": 3, "open": 97.0, "high": 99.0, "low": 96.2, "close": 98.0},
{"ts_open": 3, "ts_close": 4, "open": 97.2, "high": 98.0, "low": 96.5, "close": 97.4},
{"ts_open": 4, "ts_close": 5, "open": 98.5, "high": 103.5, "low": 97.0, "close": 103.0},
],
replay_config=BacktestReplayConfig(left_bars=1, right_bars=1),
)
self.assertEqual(outcome.outcome, "win")
self.assertEqual(outcome.meta["backtest_mode"], "replay_no_lookahead")
self.assertEqual(outcome.meta["replay_entry_frame_index"], 1)
self.assertEqual(outcome.meta["replay_known_candles_at_entry"], 2)
self.assertEqual(outcome.meta["replay_confirmed_swing_count_at_entry"], 0)
self.assertEqual(outcome.meta["replay_confirmed_swing_count_total"], 2)
self.assertEqual(outcome.meta["replay_last_confirmed_swing_at_entry"], None)
self.assertEqual(outcome.meta["replay_exit_frame_index"], 3)
self.assertEqual(outcome.meta["replay_left_bars"], 1)
self.assertEqual(outcome.meta["replay_right_bars"], 1)
def test_summary_includes_expectancy_and_profit_factor_r(self) -> None:
outcomes = [
BacktestOutcome(
@@ -326,6 +363,72 @@ class BacktestServiceRunTests(unittest.TestCase):
self.assertEqual(repository.run_config["quality_gate"]["min_total_signals"], 100)
self.assertEqual(repository.run_config["quality_gate"]["min_executed_signals"], 30)
def test_run_backtest_persists_replay_mode_and_replay_meta(self) -> None:
case = self
class FakeBacktestRepository:
def __init__(self) -> None:
self.results = []
self.run_config = {}
def fetch_candidate_signals(self, instrument_id: int, limit: int):
case.assertEqual(instrument_id, 1)
case.assertEqual(limit, 10)
return [
{
"id": 8,
"created_ts": datetime(2026, 4, 29, 9, 0, tzinfo=timezone.utc),
"expires_ts": datetime(2026, 4, 29, 10, 0, tzinfo=timezone.utc),
"side": "buy",
"entry_low": 96.0,
"entry_high": 97.0,
"stop_loss": 95.0,
"tp1": 100.0,
"tp2": 103.0,
"rr_tp1": 1.0,
"rr_tp2": 3.0,
}
]
def insert_backtest_run(self, **kwargs):
self.run_config = kwargs["config"]
return 77
def fetch_sessions_covering_window(self, **kwargs):
return [{"session_code": "LONDON"}]
def fetch_future_candles(self, **kwargs):
return [
{"ts_open": 1, "ts_close": 2, "open": 97.7, "high": 98.0, "low": 97.5, "close": 97.8},
{"ts_open": 2, "ts_close": 3, "open": 97.0, "high": 99.0, "low": 96.2, "close": 98.0},
{"ts_open": 3, "ts_close": 4, "open": 97.2, "high": 98.0, "low": 96.5, "close": 97.4},
{"ts_open": 4, "ts_close": 5, "open": 98.5, "high": 103.5, "low": 97.0, "close": 103.0},
]
def insert_backtest_result(self, **kwargs):
self.results.append(kwargs)
def update_backtest_run_summary(self, backtest_run_id: int, summary: dict) -> None:
return None
repository = FakeBacktestRepository()
service = BacktestService(repository=repository)
run_id, outcomes = service.run_backtest(
instrument_id=1,
limit=10,
mode="replay_no_lookahead",
replay_config=BacktestReplayConfig(left_bars=1, right_bars=1),
)
self.assertEqual(run_id, 77)
self.assertEqual(len(outcomes), 1)
self.assertEqual(repository.run_config["mode"], "replay_no_lookahead")
self.assertEqual(repository.run_config["replay"]["left_bars"], 1)
self.assertEqual(repository.run_config["replay"]["right_bars"], 1)
self.assertEqual(repository.results[0]["meta"]["backtest_mode"], "replay_no_lookahead")
self.assertEqual(repository.results[0]["meta"]["replay_confirmed_swing_count_total"], 2)
class BacktestRepositorySerializationTests(unittest.TestCase):
def test_insert_backtest_result_serializes_datetime_in_meta(self) -> None:
+57 -1
View File
@@ -5,7 +5,7 @@ import unittest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test")
from src.services.fvg import compute_fvg_fill_state, fvg_matches_candidate, fvg_state_matches_update
from src.services.fvg.fvg_service import FVGCandidate
from src.services.fvg.fvg_service import FVGCandidate, FVGService
class FVGStateTests(unittest.TestCase):
@@ -20,7 +20,13 @@ class FVGStateTests(unittest.TestCase):
self.assertEqual(status, "touched")
self.assertAlmostEqual(fill_pct, 0.2)
self.assertEqual(meta["first_touch_ts"], 1)
self.assertEqual(meta["last_touch_ts"], 1)
self.assertEqual(meta["touch_count"], 1)
self.assertFalse(meta["invalidated"])
self.assertEqual(meta["status_reason"], "partial_fill")
self.assertFalse(meta["has_data_gap"])
self.assertEqual(meta["gap_count"], 0)
self.assertEqual(meta["data_quality_warnings"], [])
def test_normalizes_datetime_touch_timestamp_for_meta(self) -> None:
fill_pct, status, meta = compute_fvg_fill_state(
@@ -33,6 +39,7 @@ class FVGStateTests(unittest.TestCase):
self.assertEqual(status, "touched")
self.assertAlmostEqual(fill_pct, 0.2)
self.assertEqual(meta["first_touch_ts"], "2026-04-24T12:00:00+00:00")
self.assertEqual(meta["last_touch_ts"], "2026-04-24T12:00:00+00:00")
def test_marks_fvg_mitigated_when_fully_filled(self) -> None:
fill_pct, status, meta = compute_fvg_fill_state(
@@ -45,6 +52,8 @@ class FVGStateTests(unittest.TestCase):
self.assertEqual(status, "mitigated")
self.assertEqual(fill_pct, 1.0)
self.assertFalse(meta["invalidated"])
self.assertEqual(meta["status_reason"], "mitigation_threshold_reached")
self.assertEqual(meta["touch_count"], 1)
def test_marks_fvg_invalidated_when_structure_breaks_through_zone(self) -> None:
fill_pct, status, meta = compute_fvg_fill_state(
@@ -57,6 +66,27 @@ class FVGStateTests(unittest.TestCase):
self.assertEqual(status, "invalidated")
self.assertGreater(fill_pct, 0.0)
self.assertTrue(meta["invalidated"])
self.assertEqual(meta["status_reason"], "structure_break")
def test_records_data_gap_warning_when_post_fvg_candles_skip_forward(self) -> None:
fill_pct, status, meta = compute_fvg_fill_state(
direction="bullish",
upper=105.0,
lower=100.0,
candles=[
{"ts_open": 1, "ts_close": 2, "high": 104.0, "low": 103.0, "close": 103.5},
{"ts_open": 4, "ts_close": 5, "high": 104.5, "low": 103.5, "close": 104.0},
],
)
self.assertEqual(status, "touched")
self.assertAlmostEqual(fill_pct, 0.2)
self.assertEqual(meta["first_touch_ts"], 1)
self.assertEqual(meta["last_touch_ts"], 4)
self.assertEqual(meta["touch_count"], 2)
self.assertTrue(meta["has_data_gap"])
self.assertEqual(meta["gap_count"], 1)
self.assertEqual(meta["data_quality_warnings"], ["candle_gap_detected"])
class FVGIdempotencyTests(unittest.TestCase):
@@ -114,5 +144,31 @@ class FVGIdempotencyTests(unittest.TestCase):
self.assertTrue(fvg_state_matches_update(row, 0.9327354260089334, "touched", meta))
class FVGDetectionTests(unittest.TestCase):
def test_detect_fvg_collapses_duplicate_displacement_context_to_latest_id(self) -> None:
class StubRepo:
def fetch_recent_candles(self, instrument_id, timeframe, limit=200):
return [
{"ts_open": 1, "ts_close": 2, "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.5},
{"ts_open": 2, "ts_close": 3, "open": 100.5, "high": 103.0, "low": 100.2, "close": 102.8},
{"ts_open": 3, "ts_close": 4, "open": 103.5, "high": 106.0, "low": 104.0, "close": 105.5},
]
def fetch_recent_displacement(self, instrument_id, timeframe, limit=50):
return [
{"id": 7, "direction": "bullish", "end_ts": 3, "is_valid": True},
{"id": 9, "direction": "bullish", "end_ts": 3, "is_valid": True},
]
service = FVGService(repository=StubRepo())
candidates = service.detect_fvg(instrument_id=1, timeframe="1m")
self.assertEqual(len(candidates), 1)
self.assertEqual(candidates[0].direction, "bullish")
self.assertEqual(candidates[0].related_displacement_id, 9)
self.assertTrue(candidates[0].meta["has_displacement_context"])
if __name__ == "__main__":
unittest.main()
+42 -1
View File
@@ -4,7 +4,7 @@ import unittest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test")
from src.services.liquidity import LiquidityPoolCandidate, build_equal_pool_candidates, dedupe_candidates, pool_matches_candidate
from src.services.liquidity import LiquidityPoolCandidate, LiquidityPoolService, build_equal_pool_candidates, dedupe_candidates, pool_matches_candidate
class EqualLiquidityPoolTests(unittest.TestCase):
@@ -144,6 +144,47 @@ class LiquidityIdempotencyTests(unittest.TestCase):
self.assertTrue(pool_matches_candidate(pool, candidate))
def test_persist_candidates_skips_equivalent_merged_level_when_pool_type_differs(self) -> None:
candidate = LiquidityPoolCandidate(
side="buy_side",
pool_type="equal_highs",
price_low=Decimal("100.00"),
price_high=Decimal("100.20"),
reference_ids=[1, 2],
created_ts="2026-04-21T00:00:00Z",
meta={"merged_pool_types": ["equal_highs", "prior_high"], "source_pool_types": ["equal_highs", "prior_high"]},
)
class StubRepo:
def __init__(self):
self.insert_calls = 0
def fetch_recent_pools_at_level(self, instrument_id, timeframe, side, price_low, price_high, limit=10):
return [
{
"side": "buy_side",
"pool_type": "prior_high",
"price_low": Decimal("100.00"),
"price_high": Decimal("100.20"),
"reference_ids": [1, 2],
"meta": {
"merged_pool_types": ["equal_highs", "prior_high"],
"source_pool_types": ["equal_highs", "prior_high"],
},
}
]
def insert_pool(self, **kwargs):
self.insert_calls += 1
repo = StubRepo()
service = LiquidityPoolService(repository=repo)
persisted = service.persist_candidates(instrument_id=1, timeframe="1m", candidates=[candidate])
self.assertEqual(persisted, 0)
self.assertEqual(repo.insert_calls, 0)
if __name__ == "__main__":
unittest.main()
+104
View File
@@ -22,6 +22,8 @@ class MSSAcceptanceTests(unittest.TestCase):
self.assertTrue(result["has_retest"])
self.assertTrue(result["held_boundary"])
self.assertFalse(result["reclaimed_old_control"])
self.assertEqual(result["state"], "accepted")
self.assertEqual(result["status_reason"], "retest_held")
def test_bearish_acceptance_fails_when_close_reclaims_old_control(self) -> None:
result = evaluate_mss_acceptance(
@@ -37,6 +39,25 @@ class MSSAcceptanceTests(unittest.TestCase):
self.assertTrue(result["has_retest"])
self.assertFalse(result["held_boundary"])
self.assertTrue(result["reclaimed_old_control"])
self.assertEqual(result["state"], "invalidated")
self.assertEqual(result["status_reason"], "old_control_reclaimed")
def test_acceptance_marks_rejected_when_retest_is_not_observed(self) -> None:
result = evaluate_mss_acceptance(
direction="bullish",
broken_level=100.0,
candles=[
{"low": 100.2, "high": 102.0, "close": 101.7},
{"low": 100.4, "high": 103.1, "close": 102.6},
],
)
self.assertFalse(result["accepted"])
self.assertFalse(result["has_retest"])
self.assertTrue(result["held_boundary"])
self.assertFalse(result["reclaimed_old_control"])
self.assertEqual(result["state"], "rejected")
self.assertEqual(result["status_reason"], "retest_not_observed")
def test_detect_mss_candidates_only_returns_confirmed_acceptance(self) -> None:
swings = [
@@ -61,6 +82,56 @@ class MSSAcceptanceTests(unittest.TestCase):
self.assertTrue(candidates[0].accepted)
self.assertEqual(candidates[0].direction, "bullish")
self.assertEqual(candidates[0].meta["retest_count"], 1)
self.assertEqual(candidates[0].meta["state"], "accepted")
self.assertEqual(candidates[0].meta["status_reason"], "retest_held")
def test_detect_mss_candidates_collapses_duplicate_displacement_context_to_latest_id(self) -> None:
swings = [
{"id": 1, "kind": "swing_high", "price": 100.0, "ts": 2},
{"id": 2, "kind": "swing_low", "price": 95.0, "ts": 1},
]
displacements = [
{"id": 9, "direction": "bullish", "end_ts": 5, "is_valid": True},
{"id": 11, "direction": "bullish", "end_ts": 5, "is_valid": True},
]
candles = [
{"ts_open": 1, "ts_close": 2, "open": 97.0, "high": 100.0, "low": 96.0, "close": 98.0},
{"ts_open": 2, "ts_close": 3, "open": 98.0, "high": 99.0, "low": 95.0, "close": 96.0},
{"ts_open": 3, "ts_close": 4, "open": 96.0, "high": 102.0, "low": 95.5, "close": 101.3},
{"ts_open": 4, "ts_close": 5, "open": 101.3, "high": 103.0, "low": 100.5, "close": 102.2},
{"ts_open": 5, "ts_close": 6, "open": 102.2, "high": 102.8, "low": 99.9, "close": 100.4},
{"ts_open": 6, "ts_close": 7, "open": 100.4, "high": 103.5, "low": 100.2, "close": 103.0},
]
candidates = detect_mss_candidates(swings=swings, displacements=displacements, candles=candles, acceptance_window=3)
self.assertEqual(len(candidates), 1)
self.assertEqual(candidates[0].displacement_event_id, 11)
self.assertEqual(candidates[0].direction, "bullish")
def test_detect_mss_candidates_returns_latest_rejected_state_when_retest_is_missing(self) -> None:
swings = [
{"id": 1, "kind": "swing_high", "price": 100.0, "ts": 2},
{"id": 2, "kind": "swing_low", "price": 95.0, "ts": 1},
]
displacements = [
{"id": 9, "direction": "bullish", "end_ts": 5, "is_valid": True},
]
candles = [
{"ts_open": 1, "ts_close": 2, "open": 97.0, "high": 100.0, "low": 96.0, "close": 98.0},
{"ts_open": 2, "ts_close": 3, "open": 98.0, "high": 99.0, "low": 95.0, "close": 96.0},
{"ts_open": 3, "ts_close": 4, "open": 96.0, "high": 102.0, "low": 95.5, "close": 101.3},
{"ts_open": 4, "ts_close": 5, "open": 101.3, "high": 103.0, "low": 100.5, "close": 102.2},
{"ts_open": 5, "ts_close": 6, "open": 102.2, "high": 103.4, "low": 100.3, "close": 102.9},
{"ts_open": 6, "ts_close": 7, "open": 102.9, "high": 104.1, "low": 100.2, "close": 103.6},
]
candidates = detect_mss_candidates(swings=swings, displacements=displacements, candles=candles, acceptance_window=3)
self.assertEqual(len(candidates), 1)
self.assertFalse(candidates[0].accepted)
self.assertEqual(candidates[0].meta["state"], "rejected")
self.assertEqual(candidates[0].meta["status_reason"], "retest_not_observed")
class MSSIdempotencyTests(unittest.TestCase):
@@ -121,6 +192,39 @@ class MSSIdempotencyTests(unittest.TestCase):
self.assertEqual(persisted, 0)
self.assertEqual(repo.insert_calls, 0)
def test_persist_mss_records_rejected_state_for_downstream_consumers(self) -> None:
candidate = MSSCandidate(
direction="bullish",
broken_level=100.0,
reference_swing_id=10,
displacement_event_id=20,
accepted=False,
ts="t",
meta={"state": "rejected", "status_reason": "retest_not_observed"},
)
class StubRepo:
def __init__(self):
self.insert_calls = 0
self.last_insert = None
def fetch_latest_mss_event(self, instrument_id, timeframe):
return None
def insert_mss_event(self, **kwargs):
self.insert_calls += 1
self.last_insert = kwargs
repo = StubRepo()
service = MSSService(repository=repo)
persisted = service.persist_mss(instrument_id=1, timeframe="1m", candidates=[candidate])
self.assertEqual(persisted, 1)
self.assertEqual(repo.insert_calls, 1)
self.assertFalse(repo.last_insert["accepted"])
self.assertEqual(repo.last_insert["meta"]["state"], "rejected")
if __name__ == "__main__":
unittest.main()
+56 -6
View File
@@ -31,17 +31,51 @@ class PreMarketReportTests(unittest.TestCase):
class IntradayReportTests(unittest.TestCase):
def test_builds_intraday_explanation_and_invalidations(self) -> None:
def test_builds_intraday_explanation_with_richer_structure_and_fvg_state(self) -> None:
report = IntradayReportService().build_report(
sweep_event={"side_swept": "buy_side", "close_back_inside": True},
structure_event={"event_type": "MSS", "direction": "bearish", "accepted": True},
fvg_zone={"direction": "bearish", "status": "mitigated", "fill_pct": 1.0, "meta": {}},
trade_signal={"status": "awaiting_confirmation", "side": "sell", "session_code": "NY_AM", "invalidations": ["session_mismatch"]},
structure_event={
"event_type": "MSS",
"direction": "bearish",
"accepted": False,
"meta": {"state": "invalidated", "status_reason": "old_control_reclaimed"},
},
fvg_zone={
"direction": "bearish",
"status": "mitigated",
"fill_pct": 1.0,
"meta": {
"status_reason": "mitigation_threshold_reached",
"data_quality_warnings": ["candle_gap_detected"],
},
},
trade_signal={
"status": "awaiting_confirmation",
"side": "sell",
"session_code": "NY_AM",
"invalidations": ["session_mismatch"],
"meta": {
"structure_state": "fvg_mitigated",
"mss_status_reason": "old_control_reclaimed",
"fvg_status_reason": "mitigation_threshold_reached",
"fvg_data_quality_warnings": ["candle_gap_detected"],
},
},
)
self.assertEqual(report["report_type"], "intraday_explanation")
self.assertFalse(report["signal"]["is_valid"])
self.assertIn("fvg_no_longer_actionable", report["invalidations"])
self.assertEqual(report["structure"]["state"], "invalidated")
self.assertEqual(report["structure"]["status_reason"], "old_control_reclaimed")
self.assertEqual(report["fvg"]["status"], "mitigated")
self.assertEqual(report["fvg"]["status_reason"], "mitigation_threshold_reached")
self.assertEqual(report["signal"]["structure_state"], "fvg_mitigated")
self.assertEqual(report["signal"]["mss_status_reason"], "old_control_reclaimed")
self.assertEqual(report["signal"]["fvg_status_reason"], "mitigation_threshold_reached")
self.assertEqual(report["signal"]["fvg_data_quality_warnings"], ["candle_gap_detected"])
self.assertIn("fvg_mitigated", report["invalidations"])
self.assertIn("mss_invalidated", report["invalidations"])
self.assertIn("candle_gap_detected", report["invalidations"])
self.assertIn("session_mismatch", report["invalidations"])
@@ -49,7 +83,18 @@ class PostTradeReviewTests(unittest.TestCase):
def test_builds_post_trade_review(self) -> None:
report = PostTradeReviewService().build_report(
trade_execution={"status": "rejected", "meta": {"risk_checks": ["session_not_allowed"]}},
trade_signal={"status": "awaiting_context", "invalidations": ["session_mismatch"]},
trade_signal={
"status": "awaiting_context",
"invalidations": ["session_mismatch"],
"meta": {
"structure_state": "mss_invalidated",
"mss_state": "invalidated",
"mss_status_reason": "old_control_reclaimed",
"fvg_status": "touched",
"fvg_status_reason": "partial_fill",
"fvg_data_quality_warnings": ["candle_gap_detected"],
},
},
backtest_result={"outcome": "loss", "invalidated_before_entry": False, "error_tags": ["same_candle_entry_exit_ambiguous"]},
)
@@ -59,7 +104,12 @@ class PostTradeReviewTests(unittest.TestCase):
self.assertIn("session_not_allowed", report["error_types"])
self.assertEqual(report["execution_audit"]["audit_status"], "review")
self.assertEqual(report["execution_audit"]["lifecycle_state"], "rejected")
self.assertEqual(report["signal_context"]["structure_state"], "mss_invalidated")
self.assertEqual(report["signal_context"]["mss_status_reason"], "old_control_reclaimed")
self.assertEqual(report["signal_context"]["fvg_status_reason"], "partial_fill")
self.assertIn("inspect_stop_and_structure_context", report["improvement_notes"])
self.assertIn("review_signal_structure_state", report["improvement_notes"])
self.assertIn("review_signal_data_quality_warnings", report["improvement_notes"])
self.assertIn("review_same_candle_ambiguity_cases", report["improvement_notes"])
+4 -4
View File
@@ -90,12 +90,12 @@ class RuntimeScriptJsonTests(unittest.TestCase):
self.assertEqual(exit_code, 2)
run_migrations.assert_called_once_with(env={"MIGRATIONS_OUTPUT_JSON": "1"})
def test_run_dev_preserves_zero_exit_code(self) -> None:
with patch("scripts.run_dev.app_main") as app_main:
exit_code = run_dev.main()
def test_run_dev_uses_env_and_returns_when_server_stops(self) -> None:
with patch("scripts.run_dev.serve_api") as serve_api:
exit_code = run_dev.main(env={"AI_ICT_API_HOST": "127.0.0.1", "AI_ICT_API_PORT": "8124"})
self.assertEqual(exit_code, 0)
app_main.assert_called_once_with()
serve_api.assert_called_once_with(host="127.0.0.1", port=8124)
def test_run_api_server_uses_env_and_returns_when_server_stops(self) -> None:
with patch("scripts.run_api_server.serve_api") as serve_api:
+60 -11
View File
@@ -46,41 +46,76 @@ class SignalSessionFilterTests(unittest.TestCase):
class SignalStructureAlignmentTests(unittest.TestCase):
def test_rejects_unaccepted_mss(self) -> None:
def test_rejects_rejected_mss_with_explicit_state_reason(self) -> None:
result = validate_signal_context(
bias={"draw_side": "buy_side"},
fvg={"direction": "bullish", "status": "open", "meta": {}},
mss={"direction": "bullish", "accepted": False},
mss={"direction": "bullish", "accepted": False, "meta": {"state": "rejected", "status_reason": "retest_not_observed"}},
)
self.assertFalse(result["is_actionable"])
self.assertEqual(result["reason"], "mss_not_accepted")
self.assertEqual(result["reason"], "mss_rejected")
self.assertEqual(result["structure_state"], "mss_rejected")
self.assertEqual(result["mss_state"], "rejected")
self.assertEqual(result["mss_status_reason"], "retest_not_observed")
def test_rejects_invalidated_mss_with_explicit_state_reason(self) -> None:
result = validate_signal_context(
bias={"draw_side": "buy_side"},
fvg={"direction": "bullish", "status": "open", "meta": {}},
mss={"direction": "bullish", "accepted": False, "meta": {"state": "invalidated", "status_reason": "old_control_reclaimed"}},
)
self.assertFalse(result["is_actionable"])
self.assertEqual(result["reason"], "mss_invalidated")
self.assertEqual(result["structure_state"], "mss_invalidated")
self.assertEqual(result["mss_state"], "invalidated")
self.assertEqual(result["mss_status_reason"], "old_control_reclaimed")
def test_rejects_mitigated_or_invalidated_fvg(self) -> None:
mitigated = validate_signal_context(
bias={"draw_side": "buy_side"},
fvg={"direction": "bullish", "status": "mitigated", "meta": {}},
mss={"direction": "bullish", "accepted": True},
fvg={"direction": "bullish", "status": "mitigated", "meta": {"status_reason": "mitigation_threshold_reached"}},
mss={"direction": "bullish", "accepted": True, "meta": {"state": "accepted", "status_reason": "retest_held"}},
)
invalidated = validate_signal_context(
bias={"draw_side": "buy_side"},
fvg={"direction": "bullish", "status": "touched", "meta": {"invalidated": True}},
mss={"direction": "bullish", "accepted": True},
fvg={"direction": "bullish", "status": "touched", "meta": {"invalidated": True, "status_reason": "structure_break"}},
mss={"direction": "bullish", "accepted": True, "meta": {"state": "accepted", "status_reason": "retest_held"}},
)
self.assertEqual(mitigated["reason"], "fvg_not_actionable")
self.assertEqual(mitigated["reason"], "fvg_mitigated")
self.assertEqual(mitigated["structure_state"], "fvg_mitigated")
self.assertEqual(mitigated["fvg_status_reason"], "mitigation_threshold_reached")
self.assertEqual(invalidated["reason"], "fvg_invalidated")
self.assertEqual(invalidated["structure_state"], "fvg_invalidated")
self.assertEqual(invalidated["fvg_status_reason"], "structure_break")
def test_accepts_aligned_structure(self) -> None:
result = validate_signal_context(
bias={"draw_side": "sell_side"},
fvg={"direction": "bearish", "status": "touched", "meta": {}, "related_displacement_id": 7},
mss={"direction": "bearish", "accepted": True, "displacement_event_id": 7},
fvg={
"direction": "bearish",
"status": "touched",
"meta": {"status_reason": "partial_fill", "touch_count": 1},
"related_displacement_id": 7,
},
mss={
"direction": "bearish",
"accepted": True,
"displacement_event_id": 7,
"meta": {"state": "accepted", "status_reason": "retest_held"},
},
)
self.assertTrue(result["is_actionable"])
self.assertEqual(result["side"], "sell")
self.assertEqual(result["reason"], "aligned")
self.assertEqual(result["structure_state"], "aligned")
self.assertEqual(result["mss_state"], "accepted")
self.assertEqual(result["mss_status_reason"], "retest_held")
self.assertEqual(result["fvg_status"], "touched")
self.assertEqual(result["fvg_status_reason"], "partial_fill")
class SignalRRFilterTests(unittest.TestCase):
@@ -116,6 +151,13 @@ class SignalRRFilterTests(unittest.TestCase):
self.assertGreaterEqual(candidate.rr_tp2, 2.0)
self.assertTrue(candidate.meta["evidence_quality"]["is_complete"])
self.assertEqual(candidate.meta["rr_filter"]["reason"], "rr_ok")
self.assertEqual(candidate.meta["structure_state"], "aligned")
self.assertEqual(candidate.meta["mss_state"], "accepted")
self.assertEqual(candidate.meta["mss_status_reason"], "retest_held")
self.assertEqual(candidate.meta["fvg_status"], "open")
self.assertEqual(candidate.meta["fvg_status_reason"], "no_touch")
self.assertEqual(candidate.meta["fvg_touch_count"], 0)
self.assertEqual(candidate.meta["fvg_data_quality_warnings"], [])
def test_build_signal_rejects_candidate_when_required_evidence_is_missing(self) -> None:
service = SignalService(
@@ -180,7 +222,14 @@ class _SignalStubRepository:
return self.fvg
def fetch_latest_mss(self, instrument_id: int, timeframe: str):
return {"id": 3, "direction": "bullish", "accepted": True, "broken_level": 100.0, "displacement_event_id": 7}
return {
"id": 3,
"direction": "bullish",
"accepted": True,
"broken_level": 100.0,
"displacement_event_id": 7,
"meta": {"state": "accepted", "status_reason": "retest_held"},
}
def fetch_session_at(self, instrument_id: int, ts):
return {"session_code": "LONDON"}
Generated
+440
View File
@@ -0,0 +1,440 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "ai-ict"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "sqlalchemy" },
]
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.27" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.1" },
{ name = "pydantic", specifier = ">=2.7" },
{ name = "pydantic-settings", specifier = ">=2.2" },
{ name = "sqlalchemy", specifier = ">=2.0" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "certifi"
version = "2026.4.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
]
[[package]]
name = "greenlet"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/0f/a91f143f356523ff682309732b175765a9bc2836fd7c081c2c67fedc1ad4/greenlet-3.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082", size = 284726, upload-time = "2026-04-27T12:20:51.402Z" },
{ url = "https://files.pythonhosted.org/packages/95/82/800646c7ffc5dbabd75ddd2f6b519bb898c0c9c969e5d0473bfe5d20bcce/greenlet-3.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3", size = 604264, upload-time = "2026-04-27T12:52:39.494Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ac/354867c0bba812fc33b15bc55aedafedd0aee3c7dd91dfca22444157dc0c/greenlet-3.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c", size = 616099, upload-time = "2026-04-27T12:59:39.623Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b0/815bece7399e01cadb69014219eebd0042339875c59a59b0820a46ece356/greenlet-3.5.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662", size = 615198, upload-time = "2026-04-27T12:25:25.928Z" },
{ url = "https://files.pythonhosted.org/packages/10/80/3b2c0a895d6698f6ddb31b07942ebfa982f3e30888bc5546a5b5990de8b2/greenlet-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b", size = 1574927, upload-time = "2026-04-27T12:53:25.81Z" },
{ url = "https://files.pythonhosted.org/packages/44/0e/f354af514a4c61454dbc68e44d47544a5a4d6317e30b77ddfa3a09f4c5f3/greenlet-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4", size = 1642683, upload-time = "2026-04-27T12:25:23.9Z" },
{ url = "https://files.pythonhosted.org/packages/fa/6a/87f38255201e993a1915265ebb80cd7c2c78b04a45744995abbf6b259fd8/greenlet-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8", size = 238115, upload-time = "2026-04-27T12:21:48.845Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f8/450fe3c5938fa737ea4d22699772e6e34e8e24431a47bf4e8a1ceed4a98e/greenlet-3.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339", size = 235017, upload-time = "2026-04-27T12:22:26.768Z" },
{ url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" },
{ url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" },
{ url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" },
{ url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" },
{ url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" },
{ url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" },
{ url = "https://files.pythonhosted.org/packages/cb/cb/baa584cb00532126ffe12d9787db0a60c5a4f55c27bfe2666df5d4c30a32/greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2", size = 235615, upload-time = "2026-04-27T12:21:38.57Z" },
{ url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" },
{ url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" },
{ url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" },
{ url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" },
{ url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" },
{ url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" },
{ url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" },
{ url = "https://files.pythonhosted.org/packages/b6/b7/9c5c3d653bd4ff614277c049ac676422e2c557db47b4fe43e6313fc005dc/greenlet-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b", size = 235525, upload-time = "2026-04-27T12:23:12.308Z" },
{ url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" },
{ url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" },
{ url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" },
{ url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" },
{ url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" },
{ url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" },
{ url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" },
{ url = "https://files.pythonhosted.org/packages/4e/62/1c498375cee177b55d980c1db319f26470e5309e54698c8f8fc06c0fd539/greenlet-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988", size = 236862, upload-time = "2026-04-27T12:23:24.957Z" },
{ url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" },
{ url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" },
{ url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" },
{ url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" },
{ url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
]
[[package]]
name = "psycopg"
version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" },
]
[package.optional-dependencies]
binary = [
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
]
[[package]]
name = "psycopg-binary"
version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/c0/b389119dd754483d316805260f3e73cdcad97925839107cc7a296f6132b1/psycopg_binary-3.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a89bb9ee11177b2995d87186b1d9fa892d8ea725e85eab28c6525e4cc14ee048", size = 4609740, upload-time = "2026-02-18T16:47:51.093Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e3/9976eef20f61840285174d360da4c820a311ab39d6b82fa09fbb545be825/psycopg_binary-3.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f7d0cf072c6fbac3795b08c98ef9ea013f11db609659dcfc6b1f6cc31f9e181", size = 4676837, upload-time = "2026-02-18T16:47:55.523Z" },
{ url = "https://files.pythonhosted.org/packages/9f/f2/d28ba2f7404fd7f68d41e8a11df86313bd646258244cb12a8dd83b868a97/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:90eecd93073922f085967f3ed3a98ba8c325cbbc8c1a204e300282abd2369e13", size = 5497070, upload-time = "2026-02-18T16:47:59.929Z" },
{ url = "https://files.pythonhosted.org/packages/de/2f/6c5c54b815edeb30a281cfcea96dc93b3bb6be939aea022f00cab7aa1420/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dac7ee2f88b4d7bb12837989ca354c38d400eeb21bce3b73dac02622f0a3c8d6", size = 5172410, upload-time = "2026-02-18T16:48:05.665Z" },
{ url = "https://files.pythonhosted.org/packages/51/75/8206c7008b57de03c1ada46bd3110cc3743f3fd9ed52031c4601401d766d/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b62cf8784eb6d35beaee1056d54caf94ec6ecf2b7552395e305518ab61eb8fd2", size = 6763408, upload-time = "2026-02-18T16:48:13.541Z" },
{ url = "https://files.pythonhosted.org/packages/d4/5a/ea1641a1e6c8c8b3454b0fcb43c3045133a8b703e6e824fae134088e63bd/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a39f34c9b18e8f6794cca17bfbcd64572ca2482318db644268049f8c738f35a6", size = 5006255, upload-time = "2026-02-18T16:48:22.176Z" },
{ url = "https://files.pythonhosted.org/packages/aa/fb/538df099bf55ae1637d52d7ccb6b9620b535a40f4c733897ac2b7bb9e14c/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:883d68d48ca9ff3cb3d10c5fdebea02c79b48eecacdddbf7cce6e7cdbdc216b8", size = 4532694, upload-time = "2026-02-18T16:48:27.338Z" },
{ url = "https://files.pythonhosted.org/packages/a1/d1/00780c0e187ea3c13dfc53bd7060654b2232cd30df562aac91a5f1c545ac/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:cab7bc3d288d37a80aa8c0820033250c95e40b1c2b5c57cf59827b19c2a8b69d", size = 4222833, upload-time = "2026-02-18T16:48:31.221Z" },
{ url = "https://files.pythonhosted.org/packages/7a/34/a07f1ff713c51d64dc9f19f2c32be80299a2055d5d109d5853662b922cb4/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:56c767007ca959ca32f796b42379fc7e1ae2ed085d29f20b05b3fc394f3715cc", size = 3952818, upload-time = "2026-02-18T16:48:35.869Z" },
{ url = "https://files.pythonhosted.org/packages/d3/67/d33f268a7759b4445f3c9b5a181039b01af8c8263c865c1be7a6444d4749/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:da2f331a01af232259a21573a01338530c6016dcfad74626c01330535bcd8628", size = 4258061, upload-time = "2026-02-18T16:48:41.365Z" },
{ url = "https://files.pythonhosted.org/packages/b4/3b/0d8d2c5e8e29ccc07d28c8af38445d9d9abcd238d590186cac82ee71fc84/psycopg_binary-3.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:19f93235ece6dbfc4036b5e4f6d8b13f0b8f2b3eeb8b0bd2936d406991bcdd40", size = 3558915, upload-time = "2026-02-18T16:48:46.679Z" },
{ url = "https://files.pythonhosted.org/packages/90/15/021be5c0cbc5b7c1ab46e91cc3434eb42569f79a0592e67b8d25e66d844d/psycopg_binary-3.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d", size = 4591170, upload-time = "2026-02-18T16:48:55.594Z" },
{ url = "https://files.pythonhosted.org/packages/f1/54/a60211c346c9a2f8c6b272b5f2bbe21f6e11800ce7f61e99ba75cf8b63e1/psycopg_binary-3.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8", size = 4670009, upload-time = "2026-02-18T16:49:03.608Z" },
{ url = "https://files.pythonhosted.org/packages/c1/53/ac7c18671347c553362aadbf65f92786eef9540676ca24114cc02f5be405/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df", size = 5469735, upload-time = "2026-02-18T16:49:10.128Z" },
{ url = "https://files.pythonhosted.org/packages/7f/c3/4f4e040902b82a344eff1c736cde2f2720f127fe939c7e7565706f96dd44/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351", size = 5152919, upload-time = "2026-02-18T16:49:16.335Z" },
{ url = "https://files.pythonhosted.org/packages/0c/e7/d929679c6a5c212bcf738806c7c89f5b3d0919f2e1685a0e08d6ff877945/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d", size = 6738785, upload-time = "2026-02-18T16:49:22.687Z" },
{ url = "https://files.pythonhosted.org/packages/69/b0/09703aeb69a9443d232d7b5318d58742e8ca51ff79f90ffe6b88f1db45e7/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2", size = 4979008, upload-time = "2026-02-18T16:49:27.313Z" },
{ url = "https://files.pythonhosted.org/packages/cc/a6/e662558b793c6e13a7473b970fee327d635270e41eded3090ef14045a6a5/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e", size = 4508255, upload-time = "2026-02-18T16:49:31.575Z" },
{ url = "https://files.pythonhosted.org/packages/5f/7f/0f8b2e1d5e0093921b6f324a948a5c740c1447fbb45e97acaf50241d0f39/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc", size = 4189166, upload-time = "2026-02-18T16:49:35.801Z" },
{ url = "https://files.pythonhosted.org/packages/92/ec/ce2e91c33bc8d10b00c87e2f6b0fb570641a6a60042d6a9ae35658a3a797/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0", size = 3924544, upload-time = "2026-02-18T16:49:41.129Z" },
{ url = "https://files.pythonhosted.org/packages/c5/2f/7718141485f73a924205af60041c392938852aa447a94c8cbd222ff389a1/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830", size = 4235297, upload-time = "2026-02-18T16:49:46.726Z" },
{ url = "https://files.pythonhosted.org/packages/57/f9/1add717e2643a003bbde31b1b220172e64fbc0cb09f06429820c9173f7fc/psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14", size = 3547659, upload-time = "2026-02-18T16:49:52.999Z" },
{ url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" },
{ url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" },
{ url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" },
{ url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" },
{ url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" },
{ url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" },
{ url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" },
{ url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" },
{ url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" },
{ url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" },
{ url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" },
{ url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" },
{ url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" },
{ url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" },
{ url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" },
{ url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" },
{ url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" },
{ url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" },
{ url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" },
{ url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" },
]
[[package]]
name = "pydantic"
version = "2.13.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/a2/1ba90a83e85a3f94c796b184f3efde9c72f2830dcda493eea8d59ba78e6d/pydantic_core-2.46.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5", size = 2106740, upload-time = "2026-04-20T14:41:20.932Z" },
{ url = "https://files.pythonhosted.org/packages/b6/f6/99ae893c89a0b9d3daec9f95487aa676709aa83f67643b3f0abaf4ab628a/pydantic_core-2.46.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c", size = 1948293, upload-time = "2026-04-20T14:43:42.115Z" },
{ url = "https://files.pythonhosted.org/packages/3e/b8/2e8e636dc9e3f16c2e16bf0849e24be82c5ee82c603c65fc0326666328fc/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e", size = 1973222, upload-time = "2026-04-20T14:41:57.841Z" },
{ url = "https://files.pythonhosted.org/packages/34/36/0e730beec4d83c5306f417afbd82ff237d9a21e83c5edf675f31ed84c1fe/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287", size = 2053852, upload-time = "2026-04-20T14:40:43.077Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f0/3071131f47e39136a17814576e0fada9168569f7f8c0e6ac4d1ede6a4958/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe", size = 2221134, upload-time = "2026-04-20T14:43:03.349Z" },
{ url = "https://files.pythonhosted.org/packages/2f/a9/a2dc023eec5aa4b02a467874bad32e2446957d2adcab14e107eab502e978/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050", size = 2279785, upload-time = "2026-04-20T14:41:19.285Z" },
{ url = "https://files.pythonhosted.org/packages/0a/44/93f489d16fb63fbd41c670441536541f6e8cfa1e5a69f40bc9c5d30d8c90/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2", size = 2089404, upload-time = "2026-04-20T14:43:10.108Z" },
{ url = "https://files.pythonhosted.org/packages/2a/78/8692e3aa72b2d004f7a5d937f1dfdc8552ba26caf0bec75f342c40f00dec/pydantic_core-2.46.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa", size = 2114898, upload-time = "2026-04-20T14:44:51.475Z" },
{ url = "https://files.pythonhosted.org/packages/6a/62/e83133f2e7832532060175cebf1f13748f4c7e7e7165cdd1f611f174494b/pydantic_core-2.46.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c", size = 2157856, upload-time = "2026-04-20T14:43:46.64Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ec/6a500e3ad7718ee50583fae79c8651f5d37e3abce1fa9ae177ae65842c53/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf", size = 2180168, upload-time = "2026-04-20T14:42:00.302Z" },
{ url = "https://files.pythonhosted.org/packages/d8/53/8267811054b1aa7fc1dc7ded93812372ef79a839f5e23558136a6afbfde1/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b", size = 2322885, upload-time = "2026-04-20T14:41:05.253Z" },
{ url = "https://files.pythonhosted.org/packages/c8/c1/1c0acdb3aa0856ddc4ecc55214578f896f2de16f400cf51627eb3c26c1c4/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e", size = 2360328, upload-time = "2026-04-20T14:41:43.991Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d0/ef39cd0f4a926814f360e71c1adeab48ad214d9727e4deb48eedfb5bce1a/pydantic_core-2.46.3-cp311-cp311-win32.whl", hash = "sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb", size = 1979464, upload-time = "2026-04-20T14:43:12.215Z" },
{ url = "https://files.pythonhosted.org/packages/18/9c/f41951b0d858e343f1cf09398b2a7b3014013799744f2c4a8ad6a3eec4f2/pydantic_core-2.46.3-cp311-cp311-win_amd64.whl", hash = "sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346", size = 2070837, upload-time = "2026-04-20T14:41:47.707Z" },
{ url = "https://files.pythonhosted.org/packages/9f/1e/264a17cd582f6ed50950d4d03dd5fefd84e570e238afe1cb3e25cf238769/pydantic_core-2.46.3-cp311-cp311-win_arm64.whl", hash = "sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6", size = 2053647, upload-time = "2026-04-20T14:42:27.535Z" },
{ url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" },
{ url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" },
{ url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" },
{ url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" },
{ url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" },
{ url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" },
{ url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" },
{ url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" },
{ url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" },
{ url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" },
{ url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" },
{ url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" },
{ url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" },
{ url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" },
{ url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" },
{ url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" },
{ url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" },
{ url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" },
{ url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" },
{ url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" },
{ url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" },
{ url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" },
{ url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" },
{ url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" },
{ url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" },
{ url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" },
{ url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" },
{ url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" },
{ url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" },
{ url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" },
{ url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" },
{ url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" },
{ url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" },
{ url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" },
{ url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" },
{ url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" },
{ url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" },
{ url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" },
{ url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" },
{ url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" },
{ url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" },
{ url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" },
{ url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" },
{ url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" },
{ url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" },
{ url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" },
{ url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" },
{ url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" },
{ url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" },
{ url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" },
{ url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" },
{ url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" },
{ url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" },
{ url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" },
{ url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" },
{ url = "https://files.pythonhosted.org/packages/66/7f/03dbad45cd3aa9083fbc93c210ae8b005af67e4136a14186950a747c6874/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:9715525891ed524a0a1eb6d053c74d4d4ad5017677fb00af0b7c2644a31bae46", size = 2105683, upload-time = "2026-04-20T14:42:19.779Z" },
{ url = "https://files.pythonhosted.org/packages/26/22/4dc186ac8ea6b257e9855031f51b62a9637beac4d68ac06bee02f046f836/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:9d2f400712a99a013aff420ef1eb9be077f8189a36c1e3ef87660b4e1088a874", size = 1940052, upload-time = "2026-04-20T14:43:59.274Z" },
{ url = "https://files.pythonhosted.org/packages/0d/ca/d376391a5aff1f2e8188960d7873543608130a870961c2b6b5236627c116/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd2aab0e2e9dc2daf36bd2686c982535d5e7b1d930a1344a7bb6e82baab42a76", size = 1988172, upload-time = "2026-04-20T14:41:17.469Z" },
{ url = "https://files.pythonhosted.org/packages/0e/6b/523b9f85c23788755d6ab949329de692a2e3a584bc6beb67fef5e035aa9d/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e9d76736da5f362fabfeea6a69b13b7f2be405c6d6966f06b2f6bfff7e64531", size = 2128596, upload-time = "2026-04-20T14:40:41.707Z" },
{ url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" },
{ url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" },
{ url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" },
{ url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" },
{ url = "https://files.pythonhosted.org/packages/1f/da/99d40830684f81dec901cac521b5b91c095394cc1084b9433393cde1c2df/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25", size = 2107973, upload-time = "2026-04-20T14:42:06.175Z" },
{ url = "https://files.pythonhosted.org/packages/99/a5/87024121818d75bbb2a98ddbaf638e40e7a18b5e0f5492c9ca4b1b316107/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3", size = 1947191, upload-time = "2026-04-20T14:43:14.319Z" },
{ url = "https://files.pythonhosted.org/packages/60/62/0c1acfe10945b83a6a59d19fbaa92f48825381509e5701b855c08f13db76/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536", size = 2123791, upload-time = "2026-04-20T14:43:22.766Z" },
{ url = "https://files.pythonhosted.org/packages/75/3e/3b2393b4c8f44285561dc30b00cf307a56a2eff7c483a824db3b8221ca51/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1", size = 2153197, upload-time = "2026-04-20T14:44:27.932Z" },
{ url = "https://files.pythonhosted.org/packages/ba/75/5af02fb35505051eee727c061f2881c555ab4f8ddb2d42da715a42c9731b/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c", size = 2181073, upload-time = "2026-04-20T14:43:20.729Z" },
{ url = "https://files.pythonhosted.org/packages/10/92/7e0e1bd9ca3c68305db037560ca2876f89b2647deb2f8b6319005de37505/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85", size = 2315886, upload-time = "2026-04-20T14:44:04.826Z" },
{ url = "https://files.pythonhosted.org/packages/b8/d8/101655f27eaf3e44558ead736b2795d12500598beed4683f279396fa186e/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8", size = 2360528, upload-time = "2026-04-20T14:40:47.431Z" },
{ url = "https://files.pythonhosted.org/packages/07/0f/1c34a74c8d07136f0d729ffe5e1fdab04fbdaa7684f61a92f92511a84a15/pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff", size = 2184144, upload-time = "2026-04-20T14:42:57Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.49"
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/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/b5/e3617cc67420f8f403efebd7b043128f94775e57e5b84e7255203390ceae/sqlalchemy-2.0.49-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe", size = 2159126, upload-time = "2026-04-03T16:50:13.242Z" },
{ url = "https://files.pythonhosted.org/packages/20/9b/91ca80403b17cd389622a642699e5f6564096b698e7cdcbcbb6409898bc4/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014", size = 3315509, upload-time = "2026-04-03T16:54:49.332Z" },
{ url = "https://files.pythonhosted.org/packages/b1/61/0722511d98c54de95acb327824cb759e8653789af2b1944ab1cc69d32565/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536", size = 3315014, upload-time = "2026-04-03T16:56:56.376Z" },
{ url = "https://files.pythonhosted.org/packages/46/55/d514a653ffeb4cebf4b54c47bec32ee28ad89d39fafba16eeed1d81dccd5/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88", size = 3267388, upload-time = "2026-04-03T16:54:51.272Z" },
{ url = "https://files.pythonhosted.org/packages/2f/16/0dcc56cb6d3335c1671a2258f5d2cb8267c9a2260e27fde53cbfb1b3540a/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700", size = 3289602, upload-time = "2026-04-03T16:56:57.63Z" },
{ url = "https://files.pythonhosted.org/packages/51/6c/f8ab6fb04470a133cd80608db40aa292e6bae5f162c3a3d4ab19544a67af/sqlalchemy-2.0.49-cp311-cp311-win32.whl", hash = "sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a", size = 2119044, upload-time = "2026-04-03T17:00:53.455Z" },
{ url = "https://files.pythonhosted.org/packages/c4/59/55a6d627d04b6ebb290693681d7683c7da001eddf90b60cfcc41ee907978/sqlalchemy-2.0.49-cp311-cp311-win_amd64.whl", hash = "sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af", size = 2143642, upload-time = "2026-04-03T17:00:54.769Z" },
{ url = "https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload-time = "2026-04-03T16:53:07.132Z" },
{ url = "https://files.pythonhosted.org/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload-time = "2026-04-03T17:07:40Z" },
{ url = "https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload-time = "2026-04-03T17:12:23.374Z" },
{ url = "https://files.pythonhosted.org/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646, upload-time = "2026-04-03T17:07:41.949Z" },
{ url = "https://files.pythonhosted.org/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695, upload-time = "2026-04-03T17:12:25.642Z" },
{ url = "https://files.pythonhosted.org/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483, upload-time = "2026-04-03T17:05:40.896Z" },
{ url = "https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494, upload-time = "2026-04-03T17:05:42.282Z" },
{ url = "https://files.pythonhosted.org/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" },
{ url = "https://files.pythonhosted.org/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" },
{ url = "https://files.pythonhosted.org/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" },
{ url = "https://files.pythonhosted.org/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7", size = 3228832, upload-time = "2026-04-03T17:07:45.38Z" },
{ url = "https://files.pythonhosted.org/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33", size = 3267000, upload-time = "2026-04-03T17:12:29.657Z" },
{ url = "https://files.pythonhosted.org/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b", size = 2115641, upload-time = "2026-04-03T17:05:43.989Z" },
{ url = "https://files.pythonhosted.org/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148", size = 2141498, upload-time = "2026-04-03T17:05:45.7Z" },
{ url = "https://files.pythonhosted.org/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518", size = 3560807, upload-time = "2026-04-03T16:58:31.666Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d", size = 3527481, upload-time = "2026-04-03T17:06:00.155Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0", size = 3468565, upload-time = "2026-04-03T16:58:33.414Z" },
{ url = "https://files.pythonhosted.org/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08", size = 3477769, upload-time = "2026-04-03T17:06:02.787Z" },
{ url = "https://files.pythonhosted.org/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d", size = 2143319, upload-time = "2026-04-03T17:02:04.328Z" },
{ url = "https://files.pythonhosted.org/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba", size = 2175104, upload-time = "2026-04-03T17:02:05.989Z" },
{ url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" },
{ url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" },
{ url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" },
{ url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" },
{ url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" },
{ url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" },
{ url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" },
{ url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" },
{ url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" },
{ url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" },
{ url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" },
{ url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "tzdata"
version = "2026.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
]